commit 7ef408afe79a262bdb290b1ba0e527ec3553a45f Author: test2 Date: Wed Jul 8 00:41:50 2026 +0300 Add reverse proxy masking and Cloudflare panel SSL via DNS-01. Introduce Caddy-based reverse proxy with decoy site and DPI masking, plus automatic Let's Encrypt certificate issuance for the panel through Cloudflare API without binding port 443. Co-authored-by: Cursor diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..40fd03e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,102 @@ +name: Build Binaries + +on: + push: + branches: [ "main" ] + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pyinstaller + pip install -r requirements.txt + + - name: Build Linux + if: runner.os == 'Linux' + run: | + pyinstaller --name Amnezia-Web-Panel-Linux \ + --add-data "static:static" \ + --add-data "templates:templates" \ + --add-data "translations:translations" \ + --hidden-import uvicorn \ + --hidden-import fastapi \ + --clean \ + -y \ + -F app.py + + - name: Build Windows + if: runner.os == 'Windows' + run: | + pyinstaller --name Amnezia-Web-Panel-Windows ` + --add-data "static;static" ` + --add-data "templates;templates" ` + --add-data "translations;translations" ` + --hidden-import uvicorn ` + --hidden-import fastapi ` + --clean ` + -y ` + -F app.py + + - name: Build macOS + if: runner.os == 'macOS' + run: | + pyinstaller --name Amnezia-Web-Panel-macOS \ + --add-data "static:static" \ + --add-data "templates:templates" \ + --add-data "translations:translations" \ + --hidden-import uvicorn \ + --hidden-import fastapi \ + --clean \ + -y \ + -F app.py + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: binaries-${{ runner.os }} + path: dist/* + + release: + needs: build + + if: startsWith(github.ref, 'refs/tags/') + + runs-on: ubuntu-latest + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Display structure + run: ls -R artifacts + + - name: Publish Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + artifacts/**/* + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de8396c --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Virtual Environment +venv/ +env/ +.venv/ + +# PyInstaller folders +build/ +dist/ +*.spec + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +pip-log.txt +pip-delete-this-directory.txt +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.pytest_cache/ +.mypy_cache/ + +# User data +data.json +.DS_Store + +# IDEs +.vscode/ +.idea/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c6990fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.14-slim + +WORKDIR /app + +# Копируем requirements.txt и устанавливаем зависимости +COPY requirements.txt requirements.txt +RUN pip3 install --no-cache-dir -r requirements.txt + +# Копируем остальные файлы проекта +COPY . . + + +# Команда запуска приложения +CMD ["python3", "app.py"] \ No newline at end of file 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..4a9851a --- /dev/null +++ b/README.md @@ -0,0 +1,264 @@ +# Amnezia Web Panel + +A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Telemt (Telegram MTProxy)**, **AmneziaDNS**, **AdGuard Home** and **SOCKS5** services on remote Ubuntu servers — from a single dashboard. Designed to provide a premium user experience with robust administrative capabilities. + +> ### 🔄 Compatibility with Official Amnezia Client +> +> This panel is fully compatible with the official **Amnezia** applications! +> +> **How to connect an existing server:** +> 1. Add your pre-configured server by entering its **IP address**, **login** and **password** +> 2. Go to the "Added Servers" section +> 3. Wait for the automatic server verification +> 4. The panel will automatically detect: +> - ✅ Installed protocols +> - ✅ Existing users +> - ✅ Current configuration +> +> ⚡ **After verification, you can manage the server directly from the panel!** + +## ⚠️ Legal Notice + +> **This project is created solely for educational and research purposes.** +> +> **This project has never been intended for use in jurisdictions where the technologies employed are prohibited.** The author bears no responsibility for any unlawful use of this software. + +**This project merely adds an abstraction layer for managing publicly available applications.** All applications belong to their respective owners. This project does not claim ownership over, nor does it modify, any third-party applications. + +The use of traffic obfuscation tools may violate the laws of your country. Only use this software for lawful purposes, such as: + +- **Penetration testing and security research** +- **CTF (Capture The Flag) competitions** +- **Academic and scientific research** +- **Testing and securing your own networks** +- **Improving defensive security measures** +- **Educational training in cybersecurity** + +> **Nothing in this project constitutes an incitement to violate any applicable laws.** +![Servers Dashboard](https://raw.githubusercontent.com/PRVTPRO/Amnezia-Web-Panel/refs/heads/main/screen/panel1.png) + + +### Additional Sections + +
+👥 Users Management (click to expand) +
+User management interface with permissions and access controls: + +![Users Management](https://github.com/PRVTPRO/Amnezia-Web-Panel/blob/main/screen/panel1-2.png) +
+ +
+⚙️ System Settings (click to expand) +
+Configuration panel for system parameters and preferences: + +![Settings Panel](https://github.com/PRVTPRO/Amnezia-Web-Panel/blob/main/screen/panel1-3.png) +
+ +## 🚀 Key Features + +* **⚡ VPN Protocols**: + * **AmneziaWG (AWG / AWG 2.0 / AWG Legacy)**: Advanced WireGuard-based protocol with S3/S4 obfuscation to bypass deep packet inspection (DPI). Three coexisting variants — modern AWG 2.0 with full junk-packet masking, and a legacy variant for older clients. + * **Classic WireGuard**: Standard, high-performance WireGuard protocol for unmatched speed and broad device compatibility with traffic monitoring support. + * **Xray (XTLS-Reality)**: Stealthy protocol that masks VPN traffic as standard HTTPS browsing. Pinned to **Xray-core v26.x**; transparently reads both the **panel layout** (`meta.json` + `clientsTable.json`) and the **native Amnezia client layout** (`xray_*.key` files + `clientsTable`), so a node first installed via the official mobile/desktop app can be attached to the panel without re-installation. + * **Telemt (Telegram MTProxy)**: High-performance Telegram MTProxy with TLS emulation and comprehensive management (quotas, IP limits, real-time session tracking). Robust install path that auto-configures Docker's official apt/yum repository when needed. +* **🛠 Services**: + * **AmneziaDNS**: Internal DNS resolver on a private docker network (`amnezia-dns-net`, IP `172.29.172.254`) to prevent DNS leaks and blockings. + * **AdGuard Home** *(new)*: DNS-based ad blocker with a web admin UI. Two install modes: **Replace AmneziaDNS** (takes its IP, all VPN clients use AdGuard immediately) or **Side-by-side** (parallel deployment on `172.29.172.253`, web UI accessible only over the VPN by default). Optional opt-in checkboxes to expose the web UI / DoT / DoH on the host. + * **SOCKS5 Proxy** *(new)*: Single-account 3proxy-based SOCKS5 server modelled after the official Amnezia client. Auto-generated 16-character password on install, port and credentials editable later from the panel without re-install. +* **⚙️ Core Server Management**: + * **Add / Edit / Delete / Reorder** server entries — drag-and-drop reorder updates `server_id` references in saved connections automatically. + * **Live ping indicator** next to each server name — non-blocking TCP-connect probe to the SSH port, runs on the asyncio loop in parallel for all servers. + * **Clear server** wipes every Amnezia-related container, image and `/opt/amnezia` directory in a single sudo script — works for any current or future `amnezia-*` protocol. + * **Reboot** the server directly from the UI. + * Strictly concurrent protocol status polling — all 9 protocols/services checked in parallel for immediate feedback. + * **Asynchronous Processing**: Resilient, non-blocking background architecture prevents the UI panel from freezing, even if remote endpoints hang. +* **🌐 Internationalization (i18n)**: + * Full support for **English**, **Russian**, **French**, **Chinese**, and **Persian**. + * Native **RTL (Right-to-Left)** support for Persian language. +* **👥 Advanced User Management**: + * Role-based access (Admin, Support, Regular User). + * Traffic limits, status monitoring, and account expiration. + * One-click user enabling/disabling. +* **🎨 Premium UI/UX**: + * Stunning glassmorphism design. + * Dynamic **Dark/Light** mode transition. + * Fully responsive for mobile and desktop. +* **🤖 Telegram Bot Integration**: + * Notify users about new connections or limits. + * Integrated management via Telegram commands. +* **🔄 Built-in Update Checker**: + * View your current panel version directly in Settings. + * One-click check for fresh GitHub releases to stay up to date. +* **📤 Data Interoperability**: + * **Remnawave Sync**: Automatically import and sync users from Remnawave. + * **Simple Backup**: Effortless JSON-based export and restore of all panel data. +* **🔗 Public Sharing**: + * Generate password-protected links for users to download their configurations without panel access. +* **🌍 One-click Public Tunnels** *(new)*: + * Open the local panel to the internet from `/settings` using **Cloudflare Quick Tunnel** or **ngrok**. + * Shows the local server URL, installation state, running state, and issued public HTTPS URLs directly in the UI. + * Supports one-click install, enable, stop, and delete for panel-managed tunnel binaries. + * Persists tunnel PID/public URL state across panel restarts and can detect already running tunnel processes. + * Works on Windows, Linux, and Docker-friendly environments; `TUNNEL_BIN_DIR` and `TUNNEL_STATE_FILE` can override binary/state locations. +* **🔑 API Tokens for External Integrations** *(new)*: + * Issue bearer tokens from `/settings` for CI bots, monitoring, or any third-party service. + * Panel never stores the raw token — only its SHA-256 hash. The full value is shown **once** at creation; lose it and you must rotate. + * Tokens inherit the role of the admin who created them and are revoked automatically if that user is disabled or demoted. + * Send `Authorization: Bearer ` with any admin endpoint — every endpoint that accepts a session also accepts a token, no other changes. + +## 💡 Need Additional Functionality? + +If you require any custom features not currently available in the panel, **let us know – we'll implement them quickly!** + +* **Database Support**: PostgreSQL, MySQL/MariaDB, SQLite, Oracle, and MS SQL Server +* **In-Panel File Editor**: Edit configuration files inside containers directly from the web interface +* **Backup & Restore nodes/protocols**: Comprehensive backup solutions for nodes and protocols +* **Protocol Migration**: Seamlessly move protocols between nodes +* **Xray Self-Steal Mode**: Advanced Xray configuration with self-steal functionality +* **And much more!** + +**Or better yet, contribute!** + + +## 🏗 Prerequisites + +* **Python 3.10+** +* Target servers: **Ubuntu 20.04/22.04/24.04** (Architecture: x86_64 or ARM64). +* SSH access to target servers (Password or Private Key). + +## 📦 Installation + +1. **Clone the repository**: + ```bash + git clone https://github.com/PRVTPRO/Amnezia-Web-Panel.git + cd Amnezia-Web-Panel + ``` + +2. **Set up Virtual Environment**: + ```bash + python -m venv venv + source venv/bin/activate # Windows: venv\Scripts\activate + ``` + +3. **Install Dependencies**: + ```bash + pip install -r requirements.txt + ``` +## 🚀 Getting Started + +Launch the application: + +```bash +python app.py +``` + +The panel will be accessible at `http://localhost:5000`. + +## 📦 Installation Method 2 + +Download and run the executable file for your system. +``` +Windows +Linux +Mac +``` + +## 🐳 Docker Image + +https://hub.docker.com/r/prvtpro/amnezia-panel + + +### Initial Login +* **Username**: `admin` +* **Password**: `admin` +> [!IMPORTANT] +> Secure your panel by changing the default password in the **Users** section immediately after first login. + +## 🔧 Project Details + +### API Documentation + +The project includes self-documenting API endpoints, organised into clear tag groups: + +* **Swagger UI**: `/docs` +* **ReDoc**: `/redoc` (pinned to a stable bundle, Google Fonts disabled — works on networks where they're blocked) + +Routes are grouped in the docs as: + +| Group | Purpose | +| --- | --- | +| **System Templates** | HTML pages served to browsers (login, server detail, settings, /share). Not part of the JSON API. | +| **Authentication** | Login, captcha, session lifecycle. | +| **Servers** | Server inventory & host-level operations (add/edit/delete, ping, reorder, reboot, clear, stats). | +| **Protocols** | Install / uninstall / container / raw-config editing for every protocol & service on a server. | +| **Connections** | Per-protocol VPN client connections (CRUD, enable/disable, fetch config). | +| **Users** | Panel user accounts and the connections assigned to them. | +| **Self-service** | Endpoints called by a regular user for their own data (`/api/my/*`). | +| **Sharing** | Public, token-protected configuration sharing — no panel session required. | +| **Settings** | Panel-wide settings, Telegram bot, Remnawave sync, JSON backup/restore. | +| **API Tokens** | Create and revoke bearer tokens for external integrations. | + +**Authentication for external integrations** — both session cookies and `Authorization: Bearer ` are accepted on every admin endpoint. Example: + +```bash +TOKEN="awp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + +# List panel users +curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/users + +# Add a server +curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"host":"1.2.3.4","username":"root","password":"...","name":"new-srv"}' \ + http://your-panel:5000/api/servers/add + +# Cheap reachability probe for monitoring +curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/servers/0/ping +``` + +### Technology Stack +* **Backend**: FastAPI (Python), `asyncio` for concurrent SSH/probe work +* **Frontend**: Vanilla JS, Jinja2, Custom CSS (Glassmorphism, full set of CSS animations for promo blocks) +* **Database**: Local JSON storage (`data.json`) with an `asyncio.Lock` for thread-safe writes +* **SSH Engine**: Paramiko + +### Project Structure + +``` +web-panel/ +├── app.py # FastAPI entry point + all routes +├── telegram_bot.py # Optional Telegram bot integration +├── managers/ # Protocol & service managers (one file per protocol) +│ ├── ssh_manager.py # SSH abstraction (Paramiko wrapper) +│ ├── awg_manager.py # AmneziaWG / AWG 2.0 / AWG Legacy +│ ├── wireguard_manager.py # Classic WireGuard +│ ├── xray_manager.py # Xray-core (VLESS-Reality) +│ ├── telemt_manager.py # Telegram MTProxy +│ ├── dns_manager.py # AmneziaDNS (Unbound) +│ ├── adguard_manager.py # AdGuard Home +│ └── socks5_manager.py # 3proxy-based SOCKS5 +├── static/ # CSS / favicon / vendored JS +├── templates/ # Jinja2 templates +├── translations/ # en / ru / fr / zh / fa +└── data.json # Panel state (servers, users, tokens, settings) +``` + +## 🛡 Security Recommendations + +* **Reverse Proxy**: It is highly recommended to run the panel behind Nginx/Apache with an SSL certificate. +* **SSH Keys**: Use SSH keys rather than passwords for connecting to your VPN servers. +* **Secret Key**: Set a custom `SECRET_KEY` environment variable for secure session management. +* **API Tokens**: Treat each token like a password — store it in your integration's secret manager. Revoke it from `/settings` if it leaks or the integration is decommissioned. Rotate periodically; tokens inherit admin rights. + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit Pull Requests or open Issues for feature requests and bug reports. + +## 📄 License + +This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](../LICENSE) file for details. + + +--- +*Built with ❤️ for the Amnezia community.* diff --git a/app.py b/app.py new file mode 100644 index 0000000..93cb77a --- /dev/null +++ b/app.py @@ -0,0 +1,3733 @@ +import os +import sys +import json +import logging +import base64 +import hashlib +import secrets +import uuid +import asyncio +import platform +import re +import shutil +import subprocess +import tarfile +import threading +import time +import urllib.request +import zipfile +import signal +from datetime import datetime +import io +from fastapi.responses import JSONResponse, RedirectResponse, HTMLResponse, StreamingResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from fastapi import FastAPI, Request, Query, UploadFile, File +from starlette.middleware.sessions import SessionMiddleware +from pydantic import BaseModel +from typing import Optional, List, Dict +import uvicorn +import httpx + +try: + from multicolorcaptcha import CaptchaGenerator +except ImportError: + CaptchaGenerator = None + +from managers.ssh_manager import SSHManager +from managers.awg_manager import AWGManager +from managers.xray_manager import XrayManager +from managers.wireguard_manager import WireGuardManager +import telegram_bot as tg_bot + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Ordered list of OpenAPI tag groups — the order here drives the section order in /docs and /redoc. +OPENAPI_TAGS = [ + {"name": "System Templates", "description": "HTML pages served to browsers. These return Jinja-rendered templates rather than a JSON contract — they are not part of the public API and are listed here only for completeness."}, + {"name": "Authentication", "description": "Login, captcha, and session lifecycle."}, + {"name": "Servers", "description": "Server inventory, lifecycle and host-level operations (add, edit, delete, ping, reorder, reboot, clear, stats, status check)."}, + {"name": "Protocols", "description": "Install, uninstall, container start/stop and raw config editing for the protocols/services on a server (AWG, Xray, WireGuard, Telemt, AmneziaDNS, AdGuard Home, SOCKS5)."}, + {"name": "Connections", "description": "Per-protocol VPN client connections on a server (CRUD plus enable/disable and config retrieval)."}, + {"name": "Users", "description": "Panel user accounts and the connections assigned to them."}, + {"name": "Self-service", "description": "Endpoints called by a regular user for their own data (the /my surface)."}, + {"name": "Sharing", "description": "Public, token-protected configuration sharing for end users — no panel session required."}, + {"name": "Settings", "description": "Panel-wide settings, Telegram bot, Remnawave sync, JSON backup/restore."}, + {"name": "API Tokens", "description": "Bearer tokens for external integrations. Send the token in `Authorization: Bearer `; tokens have admin-equivalent rights and are tied to the admin user that created them."}, +] + +app = FastAPI( + title="Amnezia Web Panel", + openapi_tags=OPENAPI_TAGS, + # FastAPI's stock /redoc loads the JS bundle from `redoc@next` on jsdelivr — + # an unstable rolling tag that breaks unpredictably. Disable the default and + # serve our own /redoc just below, pinned to the stable v2 bundle. + redoc_url=None, +) + + +@app.get("/redoc", include_in_schema=False) +async def custom_redoc(): + """Self-curated ReDoc page. Differs from FastAPI's default in two ways: + pinned bundle (`redoc@2` instead of `@next`) and Google Fonts disabled + (the Montserrat/Roboto stylesheet is blocked on a lot of networks and made + the page hang for some users).""" + from fastapi.openapi.docs import get_redoc_html + return get_redoc_html( + openapi_url=app.openapi_url or "/openapi.json", + title=f"{app.title} — ReDoc", + redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js", + with_google_fonts=False, + ) +app.add_middleware(SessionMiddleware, secret_key=os.environ.get('SECRET_KEY', secrets.token_hex(32))) + +# Mount static files & templates +app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static") +templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates")) + +if getattr(sys, 'frozen', False): + application_path = os.path.dirname(sys.executable) +else: + application_path = os.path.dirname(__file__) + +DATA_FILE = os.path.join(application_path, 'data.json') +CURRENT_VERSION = "v1.4.4" +BIN_DIR = os.environ.get('TUNNEL_BIN_DIR', os.path.join(application_path, 'bin')) +TUNNEL_STATE_FILE = os.environ.get('TUNNEL_STATE_FILE', os.path.join(application_path, 'tunnels_state.json')) + + +class TunnelRuntime: + def __init__(self): + self.process = None + self.pid = None + self.public_url = '' + self.last_error = '' + self.started_at = None + self.output = [] + + +TUNNEL_RUNTIMES = { + 'cloudflare': TunnelRuntime(), + 'ngrok': TunnelRuntime(), +} +TUNNEL_LOCK = threading.Lock() +TUNNEL_URL_RE = re.compile(r'https://[^\s"\']+') + + +# ======================== Translations ======================== +TRANSLATIONS = {} + +def load_translations(): + global TRANSLATIONS + trans_dir = os.path.join(os.path.dirname(__file__), 'translations') + if os.path.exists(trans_dir): + for f in os.listdir(trans_dir): + if f.endswith('.json'): + lang = f.split('.')[0] + try: + with open(os.path.join(trans_dir, f), 'r', encoding='utf-8') as tf: + TRANSLATIONS[lang] = json.load(tf) + except Exception as e: + logger.error(f"Error loading translation {f}: {e}") + logger.info(f"Loaded translations: {list(TRANSLATIONS.keys())}") + +def _t(text_id, lang='en'): + lang_batch = TRANSLATIONS.get(lang, TRANSLATIONS.get('en', {})) + return lang_batch.get(text_id, text_id) + +load_translations() + + +# ======================== Helpers ======================== + +# Global lock for data.json access to prevent race conditions during async operations +DATA_LOCK = asyncio.Lock() + + +def load_data(): + if os.path.exists(DATA_FILE): + with open(DATA_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + else: + data = {} + data.setdefault('servers', []) + data.setdefault('users', []) + data.setdefault('user_connections', []) + data.setdefault('api_tokens', []) + data.setdefault('settings', { + 'appearance': { + 'title': 'Amnezia', + 'logo': '❤️', + 'subtitle': 'Web Panel' + }, + 'sync': { + 'remnawave_url': '', + 'remnawave_api_key': '', + 'remnawave_sync': False, + 'remnawave_sync_users': False, + 'remnawave_create_conns': False, + 'remnawave_server_id': 0, + 'remnawave_protocol': 'awg' + } + }) + return data + + +def save_data(data): + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + +async def save_data_async(data): + """Saves data to file in a thread-safe way.""" + async with DATA_LOCK: + await asyncio.to_thread(save_data, data) + + +def get_ssh(server): + return SSHManager( + host=server['host'], + port=server.get('ssh_port', 22), + username=server['username'], + password=server.get('password'), + private_key=server.get('private_key'), + ) + + +def get_panel_local_url(request: Optional[Request] = None): + data = load_data() + ssl_conf = data.get('settings', {}).get('ssl', {}) + scheme = 'https' if ssl_conf.get('enabled') else 'http' + host = '127.0.0.1' + port = ssl_conf.get('panel_port', 5000) or 5000 + if request: + scheme = request.url.scheme or scheme + host = request.url.hostname or host + port = request.url.port or port + return f"{scheme}://{host}:{port}" + + +def get_panel_tunnel_target_url(): + data = load_data() + ssl_conf = data.get('settings', {}).get('ssl', {}) + scheme = 'https' if ssl_conf.get('enabled') else 'http' + port = ssl_conf.get('panel_port', 5000) or 5000 + return f"{scheme}://127.0.0.1:{port}" + + +def get_tunnel_command_name(provider: str): + if provider == 'cloudflare': + return 'cloudflared.exe' if os.name == 'nt' else 'cloudflared' + if provider == 'ngrok': + return 'ngrok.exe' if os.name == 'nt' else 'ngrok' + raise ValueError('Unsupported tunnel provider') + + +def get_tunnel_binary_path(provider: str): + return os.path.join(BIN_DIR, get_tunnel_command_name(provider)) + + +def find_tunnel_binary(provider: str): + bundled = get_tunnel_binary_path(provider) + if os.path.exists(bundled): + return bundled + return shutil.which(get_tunnel_command_name(provider)) + + +def is_tunnel_installed(provider: str): + return bool(find_tunnel_binary(provider)) + + +def load_tunnel_state(): + if not os.path.exists(TUNNEL_STATE_FILE): + return {} + try: + with open(TUNNEL_STATE_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logger.warning(f"Failed to load tunnel state: {e}") + return {} + + +def save_tunnel_state(state): + with open(TUNNEL_STATE_FILE, 'w', encoding='utf-8') as f: + json.dump(state, f, indent=2, ensure_ascii=False) + + +def update_tunnel_state(provider: str, **updates): + state = load_tunnel_state() + provider_state = state.get(provider, {}) + provider_state.update(updates) + state[provider] = provider_state + save_tunnel_state(state) + + +def clear_tunnel_state(provider: str): + state = load_tunnel_state() + if provider in state: + state.pop(provider) + save_tunnel_state(state) + + +def pid_is_running(pid): + if not pid: + return False + try: + os.kill(int(pid), 0) + return True + except OSError: + return False + except Exception: + return False + + +def kill_pid(pid): + if not pid: + return + if os.name == 'nt': + subprocess.run(['taskkill', '/PID', str(pid), '/T', '/F'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if not pid_is_running(pid): + return + time.sleep(0.2) + else: + try: + os.kill(int(pid), signal.SIGTERM) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if not pid_is_running(pid): + return + time.sleep(0.2) + if pid_is_running(pid): + os.kill(int(pid), signal.SIGKILL) + except ProcessLookupError: + return + + +def wait_for_path_release(path, seconds: int = 5): + if os.name != 'nt': + return + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + try: + if not os.path.exists(path): + return + with open(path, 'ab'): + return + except PermissionError: + time.sleep(0.25) + + +def find_running_tunnel_pids_in_proc(provider: str, binary_path: str = ''): + proc_dir = '/proc' + if os.name == 'nt' or not os.path.isdir(proc_dir): + return [] + command_name = get_tunnel_command_name(provider).lower() + command_base = command_name.replace('.exe', '') + markers = ['tunnel', '--url'] if provider == 'cloudflare' else ['http'] + expected_binary = os.path.abspath(binary_path).lower() if binary_path else '' + pids = [] + try: + for entry in os.listdir(proc_dir): + if not entry.isdigit(): + continue + pid = int(entry) + if pid == os.getpid(): + continue + cmdline_path = os.path.join(proc_dir, entry, 'cmdline') + try: + with open(cmdline_path, 'rb') as f: + cmdline = f.read().replace(b'\x00', b' ').decode('utf-8', errors='replace').strip() + except Exception: + continue + lowered = cmdline.lower() + exe_path = '' + try: + exe_path = os.path.abspath(os.readlink(os.path.join(proc_dir, entry, 'exe'))).lower() + except Exception: + pass + path_matches = bool(expected_binary and exe_path == expected_binary) + command_matches = (command_name in lowered or command_base in lowered) and all(marker in lowered for marker in markers) + if path_matches or command_matches: + if pid_is_running(pid): + pids.append(pid) + except Exception as e: + logger.debug(f"Failed to scan /proc for running {provider} tunnel process: {e}") + return pids + + +def find_running_tunnel_pids(provider: str, binary_path: str = ''): + command_name = get_tunnel_command_name(provider).lower() + process_name = command_name[:-4] if command_name.endswith('.exe') else command_name + markers = ['tunnel', '--url'] if provider == 'cloudflare' else ['http'] + expected_binary = os.path.normcase(os.path.abspath(binary_path or get_tunnel_binary_path(provider))) + pids = [] + try: + if os.name == 'nt': + ps_script = f"Get-CimInstance Win32_Process -Filter \"name='{command_name}'\" | Select-Object ProcessId,CommandLine,ExecutablePath | ConvertTo-Json -Compress" + result = subprocess.run(['powershell', '-NoProfile', '-Command', ps_script], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5) + rows = [] + if result.returncode == 0 and result.stdout.strip(): + payload = json.loads(result.stdout) + if payload: + rows = payload if isinstance(payload, list) else [payload] + if not rows: + ps_script = f"Get-Process -Name '{process_name}' -ErrorAction SilentlyContinue | Select-Object Id,Path | ConvertTo-Json -Compress" + result = subprocess.run(['powershell', '-NoProfile', '-Command', ps_script], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5) + if result.returncode == 0 and result.stdout.strip(): + payload = json.loads(result.stdout) + proc_rows = payload if isinstance(payload, list) else ([payload] if payload else []) + rows = [{'ProcessId': r.get('Id'), 'ExecutablePath': r.get('Path'), 'CommandLine': ''} for r in proc_rows] + for row in rows: + if not row: + continue + pid = int(row.get('ProcessId') or 0) + command_line = str(row.get('CommandLine') or '').lower() + executable = os.path.normcase(os.path.abspath(str(row.get('ExecutablePath') or ''))) if row.get('ExecutablePath') else '' + path_matches = bool(expected_binary and executable == expected_binary) + command_matches = command_name in command_line and all(marker in command_line for marker in markers) + if pid and pid != os.getpid() and (path_matches or command_matches) and pid_is_running(pid): + pids.append(pid) + if not pids and os.path.exists(expected_binary): + result = subprocess.run(['tasklist', '/FI', f'IMAGENAME eq {command_name}', '/FO', 'CSV', '/NH'], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5) + for line in result.stdout.splitlines(): + match = re.match(r'"[^"]+","(\d+)"', line.strip()) + if match: + pid = int(match.group(1)) + if pid != os.getpid() and pid_is_running(pid): + pids.append(pid) + return list(dict.fromkeys(pids)) + else: + result = subprocess.run( + ['ps', '-eo', 'pid=,args='], + capture_output=True, + text=True, + encoding='utf-8', + errors='replace', + timeout=5, + ) + lines = result.stdout.splitlines() + + for line in lines: + text = line.strip() + lowered = text.lower() + if command_name not in lowered or not all(marker in lowered for marker in markers): + continue + pid_match = re.match(r'\s*(\d+)\s+', text) + if pid_match: + pid = int(pid_match.group(1)) + if pid != os.getpid() and pid_is_running(pid): + pids.append(pid) + except Exception as e: + logger.debug(f"Failed to scan running {provider} tunnel process: {e}") + pids.extend(find_running_tunnel_pids_in_proc(provider, expected_binary)) + return list(dict.fromkeys(pids)) + + +def find_running_tunnel_pid(provider: str): + pids = find_running_tunnel_pids(provider) + return pids[0] if pids else None + + +def kill_tunnel_processes(provider: str, binary_path: str = '', include_all_by_name: bool = False): + pids = find_running_tunnel_pids(provider, binary_path) + for pid in pids: + kill_pid(pid) + if os.name == 'nt' and include_all_by_name: + subprocess.run(['taskkill', '/IM', get_tunnel_command_name(provider), '/T', '/F'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) + return pids + + +def get_tunnel_download(provider: str): + system = platform.system().lower() + machine = platform.machine().lower() + + if provider == 'cloudflare': + if system == 'windows': + arch = 'amd64' if machine in ('amd64', 'x86_64') else '386' + return f'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-{arch}.exe', 'exe' + if system == 'darwin': + arch = 'arm64' if 'arm' in machine else 'amd64' + return f'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-{arch}.tgz', 'tgz' + arch = 'arm64' if 'aarch64' in machine or 'arm64' in machine else 'amd64' + return f'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-{arch}', 'bin' + + if provider == 'ngrok': + if system == 'windows': + os_part = 'windows' + ext = 'zip' + elif system == 'darwin': + os_part = 'darwin' + ext = 'zip' + else: + os_part = 'linux' + ext = 'tgz' + arch = 'arm64' if 'aarch64' in machine or 'arm64' in machine else 'amd64' + return f'https://bin.equinox.io/c/bNyj1mQVY4c/ngrok-v3-stable-{os_part}-{arch}.{ext}', ext + + raise ValueError('Unsupported tunnel provider') + + +def install_tunnel_binary(provider: str): + if is_tunnel_installed(provider): + return find_tunnel_binary(provider) + + os.makedirs(BIN_DIR, exist_ok=True) + url, archive_type = get_tunnel_download(provider) + target = get_tunnel_binary_path(provider) + tmp_path = os.path.join(BIN_DIR, f'{provider}.download') + logger.info(f"Downloading {provider} tunnel binary from {url}") + urllib.request.urlretrieve(url, tmp_path) + + if archive_type in ('bin', 'exe'): + shutil.move(tmp_path, target) + elif archive_type == 'zip': + with zipfile.ZipFile(tmp_path) as zf: + member = next((m for m in zf.namelist() if os.path.basename(m).lower() == get_tunnel_command_name(provider).lower()), None) + if not member: + raise RuntimeError(f'{provider} binary not found in downloaded archive') + with zf.open(member) as src, open(target, 'wb') as dst: + shutil.copyfileobj(src, dst) + os.remove(tmp_path) + elif archive_type == 'tgz': + with tarfile.open(tmp_path, 'r:gz') as tf: + member = next((m for m in tf.getmembers() if os.path.basename(m.name).lower() == get_tunnel_command_name(provider).lower()), None) + if not member: + raise RuntimeError(f'{provider} binary not found in downloaded archive') + extracted = tf.extractfile(member) + if not extracted: + raise RuntimeError(f'Cannot extract {provider} binary') + with extracted as src, open(target, 'wb') as dst: + shutil.copyfileobj(src, dst) + os.remove(tmp_path) + else: + raise RuntimeError(f'Unsupported download format: {archive_type}') + + if os.name != 'nt': + os.chmod(target, 0o755) + return target + + +def get_tunnel_public_urls(provider: str, runtime: TunnelRuntime): + urls = [] + if runtime.public_url: + urls.append(runtime.public_url) + state_url = load_tunnel_state().get(provider, {}).get('public_url') + if state_url: + urls.append(state_url) + if provider == 'ngrok' and runtime.process and runtime.process.poll() is None: + try: + with urllib.request.urlopen('http://127.0.0.1:4040/api/tunnels', timeout=1) as resp: + payload = json.loads(resp.read().decode('utf-8')) + for item in payload.get('tunnels', []): + public_url = item.get('public_url') + if public_url and public_url.startswith('https://'): + urls.append(public_url) + except Exception: + pass + return list(dict.fromkeys(urls)) + + +def watch_tunnel_output(provider: str): + runtime = TUNNEL_RUNTIMES[provider] + process = runtime.process + if not process or not process.stdout: + return + try: + for line in iter(process.stdout.readline, ''): + if not line: + break + text = line.strip() + with TUNNEL_LOCK: + runtime.output.append(text) + runtime.output = runtime.output[-60:] + match = TUNNEL_URL_RE.search(text) + if match: + runtime.public_url = match.group(0).rstrip('.,') + update_tunnel_state(provider, public_url=runtime.public_url) + except Exception as e: + with TUNNEL_LOCK: + runtime.last_error = str(e) + + +def build_tunnel_command(provider: str, binary: str, local_url: str, authtoken: str = ''): + if provider == 'cloudflare': + return [binary, 'tunnel', '--url', local_url, '--no-autoupdate'] + if provider == 'ngrok': + cmd = [binary, 'http', local_url, '--log=stdout'] + return cmd + raise ValueError('Unsupported tunnel provider') + + +def start_tunnel(provider: str, local_url: str, authtoken: str = ''): + binary = find_tunnel_binary(provider) + if not binary: + raise RuntimeError(f'{provider} is not installed') + + runtime = TUNNEL_RUNTIMES[provider] + with TUNNEL_LOCK: + if runtime.process and runtime.process.poll() is None: + return runtime + runtime.public_url = '' + runtime.last_error = '' + runtime.output = [] + runtime.started_at = datetime.now().isoformat() + + creationflags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0 + env = os.environ.copy() + if provider == 'ngrok' and authtoken: + env['NGROK_AUTHTOKEN'] = authtoken + + runtime.process = subprocess.Popen( + build_tunnel_command(provider, binary, local_url, authtoken), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding='utf-8', + errors='replace', + cwd=application_path, + env=env, + creationflags=creationflags, + ) + runtime.pid = runtime.process.pid + update_tunnel_state( + provider, + pid=runtime.pid, + public_url='', + started_at=runtime.started_at, + binary=os.path.abspath(binary), + ) + + threading.Thread(target=watch_tunnel_output, args=(provider,), daemon=True).start() + return runtime + + +def stop_tunnel(provider: str): + runtime = TUNNEL_RUNTIMES[provider] + process = runtime.process + state = load_tunnel_state().get(provider, {}) + pids_to_stop = [] + if process and process.poll() is None: + pids_to_stop.append(process.pid) + try: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + except Exception as e: + with TUNNEL_LOCK: + runtime.last_error = str(e) + raise + if state.get('pid') and pid_is_running(state.get('pid')): + pids_to_stop.append(state.get('pid')) + pids_to_stop.extend(find_running_tunnel_pids(provider, state.get('binary') or get_tunnel_binary_path(provider))) + for pid in dict.fromkeys(pids_to_stop): + if pid_is_running(pid): + kill_pid(pid) + + with TUNNEL_LOCK: + runtime.process = None + runtime.pid = None + runtime.public_url = '' + runtime.started_at = None + runtime.last_error = '' + clear_tunnel_state(provider) + return runtime + + +def delete_tunnel_binary(provider: str): + stop_tunnel(provider) + bundled = get_tunnel_binary_path(provider) + if os.path.exists(bundled): + wait_for_path_release(bundled, seconds=8) + try: + os.remove(bundled) + except PermissionError: + killed = kill_tunnel_processes(provider, bundled, include_all_by_name=True) + if killed: + wait_for_path_release(bundled, seconds=8) + try: + os.remove(bundled) + except PermissionError: + raise RuntimeError('Cannot delete tunnel binary because Windows still keeps it locked. All detected cloudflared/ngrok processes were stopped; close any antivirus scan, terminal, or external process using the file and try again.') + elif shutil.which(get_tunnel_command_name(provider)): + raise RuntimeError('This tunnel binary is installed system-wide. Remove it from PATH manually or use the panel-managed installation.') + + tmp_path = os.path.join(BIN_DIR, f'{provider}.download') + if os.path.exists(tmp_path): + os.remove(tmp_path) + + runtime = TUNNEL_RUNTIMES[provider] + with TUNNEL_LOCK: + runtime.output = [] + clear_tunnel_state(provider) + return runtime + + +def get_tunnel_status(provider: str): + runtime = TUNNEL_RUNTIMES[provider] + state = load_tunnel_state().get(provider, {}) + running = bool(runtime.process and runtime.process.poll() is None) + if not running and state.get('pid'): + running = pid_is_running(state.get('pid')) + if running: + runtime.pid = state.get('pid') + runtime.started_at = runtime.started_at or state.get('started_at') + runtime.public_url = runtime.public_url or state.get('public_url', '') + else: + clear_tunnel_state(provider) + state = {} + if not running and is_tunnel_installed(provider): + found_pid = find_running_tunnel_pid(provider) + if found_pid: + running = True + runtime.pid = found_pid + runtime.started_at = runtime.started_at or datetime.now().isoformat() + update_tunnel_state( + provider, + pid=found_pid, + public_url=runtime.public_url, + started_at=runtime.started_at, + binary=os.path.abspath(find_tunnel_binary(provider) or ''), + ) + state = load_tunnel_state().get(provider, {}) + if runtime.process and not running and not runtime.last_error and runtime.output: + runtime.last_error = runtime.output[-1] + urls = get_tunnel_public_urls(provider, runtime) + return { + 'installed': is_tunnel_installed(provider), + 'running': running, + 'public_url': urls[0] if urls else '', + 'public_urls': urls, + 'last_error': runtime.last_error, + 'started_at': runtime.started_at or state.get('started_at'), + } + + +async def wait_for_tunnel_url(provider: str, seconds: int = 20): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + status = get_tunnel_status(provider) + if status.get('public_url') or not status.get('running'): + return status + await asyncio.sleep(0.5) + return get_tunnel_status(provider) + + +def get_protocol_manager(ssh, protocol: str): + if protocol == 'xray': + from managers.xray_manager import XrayManager + return XrayManager(ssh) + elif protocol == 'telemt': + from managers.telemt_manager import TelemtManager + return TelemtManager(ssh) + elif protocol == 'dns': + from managers.dns_manager import DNSManager + return DNSManager(ssh) + elif protocol == 'wireguard': + from managers.wireguard_manager import WireGuardManager + return WireGuardManager(ssh) + elif protocol == 'socks5': + from managers.socks5_manager import Socks5Manager + return Socks5Manager(ssh) + elif protocol == 'adguard': + from managers.adguard_manager import AdguardManager + return AdguardManager(ssh) + elif protocol == 'revproxy': + from managers.revproxy_manager import RevproxyManager + return RevproxyManager(ssh) + from managers.awg_manager import AWGManager + return AWGManager(ssh) + + +def _manager_call(manager, method, protocol, *args, **kwargs): + """Unified call: WireGuard manager methods don't take protocol_type argument.""" + fn = getattr(manager, method) + if isinstance(manager, WireGuardManager): + return fn(*args, **kwargs) + return fn(protocol, *args, **kwargs) + + +def generate_vpn_link(config_text): + b64 = base64.b64encode(config_text.strip().encode('utf-8')).decode('utf-8') + return f"vpn://{b64}" + + +# ===================== API tokens ===================== + +API_TOKEN_PREFIX = 'awp_' # "Amnezia Web Panel" — makes tokens visually distinct in logs / configs +API_TOKEN_TOUCH_INTERVAL = 300 # don't re-write data.json more than once per 5 min per token + + +def _hash_api_token(raw: str) -> str: + """One-way hash of a raw token. We never store the original token — only the + SHA-256 digest, plus a short prefix for the UI to identify rotations.""" + return hashlib.sha256(raw.encode('utf-8')).hexdigest() + + +def _generate_api_token() -> str: + """Generate a fresh bearer token. ~256 bits of entropy with a recognizable + 'awp_' prefix so leaked tokens are obvious in source control / pastes.""" + return f"{API_TOKEN_PREFIX}{secrets.token_urlsafe(32)}" + + +def _resolve_api_token(data: dict, raw_token: str): + """Match a raw bearer token against stored hashes. Returns the user record + that owns the token, or None if the token is unknown / its owner is gone / + its owner is no longer admin-or-support.""" + if not raw_token: + return None + token_hash = _hash_api_token(raw_token) + entry = next( + (t for t in data.get('api_tokens', []) if t.get('token_hash') == token_hash), + None, + ) + if not entry: + return None + user = next((u for u in data.get('users', []) if u['id'] == entry.get('user_id')), None) + if not user: + return None + # Disabled or downgraded admins should not have a working token any more. + if not user.get('enabled', True): + return None + if user.get('role') not in ('admin', 'support'): + return None + return (entry, user) + + +def _touch_api_token(token_entry: dict) -> bool: + """Update last_used_at on a token entry, but only if enough time has passed + since the previous touch — avoids hot-write loops under load. Returns True + if the entry was updated and the caller should persist data.""" + now = datetime.now() + last = token_entry.get('last_used_at') + if last: + try: + prev = datetime.fromisoformat(last) + if (now - prev).total_seconds() < API_TOKEN_TOUCH_INTERVAL: + return False + except Exception: + pass + token_entry['last_used_at'] = now.isoformat() + return True + + +def hash_password(password: str) -> str: + salt = secrets.token_hex(16) + h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000) + return f"{salt}${h.hex()}" + + +def verify_password(password: str, password_hash: str) -> bool: + try: + salt, h = password_hash.split('$', 1) + new_h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000) + return new_h.hex() == h + except Exception: + return False + + +async def perform_delete_user(data: dict, user_id: str): + user = next((u for u in data['users'] if u['id'] == user_id), None) + if not user: + return False + # Remove user's connections from servers + user_conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id] + for uc in user_conns: + try: + sid = uc['server_id'] + if sid < len(data['servers']): + server = data['servers'][sid] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, uc['protocol']) + _manager_call(manager, 'remove_client', uc['protocol'], uc['client_id']) + ssh.disconnect() + except Exception as e: + logger.warning(f"Failed to remove connection {uc['client_id']} during user delete: {e}") + data['user_connections'] = [c for c in data.get('user_connections', []) if c['user_id'] != user_id] + data['users'] = [u for u in data['users'] if u['id'] != user_id] + return True + + +async def perform_toggle_user(data: dict, user_id: str, enable: bool) -> bool: + """Enable or disable a user and propagate the change to all their VPN connections.""" + user = next((u for u in data['users'] if u['id'] == user_id), None) + if not user: + return False + + user['enabled'] = enable + + user_conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id] + for uc in user_conns: + try: + sid = uc['server_id'] + if sid >= len(data['servers']): + continue + server = data['servers'][sid] + ssh = get_ssh(server) + await asyncio.to_thread(ssh.connect) + manager = get_protocol_manager(ssh, uc['protocol']) + await asyncio.to_thread( + _manager_call, manager, 'toggle_client', uc['protocol'], uc['client_id'], enable + ) + await asyncio.to_thread(ssh.disconnect) + except Exception as e: + logger.warning(f"Failed to toggle connection {uc['client_id']} during user toggle: {e}") + + return True + + +async def perform_mass_operations(delete_uids: List[str] = None, toggle_uids: List[tuple] = None, create_conns: List[dict] = None): + """ + Executes multiple SSH operations efficiently. + Reloads data inside to ensure we don't overwrite other changes. + """ + data = load_data() + server_ops = {} + + def get_ops(sid): + if sid not in server_ops: + server_ops[sid] = {'delete': [], 'toggle': [], 'create': []} + return server_ops[sid] + + if delete_uids: + for uid in delete_uids: + conns = [c for c in data.get('user_connections', []) if c['user_id'] == uid] + for c in conns: get_ops(c['server_id'])['delete'].append(c) + + if toggle_uids: + for uid, enabled in toggle_uids: + conns = [c for c in data.get('user_connections', []) if c['user_id'] == uid] + for c in conns: get_ops(c['server_id'])['toggle'].append((c, enabled)) + + if create_conns: + for req in create_conns: get_ops(req['server_id'])['create'].append(req) + + async def run_server_ops(srv_id, ops): + # We re-load data inside to be absolutely sure about current state + # but for performance we'll use the passed srv_id + current_data = load_data() + if srv_id >= len(current_data['servers']): return + srv = current_data['servers'][srv_id] + + try: + ssh = get_ssh(srv) + await asyncio.to_thread(ssh.connect) + + # 1. Deletes + for c in ops['delete']: + manager = get_protocol_manager(ssh, c['protocol']) + await asyncio.to_thread(_manager_call, manager, 'remove_client', c['protocol'], c['client_id']) + # Incremental delete from data + async with DATA_LOCK: + current_data = load_data() + current_data['user_connections'] = [conn for conn in current_data['user_connections'] if conn['id'] != c['id']] + save_data(current_data) + + # 2. Toggles + for c, enabled in ops['toggle']: + manager = get_protocol_manager(ssh, c['protocol']) + await asyncio.to_thread(_manager_call, manager, 'toggle_client', c['protocol'], c['client_id'], enabled) + # Incremental toggle in data + async with DATA_LOCK: + current_data = load_data() + # We also need to update user status if it was a user toggle + # Wait, mass ops caller usually handles user enabled status. + # Here we just toggle the actual wireguard peer. + save_data(current_data) + + # 3. Creates + for c_req in ops['create']: + proto_info = srv.get('protocols', {}).get(c_req['protocol'], {}) + port = proto_info.get('port', '55424') + manager = get_protocol_manager(ssh, c_req['protocol']) + + if c_req['protocol'] == 'wireguard': + res = await asyncio.to_thread(manager.add_client, c_req['name'], srv['host']) + else: + res = await asyncio.to_thread(_manager_call, manager, 'add_client', c_req['protocol'], c_req['name'], srv['host'], port) + + if res.get('client_id'): + new_conn = { + 'id': str(uuid.uuid4()), + 'user_id': c_req['user_id'], + 'server_id': srv_id, + 'protocol': c_req['protocol'], + 'client_id': res['client_id'], + 'name': c_req['name'], + 'created_at': datetime.now().isoformat(), + } + async with DATA_LOCK: + current_data = load_data() + current_data['user_connections'].append(new_conn) + save_data(current_data) + + await asyncio.to_thread(ssh.disconnect) + except Exception as e: + logger.error(f"Mass ops failed for server {srv_id}: {e}") + + # Run all servers in parallel + tasks = [run_server_ops(sid, ops) for sid, ops in server_ops.items()] + if tasks: + await asyncio.gather(*tasks) + + # 4. Final user-level cleanup (delete/toggle users metadata) + async with DATA_LOCK: + current_data = load_data() + if delete_uids: + current_data['users'] = [u for u in current_data['users'] if u['id'] not in delete_uids] + current_data['user_connections'] = [c for c in current_data.get('user_connections', []) if c['user_id'] not in delete_uids] + if toggle_uids: + for uid, enabled in toggle_uids: + user = next((u for u in current_data['users'] if u['id'] == uid), None) + if user: user['enabled'] = enabled + save_data(current_data) + + return True + + +async def sync_users_with_remnawave(data: dict): + settings = data.get('settings', {}).get('sync', {}) + if not settings.get('remnawave_sync_users'): + return 0, "Synchronization is disabled in settings" + + url = settings.get('remnawave_url') + api_key = settings.get('remnawave_api_key') + if not url or not api_key: + return 0, "Remnawave URL or API Key not configured" + + api_url = url.rstrip('/') + '/api/users' + headers = {"Authorization": f"Bearer {api_key}"} + + try: + rw_users = [] + async with httpx.AsyncClient(timeout=30.0) as client: + page_size = 50 # Use a smaller page size that is more likely to be accepted + current_start = 0 + while True: + resp = await client.get(f"{api_url}?size={page_size}&start={current_start}", headers=headers) + if resp.status_code != 200: + return 0, f"Remnawave API error: {resp.status_code} {resp.text}" + + page_data = resp.json() + response_obj = page_data.get('response', {}) + page_users = response_obj.get('users', []) + total_count = response_obj.get('total', 0) + + if not page_users: + break + + rw_users.extend(page_users) + logger.info(f"Fetched {len(rw_users)} / {total_count} users from Remnawave...") + + if len(rw_users) >= total_count or len(page_users) == 0: + break + + current_start += len(page_users) + + rw_uuids = {u['uuid'] for u in rw_users} + + # 1. Handle deletion (users that have remnawave_uuid but are no longer in Remnawave) + to_delete_ids = [] + for u in data['users']: + if u.get('remnawave_uuid') and u['remnawave_uuid'] not in rw_uuids: + to_delete_ids.append(u['id']) + + if to_delete_ids: + logger.info(f"Removing {len(to_delete_ids)} users deleted in Remnawave") + await perform_mass_operations(delete_uids=to_delete_ids) + + # 2. Sync / Create users + synced_count = 0 + to_toggle = [] # list of (user_id, enabled) + to_create_conns = [] # list of dicts + + for rw_u in rw_users: + # We reload data in each loop step to handle concurrency + data = load_data() + local_u = next((u for u in data['users'] if u.get('remnawave_uuid') == rw_u['uuid']), None) + if not local_u: + local_u = next((u for u in data['users'] if u['username'] == rw_u['username']), None) + + is_active = (rw_u.get('status') == 'ACTIVE') + + if local_u: + local_u['username'] = rw_u['username'] + local_u['telegramId'] = rw_u.get('telegramId') + local_u['email'] = rw_u.get('email') + local_u['description'] = rw_u.get('description') + local_u['remnawave_uuid'] = rw_u['uuid'] + + if local_u.get('enabled', True) != is_active: + to_toggle.append((local_u['id'], is_active)) + + # Save metadata immediately + async with DATA_LOCK: + current = load_data() + # Update index + idx = next((i for i, u in enumerate(current['users']) if u['id'] == local_u['id']), -1) + if idx != -1: + current['users'][idx] = local_u + save_data(current) + + synced_count += 1 + else: + new_id = str(uuid.uuid4()) + new_user = { + 'id': new_id, + 'username': rw_u['username'], + 'password_hash': '', + 'role': 'user', + 'telegramId': rw_u.get('telegramId'), + 'email': rw_u.get('email'), + 'description': rw_u.get('description'), + 'enabled': is_active, + 'created_at': datetime.now().isoformat(), + 'remnawave_uuid': rw_u['uuid'], + 'share_enabled': False, + 'share_token': secrets.token_urlsafe(16), + 'share_password_hash': None, + } + async with DATA_LOCK: + current = load_data() + current['users'].append(new_user) + save_data(current) + + if settings.get('remnawave_create_conns'): + sid = settings.get('remnawave_server_id') + if sid is not None: + to_create_conns.append({ + 'user_id': new_id, + 'server_id': sid, + 'protocol': settings.get('remnawave_protocol', 'awg'), + 'name': f"{rw_u['username']}_vpn" + }) + synced_count += 1 + + # Execute all collected mass operations + if to_toggle or to_create_conns: + logger.info(f"Executing mass ops for Remnawave sync: toggle={len(to_toggle)}, create={len(to_create_conns)}") + await perform_mass_operations(toggle_uids=to_toggle, create_conns=to_create_conns) + + return synced_count, "Successfully synchronized with Remnawave" + + except Exception as e: + logger.exception("Synchronization error") + return 0, f"Error: {str(e)}" + + +def get_current_user(request: Request): + user_id = request.session.get('user_id') + if not user_id: + return None + data = load_data() + for u in data.get('users', []): + if u['id'] == user_id: + return u + return None + + +def tpl(request, template, **kwargs): + data = load_data() + lang = request.cookies.get('lang', 'en') + ctx = { + 'request': request, + 'current_user': get_current_user(request), + 'site_settings': data.get('settings', {}).get('appearance', {}), + 'captcha_settings': data.get('settings', {}).get('captcha', {}), + 'telegram_settings': data.get('settings', {}).get('telegram', {}), + 'bot_running': tg_bot.is_running(), + 'lang': lang, + '_': lambda text_id: _t(text_id, lang), + 'translations_json': json.dumps(TRANSLATIONS.get(lang, TRANSLATIONS.get('en', {}))), + 'all_translations_json': json.dumps(TRANSLATIONS) + } + ctx.update(kwargs) + return templates.TemplateResponse(template, ctx) + + +# ======================== Pydantic Models ======================== + +class LoginRequest(BaseModel): + username: str + password: str + captcha: Optional[str] = None + + +class AddServerRequest(BaseModel): + host: str = '' + ssh_port: int = 22 + username: str = '' + password: str = '' + private_key: str = '' + name: str = '' + + +class EditServerRequest(BaseModel): + name: str = '' + host: str = '' + ssh_port: int = 22 + username: str = '' + # Optional[str] = None lets the client distinguish "leave field as is" + # (omit / null) from "explicitly clear" (empty string). Both credential + # fields can be omitted to keep current auth unchanged. + password: Optional[str] = None + private_key: Optional[str] = None + + +class ReorderServersRequest(BaseModel): + # `order[i]` is the *old* server index now at position `i` in the new layout. + order: List[int] + + +class InstallProtocolRequest(BaseModel): + protocol: str = 'awg' + port: str = '55424' + tls_emulation: Optional[bool] = None + tls_domain: Optional[str] = None + max_connections: Optional[int] = None + # SOCKS5 + socks5_username: Optional[str] = None + socks5_password: Optional[str] = None + # AdGuard Home + adguard_mode: Optional[str] = None # 'replace' or 'sidebyside' + adguard_web_port: Optional[int] = None + adguard_expose_web: Optional[bool] = None + adguard_dot_port: Optional[int] = None + adguard_doh_port: Optional[int] = None + adguard_expose_dns: Optional[bool] = None + adguard_expose_dot: Optional[bool] = None + adguard_expose_doh: Optional[bool] = None + # Reverse Proxy (Caddy) + revproxy_domain: Optional[str] = None + revproxy_site_title: Optional[str] = None + revproxy_backend: Optional[str] = None # none, xray, telemt + revproxy_backend_port: Optional[int] = None + revproxy_vpn_path: Optional[str] = None + revproxy_tls_email: Optional[str] = None + revproxy_telemt_mask: Optional[bool] = None + + +class Socks5SettingsRequest(BaseModel): + port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = None + + +class RevproxySettingsRequest(BaseModel): + domain: Optional[str] = None + site_title: Optional[str] = None + backend: Optional[str] = None + backend_port: Optional[int] = None + vpn_path: Optional[str] = None + tls_email: Optional[str] = None + telemt_mask: Optional[bool] = None + + +class ProtocolRequest(BaseModel): + protocol: str = 'awg' + + +class AddConnectionRequest(BaseModel): + protocol: str = 'awg' + name: str = 'Connection' + user_id: Optional[str] = None + telemt_quota: Optional[str] = None + telemt_max_ips: Optional[int] = None + telemt_expiry: Optional[str] = None + telemt_secret: Optional[str] = None + telemt_ad_tag: Optional[str] = None + telemt_max_conns: Optional[int] = None + + +class EditConnectionRequest(BaseModel): + protocol: str = 'telemt' + client_id: str = '' + telemt_quota: Optional[str] = None + telemt_max_ips: Optional[int] = None + telemt_expiry: Optional[str] = None + telemt_secret: Optional[str] = None + telemt_ad_tag: Optional[str] = None + telemt_max_conns: Optional[int] = None + + +class ConnectionActionRequest(BaseModel): + protocol: str = 'awg' + client_id: str = '' + + +class ToggleConnectionRequest(BaseModel): + protocol: str = 'awg' + client_id: str = '' + enable: bool = True + + +class AddUserRequest(BaseModel): + username: str + password: str + role: str = 'user' + telegramId: Optional[str] = None + email: Optional[str] = None + description: Optional[str] = None + traffic_limit: Optional[float] = 0 + traffic_reset_strategy: Optional[str] = 'never' + server_id: Optional[int] = None + protocol: Optional[str] = None + connection_name: Optional[str] = None + expiration_date: Optional[str] = None + telemt_quota: Optional[str] = None + telemt_max_ips: Optional[int] = None + telemt_expiry: Optional[str] = None + telemt_secret: Optional[str] = None + telemt_ad_tag: Optional[str] = None + telemt_max_conns: Optional[int] = None + + + +class ServerConfigSaveRequest(BaseModel): + protocol: str + config: str + + +class AppearanceSettings(BaseModel): + title: str = 'Amnezia' + logo: str = '🛡' + subtitle: str = 'Web Panel' + + +class SyncSettings(BaseModel): + remnawave_url: str = '' + remnawave_api_key: str = '' + remnawave_sync: bool = False + remnawave_sync_users: bool = False + remnawave_create_conns: bool = False + remnawave_server_id: int = 0 + remnawave_protocol: str = 'awg' + +class CaptchaSettings(BaseModel): + enabled: bool = False + + +class SSLSettings(BaseModel): + enabled: bool = False + domain: str = '' + cert_path: str = '' + key_path: str = '' + cert_text: str = '' + key_text: str = '' + panel_port: int = 5000 + # Certificate provider: 'manual' (paths/text) or 'cloudflare' (auto ACME dns-01) + provider: str = 'manual' + cf_token: str = '' + acme_email: str = '' + acme_staging: bool = False + + +class CloudflareIssueRequest(BaseModel): + domain: str = '' + cf_token: str = '' + acme_email: str = '' + acme_staging: bool = False + +class TelegramSettings(BaseModel): + token: str = '' + enabled: bool = False + + + + +class UpdateUserRequest(BaseModel): + telegramId: Optional[str] = None + email: Optional[str] = None + description: Optional[str] = None + traffic_limit: Optional[float] = 0 + traffic_reset_strategy: Optional[str] = None + expiration_date: Optional[str] = None + password: Optional[str] = None + + + +class SaveSettingsRequest(BaseModel): + appearance: AppearanceSettings + sync: SyncSettings + captcha: CaptchaSettings + telegram: TelegramSettings + ssl: SSLSettings + + +class ToggleUserRequest(BaseModel): + enabled: bool + + +class AddUserConnectionRequest(BaseModel): + server_id: int + protocol: str = 'awg' + name: str = 'VPN Connection' + client_id: Optional[str] = None + telemt_quota: Optional[str] = None + telemt_max_ips: Optional[int] = None + telemt_expiry: Optional[str] = None + telemt_secret: Optional[str] = None + telemt_ad_tag: Optional[str] = None + telemt_max_conns: Optional[int] = None + + +class CreateApiTokenRequest(BaseModel): + name: str + + +class ShareSetupRequest(BaseModel): + enabled: bool + password: Optional[str] = None + + +class ShareAuthRequest(BaseModel): + password: str + + +class TunnelStartRequest(BaseModel): + authtoken: Optional[str] = None + + +# ======================== Startup ======================== + +@app.on_event("startup") +async def startup(): + data = load_data() + changed = False + if not data.get('users'): + data['users'] = [{ + 'id': str(uuid.uuid4()), + 'username': 'admin', + 'password_hash': hash_password('admin'), + 'role': 'admin', + 'enabled': True, + 'created_at': datetime.now().isoformat(), + }] + changed = True + logger.info("Default admin created (admin / admin)") + + # Migration for sharing fields and traffic reset strategy + for u in data['users']: + migrated = False + if 'share_enabled' not in u: + u['share_enabled'] = False + migrated = True + if not u.get('share_token'): + u['share_token'] = secrets.token_urlsafe(16) + migrated = True + if 'share_password_hash' not in u: + u['share_password_hash'] = None + migrated = True + + # Traffic reset strategy and total traffic + if 'traffic_reset_strategy' not in u: + u['traffic_reset_strategy'] = 'never' + migrated = True + if 'traffic_total' not in u: + u['traffic_total'] = u.get('traffic_used', 0) + migrated = True + if 'last_reset_at' not in u: + u['last_reset_at'] = datetime.now().isoformat() + migrated = True + if 'expiration_date' not in u: + u['expiration_date'] = None + migrated = True + + if migrated: + changed = True + logger.info(f"Migrated user {u['username']} to new traffic/sharing fields") + + # API tokens collection — initialise lazily on first run. + if 'api_tokens' not in data: + data['api_tokens'] = [] + changed = True + logger.info("Initialised empty api_tokens collection") + + # SSL settings migration + if 'ssl' not in data.get('settings', {}): + if 'settings' not in data: data['settings'] = {} + data['settings']['ssl'] = { + 'enabled': False, + 'domain': '', + 'cert_path': '', + 'key_path': '', + 'cert_text': '', + 'key_text': '', + 'panel_port': 5000, + 'provider': 'manual', + 'cf_token': '', + 'acme_email': '', + 'acme_staging': False, + } + changed = True + logger.info("Migrated SSL settings") + else: + # Backfill Cloudflare fields on older installs. + ssl_block = data['settings']['ssl'] + for key, default in (('provider', 'manual'), ('cf_token', ''), + ('acme_email', ''), ('acme_staging', False)): + if key not in ssl_block: + ssl_block[key] = default + changed = True + + if changed: + save_data(data) + + # Start periodic background tasks + asyncio.create_task(periodic_background_tasks()) + + # Start Telegram bot if enabled + tg_cfg = data.get('settings', {}).get('telegram', {}) + if tg_cfg.get('enabled') and tg_cfg.get('token'): + logger.info("Starting Telegram bot from saved settings...") + tg_bot.launch_bot(tg_cfg['token'], load_data, generate_vpn_link) + + +def _scrape_server_traffic(server, sid, my_conns): + server_updates = [] + try: + ssh = get_ssh(server) + ssh.connect() + for proto in ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'wireguard']: + if proto in server.get('protocols', {}): + manager = get_protocol_manager(ssh, proto) + clients = _manager_call(manager, 'get_clients', proto) + client_bytes = {} + for c in clients: + rx = c.get('userData', {}).get('dataReceivedBytes', 0) + tx = c.get('userData', {}).get('dataSentBytes', 0) + client_bytes[c.get('clientId')] = rx + tx + + for uc in my_conns: + if uc['protocol'] == proto and uc['client_id'] in client_bytes: + curr_bytes = client_bytes[uc['client_id']] + last_bytes = uc.get('last_bytes', 0) + delta = curr_bytes - last_bytes if curr_bytes >= last_bytes else curr_bytes + server_updates.append((uc['id'], delta, curr_bytes)) + ssh.disconnect() + except Exception as e: + logger.error(f"Traffic sync err server {sid}: {e}") + return server_updates + + +async def periodic_background_tasks(): + """Background task to sync traffic limits and Remnawave every 10 minutes""" + while True: + try: + # We wait before the first sync to let the app settle + await asyncio.sleep(60) + + # --- 1. TRAFFIC SYNC & LIMITS --- + logger.info("Starting background traffic sync...") + data = load_data() + + conns_by_server = {} + for uc in data.get('user_connections', []): + sid = uc['server_id'] + conns_by_server.setdefault(sid, []).append(uc) + + updates = [] + + for sid, server in enumerate(data.get('servers', [])): + if sid not in conns_by_server: continue + + # Run the blocking SSH traffic scraping in a background thread! + server_updates = await asyncio.to_thread(_scrape_server_traffic, server, sid, conns_by_server[sid]) + if server_updates: + updates.extend(server_updates) + + to_disable_uids = [] + if updates: + async with DATA_LOCK: + curr_data = load_data() + users_map = {u['id']: u for u in curr_data.get('users', [])} + uc_list = curr_data.get('user_connections', []) + uc_map = {uc['id']: uc for uc in uc_list} + + # Current date/time for reset checking + now = datetime.now() + + for uc_id, delta, curr_bytes in updates: + if uc_id in uc_map: + uc_map[uc_id]['last_bytes'] = curr_bytes + uid = uc_map[uc_id]['user_id'] + if uid in users_map: + u = users_map[uid] + # Check if reset is needed BEFORE adding new consumption + strategy = u.get('traffic_reset_strategy', 'never') + last_reset_iso = u.get('last_reset_at') + + reset_needed = False + if strategy != 'never' and last_reset_iso: + try: + last = datetime.fromisoformat(last_reset_iso) + if strategy == 'daily': + reset_needed = now.date() > last.date() + elif strategy == 'weekly': + reset_needed = now.isocalendar()[1] != last.isocalendar()[1] or now.year != last.year + elif strategy == 'monthly': + reset_needed = now.month != last.month or now.year != last.year + except: + pass + + if reset_needed: + logger.info(f"Resetting traffic for user {u['username']} (strategy: {strategy})") + u['traffic_used'] = 0 + u['last_reset_at'] = now.isoformat() + + # Update both resettable and total traffic + u['traffic_used'] = u.get('traffic_used', 0) + delta + u['traffic_total'] = u.get('traffic_total', 0) + delta + + limit = u.get('traffic_limit', 0) + if limit > 0 and u['traffic_used'] >= limit and u.get('enabled', True): + if uid not in to_disable_uids: + to_disable_uids.append(uid) + + # Check expiration date + exp_str = u.get('expiration_date') + if exp_str and u.get('enabled', True): + try: + exp_date = datetime.fromisoformat(exp_str) + if now > exp_date: + logger.info(f"Subscription expired for user {u['username']} (expired at {exp_str})") + if uid not in to_disable_uids: + to_disable_uids.append(uid) + except: + pass + save_data(curr_data) + + if to_disable_uids: + logger.info(f"Traffic limit reached, disabling users: {to_disable_uids}") + await perform_mass_operations(toggle_uids=[(uid, False) for uid in to_disable_uids]) + + # --- 2. REMNAWAVE SYNC --- + logger.info("Starting background Remnawave sync...") + data = load_data() + if data.get('settings', {}).get('sync', {}).get('remnawave_sync_users'): + count, msg = await sync_users_with_remnawave(data) + logger.info(f"Background Remnawave sync finished: {count} users updated. {msg}") + else: + logger.info("Background Remnawave sync skipped (disabled in settings)") + + except Exception as e: + logger.error(f"Error in periodic_background_tasks: {e}") + + # Wait 10 minutes before next sync + await asyncio.sleep(600) + + +# ======================== PAGE ROUTES ======================== + +@app.get('/login', response_class=HTMLResponse, tags=["System Templates"]) +async def login_page(request: Request): + if get_current_user(request): + return RedirectResponse(url='/', status_code=302) + return tpl(request, 'login.html') + + +@app.get("/set_lang/{lang}", tags=["System Templates"]) +async def set_lang(lang: str, request: Request): + ref = request.headers.get("referer", "/") + response = RedirectResponse(url=ref) + response.set_cookie(key="lang", value=lang, max_age=31536000) + return response + + +@app.get('/logout', tags=["System Templates"]) +async def logout(request: Request): + request.session.clear() + return RedirectResponse(url='/login', status_code=302) + + +@app.get('/', response_class=HTMLResponse, tags=["System Templates"]) +async def index(request: Request): + user = get_current_user(request) + if not user: + return RedirectResponse(url='/login', status_code=302) + if user['role'] == 'user': + return RedirectResponse(url='/my', status_code=302) + data = load_data() + return tpl(request, 'index.html', servers=data['servers']) + + +@app.get('/server/{server_id}', response_class=HTMLResponse, tags=["System Templates"]) +async def server_detail(request: Request, server_id: int): + user = get_current_user(request) + if not user: + return RedirectResponse(url='/login', status_code=302) + if user['role'] not in ('admin', 'support'): + return RedirectResponse(url='/my', status_code=302) + data = load_data() + if server_id >= len(data['servers']): + return RedirectResponse(url='/') + server = data['servers'][server_id] + users_list = data.get('users', []) + return tpl(request, 'server.html', server=server, server_id=server_id, users=users_list) + + +@app.get('/users', response_class=HTMLResponse, tags=["System Templates"]) +async def users_page(request: Request): + user = get_current_user(request) + if not user: + return RedirectResponse(url='/login', status_code=302) + if user['role'] not in ('admin', 'support'): + return RedirectResponse(url='/my', status_code=302) + data = load_data() + users_list = data.get('users', []) + # Count connections per user + conns = data.get('user_connections', []) + for u in users_list: + u['connections_count'] = sum(1 for c in conns if c['user_id'] == u['id']) + servers = data['servers'] + return tpl(request, 'users.html', users=users_list, servers=servers) + + +@app.get('/my', response_class=HTMLResponse, tags=["System Templates"]) +async def my_connections_page(request: Request): + user = get_current_user(request) + if not user: + return RedirectResponse(url='/login', status_code=302) + data = load_data() + conns = [c for c in data.get('user_connections', []) if c['user_id'] == user['id']] + # Enrich with server names + for c in conns: + sid = c.get('server_id', 0) + if sid < len(data['servers']): + c['server_name'] = data['servers'][sid].get('name', data['servers'][sid].get('host', '')) + else: + c['server_name'] = 'Unknown' + return tpl(request, 'my_connections.html', connections=conns) + + +# ======================== AUTH API ======================== + +@app.get('/api/auth/captcha', tags=["Authentication"]) +async def api_captcha(request: Request): + if not CaptchaGenerator: + return JSONResponse({"error": "multicolorcaptcha is not installed"}, status_code=500) + + # 2 is a multiplier for the image resolution size + generator = CaptchaGenerator(2) + captcha = generator.gen_captcha_image(difficult_level=2) + request.session['captcha_answer'] = captcha.characters + + img_bytes = io.BytesIO() + captcha.image.save(img_bytes, format='PNG') + img_bytes.seek(0) + + return StreamingResponse(img_bytes, media_type="image/png") + + +@app.post('/api/auth/login', tags=["Authentication"]) +async def api_login(request: Request, req: LoginRequest): + data = load_data() + captcha_settings = data.get('settings', {}).get('captcha', {}) + if captcha_settings.get('enabled') is True: + answer = request.session.get('captcha_answer') + lang = request.cookies.get('lang', 'ru') + if not answer or not req.captcha or answer.lower() != req.captcha.lower(): + request.session.pop('captcha_answer', None) + return JSONResponse({'error': _t('invalid_captcha', lang)}, status_code=400) + request.session.pop('captcha_answer', None) + + for u in data.get('users', []): + if u['username'] == req.username and verify_password(req.password, u['password_hash']): + lang = request.cookies.get('lang', 'ru') + if not u.get('enabled', True): + return JSONResponse({'error': _t('account_disabled', lang)}, status_code=403) + request.session['user_id'] = u['id'] + return {'status': 'success', 'role': u['role']} + lang = request.cookies.get('lang', 'ru') + return JSONResponse({'error': _t('invalid_login', lang)}, status_code=401) + + +# ======================== SERVER API (admin/support) ======================== + +def _check_admin(request): + """Authorize an admin/support action via session cookie OR Bearer token. + + Tokens are admin-equivalent and inherit the role of the user who created + them — if that user is later disabled or demoted, the token stops working. + """ + user = get_current_user(request) + if user and user['role'] in ('admin', 'support'): + return user + + auth_header = request.headers.get('Authorization', '') + if auth_header.lower().startswith('bearer '): + raw_token = auth_header[7:].strip() + data = load_data() + resolved = _resolve_api_token(data, raw_token) + if resolved: + entry, token_user = resolved + # Best-effort last-used tracking; swallow write errors so a flaky + # disk never blocks an API call from succeeding. + try: + if _touch_api_token(entry): + save_data(data) + except Exception as e: + logger.warning(f"Failed to touch API token last_used_at: {e}") + return token_user + + return None + + +@app.post('/api/servers/add', tags=["Servers"]) +async def api_add_server(request: Request, req: AddServerRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + host = req.host.strip() + username = req.username.strip() + name = req.name.strip() or host + if not host or not username: + return JSONResponse({'error': 'Host and username are required'}, status_code=400) + if not req.password and not req.private_key: + return JSONResponse({'error': 'Password or SSH key is required'}, status_code=400) + + ssh = SSHManager(host, req.ssh_port, username, req.password, req.private_key) + try: + ssh.connect() + server_info = ssh.test_connection() + ssh.disconnect() + except Exception as e: + return JSONResponse({'error': f'Connection failed: {str(e)}'}, status_code=400) + + server = { + 'name': name, 'host': host, 'ssh_port': req.ssh_port, + 'username': username, 'password': req.password, + 'private_key': req.private_key, 'server_info': server_info, + 'protocols': {}, + } + data = load_data() + data['servers'].append(server) + save_data(data) + return {'status': 'success', 'server_id': len(data['servers']) - 1, 'server_info': server_info} + except Exception as e: + logger.exception("Error adding server") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/edit', tags=["Servers"]) +async def api_edit_server(request: Request, server_id: int, req: EditServerRequest): + """Update connection details for an existing server entry. Verifies the new + credentials by SSH-connecting before persisting, so a typo can't lock us out. + """ + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + + new_host = (req.host or '').strip() or server['host'] + new_user = (req.username or '').strip() or server['username'] + new_port = int(req.ssh_port or server.get('ssh_port', 22)) + new_name = (req.name or '').strip() or server.get('name') or new_host + + # Credential resolution: a non-empty value in either field switches to + # that auth method (and clears the other). Both omitted => keep current. + if req.private_key: + new_pass, new_key = '', req.private_key + elif req.password: + new_pass, new_key = req.password, '' + else: + new_pass = server.get('password', '') + new_key = server.get('private_key', '') + + if not new_pass and not new_key: + return JSONResponse({'error': 'Password or SSH key is required'}, status_code=400) + + # Verify the new connection details before committing the change. + ssh = SSHManager(new_host, new_port, new_user, new_pass, new_key) + try: + ssh.connect() + server_info = ssh.test_connection() + ssh.disconnect() + except Exception as e: + return JSONResponse({'error': f'Connection failed: {e}'}, status_code=400) + + server['name'] = new_name + server['host'] = new_host + server['ssh_port'] = new_port + server['username'] = new_user + server['password'] = new_pass + server['private_key'] = new_key + server['server_info'] = server_info + save_data(data) + return {'status': 'success', 'server_info': server_info} + except Exception as e: + logger.exception("Error editing server") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.get('/api/servers/{server_id}/ping', tags=["Servers"]) +async def api_server_ping(request: Request, server_id: int): + """Cheap reachability check: opens a TCP connection to the SSH port, + measures RTT, immediately closes. Runs on the asyncio loop so the page + can issue many pings in parallel without blocking each other. + """ + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + host = server['host'] + port = int(server.get('ssh_port', 22)) + + import time as _time + t0 = _time.perf_counter() + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=2.0 + ) + ms = round((_time.perf_counter() - t0) * 1000) + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return {'alive': True, 'ms': ms} + except asyncio.TimeoutError: + return {'alive': False, 'error': 'timeout', 'ms': None} + except Exception as e: + return {'alive': False, 'error': str(e), 'ms': None} + + +@app.post('/api/servers/reorder', tags=["Servers"]) +async def api_reorder_servers(request: Request, req: ReorderServersRequest): + """Persist a user-defined ordering of servers. Also remaps `server_id` + references in user_connections so existing assignments survive the move. + """ + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + async with DATA_LOCK: + data = load_data() + n = len(data['servers']) + order = req.order or [] + if len(order) != n or sorted(order) != list(range(n)): + return JSONResponse( + {'error': f'Order must be a permutation of indices 0..{n - 1}'}, + status_code=400, + ) + new_servers = [data['servers'][i] for i in order] + # Map old index -> new index for user_connections remap + remap = {old: new for new, old in enumerate(order)} + for c in data.get('user_connections', []): + old_id = c.get('server_id') + if isinstance(old_id, int) and old_id in remap: + c['server_id'] = remap[old_id] + # Sync settings.sync.remnawave_server_id if it points at a moved server + sync_cfg = data.get('settings', {}).get('sync', {}) + rsid = sync_cfg.get('remnawave_server_id') + if isinstance(rsid, int) and rsid in remap: + sync_cfg['remnawave_server_id'] = remap[rsid] + data['servers'] = new_servers + save_data(data) + return {'status': 'success'} + + +@app.post('/api/servers/{server_id}/delete', tags=["Servers"]) +async def api_delete_server(request: Request, server_id: int): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + data['servers'].pop(server_id) + # Clean up connections for this server + data['user_connections'] = [c for c in data.get('user_connections', []) if c.get('server_id') != server_id] + # Adjust server_ids for connections pointing to higher indices + for c in data.get('user_connections', []): + if c.get('server_id', 0) > server_id: + c['server_id'] -= 1 + save_data(data) + return {'status': 'success'} + except Exception as e: + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/reboot', tags=["Servers"]) +async def api_reboot_server(request: Request, server_id: int): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + try: + ssh.run_sudo_command("nohup reboot > /dev/null 2>&1 &") + except Exception: + pass + try: + ssh.disconnect() + except: + pass + return {'status': 'success'} + except Exception as e: + logger.exception("Error rebooting server") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/clear', tags=["Servers"]) +async def api_clear_server(request: Request, server_id: int): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + # Match every Amnezia container by name prefix (catches awg/awg2/awg-legacy, + # wireguard, xray/ssxray, openvpn, dns, and any future amnezia-* protocol) + # plus the telemt container which doesn't share that prefix. + # Using a single script avoids one SSH round-trip per command. + cleanup_script = r""" +for c in $(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -E '^(amnezia-|telemt$)'); do + docker stop "$c" >/dev/null 2>&1 || true + docker rm -fv "$c" >/dev/null 2>&1 || true +done + +# Drop locally-built and pulled Amnezia images so reinstall starts from a clean slate +for img in $(docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -E '^(amnezia-|amneziavpn/|telemt:)'); do + docker rmi -f "$img" >/dev/null 2>&1 || true +done + +docker network rm amnezia-dns-net >/dev/null 2>&1 || true +rm -rf /opt/amnezia +""" + ssh.run_sudo_script(cleanup_script, timeout=120) + + server['protocols'] = {} + save_data(data) + ssh.disconnect() + return {'status': 'success'} + except Exception as e: + logger.exception("Error clearing server") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/stats', tags=["Servers"]) +async def api_server_stats(request: Request, server_id: int): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + stats = {} + out, _, _ = ssh.run_command( + "top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1 2>/dev/null || " + "awk '{u=$2+$4; t=$2+$4+$5; if(NR==1){pu=u;pt=t} else printf \"%.1f\", (u-pu)/(t-pt)*100}' " + "<(grep 'cpu ' /proc/stat) <(sleep 0.5 && grep 'cpu ' /proc/stat) 2>/dev/null" + ) + try: + stats['cpu'] = round(float(out.strip().split('\n')[0]), 1) + except (ValueError, IndexError): + stats['cpu'] = 0 + out, _, _ = ssh.run_command("free -b | awk 'NR==2{printf \"%d %d\", $3, $2}'") + try: + parts = out.strip().split() + used, total = int(parts[0]), int(parts[1]) + stats.update(ram_used=used, ram_total=total, ram_percent=round(used / total * 100, 1) if total > 0 else 0) + except (ValueError, IndexError): + stats.update(ram_used=0, ram_total=0, ram_percent=0) + out, _, _ = ssh.run_command("df -B1 / | awk 'NR==2{printf \"%d %d\", $3, $2}'") + try: + parts = out.strip().split() + used, total = int(parts[0]), int(parts[1]) + stats.update(disk_used=used, disk_total=total, disk_percent=round(used / total * 100, 1) if total > 0 else 0) + except (ValueError, IndexError): + stats.update(disk_used=0, disk_total=0, disk_percent=0) + out, _, _ = ssh.run_command( + "DEV=$(ip route | awk '/default/ {print $5}' | head -1); " + "cat /proc/net/dev | awk -v dev=\"$DEV:\" '$1==dev{printf \"%d %d\", $2, $10}'" + ) + try: + parts = out.strip().split() + stats['net_rx'], stats['net_tx'] = int(parts[0]), int(parts[1]) + except (ValueError, IndexError): + stats['net_rx'] = stats['net_tx'] = 0 + out, _, _ = ssh.run_command("uptime -p 2>/dev/null || uptime") + stats['uptime'] = out.strip() + ssh.disconnect() + return stats + except Exception as e: + logger.exception("Error getting server stats") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/check', tags=["Servers"]) +async def api_check_server(request: Request, server_id: int): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + # Just use awg's docker checker since it uses the same command + manager = get_protocol_manager(ssh, 'awg') + status = {'connection': 'ok', 'docker_installed': manager.check_docker_installed(), 'protocols': {}} + + changed = False + if 'protocols' not in server: + server['protocols'] = {} + + import concurrent.futures + + def check_proto(proto): + try: + p_manager = get_protocol_manager(ssh, proto) + result = _manager_call(p_manager, 'get_server_status', proto) + db_proto = server.get('protocols', {}).get(proto, {}) + if not result.get('port') and db_proto.get('port'): + result['port'] = db_proto['port'] + return proto, result, None + except Exception as e: + return proto, None, str(e) + + with concurrent.futures.ThreadPoolExecutor(max_workers=9) as executor: + futures = [executor.submit(check_proto, p) for p in ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'dns', 'wireguard', 'socks5', 'adguard', 'revproxy']] + for future in concurrent.futures.as_completed(futures): + proto, result, err = future.result() + if err: + status['protocols'][proto] = {'error': err} + else: + status['protocols'][proto] = result + if result.get('container_exists'): + if proto not in server['protocols']: + server['protocols'][proto] = { + 'installed': True, + 'port': result.get('port', '55424'), + 'awg_params': result.get('awg_params', {}) + } + changed = True + else: + if proto in server['protocols']: + del server['protocols'][proto] + changed = True + + if changed: + save_data(data) + + ssh.disconnect() + return status + except Exception as e: + logger.exception("Error checking server") + return JSONResponse({'error': str(e), 'connection': 'failed'}, status_code=500) + + +@app.post('/api/servers/{server_id}/install', tags=["Protocols"]) +async def api_install_protocol(request: Request, server_id: int, req: InstallProtocolRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + if req.protocol not in ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'dns', 'wireguard', 'socks5', 'adguard', 'revproxy']: + return JSONResponse({'error': 'Invalid protocol type'}, status_code=400) + + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, req.protocol) + + # Pass parameters to installer + if req.protocol == 'telemt': + result = manager.install_protocol( + protocol_type=req.protocol, + port=req.port, + tls_emulation=req.tls_emulation if req.tls_emulation is not None else True, + tls_domain=req.tls_domain, + max_connections=req.max_connections if req.max_connections is not None else 0 + ) + elif req.protocol == 'xray': + result = manager.install_protocol(port=req.port) + elif req.protocol == 'wireguard': + result = manager.install_protocol(port=req.port) + elif req.protocol == 'socks5': + result = manager.install_protocol( + protocol_type='socks5', + port=req.port, + username=req.socks5_username, + password=req.socks5_password, + ) + elif req.protocol == 'adguard': + result = manager.install_protocol( + protocol_type='adguard', + mode=req.adguard_mode or 'sidebyside', + web_port=req.adguard_web_port, + expose_web=bool(req.adguard_expose_web), + dns_port=req.port, + dot_port=req.adguard_dot_port, + doh_port=req.adguard_doh_port, + expose_dns=bool(req.adguard_expose_dns), + expose_dot=bool(req.adguard_expose_dot), + expose_doh=bool(req.adguard_expose_doh), + ) + elif req.protocol == 'revproxy': + result = manager.install_protocol( + protocol_type='revproxy', + domain=req.revproxy_domain, + site_title=req.revproxy_site_title, + backend=req.revproxy_backend or 'none', + backend_port=req.revproxy_backend_port, + vpn_path=req.revproxy_vpn_path, + tls_email=req.revproxy_tls_email, + enable_telemt_mask=bool(req.revproxy_telemt_mask), + ) + else: + result = manager.install_protocol(req.protocol, port=req.port) + + proto_record = { + 'installed': True, + 'port': req.port, + 'awg_params': result.get('awg_params', {}), + } + if req.protocol == 'adguard': + proto_record['mode'] = result.get('mode') + proto_record['internal_ip'] = result.get('internal_ip') + proto_record['web_port'] = result.get('web_port') + proto_record['expose_web'] = result.get('expose_web') + if req.protocol == 'revproxy': + proto_record['domain'] = result.get('domain', '') + proto_record['site_url'] = result.get('site_url', '') + proto_record['backend'] = result.get('backend', 'none') + server['protocols'][req.protocol] = proto_record + save_data(data) + ssh.disconnect() + return result + except Exception as e: + logger.exception("Error installing protocol") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.get('/api/servers/{server_id}/socks5/credentials', tags=["Protocols"]) +async def api_socks5_get_credentials(request: Request, server_id: int): + """Return the current SOCKS5 port/username/password for the panel UI.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, 'socks5') + creds = manager.get_credentials() + ssh.disconnect() + return {'status': 'success', **creds} + except Exception as e: + logger.exception("Error reading SOCKS5 credentials") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/socks5/credentials', tags=["Protocols"]) +async def api_socks5_update_credentials(request: Request, server_id: int, req: Socks5SettingsRequest): + """Apply new SOCKS5 connection settings — regenerates the 3proxy config and + reconciles the container (recreating it if the listening port changed).""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, 'socks5') + result = manager.update_credentials( + port=req.port, username=req.username, password=req.password + ) + ssh.disconnect() + # Persist the new port in the saved server record so the dashboard + # shows the right value on next check without an SSH round-trip. + if result.get('status') == 'success' and result.get('port'): + srv_proto = server.setdefault('protocols', {}).setdefault('socks5', {}) + srv_proto['port'] = str(result['port']) + srv_proto['installed'] = True + save_data(data) + return result + except Exception as e: + logger.exception("Error updating SOCKS5 credentials") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.get('/api/servers/{server_id}/revproxy/settings', tags=["Protocols"]) +async def api_revproxy_get_settings(request: Request, server_id: int): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, 'revproxy') + settings = manager.get_settings() + status = manager.get_server_status('revproxy') + ssh.disconnect() + return {'status': 'success', **settings, 'site_url': status.get('site_url', '')} + except Exception as e: + logger.exception("Error reading reverse proxy settings") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/revproxy/settings', tags=["Protocols"]) +async def api_revproxy_update_settings(request: Request, server_id: int, req: RevproxySettingsRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, 'revproxy') + result = manager.update_settings( + domain=req.domain, + site_title=req.site_title, + backend=req.backend, + backend_port=req.backend_port, + vpn_path=req.vpn_path, + tls_email=req.tls_email, + enable_telemt_mask=bool(req.telemt_mask), + ) + ssh.disconnect() + if result.get('status') == 'success': + srv_proto = server.setdefault('protocols', {}).setdefault('revproxy', {}) + srv_proto['installed'] = True + srv_proto['port'] = '443' + srv_proto['domain'] = result.get('domain', '') + srv_proto['site_url'] = result.get('site_url', '') + srv_proto['backend'] = result.get('backend', 'none') + save_data(data) + return result + except Exception as e: + logger.exception("Error updating reverse proxy settings") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/uninstall', tags=["Protocols"]) +async def api_uninstall_protocol(request: Request, server_id: int, req: ProtocolRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, req.protocol) + if req.protocol in ('xray', 'wireguard'): + manager.remove_container() + else: + manager.remove_container(req.protocol) + if req.protocol in server.get('protocols', {}): + del server['protocols'][req.protocol] + save_data(data) + ssh.disconnect() + return {'status': 'success'} + except Exception as e: + logger.exception("Error uninstalling protocol") + return JSONResponse({'error': str(e)}, status_code=500) + + +CONTAINER_NAMES = { + 'awg': 'amnezia-awg', + 'awg2': 'amnezia-awg2', + 'awg_legacy': 'amnezia-awg-legacy', + 'xray': 'amnezia-xray', + 'telemt': 'telemt', + 'dns': 'amnezia-dns', + 'wireguard': 'amnezia-wireguard', + 'socks5': 'amnezia-socks5proxy', + 'adguard': 'amnezia-adguard', + 'revproxy': 'amnezia-revproxy', +} + + +@app.post('/api/servers/{server_id}/container/toggle', tags=["Protocols"]) +async def api_container_toggle(request: Request, server_id: int, req: ProtocolRequest): + """Start or stop a protocol Docker container.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + container = CONTAINER_NAMES.get(req.protocol) + if not container: + return JSONResponse({'error': 'Unknown protocol'}, status_code=400) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + # Check current state + out, _, _ = ssh.run_sudo_command( + f"docker inspect -f '{{{{.State.Running}}}}' {container} 2>/dev/null" + ) + is_running = out.strip().lower() == 'true' + if is_running: + ssh.run_sudo_command(f"docker stop {container}") + action = 'stopped' + else: + ssh.run_sudo_command(f"docker start {container}") + action = 'started' + ssh.disconnect() + return {'status': 'success', 'action': action, 'container': container} + except Exception as e: + logger.exception("Error toggling container") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/server_config', tags=["Protocols"]) +async def api_server_config(request: Request, server_id: int, req: ProtocolRequest): + """Get the raw server-side WireGuard/Xray configuration.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + if req.protocol == 'xray': + from managers.xray_manager import XrayManager + mgr = XrayManager(ssh) + data_json = mgr._get_server_json() + import json as _json + config = _json.dumps(data_json, indent=2, ensure_ascii=False) if data_json else '' + elif req.protocol == 'telemt': + from managers.telemt_manager import TelemtManager + mgr = TelemtManager(ssh) + config = mgr._get_server_config() + elif req.protocol == 'wireguard': + from managers.wireguard_manager import WireGuardManager + mgr = WireGuardManager(ssh) + config = mgr._get_server_config() + elif req.protocol == 'revproxy': + from managers.revproxy_manager import RevproxyManager + mgr = RevproxyManager(ssh) + config = mgr.get_caddyfile() + else: + mgr = AWGManager(ssh) + config = mgr._get_server_config(req.protocol) + ssh.disconnect() + return {'config': config} + except Exception as e: + logger.exception("Error getting server config") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/server_config/save', tags=["Protocols"]) +async def api_server_config_save(request: Request, server_id: int, req: ServerConfigSaveRequest): + """Save the raw server-side WireGuard/Xray configuration and apply changes.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + if req.protocol == 'xray': + from managers.xray_manager import XrayManager + mgr = XrayManager(ssh) + import json as _json + try: + data_json = _json.loads(req.config) + except Exception as e: + ssh.disconnect() + return JSONResponse({'error': f'Invalid JSON format: {str(e)}'}, status_code=400) + mgr._save_server_json(data_json) + elif req.protocol == 'telemt': + from managers.telemt_manager import TelemtManager + mgr = TelemtManager(ssh) + mgr.save_server_config(req.protocol, req.config) + elif req.protocol == 'revproxy': + from managers.revproxy_manager import RevproxyManager + mgr = RevproxyManager(ssh) + mgr.save_server_config(req.protocol, req.config) + elif req.protocol == 'wireguard': + from managers.wireguard_manager import WireGuardManager + mgr = WireGuardManager(ssh) + mgr.save_server_config(req.config) + else: + mgr = AWGManager(ssh) + mgr.save_server_config(req.protocol, req.config) + ssh.disconnect() + return {'status': 'success'} + except Exception as e: + logger.exception("Error saving server config") + return JSONResponse({'error': str(e)}, status_code=500) + + + + +@app.get('/api/servers/{server_id}/connections', tags=["Connections"]) +async def api_get_connections(request: Request, server_id: int, protocol: str = Query(default='awg')): + if not protocol: + protocol = 'awg' + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, protocol) + clients = _manager_call(manager, 'get_clients', protocol) + ssh.disconnect() + + # Enrich with user info from user_connections + user_conns = data.get('user_connections', []) + users = data.get('users', []) + users_map = {u['id']: u for u in users} + for client in clients: + cid = client.get('clientId', '') + for uc in user_conns: + if uc.get('client_id') == cid and uc.get('server_id') == server_id and uc.get('protocol') == protocol: + uid = uc.get('user_id') + u = users_map.get(uid) + if u: + client['assigned_user'] = u['username'] + client['assigned_user_id'] = uid + break + return {'clients': clients} + except Exception as e: + logger.exception("Error getting connections") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/connections/add', tags=["Connections"]) +async def api_add_connection(request: Request, server_id: int, req: AddConnectionRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + proto_info = server.get('protocols', {}).get(req.protocol, {}) + port = proto_info.get('port', '55424') + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, req.protocol) + + if req.protocol == 'telemt': + result = manager.add_client( + req.protocol, req.name, server['host'], port, + telemt_quota=req.telemt_quota, + telemt_max_ips=req.telemt_max_ips, + telemt_expiry=req.telemt_expiry, + secret=req.telemt_secret, + user_ad_tag=req.telemt_ad_tag, + max_tcp_conns=req.telemt_max_conns + ) + elif req.protocol == 'wireguard': + result = manager.add_client(req.name, server['host']) + else: + result = manager.add_client(req.protocol, req.name, server['host'], port) + ssh.disconnect() + + if result.get('config'): + result['vpn_link'] = generate_vpn_link(result['config']) + + # Link connection to user if specified + if req.user_id and result.get('client_id'): + conn = { + 'id': str(uuid.uuid4()), + 'user_id': req.user_id, + 'server_id': server_id, + 'protocol': req.protocol, + 'client_id': result['client_id'], + 'name': req.name, + 'created_at': datetime.now().isoformat(), + } + data['user_connections'].append(conn) + save_data(data) + + return result + except Exception as e: + logger.exception("Error adding connection") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/connections/remove', tags=["Connections"]) +async def api_remove_connection(request: Request, server_id: int, req: ConnectionActionRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + if not req.client_id: + return JSONResponse({'error': 'Client ID is required'}, status_code=400) + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, req.protocol) + _manager_call(manager, 'remove_client', req.protocol, req.client_id) + ssh.disconnect() + # Remove from user_connections + data['user_connections'] = [ + c for c in data.get('user_connections', []) + if not (c.get('client_id') == req.client_id and c.get('server_id') == server_id) + ] + save_data(data) + return {'status': 'success'} + except Exception as e: + logger.exception("Error removing connection") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/connections/edit', tags=["Connections"]) +async def api_edit_connection(request: Request, server_id: int, req: EditConnectionRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, req.protocol) + + edit_params = {} + if req.protocol == 'telemt': + edit_params['telemt_quota'] = req.telemt_quota + edit_params['telemt_max_ips'] = req.telemt_max_ips + edit_params['telemt_expiry'] = req.telemt_expiry + edit_params['secret'] = req.telemt_secret + edit_params['user_ad_tag'] = req.telemt_ad_tag + edit_params['max_tcp_conns'] = req.telemt_max_conns + + result = manager.edit_client(req.protocol, req.client_id, edit_params) + ssh.disconnect() + return result + except Exception as e: + logger.exception("Error editing connection") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/connections/config', tags=["Connections"]) +async def api_get_connection_config(request: Request, server_id: int, req: ConnectionActionRequest): + user = get_current_user(request) + if not user: + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + # Users can only view their own connections + if user['role'] == 'user': + owned = any( + c for c in data.get('user_connections', []) + if c.get('client_id') == req.client_id and c.get('server_id') == server_id and c.get('user_id') == user['id'] + ) + if not owned: + return JSONResponse({'error': 'Forbidden'}, status_code=403) + server = data['servers'][server_id] + if not req.client_id: + return JSONResponse({'error': 'Client ID is required'}, status_code=400) + proto_info = server.get('protocols', {}).get(req.protocol, {}) + port = proto_info.get('port', '55424') + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, req.protocol) + config = _manager_call(manager, 'get_client_config', req.protocol, req.client_id, server['host'], port) + ssh.disconnect() + vpn_link = generate_vpn_link(config) if config else '' + return {'config': config, 'vpn_link': vpn_link} + except Exception as e: + logger.exception("Error getting connection config") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/connections/toggle', tags=["Connections"]) +async def api_toggle_connection(request: Request, server_id: int, req: ToggleConnectionRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + if not req.client_id: + return JSONResponse({'error': 'Client ID is required'}, status_code=400) + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, req.protocol) + _manager_call(manager, 'toggle_client', req.protocol, req.client_id, req.enable) + ssh.disconnect() + status = 'enabled' if req.enable else 'disabled' + return {'status': 'success', 'enabled': req.enable, 'message': f'Connection {status}'} + except Exception as e: + logger.exception("Error toggling connection") + return JSONResponse({'error': str(e)}, status_code=500) + + +# ======================== USER API (admin only) ======================== + +@app.get('/api/users', tags=["Users"]) +async def api_list_users(request: Request, search: str = '', page: int = 1, size: int = 10): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + all_users = data.get('users', []) + conns = data.get('user_connections', []) + + # Filter + filtered = [] + search = search.lower() + for u in all_users: + if search: + match = (search in u['username'].lower() or + (u.get('email') and search in u['email'].lower()) or + (u.get('telegramId') and search in str(u['telegramId']).lower())) + if not match: + continue + filtered.append(u) + + total = len(filtered) + start = (page - 1) * size + end = start + size + page_items = filtered[start:end] + + users = [] + for u in page_items: + users.append({ + 'id': u['id'], 'username': u['username'], 'role': u['role'], + 'enabled': u.get('enabled', True), + 'created_at': u.get('created_at', ''), + 'telegramId': u.get('telegramId'), + 'email': u.get('email'), + 'description': u.get('description'), + 'connections_count': sum(1 for c in conns if c['user_id'] == u['id']), + 'traffic_used': u.get('traffic_used', 0), + 'traffic_total': u.get('traffic_total', 0), + 'traffic_limit': u.get('traffic_limit', 0), + 'traffic_reset_strategy': u.get('traffic_reset_strategy', 'never'), + 'last_reset_at': u.get('last_reset_at'), + "expiration_date": u.get("expiration_date"), + 'share_enabled': u.get('share_enabled', False), + 'share_token': u.get('share_token'), + 'has_share_password': bool(u.get('share_password_hash')), + 'source': 'Remnawave' if u.get('remnawave_uuid') else 'Local' + }) + return { + 'users': users, + 'total': total, + 'page': page, + 'size': size, + 'pages': (total + size - 1) // size + } + + +@app.post('/api/users/add', tags=["Users"]) +async def api_add_user(request: Request, req: AddUserRequest): + cur = get_current_user(request) + if not cur or cur['role'] != 'admin': + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + lang = request.cookies.get('lang', 'ru') + # Check duplicate + if any(u['username'] == req.username for u in data.get('users', [])): + return JSONResponse({'error': _t('user_exists', lang)}, status_code=400) + if req.role not in ('admin', 'support', 'user'): + return JSONResponse({'error': 'Invalid role'}, status_code=400) + new_user = { + 'id': str(uuid.uuid4()), + 'username': req.username, + 'password_hash': hash_password(req.password), + 'role': req.role, + 'telegramId': req.telegramId, + 'email': req.email, + 'description': req.description, + 'traffic_limit': int(req.traffic_limit * 1024**3) if req.traffic_limit else 0, + 'traffic_reset_strategy': req.traffic_reset_strategy or 'never', + 'traffic_used': 0, + 'traffic_total': 0, + 'last_reset_at': datetime.now().isoformat(), + 'expiration_date': req.expiration_date, + 'enabled': True, + 'created_at': datetime.now().isoformat(), + 'remnawave_uuid': None, + 'share_enabled': False, + 'share_token': secrets.token_urlsafe(16), + 'share_password_hash': None, + } + data['users'].append(new_user) + save_data(data) + + result = {'status': 'success', 'user_id': new_user['id']} + + # Auto-create connection if server & protocol specified + if req.server_id is not None and req.protocol: + if req.server_id < len(data['servers']): + server = data['servers'][req.server_id] + proto_info = server.get('protocols', {}).get(req.protocol, {}) + port = proto_info.get('port', '55424') + conn_name = req.connection_name or f"{req.username}_vpn" + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, req.protocol) + if req.protocol == 'telemt': + conn_result = manager.add_client( + req.protocol, conn_name, server['host'], port, + telemt_quota=req.telemt_quota, + telemt_max_ips=req.telemt_max_ips, + telemt_expiry=req.telemt_expiry, + secret=req.telemt_secret, + user_ad_tag=req.telemt_ad_tag, + max_tcp_conns=req.telemt_max_conns + ) + else: + conn_result = manager.add_client(req.protocol, conn_name, server['host'], port) + ssh.disconnect() + + if conn_result.get('client_id'): + conn = { + 'id': str(uuid.uuid4()), + 'user_id': new_user['id'], + 'server_id': req.server_id, + 'protocol': req.protocol, + 'client_id': conn_result['client_id'], + 'name': conn_name, + 'created_at': datetime.now().isoformat(), + } + data = load_data() # reload + data['user_connections'].append(conn) + save_data(data) + result['connection_created'] = True + if conn_result.get('config'): + result['config'] = conn_result['config'] + result['vpn_link'] = generate_vpn_link(conn_result['config']) + return result + except Exception as e: + logger.exception("Error adding user") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/users/{user_id}/update', tags=["Users"]) +async def api_update_user(request: Request, user_id: str, req: UpdateUserRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + user = next((u for u in data['users'] if u['id'] == user_id), None) + if not user: + return JSONResponse({'error': 'User not found'}, status_code=404) + + if req.telegramId is not None: user['telegramId'] = req.telegramId + if req.email is not None: user['email'] = req.email + if req.description is not None: user['description'] = req.description + if req.traffic_limit is not None: + new_limit = int(req.traffic_limit * 1024**3) + user['traffic_limit'] = new_limit + + if req.traffic_reset_strategy is not None: + user['traffic_reset_strategy'] = req.traffic_reset_strategy + user['last_reset_at'] = datetime.now().isoformat() + + if req.expiration_date is not None: + user['expiration_date'] = req.expiration_date or None + + if req.password: + user['password_hash'] = hash_password(req.password) + + save_data(data) + + # Auto re-enable if traffic limit increased beyond usage + if req.traffic_limit is not None: + if new_limit > 0 and user.get('traffic_used', 0) < new_limit and not user.get('enabled', True): + await perform_toggle_user(data, user_id, True) + save_data(data) + + return {'status': 'success'} + except Exception as e: + logger.exception("Error updating user") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/users/{user_id}/delete', tags=["Users"]) +async def api_delete_user(request: Request, user_id: str): + cur = get_current_user(request) + if not cur or cur['role'] != 'admin': + return JSONResponse({'error': 'Forbidden'}, status_code=403) + lang = request.cookies.get('lang', 'ru') + if cur['id'] == user_id: + return JSONResponse({'error': _t('cannot_delete_self', lang)}, status_code=400) + try: + data = load_data() + success = await perform_delete_user(data, user_id) + if not success: + return JSONResponse({'error': 'User not found'}, status_code=404) + save_data(data) + return {'status': 'success'} + except Exception as e: + logger.exception("Error deleting user") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/users/{user_id}/toggle', tags=["Users"]) +async def api_toggle_user(request: Request, user_id: str, req: ToggleUserRequest): + cur = get_current_user(request) + if not cur or cur['role'] != 'admin': + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + success = await perform_toggle_user(data, user_id, req.enabled) + if not success: + return JSONResponse({'error': 'User not found'}, status_code=404) + save_data(data) + return {'status': 'success', 'enabled': req.enabled} + except Exception as e: + logger.exception("Error toggling user") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/users/{user_id}/connections/add', tags=["Users"]) +async def api_add_user_connection(request: Request, user_id: str, req: AddUserConnectionRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + user = next((u for u in data['users'] if u['id'] == user_id), None) + if not user: + return JSONResponse({'error': 'User not found'}, status_code=404) + if req.server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][req.server_id] + proto_info = server.get('protocols', {}).get(req.protocol, {}) + port = proto_info.get('port', '55424') + ssh = get_ssh(server) + await asyncio.to_thread(ssh.connect) + manager = get_protocol_manager(ssh, req.protocol) + + if req.client_id: + # Use existing client + target_client_id = req.client_id + # Retrieve config for existing client + config = await asyncio.to_thread(manager.get_client_config, req.protocol, req.client_id, server['host'], port) + result = {'client_id': target_client_id, 'config': config} + else: + # Create new client + if req.protocol == 'telemt': + result = await asyncio.to_thread( + manager.add_client, req.protocol, req.name, server['host'], port, + telemt_quota=req.telemt_quota, + telemt_max_ips=req.telemt_max_ips, + telemt_expiry=req.telemt_expiry, + secret=req.telemt_secret, + user_ad_tag=req.telemt_ad_tag, + max_tcp_conns=req.telemt_max_conns + ) + else: + result = await asyncio.to_thread(manager.add_client, req.protocol, req.name, server['host'], port) + + await asyncio.to_thread(ssh.disconnect) + + if result.get('client_id'): + conn = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'server_id': req.server_id, + 'protocol': req.protocol, + 'client_id': result['client_id'], + 'name': req.name, + 'created_at': datetime.now().isoformat(), + } + data = load_data() + data['user_connections'].append(conn) + save_data(data) + + resp = {'status': 'success'} + if result.get('config'): + resp['config'] = result['config'] + resp['vpn_link'] = generate_vpn_link(result['config']) + return resp + except Exception as e: + logger.exception("Error adding user connection") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.get('/api/users/{user_id}/connections', tags=["Users"]) +async def api_get_user_connections(request: Request, user_id: str): + user = get_current_user(request) + if not user: + return JSONResponse({'error': 'Forbidden'}, status_code=403) + # Users can only see their own, admin/support can see all + if user['role'] == 'user' and user['id'] != user_id: + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + conns = [c for c in data.get('user_connections', []) if c['user_id'] == user_id] + for c in conns: + sid = c.get('server_id', 0) + if sid < len(data['servers']): + c['server_name'] = data['servers'][sid].get('name', '') + return {'connections': conns} + + +# ======================== MY CONNECTIONS API (for user role) ======================== + +@app.get('/api/my/connections', tags=["Self-service"]) +async def api_my_connections(request: Request): + user = get_current_user(request) + if not user: + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + conns = [c for c in data.get('user_connections', []) if c['user_id'] == user['id']] + for c in conns: + sid = c.get('server_id', 0) + if sid < len(data['servers']): + c['server_name'] = data['servers'][sid].get('name', '') + else: + c['server_name'] = 'Unknown' + return {'connections': conns} + + +@app.post('/api/users/{user_id}/share/setup', tags=["Users"]) +async def api_user_share_setup(user_id: str, req: ShareSetupRequest, request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + user = next((u for u in data['users'] if u['id'] == user_id), None) + if not user: + return JSONResponse({'error': 'User not found'}, status_code=404) + + user['share_enabled'] = req.enabled + if not user.get('share_token'): + user['share_token'] = secrets.token_urlsafe(16) + if req.password: + user['share_password_hash'] = hash_password(req.password) + elif req.password == "": # Clear + user['share_password_hash'] = None + + save_data(data) + return {'status': 'success', 'share_token': user.get('share_token')} + + +@app.get('/share/{token}', response_class=HTMLResponse, tags=["System Templates"]) +async def share_page(token: str, request: Request): + data = load_data() + user = next((u for u in data['users'] if u.get('share_token') == token), None) + if not user or not user.get('share_enabled'): + lang = request.cookies.get('lang', 'ru') + return HTMLResponse(f"

{_t('share_not_found', lang)}

{_t('share_not_found_desc', lang)}

", status_code=404) + + auth_session_key = f'share_auth_{token}' + need_password = bool(user.get('share_password_hash')) and not request.session.get(auth_session_key) + + return tpl(request, 'user_share.html', + share_user=user, + need_password=need_password, + token=token) + + +@app.post('/api/share/{token}/auth', tags=["Sharing"]) +async def api_share_auth(token: str, req: ShareAuthRequest, request: Request): + data = load_data() + user = next((u for u in data['users'] if u.get('share_token') == token), None) + if not user or not user.get('share_enabled'): + return JSONResponse({'error': 'Link expired or disabled'}, status_code=404) + + if verify_password(req.password, user.get('share_password_hash', '')): + request.session[f'share_auth_{token}'] = True + return {'status': 'success'} + else: + lang = request.cookies.get('lang', 'ru') + return JSONResponse({'error': _t('wrong_share_password', lang)}, status_code=401) + + +@app.get('/api/share/{token}/connections', tags=["Sharing"]) +async def api_share_connections(token: str, request: Request): + data = load_data() + user = next((u for u in data['users'] if u.get('share_token') == token), None) + if not user or not user.get('share_enabled'): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + + if user.get('share_password_hash'): + if not request.session.get(f'share_auth_{token}'): + return JSONResponse({'error': 'Unauthorized'}, status_code=401) + + conns = [dict(c) for c in data.get('user_connections', []) if c['user_id'] == user['id']] + for c in conns: + sid = c['server_id'] + if sid < len(data['servers']): + c['server_name'] = data['servers'][sid].get('name') or data['servers'][sid]['host'] + else: + c['server_name'] = 'Unknown' + + return {'connections': conns, 'username': user['username']} + + +@app.post('/api/share/{token}/config/{connection_id}', tags=["Sharing"]) +async def api_share_config(token: str, connection_id: str, request: Request): + data = load_data() + user = next((u for u in data['users'] if u.get('share_token') == token), None) + if not user or not user.get('share_enabled'): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + + if user.get('share_password_hash'): + if not request.session.get(f'share_auth_{token}'): + return JSONResponse({'error': 'Unauthorized'}, status_code=401) + + conn = next((c for c in data.get('user_connections', []) if c['id'] == connection_id and c['user_id'] == user['id']), None) + if not conn: + return JSONResponse({'error': 'Not found'}, status_code=404) + + try: + sid = conn['server_id'] + server = data['servers'][sid] + proto_info = server.get('protocols', {}).get(conn['protocol'], {}) + port = proto_info.get('port', '55424') + ssh = get_ssh(server) + ssh.connect() + # Use appropriate manager for the protocol + manager = get_protocol_manager(ssh, conn['protocol']) + config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port) + ssh.disconnect() + vpn_link = generate_vpn_link(config) if config else '' + return {'config': config, 'vpn_link': vpn_link} + except Exception as e: + logger.exception("Error getting shared config") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/my/connections/{connection_id}/config', tags=["Self-service"]) +async def api_my_connection_config(request: Request, connection_id: str): + user = get_current_user(request) + if not user: + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + conn = next( + (c for c in data.get('user_connections', []) if c['id'] == connection_id and c['user_id'] == user['id']), + None + ) + if not conn: + return JSONResponse({'error': 'Connection not found'}, status_code=404) + sid = conn['server_id'] + if sid >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][sid] + proto_info = server.get('protocols', {}).get(conn['protocol'], {}) + port = proto_info.get('port', '55424') + ssh = get_ssh(server) + ssh.connect() + # Use appropriate manager for the protocol (fixes Telemt/Xray not working for users) + manager = get_protocol_manager(ssh, conn['protocol']) + config = _manager_call(manager, 'get_client_config', conn['protocol'], conn['client_id'], server['host'], port) + ssh.disconnect() + vpn_link = generate_vpn_link(config) if config else '' + return {'config': config, 'vpn_link': vpn_link} + except Exception as e: + logger.exception("Error getting my connection config") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.get('/settings', tags=["System Templates"]) +async def settings_page(request: Request): + user = _check_admin(request) + if not user: + return RedirectResponse('/login') + data = load_data() + return tpl(request, 'settings.html', settings=data.get('settings', {}), servers=data.get('servers', []), current_version=CURRENT_VERSION) + + +@app.get('/api/settings', tags=["Settings"]) +async def api_get_settings(request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + return data.get('settings', {}) + + +@app.get('/api/settings/tunnels/status', tags=["Settings"]) +async def api_tunnels_status(request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + return { + 'local_server': get_panel_local_url(request), + 'cloudflare': get_tunnel_status('cloudflare'), + 'ngrok': get_tunnel_status('ngrok'), + } + + +@app.post('/api/settings/tunnels/{provider}/install', tags=["Settings"]) +async def api_tunnel_install(request: Request, provider: str): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + if provider not in TUNNEL_RUNTIMES: + return JSONResponse({'error': 'Unsupported tunnel provider'}, status_code=400) + try: + await asyncio.to_thread(install_tunnel_binary, provider) + return get_tunnel_status(provider) + except Exception as e: + logger.exception(f"Error installing {provider} tunnel") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/settings/tunnels/{provider}/start', tags=["Settings"]) +async def api_tunnel_start(request: Request, provider: str, payload: TunnelStartRequest = TunnelStartRequest()): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + if provider not in TUNNEL_RUNTIMES: + return JSONResponse({'error': 'Unsupported tunnel provider'}, status_code=400) + try: + local_url = get_panel_tunnel_target_url() + await asyncio.to_thread(start_tunnel, provider, local_url, payload.authtoken or '') + status = await wait_for_tunnel_url(provider) + if not status.get('running'): + return JSONResponse({'error': status.get('last_error') or 'Tunnel process stopped'}, status_code=500) + return status + except Exception as e: + logger.exception(f"Error starting {provider} tunnel") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/settings/tunnels/{provider}/stop', tags=["Settings"]) +async def api_tunnel_stop(request: Request, provider: str): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + if provider not in TUNNEL_RUNTIMES: + return JSONResponse({'error': 'Unsupported tunnel provider'}, status_code=400) + try: + await asyncio.to_thread(stop_tunnel, provider) + return get_tunnel_status(provider) + except Exception as e: + logger.exception(f"Error stopping {provider} tunnel") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.delete('/api/settings/tunnels/{provider}', tags=["Settings"]) +async def api_tunnel_delete(request: Request, provider: str): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + if provider not in TUNNEL_RUNTIMES: + return JSONResponse({'error': 'Unsupported tunnel provider'}, status_code=400) + try: + await asyncio.to_thread(delete_tunnel_binary, provider) + return get_tunnel_status(provider) + except Exception as e: + logger.exception(f"Error deleting {provider} tunnel") + return JSONResponse({'error': str(e)}, status_code=500) + + +# @app.post('/api/settings/save') +# async def api_save_settings(request: Request, body: SaveSettingsRequest): +# _check_admin(request) +# data = load_data() +# data['settings'] = body.dict() +# save_data(data) + +# # Trigger sync if enabled +# if body.sync.remnawave_sync_users: +# await sync_users_with_remnawave(data) +# save_data(data) + +# return {'status': 'success'} + +@app.post('/api/settings/save', tags=["Settings"]) +async def save_settings(request: Request, payload: SaveSettingsRequest): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + data['settings']['appearance'] = payload.appearance.dict() + data['settings']['sync'] = payload.sync.dict() + data['settings']['captcha'] = payload.captcha.dict() + data['settings']['telegram'] = payload.telegram.dict() + + # Preserve the auto-issued certificate blob (stored separately, not exposed + # in the settings form) across saves so users don't lose it when editing + # unrelated settings. + prev_ssl = data['settings'].get('ssl', {}) + new_ssl = payload.ssl.dict() + if prev_ssl.get('cf_cert') and 'cf_cert' not in new_ssl: + new_ssl['cf_cert'] = prev_ssl['cf_cert'] + # If the CF token field comes back empty from the form (masked), keep the + # stored one rather than wiping it. + if not new_ssl.get('cf_token') and prev_ssl.get('cf_token'): + new_ssl['cf_token'] = prev_ssl['cf_token'] + data['settings']['ssl'] = new_ssl + save_data(data) + logger.info("Settings saved (including captcha and telegram)") + + # Handle bot start/stop based on new telegram settings + tg_cfg = payload.telegram + if tg_cfg.enabled and tg_cfg.token: + if not tg_bot.is_running(): + logger.info("Starting Telegram bot (settings save)...") + tg_bot.launch_bot(tg_cfg.token, load_data, generate_vpn_link) + else: + if tg_bot.is_running(): + logger.info("Stopping Telegram bot (settings save)...") + asyncio.create_task(tg_bot.stop_bot()) + + return {"status": "success", "bot_running": tg_bot.is_running()} + + +SSL_CERT_DIR = os.path.join(application_path, 'ssl_certs') + + +def _cf_cert_paths(): + """Filesystem paths where the auto-issued panel certificate is stored.""" + return ( + os.path.join(SSL_CERT_DIR, 'panel_cert.pem'), + os.path.join(SSL_CERT_DIR, 'panel_key.pem'), + ) + + +def _write_cf_cert_files(cert_pem: str, key_pem: str): + os.makedirs(SSL_CERT_DIR, exist_ok=True) + cert_path, key_path = _cf_cert_paths() + with open(cert_path, 'w', encoding='utf-8') as f: + f.write(cert_pem.strip() + '\n') + with open(key_path, 'w', encoding='utf-8') as f: + f.write(key_pem.strip() + '\n') + try: + os.chmod(key_path, 0o600) + except OSError: + pass + return cert_path, key_path + + +@app.post('/api/settings/ssl/cloudflare/issue', tags=["Settings"]) +async def api_ssl_cloudflare_issue(request: Request, req: CloudflareIssueRequest): + """Issue (or renew) the panel's Let's Encrypt certificate via Cloudflare + dns-01. Does not bind port 80/443, so it works while the reverse proxy owns + 443. On success the cert is saved and SSL is enabled with provider=cloudflare.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + + from managers import cloudflare_ssl + + data = load_data() + ssl_conf = data.get('settings', {}).get('ssl', {}) + + domain = (req.domain or ssl_conf.get('domain', '')).strip().lower() + token = (req.cf_token or ssl_conf.get('cf_token', '')).strip() + email = (req.acme_email or ssl_conf.get('acme_email', '')).strip() + staging = bool(req.acme_staging) + + if not domain: + return JSONResponse({'error': 'Domain is required'}, status_code=400) + if not token: + return JSONResponse({'error': 'Cloudflare API token is required'}, status_code=400) + + try: + result = await asyncio.to_thread( + cloudflare_ssl.issue_certificate, + domain=domain, + cf_token=token, + email=email, + staging=staging, + ) + except cloudflare_ssl.CloudflareError as e: + logger.warning("Cloudflare error issuing cert: %s", e) + return JSONResponse({'error': f'Cloudflare: {e}'}, status_code=400) + except cloudflare_ssl.ACMEError as e: + logger.warning("ACME error issuing cert: %s", e) + return JSONResponse({'error': f'ACME: {e}'}, status_code=400) + except Exception as e: + logger.exception("Unexpected error issuing certificate") + return JSONResponse({'error': str(e)}, status_code=500) + + cert_path, key_path = _write_cf_cert_files(result['cert_pem'], result['key_pem']) + + # Persist config: enable SSL, switch provider, remember cert metadata & token. + data = load_data() + ssl_block = data['settings'].setdefault('ssl', {}) + ssl_block.update({ + 'enabled': True, + 'provider': 'cloudflare', + 'domain': domain, + 'cf_token': token, + 'acme_email': email, + 'acme_staging': staging, + 'cert_path': cert_path, + 'key_path': key_path, + # Clear pasted text so uvicorn uses the freshly issued files. + 'cert_text': '', + 'key_text': '', + 'cf_cert': { + 'issued_at': result.get('issued_at'), + 'expires_at': result.get('expires_at'), + 'staging': staging, + }, + }) + save_data(data) + + return { + 'status': 'success', + 'domain': domain, + 'issued_at': result.get('issued_at'), + 'expires_at': result.get('expires_at'), + 'staging': staging, + 'restart_required': True, + } + + +@app.get('/api/settings/ssl/status', tags=["Settings"]) +async def api_ssl_status(request: Request): + """Report current SSL/cert status for the settings UI.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + ssl_conf = data.get('settings', {}).get('ssl', {}) + cf_cert = ssl_conf.get('cf_cert', {}) or {} + expires_at = cf_cert.get('expires_at') + days_left = None + needs_renew = False + if expires_at: + try: + from managers import cloudflare_ssl + from datetime import datetime as _dt, timezone as _tz + exp = _dt.fromisoformat(expires_at) + if exp.tzinfo is None: + exp = exp.replace(tzinfo=_tz.utc) + days_left = (exp - _dt.now(_tz.utc)).days + needs_renew = cloudflare_ssl.needs_renewal(expires_at) + except Exception: + pass + return { + 'enabled': bool(ssl_conf.get('enabled')), + 'provider': ssl_conf.get('provider', 'manual'), + 'domain': ssl_conf.get('domain', ''), + 'has_cf_token': bool(ssl_conf.get('cf_token')), + 'issued_at': cf_cert.get('issued_at'), + 'expires_at': expires_at, + 'staging': cf_cert.get('staging', False), + 'days_left': days_left, + 'needs_renewal': needs_renew, + } + + +@app.post('/api/settings/telegram/toggle', tags=["Settings"]) +async def api_telegram_toggle(request: Request): + """Quick enable/disable of the bot without a full settings save.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + tg_cfg = data.get('settings', {}).get('telegram', {}) + token = tg_cfg.get('token', '') + if not token: + return JSONResponse({'error': 'Telegram token not set in settings'}, status_code=400) + + if tg_bot.is_running(): + await tg_bot.stop_bot() + tg_cfg['enabled'] = False + data['settings']['telegram'] = tg_cfg + save_data(data) + return {'status': 'stopped', 'bot_running': False} + else: + tg_bot.launch_bot(token, load_data, generate_vpn_link) + tg_cfg['enabled'] = True + data['settings']['telegram'] = tg_cfg + save_data(data) + return {'status': 'started', 'bot_running': True} + +@app.post('/api/settings/sync_now', tags=["Settings"]) +async def api_sync_now(request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + count, msg = await sync_users_with_remnawave(data) + return {'status': 'success', 'count': count, 'message': msg} + + +@app.post('/api/settings/sync_delete', tags=["Settings"]) +async def api_sync_delete(request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + to_delete_ids = [u['id'] for u in data['users'] if u.get('remnawave_uuid')] + if to_delete_ids: + await perform_mass_operations(delete_uids=to_delete_ids) + return {'status': 'success', 'count': len(to_delete_ids)} + + +@app.get('/api/servers/{server_id}/{protocol}/clients', tags=["Connections"]) +async def api_get_server_clients(request: Request, server_id: int, protocol: str): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, protocol) + clients = manager.get_clients(protocol) + ssh.disconnect() + + # Filter: only show clients that are not assigned to anyone in the panel + assigned_ids = {c['client_id'] for c in data.get('user_connections', []) if c['server_id'] == server_id and c['protocol'] == protocol} + + filtered = [] + for c in clients: + if c['clientId'] not in assigned_ids: + filtered.append({ + 'id': c['clientId'], + 'name': c.get('userData', {}).get('clientName', 'Unnamed') + }) + + return {'clients': filtered} + except Exception as e: + logger.exception("Error getting server clients") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.get('/api/settings/tokens', tags=["API Tokens"]) +async def api_list_tokens(request: Request): + """List metadata for every API token. The raw token value is never + returned by this endpoint — only its prefix and timestamps are visible + after creation, by design.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + data = load_data() + users_by_id = {u['id']: u for u in data.get('users', [])} + tokens = [] + for t in data.get('api_tokens', []): + owner = users_by_id.get(t.get('user_id')) + tokens.append({ + 'id': t.get('id'), + 'name': t.get('name', ''), + 'token_prefix': t.get('token_prefix', ''), + 'created_at': t.get('created_at'), + 'last_used_at': t.get('last_used_at'), + 'owner': owner['username'] if owner else None, + 'owner_id': t.get('user_id'), + }) + return {'tokens': tokens} + + +@app.post('/api/settings/tokens', tags=["API Tokens"]) +async def api_create_token(request: Request, req: CreateApiTokenRequest): + """Issue a new bearer token. The full token value is returned **once** in + the response and never persisted in plaintext — only its SHA-256 hash is + stored, so a leaked data.json file alone cannot be used to authenticate. + Save the value at creation time; if it's lost the token must be recreated. + """ + cur = _check_admin(request) + if not cur: + return JSONResponse({'error': 'Forbidden'}, status_code=403) + name = (req.name or '').strip() + if not name: + return JSONResponse({'error': 'Token name is required'}, status_code=400) + + raw = _generate_api_token() + token_id = str(uuid.uuid4()) + # Show enough of the token in the UI to identify it later, but not enough + # to reconstruct it: the prefix + first 4 chars of the secret part. + token_prefix = raw[:len(API_TOKEN_PREFIX) + 4] + + entry = { + 'id': token_id, + 'name': name, + 'token_hash': _hash_api_token(raw), + 'token_prefix': token_prefix, + 'user_id': cur['id'], + 'created_at': datetime.now().isoformat(), + 'last_used_at': None, + } + async with DATA_LOCK: + data = load_data() + data.setdefault('api_tokens', []).append(entry) + save_data(data) + + # `token` is returned only here — subsequent reads will not see it. + return { + 'status': 'success', + 'id': token_id, + 'name': name, + 'token': raw, + 'token_prefix': token_prefix, + 'created_at': entry['created_at'], + } + + +@app.delete('/api/settings/tokens/{token_id}', tags=["API Tokens"]) +async def api_revoke_token(request: Request, token_id: str): + """Permanently revoke a token. The associated bearer value can never be + used again, even if the same name is reissued — every token has its own hash.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + async with DATA_LOCK: + data = load_data() + before = len(data.get('api_tokens', [])) + data['api_tokens'] = [t for t in data.get('api_tokens', []) if t.get('id') != token_id] + if len(data['api_tokens']) == before: + return JSONResponse({'error': 'Token not found'}, status_code=404) + save_data(data) + return {'status': 'success'} + + +@app.get('/api/settings/backup/download', tags=["Settings"]) +async def api_backup_download(request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + if not os.path.exists(DATA_FILE): + return JSONResponse({'error': 'Data file not found'}, status_code=404) + return FileResponse(DATA_FILE, media_type='application/json', filename='data.json') + + +@app.post('/api/settings/backup/restore', tags=["Settings"]) +async def api_backup_restore(request: Request, file: UploadFile = File(...)): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + content = await file.read() + if not content: + return JSONResponse({'error': 'Empty file'}, status_code=400) + + try: + backup_data = json.loads(content) + except json.JSONDecodeError: + return JSONResponse({'error': 'Invalid JSON format'}, status_code=400) + + # Basic structure validation + required_keys = ['servers', 'users'] + missing = [k for k in required_keys if k not in backup_data] + if missing: + return JSONResponse({'error': f'Invalid structure. Missing keys: {", ".join(missing)}'}, status_code=400) + + # Ensure types are correct + if not isinstance(backup_data['servers'], list) or not isinstance(backup_data['users'], list): + return JSONResponse({'error': 'Invalid structure: servers and users must be lists'}, status_code=400) + + # Save the new data + async with DATA_LOCK: + save_data(backup_data) + + # In a real app we might want to restart or re-init background tasks + return {'status': 'success'} + except Exception as e: + logger.exception("Error during restore") + return JSONResponse({'error': str(e)}, status_code=500) + + +def _maybe_renew_cloudflare_cert(data): + """On startup, auto-renew the Cloudflare-issued cert if it's within 30 days + of expiry. Runs synchronously before uvicorn binds the port. Failures are + non-fatal: we keep serving the existing cert (or fall back to HTTP).""" + ssl_conf = data.get('settings', {}).get('ssl', {}) + if not (ssl_conf.get('enabled') and ssl_conf.get('provider') == 'cloudflare'): + return + if not (ssl_conf.get('domain') and ssl_conf.get('cf_token')): + return + + try: + from managers import cloudflare_ssl + except Exception as e: + logger.warning("Cloudflare SSL module unavailable, skipping renewal: %s", e) + return + + cf_cert = ssl_conf.get('cf_cert', {}) or {} + cert_path, key_path = _cf_cert_paths() + cert_exists = os.path.exists(cert_path) and os.path.exists(key_path) + + if cert_exists and not cloudflare_ssl.needs_renewal(cf_cert.get('expires_at')): + return # still valid + + logger.info("Cloudflare certificate needs (re)issuing — attempting now...") + try: + result = cloudflare_ssl.issue_certificate( + domain=ssl_conf['domain'], + cf_token=ssl_conf['cf_token'], + email=ssl_conf.get('acme_email', ''), + staging=bool(ssl_conf.get('acme_staging')), + ) + _write_cf_cert_files(result['cert_pem'], result['key_pem']) + ssl_conf['cert_path'] = cert_path + ssl_conf['key_path'] = key_path + ssl_conf['cf_cert'] = { + 'issued_at': result.get('issued_at'), + 'expires_at': result.get('expires_at'), + 'staging': bool(ssl_conf.get('acme_staging')), + } + save_data(data) + logger.info("Cloudflare certificate renewed, valid until %s", result.get('expires_at')) + except Exception as e: + logger.error("Automatic Cloudflare certificate renewal failed: %s", e) + + +if __name__ == '__main__': + data = load_data() + + # Auto-renew the Cloudflare panel cert before we bind the socket. + _maybe_renew_cloudflare_cert(data) + data = load_data() + ssl_conf = data.get('settings', {}).get('ssl', {}) + + cert_file = ssl_conf.get('cert_path') + key_file = ssl_conf.get('key_path') + + # If text is provided, create temporary files + temp_dir = os.path.join(os.getcwd(), 'ssl_temp') + if ssl_conf.get('enabled'): + if ssl_conf.get('cert_text') or ssl_conf.get('key_text'): + if not os.path.exists(temp_dir): + os.makedirs(temp_dir) + + if ssl_conf.get('cert_text'): + cert_file = os.path.join(temp_dir, 'cert.pem') + with open(cert_file, 'w') as f: + f.write(ssl_conf['cert_text'].strip() + '\n') + + if ssl_conf.get('key_text'): + key_file = os.path.join(temp_dir, 'key.pem') + with open(key_file, 'w') as f: + f.write(ssl_conf['key_text'].strip() + '\n') + + uvicorn_kwargs = { + "app": app, + "host": "0.0.0.0", + "port": ssl_conf.get('panel_port', 5000) + } + + if ssl_conf.get('enabled') and cert_file and key_file: + if os.path.exists(cert_file) and os.path.exists(key_file): + logger.info(f"Starting panel with HTTPS enabled on domain: {ssl_conf.get('domain')} at port {uvicorn_kwargs['port']}") + uvicorn_kwargs["ssl_certfile"] = cert_file + uvicorn_kwargs["ssl_keyfile"] = key_file + else: + logger.error("SSL certificates not found at specified paths. Starting with HTTP.") + + uvicorn.run(**uvicorn_kwargs) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..79d4b6b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: '3.8' + +services: + amnezia_panel: + image: prvtpro/amnezia-panel:1.4.4 + container_name: amnezia_panel + + ports: + - "${APP_PORT:-5000}:5000" + + volumes: + - amnezia_data:/app/data + + restart: unless-stopped + + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import socket; s=socket.socket(); s.connect(('localhost', 5000)); s.close()\""] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + amnezia_data: diff --git a/managers/__init__.py b/managers/__init__.py new file mode 100644 index 0000000..73891c1 --- /dev/null +++ b/managers/__init__.py @@ -0,0 +1,7 @@ +"""Protocol/service/SSH managers used by the web panel. + +Modules in this package are imported either directly (`from managers.ssh_manager import SSHManager`) +or lazily by name through `app.get_protocol_manager`. Keeping them in a dedicated package +makes the project root easier to scan and prevents accidental name collisions with the +generic stdlib (e.g. `socks5_manager`, `dns_manager`). +""" diff --git a/managers/adguard_manager.py b/managers/adguard_manager.py new file mode 100644 index 0000000..cf36bdd --- /dev/null +++ b/managers/adguard_manager.py @@ -0,0 +1,229 @@ +""" +AdGuard Home Manager — runs adguard/adguardhome in a Docker container, +joined to the same internal `amnezia-dns-net` network the rest of the panel +uses. Two install modes: + + * 'replace' — removes the AmneziaDNS container (if present) and takes + its static IP (172.29.172.254) so VPN clients keep using + the same upstream. + * 'sidebyside' — runs alongside AmneziaDNS on a different static IP + (172.29.172.253). VPN users can hit it on demand + (e.g. via the web admin UI from inside the tunnel). + +Initial setup of AdGuard itself runs through its built-in wizard on the web +UI port — we do not try to script it (the JSON setup API is unstable across +versions and trying to drive it programmatically tends to break on upgrade). +""" + +import logging + +logger = logging.getLogger(__name__) + + +class AdguardManager: + PROTOCOL = 'adguard' + CONTAINER_NAME = 'amnezia-adguard' + IMAGE_NAME = 'adguard/adguardhome:latest' + + NETWORK_NAME = 'amnezia-dns-net' + NETWORK_SUBNET = '172.29.172.0/24' + REPLACE_IP = '172.29.172.254' # AmneziaDNS's slot + SIDEBYSIDE_IP = '172.29.172.253' # parallel to AmneziaDNS + + HOST_DIR = '/opt/amnezia/adguard' + DEFAULT_DNS_PORT = 53 + DEFAULT_WEB_PORT = 3000 + DEFAULT_DOT_PORT = 853 + DEFAULT_DOH_PORT = 443 + + def __init__(self, ssh): + self.ssh = ssh + + # ===================== STATUS ===================== + + def check_docker_installed(self): + out, _, code = self.ssh.run_command("docker --version 2>/dev/null") + if code != 0: + return False + out2, _, _ = self.ssh.run_command( + "systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null" + ) + return 'active' in out2 or 'running' in out2.lower() + + def check_protocol_installed(self, protocol_type='adguard'): + out, _, _ = self.ssh.run_sudo_command( + f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'" + ) + return self.CONTAINER_NAME in out.strip().split('\n') + + def check_container_running(self): + out, _, _ = self.ssh.run_sudo_command( + f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'" + ) + return 'Up' in out + + def _container_ip(self): + out, _, _ = self.ssh.run_sudo_command( + f"docker inspect -f '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}} {{{{end}}}}' {self.CONTAINER_NAME} 2>/dev/null" + ) + ip = out.strip().split()[0] if out.strip() else '' + return ip + + def _detect_mode(self): + """Return 'replace' or 'sidebyside' based on the running container's IP. + Returns None if not detectable (container not running).""" + ip = self._container_ip() + if ip == self.REPLACE_IP: + return 'replace' + if ip == self.SIDEBYSIDE_IP: + return 'sidebyside' + return None + + def _exposed_web_port(self): + """Reads back the host->container port mapping for the web UI port, + so the panel can show the correct admin URL after install.""" + out, _, _ = self.ssh.run_sudo_command( + f"docker port {self.CONTAINER_NAME} 3000/tcp 2>/dev/null" + ) + if not out.strip(): + return None + # output like "0.0.0.0:3000" — take the last colon-separated chunk + last = out.strip().split('\n')[0].split(':')[-1].strip() + try: + return int(last) + except ValueError: + return None + + def get_server_status(self, protocol_type='adguard'): + exists = self.check_protocol_installed() + running = self.check_container_running() + mode = self._detect_mode() if running else None + ip = self._container_ip() if running else '' + exposed_port = self._exposed_web_port() if running else None + # When the web UI is not bound to the host the user still needs an + # admin URL (reachable via VPN) — fall back to the default :3000 the + # container listens on inside the docker network. + return { + 'container_exists': exists, + 'container_running': running, + 'mode': mode, + 'internal_ip': ip, + 'web_port': exposed_port or self.DEFAULT_WEB_PORT, + 'web_exposed': exposed_port is not None, + 'port': self.DEFAULT_DNS_PORT, + 'protocol': protocol_type, + } + + # ===================== INSTALL / REMOVE ===================== + + def _ensure_network(self): + self.ssh.run_sudo_command( + f"docker network ls | grep -q {self.NETWORK_NAME} || " + f"docker network create --subnet {self.NETWORK_SUBNET} {self.NETWORK_NAME}" + ) + + def install_protocol( + self, + protocol_type='adguard', + mode='sidebyside', + web_port=None, + expose_web=False, + dns_port=None, + dot_port=None, + doh_port=None, + expose_dns=False, + expose_dot=False, + expose_doh=False, + ): + if not self.check_docker_installed(): + return {'status': 'error', 'message': 'Docker not installed'} + if mode not in ('replace', 'sidebyside'): + return {'status': 'error', 'message': f"Invalid mode '{mode}'"} + + web_port = int(web_port or self.DEFAULT_WEB_PORT) + dns_port = int(dns_port or self.DEFAULT_DNS_PORT) + dot_port = int(dot_port or self.DEFAULT_DOT_PORT) + doh_port = int(doh_port or self.DEFAULT_DOH_PORT) + + # Persistent volumes — without these the AdGuard setup wizard would have + # to re-run on every container recreate. + self.ssh.run_sudo_command(f"mkdir -p {self.HOST_DIR}/work {self.HOST_DIR}/conf") + + self._ensure_network() + + # Replace mode: detach + remove AmneziaDNS so we can claim its IP. + if mode == 'replace': + self.ssh.run_sudo_command( + f"docker network disconnect {self.NETWORK_NAME} amnezia-dns 2>/dev/null || true" + ) + self.ssh.run_sudo_command("docker stop amnezia-dns 2>/dev/null || true") + self.ssh.run_sudo_command("docker rm -fv amnezia-dns 2>/dev/null || true") + target_ip = self.REPLACE_IP + else: + target_ip = self.SIDEBYSIDE_IP + + if self.check_protocol_installed(): + self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} 2>/dev/null || true") + self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} 2>/dev/null || true") + + self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}") + + # Build port mapping. By default ports are reachable only inside + # `amnezia-dns-net` (so VPN clients hit them via the static IP). + # Optional `expose_*` flags add host port mappings for direct access. + ports = [] + if expose_web: + ports.append(f"-p {web_port}:3000/tcp") + if expose_dns: + ports.append(f"-p {dns_port}:53/tcp") + ports.append(f"-p {dns_port}:53/udp") + if expose_dot: + ports.append(f"-p {dot_port}:853/tcp") + if expose_doh: + ports.append(f"-p {doh_port}:443/tcp") + ports_str = ' '.join(ports) + + run_cmd = ( + f"docker run -d --name {self.CONTAINER_NAME} --restart always " + f"--network {self.NETWORK_NAME} --ip {target_ip} " + f"-v {self.HOST_DIR}/work:/opt/adguardhome/work " + f"-v {self.HOST_DIR}/conf:/opt/adguardhome/conf " + f"{ports_str} " + f"{self.IMAGE_NAME}" + ) + _, err, code = self.ssh.run_sudo_command(run_cmd) + if code != 0: + return {'status': 'error', 'message': f'Failed to start container: {err}'} + + # Re-attach known VPN containers to the DNS network so they can reach + # AdGuard at target_ip (mirrors what dns_manager.py does on install). + for c in ('amnezia-awg', 'amnezia-awg2', 'amnezia-awg-legacy', 'amnezia-xray', 'amnezia-wireguard', 'telemt'): + self.ssh.run_sudo_command( + f"docker ps --format '{{{{.Names}}}}' | grep -q '^{c}$' && " + f"docker network connect {self.NETWORK_NAME} {c} 2>/dev/null || true" + ) + + url_host = self.ssh.host if expose_web else target_ip + admin_url = f"http://{url_host}:{web_port}" + return { + 'status': 'success', + 'protocol': 'adguard', + 'mode': mode, + 'internal_ip': target_ip, + 'web_port': web_port, + 'expose_web': bool(expose_web), + 'admin_url': admin_url, + 'message': 'AdGuard Home installed. Complete the setup wizard via the web UI.', + 'log': [ + f"AdGuard Home installed in '{mode}' mode", + f"Internal IP: {target_ip}", + f"Admin UI: {admin_url}" + ("" if expose_web else " (VPN-only — connect via VPN to reach it)"), + 'Open the URL above to run the AdGuard setup wizard.', + ], + } + + def remove_container(self, protocol_type='adguard'): + self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true") + self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true") + self.ssh.run_sudo_command(f"rm -rf {self.HOST_DIR}") + return True diff --git a/managers/awg_manager.py b/managers/awg_manager.py new file mode 100644 index 0000000..a38e6f6 --- /dev/null +++ b/managers/awg_manager.py @@ -0,0 +1,1298 @@ +""" +AWG Protocol Manager - handles AmneziaWG and AmneziaWG-Legacy protocol +installation, configuration, and client management on remote servers. + +Replicates the logic from: +- client/server_scripts/awg/ and awg_legacy/ +- client/configurators/wireguard_configurator.cpp +- client/ui/models/clientManagementModel.cpp +""" + +import json +import os +import secrets +import struct +import hashlib +import logging +import re +from base64 import b64encode, b64decode +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from cryptography.hazmat.primitives import serialization + +logger = logging.getLogger(__name__) + +# Default AWG parameters (from protocols_defs.h) +AWG_DEFAULTS = { + 'port': '55424', + 'mtu': '1280', + 'subnet_address': '10.8.1.0', + 'subnet_cidr': '24', + 'subnet_ip': '10.8.1.1', + 'dns1': '1.1.1.1', + 'dns2': '1.0.0.1', + # AWG obfuscation parameters + 'junk_packet_count': '3', + 'junk_packet_min_size': '10', + 'junk_packet_max_size': '30', + 'init_packet_junk_size': '15', + 'response_packet_junk_size': '18', + 'cookie_reply_packet_junk_size': '20', + 'transport_packet_junk_size': '23', + 'init_packet_magic_header': '1020325451', + 'response_packet_magic_header': '3288052141', + 'transport_packet_magic_header': '2528465083', + 'underload_packet_magic_header': '1766607858', +} + + +def generate_wg_keypair(): + """Generate a WireGuard X25519 keypair (private, public) as base64 strings.""" + private_key = X25519PrivateKey.generate() + private_bytes = private_key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption() + ) + public_bytes = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw + ) + return b64encode(private_bytes).decode(), b64encode(public_bytes).decode() + + +def generate_psk(): + """Generate a WireGuard preshared key.""" + return b64encode(secrets.token_bytes(32)).decode() + + +def generate_awg_params(use_ranges=False): + """Generate random AWG obfuscation parameters. + + For AWG 2.0 (use_ranges=True): generates H1-H4 as non-overlapping + ranges (min-max) for dynamic packet signature. Each packet gets a + random value from its range, defeating static DPI signatures. + For legacy AWG (use_ranges=False): generates fixed single H values. + """ + import random + + jc = random.randint(1, 10) + jmin = random.randint(5, 20) + jmax = random.randint(jmin + 10, jmin + 50) + s1 = random.randint(10, 50) + s2 = random.randint(10, 50) + s3 = random.randint(10, 50) + s4 = random.randint(10, 50) + + if use_ranges: + # AWG 2.0: H1-H4 as non-overlapping ranges (min-max) + # Split [1B, 4.29B] into 4 equal zones, pick random sub-range in each + # Guarantees no intersections between H1-H4 per AWG 2.0 spec + def make_ranges(total_min=1000000000, total_max=4294967295): + zone_size = (total_max - total_min) // 4 + result = [] + for i in range(4): + z_start = total_min + i * zone_size + z_end = z_start + zone_size - 1 + padding = min(100000, zone_size // 4) + a = random.randint(z_start + padding, z_end - padding) + b = random.randint(a + 1, z_end) + result.append(f"{a}-{b}") + return result + + h1, h2, h3, h4 = make_ranges() + else: + h1 = str(random.randint(100000000, 4294967295)) + h2 = str(random.randint(100000000, 4294967295)) + h3 = str(random.randint(100000000, 4294967295)) + h4 = str(random.randint(100000000, 4294967295)) + + return { + 'junk_packet_count': str(jc), + 'junk_packet_min_size': str(jmin), + 'junk_packet_max_size': str(jmax), + 'init_packet_junk_size': str(s1), + 'response_packet_junk_size': str(s2), + 'cookie_reply_packet_junk_size': str(s3), + 'transport_packet_junk_size': str(s4), + 'init_packet_magic_header': h1, + 'response_packet_magic_header': h2, + 'underload_packet_magic_header': h3, + 'transport_packet_magic_header': h4, + } + + +class AWGManager: + """Manages AmneziaWG protocol installation and client management.""" + + # Protocol types + AWG = 'awg' # New AWG (awg-go based, uses awg/awg-quick) + AWG_LEGACY = 'awg_legacy' # Legacy AWG (uses wg/wg-quick) + AWG2 = 'awg2' # AmneziaWG 2.0 (separate container amnezia-awg2) + + def __init__(self, ssh_manager): + self.ssh = ssh_manager + + def _container_name(self, protocol_type): + """Get Docker container name for protocol type.""" + if protocol_type == self.AWG_LEGACY: + return 'amnezia-awg-legacy' + if protocol_type == self.AWG2: + return 'amnezia-awg2' + return 'amnezia-awg' + + def _config_path(self, protocol_type): + """Get server config path inside container.""" + if protocol_type == self.AWG_LEGACY: + return '/opt/amnezia/awg/wg0.conf' + # Both AWG and AWG2 use awg0.conf + return '/opt/amnezia/awg/awg0.conf' + + def _config_path_candidates(self, protocol_type): + """Return possible config paths, ordered by the expected path first.""" + expected = self._config_path(protocol_type) + fallback = '/opt/amnezia/awg/awg0.conf' if protocol_type == self.AWG_LEGACY else '/opt/amnezia/awg/wg0.conf' + return [expected, fallback] + + def _resolve_config_path(self, protocol_type): + """Resolve the real config path in existing containers. + + AWG Legacy should use wg0.conf, but some older or manually modified + installations may have a different file name. Resolve the existing file + instead of requiring users to create symlinks inside the container. + """ + container_name = self._container_name(protocol_type) + candidates = self._config_path_candidates(protocol_type) + paths = ' '.join(candidates) + script = f'for p in {paths}; do if [ -f "$p" ]; then echo "$p"; exit 0; fi; done; exit 1' + out, _, code = self.ssh.run_sudo_command( + f"docker exec -i {container_name} sh -c '{script}'" + ) + if code == 0 and out.strip(): + return out.strip().splitlines()[0] + return self._config_path(protocol_type) + + def _wg_binary(self, protocol_type): + """Get the wireguard binary name.""" + if protocol_type == self.AWG_LEGACY: + return 'wg' + # AWG and AWG2 both use 'awg' binary + return 'awg' + + + def _quick_binary(self, protocol_type): + """Get the wireguard-quick binary name.""" + if protocol_type == self.AWG_LEGACY: + return 'wg-quick' + # AWG and AWG2 both use 'awg-quick' + return 'awg-quick' + + + def _interface_name(self, protocol_type, config_path=None): + """Get the interface name.""" + if config_path: + return os.path.splitext(os.path.basename(config_path))[0] + if protocol_type == self.AWG_LEGACY: + return 'wg0' + # AWG and AWG2 both use 'awg0' interface + return 'awg0' + + def _docker_image(self, protocol_type): + """Get Docker image for protocol type.""" + if protocol_type in (self.AWG, self.AWG2): + return 'amneziavpn/amneziawg-go:latest' + return 'amneziavpn/amnezia-wg:latest' + + def _clients_table_path(self): + """Path to the clients table file inside container.""" + return '/opt/amnezia/awg/clientsTable' + + def _get_subnet_ip(self, protocol_type): + """Get the subnet IP (gateway) from server config, or fallback to default.""" + try: + config = self._get_server_config(protocol_type) + for line in config.split('\n'): + if line.startswith('Address'): + addr = line.split('=')[1].strip() + ip = addr.split('/')[0] + return ip + except Exception: + pass + return AWG_DEFAULTS['subnet_ip'] + + def _get_subnet_cidr(self, protocol_type): + """Get the subnet CIDR from server config, or fallback to default.""" + try: + config = self._get_server_config(protocol_type) + for line in config.split('\n'): + if line.startswith('Address'): + addr = line.split('=')[1].strip() + if '/' in addr: + return addr.split('/')[1] + except Exception: + pass + return AWG_DEFAULTS['subnet_cidr'] + + def _get_subnet_base(self, protocol_type): + """Get the subnet network address (e.g. 172.16.21.0) from server config.""" + subnet_ip = self._get_subnet_ip(protocol_type) + cidr = int(self._get_subnet_cidr(protocol_type)) + parts = list(map(int, subnet_ip.split('.'))) + mask = (0xFFFFFFFF << (32 - cidr)) & 0xFFFFFFFF + network = struct.pack('!I', (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) + net_int = struct.unpack('!I', network)[0] & mask + net_parts = [(net_int >> 24) & 0xFF, (net_int >> 16) & 0xFF, (net_int >> 8) & 0xFF, net_int & 0xFF] + return '.'.join(map(str, net_parts)) + + # ===================== INSTALLATION ===================== + + def check_docker_installed(self): + """Check if Docker is installed and running.""" + out, err, code = self.ssh.run_command("docker --version 2>/dev/null") + if code != 0: + return False + out2, _, code2 = self.ssh.run_command("systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null") + return 'active' in out2 or 'running' in out2.lower() + + def install_docker(self): + """Install Docker on the server (mirrors install_docker.sh).""" + script = r""" +if which apt-get > /dev/null 2>&1; then pm=$(which apt-get); silent_inst="-yq install"; check_pkgs="-yq update"; docker_pkg="docker.io"; dist="debian"; +elif which dnf > /dev/null 2>&1; then pm=$(which dnf); silent_inst="-yq install"; check_pkgs="-yq check-update"; docker_pkg="docker"; dist="fedora"; +elif which yum > /dev/null 2>&1; then pm=$(which yum); silent_inst="-y -q install"; check_pkgs="-y -q check-update"; docker_pkg="docker"; dist="centos"; +elif which zypper > /dev/null 2>&1; then pm=$(which zypper); silent_inst="-nq install"; check_pkgs="-nq refresh"; docker_pkg="docker"; dist="opensuse"; +elif which pacman > /dev/null 2>&1; then pm=$(which pacman); silent_inst="-S --noconfirm --noprogressbar --quiet"; check_pkgs="-Sup"; docker_pkg="docker"; dist="archlinux"; +else echo "Packet manager not found"; exit 1; fi; +echo "Dist: $dist, Packet manager: $pm"; +if [ "$dist" = "debian" ]; then export DEBIAN_FRONTEND=noninteractive; fi; +if ! command -v docker > /dev/null 2>&1; then + $pm $check_pkgs; $pm $silent_inst $docker_pkg; + sleep 5; systemctl enable --now docker; sleep 5; +fi; +if [ "$(systemctl is-active docker)" != "active" ]; then + $pm $check_pkgs; $pm $silent_inst $docker_pkg; + sleep 5; systemctl start docker; sleep 5; +fi; +docker --version +""" + out, err, code = self.ssh.run_sudo_script(script, timeout=180) + if code != 0: + raise RuntimeError(f"Failed to install Docker: {err}") + return out + + def check_container_running(self, protocol_type): + """Check if AWG container is running.""" + container_name = self._container_name(protocol_type) + # Use ^name$ for exact match (Docker name filter does substring match) + out, _, code = self.ssh.run_sudo_command( + f"docker ps --filter name=^{container_name}$ --format '{{{{.Status}}}}'" + ) + return 'Up' in out + + def check_protocol_installed(self, protocol_type): + """Check if protocol is installed (container exists).""" + container_name = self._container_name(protocol_type) + out, _, code = self.ssh.run_sudo_command( + f"docker ps -a --filter name=^{container_name}$ --format '{{{{.Names}}}}'" + ) + # Exact match check + return container_name in out.strip().split('\n') + + def prepare_host(self, protocol_type): + """Prepare host for container (mirrors prepare_host.sh).""" + container_name = self._container_name(protocol_type) + dockerfile_folder = f"/opt/amnezia/{container_name}" + script = f""" +mkdir -p {dockerfile_folder} +if ! docker network ls | grep -q amnezia-dns-net; then + docker network create --driver bridge --subnet=172.29.172.0/24 --opt com.docker.network.bridge.name=amn0 amnezia-dns-net +fi +""" + out, err, code = self.ssh.run_sudo_script(script) + if code != 0: + logger.warning(f"prepare_host warning: {err}") + return True + + def setup_firewall(self): + """Setup host firewall (mirrors setup_host_firewall.sh).""" + script = """ +sysctl -w net.ipv4.ip_forward=1 +iptables -C INPUT -p icmp --icmp-type echo-request -j DROP 2>/dev/null || iptables -A INPUT -p icmp --icmp-type echo-request -j DROP +iptables -C FORWARD -j DOCKER-USER 2>/dev/null || iptables -A FORWARD -j DOCKER-USER 2>/dev/null +""" + self.ssh.run_sudo_script(script) + return True + + def install_protocol(self, protocol_type, port=None, awg_params=None): + """ + Full installation of AWG or AWG-Legacy protocol. + Steps: install docker -> prepare host -> build container -> + configure container -> run container -> setup firewall + """ + if port is None: + port = AWG_DEFAULTS['port'] + + if awg_params is None: + awg_params = generate_awg_params(use_ranges=(protocol_type in (self.AWG, self.AWG2))) + + container_name = self._container_name(protocol_type) + docker_image = self._docker_image(protocol_type) + config_path = self._config_path(protocol_type) + wg_bin = self._wg_binary(protocol_type) + quick_bin = self._quick_binary(protocol_type) + iface = self._interface_name(protocol_type) + + results = [] + + # Step 1: Install Docker + if not self.check_docker_installed(): + results.append("Installing Docker...") + self.install_docker() + results.append("Docker installed successfully") + else: + results.append("Docker already installed") + + # Step 2: Prepare host + results.append("Preparing host...") + self.prepare_host(protocol_type) + results.append("Host prepared") + + # Step 3: Remove old container if exists + if self.check_protocol_installed(protocol_type): + results.append("Removing old container...") + self.remove_container(protocol_type) + results.append("Old container removed") + + # Step 4: Build/Pull container + results.append("Pulling Docker image...") + dockerfile_folder = f"/opt/amnezia/{container_name}" + + # Create Dockerfile - matches original from client/server_scripts/awg/ + dockerfile_content = ( + f"FROM {docker_image}\n" + f"\n" + f'LABEL maintainer="AmneziaVPN"\n' + f"\n" + f"RUN apk add --no-cache bash curl dumb-init iptables\n" + f"RUN apk --update upgrade --no-cache\n" + f"\n" + f"RUN mkdir -p /opt/amnezia\n" + f'RUN echo "#!/bin/bash" > /opt/amnezia/start.sh && ' + f'echo "tail -f /dev/null" >> /opt/amnezia/start.sh\n' + f"RUN chmod a+x /opt/amnezia/start.sh\n" + f"\n" + f'ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]\n' + ) + self.ssh.run_sudo_command(f"mkdir -p {dockerfile_folder}") + self.ssh.upload_file_sudo(dockerfile_content, f"{dockerfile_folder}/Dockerfile") + + out, err, code = self.ssh.run_sudo_command( + f"docker build --no-cache --pull -t {container_name} {dockerfile_folder}", + timeout=300 + ) + if code != 0: + raise RuntimeError(f"Failed to build container: {err}") + results.append("Docker image built successfully") + + # Step 5: Run container + results.append("Starting container...") + run_cmd = f"""docker run -d \ +--restart always \ +--privileged \ +--cap-add=NET_ADMIN \ +--cap-add=SYS_MODULE \ +-p {port}:{port}/udp \ +-v /lib/modules:/lib/modules \ +--sysctl="net.ipv4.conf.all.src_valid_mark=1" \ +--name {container_name} \ +{container_name}""" + + out, err, code = self.ssh.run_sudo_command(run_cmd) + if code != 0: + raise RuntimeError(f"Failed to run container: {err}") + + # Connect to DNS network + self.ssh.run_sudo_command(f"docker network connect amnezia-dns-net {container_name}") + + # Wait for container to be fully running + results.append("Waiting for container to start...") + self._wait_container_running(container_name) + results.append("Container started") + + # Step 6: Configure container (generate server keys and config) + results.append("Configuring AWG...") + self._configure_container(protocol_type, port, awg_params) + results.append("AWG configured") + + # Step 7: Upload and run start script + results.append("Starting AWG service...") + self._upload_start_script(protocol_type, port, awg_params) + results.append("AWG service started") + + # Step 8: Setup firewall + results.append("Setting up firewall...") + self.setup_firewall() + results.append("Firewall configured") + + return { + 'status': 'success', + 'protocol': protocol_type, + 'port': port, + 'awg_params': awg_params, + 'log': results, + } + + def _wait_container_running(self, container_name, timeout=30): + """Wait for a container to be in 'running' state.""" + import time + last_status = 'unknown' + for i in range(timeout // 2): + out, _, _ = self.ssh.run_sudo_command( + f"docker inspect --format='{{{{.State.Status}}}}' {container_name}" + ) + last_status = out.strip().strip("'\"") + if last_status == 'running': + logger.info(f"Container {container_name} is running") + time.sleep(1) + return True + logger.info(f"Container {container_name} status: {last_status}, waiting...") + time.sleep(2) + + # Container failed to start — fetch logs for diagnostics + logs_out, _, _ = self.ssh.run_sudo_command( + f"docker logs --tail 50 {container_name} 2>&1" + ) + raise RuntimeError( + f"Container {container_name} did not start within {timeout}s " + f"(status: {last_status}). Logs:\n{logs_out}" + ) + + def _configure_container(self, protocol_type, port, awg_params): + """Configure the AWG container (generate keys and server config).""" + container_name = self._container_name(protocol_type) + wg_bin = self._wg_binary(protocol_type) + config_path = self._config_path(protocol_type) + + subnet_ip = self._get_subnet_ip(protocol_type) + subnet_cidr = self._get_subnet_cidr(protocol_type) + + # Build the server config generation script + if protocol_type in (self.AWG, self.AWG2): + config_script = f""" +mkdir -p /opt/amnezia/awg +cd /opt/amnezia/awg +WIREGUARD_SERVER_PRIVATE_KEY=$({wg_bin} genkey) +echo $WIREGUARD_SERVER_PRIVATE_KEY > /opt/amnezia/awg/wireguard_server_private_key.key + +WIREGUARD_SERVER_PUBLIC_KEY=$(echo $WIREGUARD_SERVER_PRIVATE_KEY | {wg_bin} pubkey) +echo $WIREGUARD_SERVER_PUBLIC_KEY > /opt/amnezia/awg/wireguard_server_public_key.key + +WIREGUARD_PSK=$({wg_bin} genpsk) +echo $WIREGUARD_PSK > /opt/amnezia/awg/wireguard_psk.key + +cat > {config_path} < /opt/amnezia/awg/wireguard_server_private_key.key + +WIREGUARD_SERVER_PUBLIC_KEY=$(echo $WIREGUARD_SERVER_PRIVATE_KEY | {wg_bin} pubkey) +echo $WIREGUARD_SERVER_PUBLIC_KEY > /opt/amnezia/awg/wireguard_server_public_key.key + +WIREGUARD_PSK=$({wg_bin} genpsk) +echo $WIREGUARD_PSK > /opt/amnezia/awg/wireguard_psk.key + +cat > {config_path} </dev/null + +# start daemons if configured +if [ -f {config_path} ]; then {quick_bin} up {config_path}; fi + +# Allow traffic on the TUN interface +IFACE=$(basename {config_path} .conf) +iptables -A INPUT -i $IFACE -j ACCEPT +iptables -A FORWARD -i $IFACE -j ACCEPT +iptables -A OUTPUT -o $IFACE -j ACCEPT + +# Allow forwarding traffic only from the VPN +iptables -A FORWARD -i $IFACE -o eth0 -s $SUBNET -j ACCEPT +iptables -A FORWARD -i $IFACE -o eth1 -s $SUBNET -j ACCEPT + +iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT + +iptables -t nat -A POSTROUTING -s $SUBNET -o eth0 -j MASQUERADE +iptables -t nat -A POSTROUTING -s $SUBNET -o eth1 -j MASQUERADE + +tail -f /dev/null +""" + + # Upload start script to container via SFTP + docker cp + self.ssh.upload_file(start_script, "/tmp/_amnz_start.sh") + self.ssh.run_sudo_command(f"docker cp /tmp/_amnz_start.sh {container_name}:/opt/amnezia/start.sh") + self.ssh.run_sudo_command(f"docker exec {container_name} chmod +x /opt/amnezia/start.sh") + self.ssh.run_command("rm -f /tmp/_amnz_start.sh") + + # Restart to apply the start script + self.ssh.run_sudo_command(f"docker restart {container_name}") + import time + time.sleep(5) + + def remove_container(self, protocol_type): + """Remove AWG container (mirrors remove_container.sh).""" + container_name = self._container_name(protocol_type) + self.ssh.run_sudo_command(f"docker stop {container_name}") + self.ssh.run_sudo_command(f"docker rm -fv {container_name}") + self.ssh.run_sudo_command(f"docker rmi {container_name}") + return True + + # ===================== CLIENT MANAGEMENT ===================== + + def _get_clients_table(self, protocol_type): + """Get the clients table from the server.""" + container_name = self._container_name(protocol_type) + clients_table_path = self._clients_table_path() + + out, err, code = self.ssh.run_sudo_command( + f"docker exec -i {container_name} cat {clients_table_path} 2>/dev/null" + ) + if code != 0 or not out.strip(): + return [] + + try: + data = json.loads(out) + if isinstance(data, list): + return data + elif isinstance(data, dict): + # Migration from old format + result = [] + for client_id, info in data.items(): + result.append({ + 'clientId': client_id, + 'userData': { + 'clientName': info.get('clientName', 'Unknown'), + } + }) + return result + except json.JSONDecodeError: + return [] + + def _save_clients_table(self, protocol_type, clients_table): + """Save the clients table to the server.""" + container_name = self._container_name(protocol_type) + clients_table_path = self._clients_table_path() + content = json.dumps(clients_table, indent=2) + + # Write to /tmp via SFTP, then docker cp into container + self.ssh.upload_file(content, "/tmp/_amnz_clients.json") + self.ssh.run_sudo_command( + f"docker cp /tmp/_amnz_clients.json {container_name}:{clients_table_path}" + ) + self.ssh.run_command("rm -f /tmp/_amnz_clients.json") + + def _get_server_config(self, protocol_type): + """Get the server WireGuard config.""" + container_name = self._container_name(protocol_type) + config_path = self._resolve_config_path(protocol_type) + + out, err, code = self.ssh.run_sudo_command( + f"docker exec -i {container_name} cat {config_path}" + ) + if code != 0: + raise RuntimeError(f"Failed to get server config: {err}") + return out + + def save_server_config(self, protocol_type, config_content): + """Save the server WireGuard config and restart container.""" + container_name = self._container_name(protocol_type) + config_path = self._resolve_config_path(protocol_type) + + # Upload new config into container via SFTP + docker cp + self.ssh.upload_file(config_content.replace('\r\n', '\n'), "/tmp/_amnz_edit_config.conf") + self.ssh.run_sudo_command(f"docker cp /tmp/_amnz_edit_config.conf {container_name}:{config_path}") + self.ssh.run_command("rm -f /tmp/_amnz_edit_config.conf") + + # Regenerate start script so iptables rules pick up the (possibly changed) subnet + quick_bin = self._quick_binary(protocol_type) + start_script = f"""#!/bin/bash +echo "Container startup" + +# Read subnet from server config dynamically +SUBNET=$(grep '^Address' {config_path} | head -1 | cut -d'=' -f2 | tr -d ' ') +if [ -z "$SUBNET" ]; then + SUBNET={AWG_DEFAULTS['subnet_ip']}/{AWG_DEFAULTS['subnet_cidr']} +fi + +# kill daemons in case of restart +{quick_bin} down {config_path} 2>/dev/null + +# start daemons if configured +if [ -f {config_path} ]; then {quick_bin} up {config_path}; fi + +# Allow traffic on the TUN interface +IFACE=$(basename {config_path} .conf) +iptables -A INPUT -i $IFACE -j ACCEPT +iptables -A FORWARD -i $IFACE -j ACCEPT +iptables -A OUTPUT -o $IFACE -j ACCEPT + +# Allow forwarding traffic only from the VPN +iptables -A FORWARD -i $IFACE -o eth0 -s $SUBNET -j ACCEPT +iptables -A FORWARD -i $IFACE -o eth1 -s $SUBNET -j ACCEPT + +iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT + +iptables -t nat -A POSTROUTING -s $SUBNET -o eth0 -j MASQUERADE +iptables -t nat -A POSTROUTING -s $SUBNET -o eth1 -j MASQUERADE + +tail -f /dev/null +""" + self.ssh.upload_file(start_script, "/tmp/_amnz_start.sh") + self.ssh.run_sudo_command(f"docker cp /tmp/_amnz_start.sh {container_name}:/opt/amnezia/start.sh") + self.ssh.run_sudo_command(f"docker exec {container_name} chmod +x /opt/amnezia/start.sh") + self.ssh.run_command("rm -f /tmp/_amnz_start.sh") + + # Restart container to apply all changes (including port and interface changes) + self.ssh.run_sudo_command(f"docker restart {container_name}") + + def _get_server_public_key(self, protocol_type): + """Get server public key.""" + container_name = self._container_name(protocol_type) + out, err, code = self.ssh.run_sudo_command( + f"docker exec -i {container_name} cat /opt/amnezia/awg/wireguard_server_public_key.key" + ) + if code != 0: + raise RuntimeError(f"Failed to get server public key: {err}") + return out.strip() + + def _get_server_psk(self, protocol_type): + """Get server preshared key.""" + container_name = self._container_name(protocol_type) + out, err, code = self.ssh.run_sudo_command( + f"docker exec -i {container_name} cat /opt/amnezia/awg/wireguard_psk.key" + ) + if code != 0: + raise RuntimeError(f"Failed to get PSK: {err}") + return out.strip() + + def _get_awg_params_from_config(self, protocol_type): + """Extract AWG obfuscation params from server config.""" + config = self._get_server_config(protocol_type) + params = {} + # Mapping from server config keys to our param dictionary keys + param_map = { + 'ListenPort': 'port', + 'Jc': 'junk_packet_count', + 'Jmin': 'junk_packet_min_size', + 'Jmax': 'junk_packet_max_size', + 'S1': 'init_packet_junk_size', + 'S2': 'response_packet_junk_size', + 'S3': 'cookie_reply_packet_junk_size', + 'S4': 'transport_packet_junk_size', + 'H1': 'init_packet_magic_header', + 'H2': 'response_packet_magic_header', + 'H3': 'underload_packet_magic_header', + 'H4': 'transport_packet_magic_header', + 'I1': 'i1', + 'I2': 'i2', + 'I3': 'i3', + 'I4': 'i4', + 'I5': 'i5', + 'CPS': 'cps', + } + + for line in config.split('\n'): + line = line.strip() + # Support both 'key=value' and 'key = value' + if '=' in line and not line.startswith('#') and not line.startswith('['): + parts = line.split('=', 1) + key = parts[0].strip() + val = parts[1].strip() + if key in param_map: + params[param_map[key]] = val + + return params + + def _get_used_ips(self, protocol_type): + """Get list of IPs already assigned in the config.""" + config = self._get_server_config(protocol_type) + ips = [] + for line in config.split('\n'): + line = line.strip() + if line.startswith('AllowedIPs'): + match = re.search(r'(\d+\.\d+\.\d+\.\d+)', line) + if match: + ips.append(match.group(1)) + elif line.startswith('Address'): + match = re.search(r'(\d+\.\d+\.\d+\.\d+)', line) + if match: + ips.append(match.group(1)) + return ips + + def _get_next_ip(self, protocol_type): + """Calculate the next available IP for a new client.""" + used_ips = self._get_used_ips(protocol_type) + if not used_ips: + base = self._get_subnet_base(protocol_type) + parts = base.split('.') + parts[3] = '2' + return '.'.join(parts) + + # Get the last used IP and increment + last_ip = used_ips[-1] + parts = last_ip.split('.') + last_octet = int(parts[3]) + + if last_octet == 254: + next_octet = last_octet + 3 + elif last_octet == 255: + next_octet = last_octet + 2 + else: + next_octet = last_octet + 1 + + parts[3] = str(next_octet) + return '.'.join(parts) + + def _parse_peers_from_config(self, protocol_type): + """Parse [Peer] sections from WireGuard server config and return dict of pubkey -> {allowedIps}.""" + try: + config = self._get_server_config(protocol_type) + except Exception: + return {} + + peers = {} + current_key = None + for line in config.split('\n'): + line = line.strip() + if line == '[Peer]': + current_key = None + elif current_key is None and line.startswith('PublicKey'): + current_key = line.split('=', 1)[1].strip() + peers[current_key] = {'allowedIps': ''} + elif current_key and line.startswith('AllowedIPs'): + peers[current_key]['allowedIps'] = line.split('=', 1)[1].strip() + return peers + + def get_clients(self, protocol_type): + """Get list of all clients.""" + clients_table = self._get_clients_table(protocol_type) + + # Also try to get live data from wg show + try: + wg_show_data = self._wg_show(protocol_type) + except Exception: + wg_show_data = {} + + # Enrich clients table with wg show data + known_ids = set() + for client in clients_table: + client_id = client.get('clientId', '') + known_ids.add(client_id) + if client_id in wg_show_data: + show_data = wg_show_data[client_id] + user_data = client.get('userData', {}) + user_data['latestHandshake'] = show_data.get('latestHandshake', '') + user_data['dataReceived'] = show_data.get('dataReceived', '') + user_data['dataSent'] = show_data.get('dataSent', '') + user_data['dataReceivedBytes'] = show_data.get('dataReceivedBytes', 0) + user_data['dataSentBytes'] = show_data.get('dataSentBytes', 0) + user_data['allowedIps'] = show_data.get('allowedIps', '') + client['userData'] = user_data + + # Pick up peers from conf that are NOT in clientsTable (created via native Amnezia app) + try: + conf_peers = self._parse_peers_from_config(protocol_type) + for pub_key, peer_info in conf_peers.items(): + if pub_key in known_ids: + continue # already in table + show_data = wg_show_data.get(pub_key, {}) + # Derive display name from AllowedIPs (e.g. 10.8.1.5/32 -> peer-10.8.1.5) + allowed_ip = peer_info.get('allowedIps', '') or show_data.get('allowedIps', '') + ip_part = '' + if allowed_ip: + import re as _re + m = _re.search(r'(\d+\.\d+\.\d+\.\d+)', allowed_ip) + if m: + ip_part = m.group(1) + display_name = f'External ({ip_part})' if ip_part else 'External (native app)' + clients_table.append({ + 'clientId': pub_key, + 'userData': { + 'clientName': display_name, + 'clientPrivateKey': '', # not available + 'externalClient': True, + 'clientIp': ip_part, + 'latestHandshake': show_data.get('latestHandshake', ''), + 'dataReceived': show_data.get('dataReceived', ''), + 'dataSent': show_data.get('dataSent', ''), + 'dataReceivedBytes': show_data.get('dataReceivedBytes', 0), + 'dataSentBytes': show_data.get('dataSentBytes', 0), + 'allowedIps': allowed_ip, + } + }) + except Exception as e: + logger.warning(f'get_clients: failed to parse conf peers: {e}') + + return clients_table + + def _parse_bytes(self, size_str): + """Parse human readable size string like '1.50 MiB' into bytes.""" + try: + parts = size_str.strip().split() + if len(parts) != 2: return 0 + val, unit = float(parts[0]), parts[1] + units = {'B': 1, 'KiB': 1024, 'MiB': 1024**2, 'GiB': 1024**3, 'TiB': 1024**4} + return int(val * units.get(unit, 1)) + except Exception: + return 0 + + def _wg_show(self, protocol_type): + """Run 'wg show all' and parse output.""" + container_name = self._container_name(protocol_type) + wg_bin = self._wg_binary(protocol_type) + + out, err, code = self.ssh.run_sudo_command( + f"docker exec -i {container_name} bash -c '{wg_bin} show all'" + ) + if code != 0 or not out.strip(): + return {} + + result = {} + current_peer = None + + for line in out.split('\n'): + line = line.strip() + if line.startswith('peer:'): + current_peer = line.split(':', 1)[1].strip() + result[current_peer] = {} + elif current_peer and ':' in line: + key, value = line.split(':', 1) + key = key.strip() + value = value.strip() + if key == 'latest handshake': + result[current_peer]['latestHandshake'] = value + elif key == 'transfer': + parts = value.split(',') + if len(parts) == 2: + received = parts[0].strip().replace(' received', '') + sent = parts[1].strip().replace(' sent', '') + result[current_peer]['dataReceived'] = received + result[current_peer]['dataSent'] = sent + result[current_peer]['dataReceivedBytes'] = self._parse_bytes(received) + result[current_peer]['dataSentBytes'] = self._parse_bytes(sent) + elif key == 'allowed ips': + result[current_peer]['allowedIps'] = value + + return result + + def add_client(self, protocol_type, client_name, server_host, port): + """ + Add a new client/peer to the AWG config. + Returns the client config as a string for the .conf file. + """ + container_name = self._container_name(protocol_type) + wg_bin = self._wg_binary(protocol_type) + config_path = self._resolve_config_path(protocol_type) + iface = self._interface_name(protocol_type, config_path) + + # Generate client keys + client_priv_key, client_pub_key = generate_wg_keypair() + + # Get server info + server_pub_key = self._get_server_public_key(protocol_type) + psk = self._get_server_psk(protocol_type) + + # Get next available IP + client_ip = self._get_next_ip(protocol_type) + + # Get AWG params from server config + awg_params = self._get_awg_params_from_config(protocol_type) + + # Add peer to server config + peer_section = f""" +[Peer] +PublicKey = {client_pub_key} +PresharedKey = {psk} +AllowedIPs = {client_ip}/32 + +""" + # Append peer to server config + escaped_peer = peer_section.replace("'", "'\\''") + self.ssh.run_sudo_command( + f"docker exec -i {container_name} bash -c 'echo \"{escaped_peer}\" >> {config_path}'" + ) + + # Sync config without restart + self.ssh.run_sudo_command( + f"docker exec -i {container_name} bash -c '{wg_bin} syncconf {iface} <({wg_bin}-quick strip {config_path})'" + ) + + # Update clients table — store keys for config reconstruction + clients_table = self._get_clients_table(protocol_type) + new_client = { + 'clientId': client_pub_key, + 'userData': { + 'clientName': client_name, + 'creationDate': __import__('datetime').datetime.now().isoformat(), + 'clientPrivateKey': client_priv_key, + 'clientIp': client_ip, + 'psk': psk, + 'enabled': True, + } + } + clients_table.append(new_client) + self._save_clients_table(protocol_type, clients_table) + + # Build client config + awg_params = self._get_awg_params_from_config(protocol_type) + if awg_params.get('port'): + port = awg_params['port'] + + dns1 = AWG_DEFAULTS['dns1'] + dns2 = AWG_DEFAULTS['dns2'] + + # Check if AmneziaDNS is installed + out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'") + if 'amnezia-dns' in out: + dns1 = '172.29.172.254' + + mtu = AWG_DEFAULTS['mtu'] + + # Standard fields + config_lines = [ + f"Address = {client_ip}/32", + f"DNS = {dns1}, {dns2}", + f"PrivateKey = {client_priv_key}", + f"MTU = {mtu}" + ] + + # Conditional obfuscation fields + mapping = [ + ('junk_packet_count', 'Jc'), + ('junk_packet_min_size', 'Jmin'), + ('junk_packet_max_size', 'Jmax'), + ('init_packet_junk_size', 'S1'), + ('response_packet_junk_size', 'S2'), + ('cookie_reply_packet_junk_size', 'S3'), + ('transport_packet_junk_size', 'S4'), + ('init_packet_magic_header', 'H1'), + ('response_packet_magic_header', 'H2'), + ('underload_packet_magic_header', 'H3'), + ('transport_packet_magic_header', 'H4'), + ('i1', 'I1'), + ('i2', 'I2'), + ('i3', 'I3'), + ('i4', 'I4'), + ('i5', 'I5'), + ('cps', 'CPS') + ] + + for param_key, config_key in mapping: + val = awg_params.get(param_key) + if val: + # Basic compatibility filtering + if protocol_type == self.AWG_LEGACY and config_key in ('S3', 'S4', 'I1', 'I2', 'I3', 'I4', 'I5', 'CPS'): + continue + config_lines.append(f"{config_key} = {val}") + + client_config = "[Interface]\n" + "\n".join(config_lines) + f""" + +[Peer] +PublicKey = {server_pub_key} +PresharedKey = {psk} +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = {server_host}:{port} +PersistentKeepalive = 25 +""" + + return { + 'client_name': client_name, + 'client_id': client_pub_key, + 'client_ip': client_ip, + 'config': client_config, + } + + def get_client_config(self, protocol_type, client_id, server_host, port): + """Reconstruct client config from stored data.""" + clients_table = self._get_clients_table(protocol_type) + client = None + for c in clients_table: + if c.get('clientId') == client_id: + client = c + break + + if not client: + raise RuntimeError(f"Client {client_id} not found") + + ud = client.get('userData', {}) + client_priv_key = ud.get('clientPrivateKey', '') + client_ip = ud.get('clientIp', '') + psk = ud.get('psk', '') + + if not client_priv_key: + raise RuntimeError("Client private key not stored. Config cannot be reconstructed.") + + server_pub_key = self._get_server_public_key(protocol_type) + if not psk: + psk = self._get_server_psk(protocol_type) + + awg_params = self._get_awg_params_from_config(protocol_type) + if awg_params.get('port'): + port = awg_params['port'] + + dns1 = AWG_DEFAULTS['dns1'] + dns2 = AWG_DEFAULTS['dns2'] + + # Check if AmneziaDNS is installed + out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'") + if 'amnezia-dns' in out: + dns1 = '172.29.172.254' + + mtu = AWG_DEFAULTS['mtu'] + + # Standard fields + config_lines = [ + f"Address = {client_ip}/32", + f"DNS = {dns1}, {dns2}", + f"PrivateKey = {client_priv_key}", + f"MTU = {mtu}" + ] + + # Conditional obfuscation fields + mapping = [ + ('junk_packet_count', 'Jc'), + ('junk_packet_min_size', 'Jmin'), + ('junk_packet_max_size', 'Jmax'), + ('init_packet_junk_size', 'S1'), + ('response_packet_junk_size', 'S2'), + ('cookie_reply_packet_junk_size', 'S3'), + ('transport_packet_junk_size', 'S4'), + ('init_packet_magic_header', 'H1'), + ('response_packet_magic_header', 'H2'), + ('underload_packet_magic_header', 'H3'), + ('transport_packet_magic_header', 'H4'), + ('i1', 'I1'), + ('i2', 'I2'), + ('i3', 'I3'), + ('i4', 'I4'), + ('i5', 'I5'), + ('cps', 'CPS') + ] + + for param_key, config_key in mapping: + val = awg_params.get(param_key) + if val: + # Basic compatibility filtering + if protocol_type == self.AWG_LEGACY and config_key in ('S3', 'S4', 'I1', 'I2', 'I3', 'I4', 'I5', 'CPS'): + continue + config_lines.append(f"{config_key} = {val}") + + config = "[Interface]\n" + "\n".join(config_lines) + f""" + +[Peer] +PublicKey = {server_pub_key} +PresharedKey = {psk} +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = {server_host}:{port} +PersistentKeepalive = 25 +""" + return config + + def toggle_client(self, protocol_type, client_id, enable): + """Enable or disable a client by adding/removing their [Peer] from server config.""" + container_name = self._container_name(protocol_type) + wg_bin = self._wg_binary(protocol_type) + config_path = self._resolve_config_path(protocol_type) + iface = self._interface_name(protocol_type, config_path) + + if enable: + # Re-add peer to server config + clients_table = self._get_clients_table(protocol_type) + client = None + for c in clients_table: + if c.get('clientId') == client_id: + client = c + break + if not client: + raise RuntimeError(f"Client {client_id} not found") + + ud = client.get('userData', {}) + psk = ud.get('psk', '') + client_ip = ud.get('clientIp', '') + + if not psk: + psk = self._get_server_psk(protocol_type) + + peer_section = f""" +[Peer] +PublicKey = {client_id} +PresharedKey = {psk} +AllowedIPs = {client_ip}/32 + +""" + escaped_peer = peer_section.replace("'", "'\\''") + self.ssh.run_sudo_command( + f"docker exec -i {container_name} bash -c 'echo \"{escaped_peer}\" >> {config_path}'" + ) + else: + # Remove peer from server config + config = self._get_server_config(protocol_type) + sections = config.split('[') + new_sections = [] + for section in sections: + if not section.strip(): + continue + if client_id in section: + continue + new_sections.append(section) + + new_config = '[' + '['.join(new_sections) + self.ssh.upload_file(new_config, "/tmp/_amnz_config.conf") + self.ssh.run_sudo_command( + f"docker cp /tmp/_amnz_config.conf {container_name}:{config_path}" + ) + self.ssh.run_command("rm -f /tmp/_amnz_config.conf") + + # Sync config + self.ssh.run_sudo_command( + f"docker exec -i {container_name} bash -c '{wg_bin} syncconf {iface} <({wg_bin}-quick strip {config_path})'" + ) + + # Update enabled status in clients table + clients_table = self._get_clients_table(protocol_type) + for c in clients_table: + if c.get('clientId') == client_id: + c.setdefault('userData', {})['enabled'] = enable + break + self._save_clients_table(protocol_type, clients_table) + + def remove_client(self, protocol_type, client_id): + """Remove a client from AWG config (mirrors revokeWireGuard).""" + container_name = self._container_name(protocol_type) + wg_bin = self._wg_binary(protocol_type) + config_path = self._resolve_config_path(protocol_type) + iface = self._interface_name(protocol_type, config_path) + + # Get current config + config = self._get_server_config(protocol_type) + + # Split by [Peer] sections and remove the matching one + sections = config.split('[') + new_sections = [] + for section in sections: + if not section.strip(): + continue + if client_id in section: + continue + new_sections.append(section) + + new_config = '[' + '['.join(new_sections) + + # Upload new config into container via SFTP + docker cp + self.ssh.upload_file(new_config, "/tmp/_amnz_config.conf") + self.ssh.run_sudo_command( + f"docker cp /tmp/_amnz_config.conf {container_name}:{config_path}" + ) + self.ssh.run_command("rm -f /tmp/_amnz_config.conf") + + # Sync config + self.ssh.run_sudo_command( + f"docker exec -i {container_name} bash -c '{wg_bin} syncconf {iface} <({wg_bin}-quick strip {config_path})'" + ) + + # Update clients table + clients_table = self._get_clients_table(protocol_type) + clients_table = [c for c in clients_table if c.get('clientId') != client_id] + self._save_clients_table(protocol_type, clients_table) + + return True + + def get_server_status(self, protocol_type): + """Get detailed status of the AWG server.""" + container_name = self._container_name(protocol_type) + + info = { + 'container_exists': self.check_protocol_installed(protocol_type), + 'container_running': False, + 'protocol': protocol_type, + } + + if info['container_exists']: + info['container_running'] = self.check_container_running(protocol_type) + + if info['container_running']: + try: + config = self._get_server_config(protocol_type) + # Extract port + for line in config.split('\n'): + if 'ListenPort' in line: + info['port'] = line.split('=')[1].strip() + break + info['awg_params'] = self._get_awg_params_from_config(protocol_type) + info['clients_count'] = len(self._get_clients_table(protocol_type)) + except Exception as e: + info['error'] = str(e) + + return info \ No newline at end of file diff --git a/managers/cloudflare_ssl.py b/managers/cloudflare_ssl.py new file mode 100644 index 0000000..8080c14 --- /dev/null +++ b/managers/cloudflare_ssl.py @@ -0,0 +1,411 @@ +""" +Cloudflare DNS-01 ACME client for issuing the panel's own Let's Encrypt +certificate WITHOUT binding port 80/443. + +Why not certbot: the panel ships as a single PyInstaller binary, so we implement +a minimal ACME v2 (RFC 8555) flow using `cryptography` + `httpx` only, both of +which are already dependencies. Validation is done via a Cloudflare DNS TXT +record (dns-01), which never touches the HTTP port — so the panel can keep +running on any port while the reverse proxy owns 443. + +Public entry points: + issue_certificate(domain, cf_token, email, ...) -> {cert_pem, key_pem, ...} + get_cert_expiry(cert_pem) -> datetime | None +""" + +import base64 +import hashlib +import json +import logging +import time +from datetime import datetime, timezone + +import httpx +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa +from cryptography.x509.oid import NameOID + +logger = logging.getLogger(__name__) + +# Let's Encrypt production directory. Staging is available for testing but its +# certs are not browser-trusted, so we default to production. +ACME_DIRECTORY_PROD = "https://acme-v02.api.letsencrypt.org/directory" +ACME_DIRECTORY_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory" + +CF_API_BASE = "https://api.cloudflare.com/client/v4" + +USER_AGENT = "amnezia-panel-acme/1.0" + + +def _b64(data: bytes) -> str: + """base64url without padding, per JWS spec.""" + return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") + + +class CloudflareError(Exception): + pass + + +class ACMEError(Exception): + pass + + +# ============================================================ +# Cloudflare DNS API +# ============================================================ + +class CloudflareDNS: + def __init__(self, token: str, timeout: float = 30.0): + self.token = token.strip() + self._client = httpx.Client( + base_url=CF_API_BASE, + headers={ + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + "User-Agent": USER_AGENT, + }, + timeout=timeout, + ) + + def close(self): + self._client.close() + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() + + def _check(self, resp: httpx.Response) -> dict: + try: + body = resp.json() + except Exception: + raise CloudflareError(f"Cloudflare returned non-JSON ({resp.status_code}): {resp.text[:200]}") + if not body.get("success", False): + errors = body.get("errors", []) + msg = "; ".join(e.get("message", str(e)) for e in errors) or resp.text[:200] + raise CloudflareError(f"Cloudflare API error: {msg}") + return body + + def verify_token(self) -> bool: + resp = self._client.get("/user/tokens/verify") + self._check(resp) + return True + + def find_zone_id(self, domain: str) -> str: + """Resolve the zone that owns `domain`. Tries the domain and each parent + (sub.example.com -> example.com) so subdomains resolve to the apex zone.""" + parts = domain.split(".") + candidates = [".".join(parts[i:]) for i in range(len(parts) - 1)] + for zone_name in candidates: + resp = self._client.get("/zones", params={"name": zone_name}) + body = self._check(resp) + results = body.get("result", []) + if results: + return results[0]["id"] + raise CloudflareError( + f"No Cloudflare zone found for '{domain}'. Ensure the domain is added to this Cloudflare account and the token has Zone:Read." + ) + + def create_txt_record(self, zone_id: str, name: str, content: str) -> str: + resp = self._client.post( + f"/zones/{zone_id}/dns_records", + json={"type": "TXT", "name": name, "content": content, "ttl": 60}, + ) + body = self._check(resp) + return body["result"]["id"] + + def delete_record(self, zone_id: str, record_id: str): + try: + self._client.delete(f"/zones/{zone_id}/dns_records/{record_id}") + except Exception as e: + logger.warning("Failed to delete Cloudflare TXT record %s: %s", record_id, e) + + +# ============================================================ +# Minimal ACME v2 client (RFC 8555) — dns-01 only +# ============================================================ + +class ACMEClient: + def __init__(self, directory_url: str = ACME_DIRECTORY_PROD, timeout: float = 60.0): + self.directory_url = directory_url + self._client = httpx.Client( + headers={"User-Agent": USER_AGENT, "Content-Type": "application/jose+json"}, + timeout=timeout, + ) + self._directory = None + self._nonce = None + self._account_key = None + self._kid = None + + def close(self): + self._client.close() + + # ---- account key (RSA 2048) ---- + + def _gen_account_key(self): + self._account_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + def _jwk(self) -> dict: + pub = self._account_key.public_key().public_numbers() + e = pub.e.to_bytes((pub.e.bit_length() + 7) // 8, "big") + n = pub.n.to_bytes((pub.n.bit_length() + 7) // 8, "big") + return {"kty": "RSA", "e": _b64(e), "n": _b64(n)} + + def _thumbprint(self) -> str: + jwk = self._jwk() + canonical = json.dumps({"e": jwk["e"], "kty": jwk["kty"], "n": jwk["n"]}, separators=(",", ":"), sort_keys=True) + return _b64(hashlib.sha256(canonical.encode()).digest()) + + # ---- low-level JWS transport ---- + + def _load_directory(self): + resp = self._client.get(self.directory_url) + resp.raise_for_status() + self._directory = resp.json() + + def _get_nonce(self) -> str: + if self._nonce: + n, self._nonce = self._nonce, None + return n + resp = self._client.head(self._directory["newNonce"]) + return resp.headers["Replay-Nonce"] + + def _signed_request(self, url: str, payload, use_jwk: bool = False): + protected = {"alg": "RS256", "nonce": self._get_nonce(), "url": url} + if use_jwk: + protected["jwk"] = self._jwk() + else: + protected["kid"] = self._kid + + protected_b64 = _b64(json.dumps(protected, separators=(",", ":")).encode()) + if payload == "": + payload_b64 = "" # POST-as-GET + else: + payload_b64 = _b64(json.dumps(payload, separators=(",", ":")).encode()) + + signing_input = f"{protected_b64}.{payload_b64}".encode() + signature = self._account_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256()) + + jose = {"protected": protected_b64, "payload": payload_b64, "signature": _b64(signature)} + resp = self._client.post(url, content=json.dumps(jose)) + if "Replay-Nonce" in resp.headers: + self._nonce = resp.headers["Replay-Nonce"] + + if resp.status_code >= 400: + try: + err = resp.json() + raise ACMEError(f"ACME error {resp.status_code}: {err.get('detail', resp.text)}") + except (ValueError, KeyError): + raise ACMEError(f"ACME error {resp.status_code}: {resp.text[:300]}") + return resp + + # ---- ACME flow ---- + + def register_account(self, email: str): + payload = {"termsOfServiceAgreed": True} + if email: + payload["contact"] = [f"mailto:{email}"] + resp = self._signed_request(self._directory["newAccount"], payload, use_jwk=True) + self._kid = resp.headers["Location"] + + def new_order(self, domains: list) -> dict: + payload = {"identifiers": [{"type": "dns", "value": d} for d in domains]} + resp = self._signed_request(self._directory["newOrder"], payload) + order = resp.json() + order["_url"] = resp.headers["Location"] + return order + + def get_authorization(self, auth_url: str) -> dict: + resp = self._signed_request(auth_url, "") + return resp.json() + + def dns_challenge_value(self, token: str) -> str: + key_auth = f"{token}.{self._thumbprint()}" + return _b64(hashlib.sha256(key_auth.encode()).digest()) + + def trigger_challenge(self, challenge_url: str): + self._signed_request(challenge_url, {}) + + def poll_authorization(self, auth_url: str, attempts: int = 30, delay: float = 5.0) -> str: + for _ in range(attempts): + auth = self.get_authorization(auth_url) + status = auth.get("status") + if status == "valid": + return "valid" + if status == "invalid": + challenges = auth.get("challenges", []) + detail = "" + for c in challenges: + if c.get("error"): + detail = c["error"].get("detail", "") + raise ACMEError(f"DNS validation failed: {detail or 'authorization invalid'}") + time.sleep(delay) + raise ACMEError("Timed out waiting for DNS validation") + + def finalize_order(self, finalize_url: str, csr_der: bytes) -> dict: + resp = self._signed_request(finalize_url, {"csr": _b64(csr_der)}) + return resp.json() + + def poll_order(self, order_url: str, attempts: int = 20, delay: float = 3.0) -> dict: + for _ in range(attempts): + resp = self._signed_request(order_url, "") + order = resp.json() + if order.get("status") == "valid": + return order + if order.get("status") == "invalid": + raise ACMEError("Order became invalid during finalization") + time.sleep(delay) + raise ACMEError("Timed out waiting for certificate issuance") + + def download_certificate(self, cert_url: str) -> str: + resp = self._signed_request(cert_url, "") + return resp.text + + +def _build_csr(domains: list): + """Generate an EC P-256 leaf key + CSR for the given domains.""" + key = ec.generate_private_key(ec.SECP256R1()) + builder = x509.CertificateSigningRequestBuilder().subject_name( + x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, domains[0])]) + ) + san = x509.SubjectAlternativeName([x509.DNSName(d) for d in domains]) + builder = builder.add_extension(san, critical=False) + csr = builder.sign(key, hashes.SHA256()) + + key_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + return csr.public_bytes(serialization.Encoding.DER), key_pem + + +def issue_certificate( + domain: str, + cf_token: str, + email: str = "", + staging: bool = False, + dns_propagation_wait: int = 20, + progress=None, +) -> dict: + """ + Issue a Let's Encrypt certificate for `domain` using Cloudflare dns-01. + + Returns dict: {cert_pem, key_pem, domain, expires_at (iso), issued_at (iso)} + Raises CloudflareError / ACMEError on failure. + """ + def log(msg): + logger.info(msg) + if progress: + try: + progress(msg) + except Exception: + pass + + domain = domain.strip().lower().rstrip(".") + if not domain: + raise ACMEError("Domain is required") + + directory = ACME_DIRECTORY_STAGING if staging else ACME_DIRECTORY_PROD + + cf = CloudflareDNS(cf_token) + acme = ACMEClient(directory) + txt_record_id = None + zone_id = None + try: + log("Verifying Cloudflare API token...") + cf.verify_token() + zone_id = cf.find_zone_id(domain) + log(f"Cloudflare zone resolved for {domain}") + + log("Setting up ACME account...") + acme._gen_account_key() + acme._load_directory() + acme.register_account(email) + + log("Creating certificate order...") + order = acme.new_order([domain]) + + for auth_url in order["authorizations"]: + auth = acme.get_authorization(auth_url) + identifier = auth["identifier"]["value"] + + dns_challenge = next( + (c for c in auth["challenges"] if c["type"] == "dns-01"), None + ) + if not dns_challenge: + raise ACMEError("No dns-01 challenge offered by ACME server") + + txt_value = acme.dns_challenge_value(dns_challenge["token"]) + record_name = f"_acme-challenge.{identifier}" + + log(f"Creating DNS TXT record {record_name} ...") + txt_record_id = cf.create_txt_record(zone_id, record_name, txt_value) + + log(f"Waiting {dns_propagation_wait}s for DNS propagation...") + time.sleep(dns_propagation_wait) + + log("Asking Let's Encrypt to validate...") + acme.trigger_challenge(dns_challenge["url"]) + acme.poll_authorization(auth_url) + log("Domain validated ✓") + + if txt_record_id: + cf.delete_record(zone_id, txt_record_id) + txt_record_id = None + + log("Generating key and CSR...") + csr_der, key_pem = _build_csr([domain]) + + log("Finalizing order...") + acme.finalize_order(order["finalize"], csr_der) + final_order = acme.poll_order(order["_url"]) + + log("Downloading certificate...") + cert_pem = acme.download_certificate(final_order["certificate"]) + + expires_at = get_cert_expiry(cert_pem) + result = { + "cert_pem": cert_pem, + "key_pem": key_pem, + "domain": domain, + "issued_at": datetime.now(timezone.utc).isoformat(), + "expires_at": expires_at.isoformat() if expires_at else None, + "staging": staging, + } + log("Certificate issued successfully ✓") + return result + finally: + if txt_record_id and zone_id: + cf.delete_record(zone_id, txt_record_id) + cf.close() + acme.close() + + +def get_cert_expiry(cert_pem: str): + """Return the notAfter datetime (UTC) of the first cert in the PEM chain.""" + try: + cert = x509.load_pem_x509_certificate(cert_pem.encode()) + try: + return cert.not_valid_after_utc + except AttributeError: + return cert.not_valid_after.replace(tzinfo=timezone.utc) + except Exception as e: + logger.warning("Could not parse certificate expiry: %s", e) + return None + + +def needs_renewal(expires_at_iso: str, days_before: int = 30) -> bool: + """True if the cert expires within `days_before` days (or is unparseable).""" + if not expires_at_iso: + return True + try: + expires = datetime.fromisoformat(expires_at_iso) + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + remaining = (expires - datetime.now(timezone.utc)).days + return remaining <= days_before + except Exception: + return True diff --git a/managers/dns_manager.py b/managers/dns_manager.py new file mode 100644 index 0000000..9ac1575 --- /dev/null +++ b/managers/dns_manager.py @@ -0,0 +1,83 @@ + +import os +import logging + +logger = logging.getLogger(__name__) + +class DNSManager: + def __init__(self, ssh): + self.ssh = ssh + + def install_protocol(self, protocol_type='dns', port='53'): + """Install AmneziaDNS service.""" + try: + # 1. Check if docker is installed + out, _, _ = self.ssh.run_command("docker --version") + if "docker version" not in out.lower(): + return {"status": "error", "message": "Docker not installed"} + + # 2. Prepare directory + self.ssh.run_sudo_command("mkdir -p /opt/amnezia/dns") + + # 3. Create Dockerfile matching official Amnezia implementation + # We use Unbound (mvance/unbound) with DNS-over-TLS to Cloudflare + forward_config = """forward-zone: + name: "." + forward-tls-upstream: yes + forward-addr: 1.1.1.1@853 + forward-addr: 1.0.0.1@853 +""" + self.ssh.write_file("/opt/amnezia/dns/forward-records.conf", forward_config) + + dockerfile = """ +FROM mvance/unbound:latest +LABEL maintainer="AmneziaVPN" +COPY forward-records.conf /opt/unbound/etc/unbound/forward-records.conf +""" + self.ssh.write_file("/opt/amnezia/dns/Dockerfile", dockerfile) + + # 4. Build and run + self.ssh.run_sudo_command("docker build -t amnezia-dns /opt/amnezia/dns") + self.ssh.run_sudo_command("docker stop amnezia-dns || true") + self.ssh.run_sudo_command("docker rm amnezia-dns || true") + + # Create internal network for DNS (like original Amnezia client) + self.ssh.run_sudo_command("docker network ls | grep -q amnezia-dns-net || docker network create --subnet 172.29.172.0/24 amnezia-dns-net") + + # Use internal network with static IP. Do not expose 53 on host to avoid systemd-resolved conflict. + cmd = "docker run -d --name amnezia-dns --restart always --network amnezia-dns-net --ip=172.29.172.254 amnezia-dns" + self.ssh.run_sudo_command(cmd) + + # Connect existing VPN containers to the DNS network + vpn_containers = ['amnezia-awg', 'amnezia-awg2', 'amnezia-awg-legacy', 'amnezia-xray', 'telemt'] + for c in vpn_containers: + self.ssh.run_sudo_command(f"docker ps | grep -q {c} && docker network connect amnezia-dns-net {c} || true") + + return {"status": "success", "message": "AmneziaDNS installed successfully"} + except Exception as e: + logger.exception("Error installing DNS") + return {"status": "error", "message": str(e)} + + def get_server_status(self, protocol_type='dns'): + """Check if AmneziaDNS container is running.""" + try: + out, _, _ = self.ssh.run_sudo_command("docker ps --filter name=^amnezia-dns$ --format '{{.Status}}'") + is_running = 'Up' in out + + out_exists, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'") + container_exists = 'amnezia-dns' in out_exists.strip().split('\n') + + return { + "container_exists": container_exists, + "container_running": is_running, + "port": "53", + "protocol": protocol_type + } + except Exception as e: + return {"error": str(e)} + + def remove_container(self, protocol_type='dns'): + """Remove AmneziaDNS container.""" + self.ssh.run_sudo_command("docker stop amnezia-dns || true") + self.ssh.run_sudo_command("docker rm amnezia-dns || true") + self.ssh.run_sudo_command("rm -rf /opt/amnezia/dns") diff --git a/managers/revproxy_manager.py b/managers/revproxy_manager.py new file mode 100644 index 0000000..512ce6c --- /dev/null +++ b/managers/revproxy_manager.py @@ -0,0 +1,410 @@ +""" +Reverse Proxy Manager — Caddy 2 front for traffic masking. + +Serves a polished decoy website on ports 80/443 and optionally reverse-proxies +a secret path to VPN backends (Xray, Telemt) on the internal Docker network. +Reduces DPI footprint: probes see a normal HTTPS site instead of a VPN endpoint. +""" + +import json +import logging +import os +import re + +logger = logging.getLogger(__name__) + +BACKEND_UPSTREAMS = { + 'none': None, + 'xray': 'amnezia-xray', + 'telemt': 'telemt', +} + +DEFAULT_VPN_PATH = '/cdn-cgi/challenge' +DEFAULT_BACKEND_PORT = 8443 + + +class RevproxyManager: + PROTOCOL = 'revproxy' + CONTAINER_NAME = 'amnezia-revproxy' + REMOTE_DIR = '/opt/amnezia/revproxy' + SETTINGS_FILE = 'settings.json' + + def __init__(self, ssh): + self.ssh = ssh + + # ===================== STATUS ===================== + + def check_docker_installed(self): + out, _, code = self.ssh.run_command('docker --version 2>/dev/null') + return code == 0 and bool(out.strip()) + + def check_protocol_installed(self, protocol_type='revproxy'): + out, _, _ = self.ssh.run_sudo_command( + f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'" + ) + return self.CONTAINER_NAME in out.strip().split('\n') + + def check_container_running(self): + out, _, _ = self.ssh.run_sudo_command( + f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'" + ) + return 'Up' in out + + def get_server_status(self, protocol_type='revproxy'): + exists = self.check_protocol_installed() + running = self.check_container_running() + settings = self._read_settings() if exists else {} + site_url = self._build_site_url(settings) + return { + 'container_exists': exists, + 'container_running': running, + 'port': 443, + 'protocol': protocol_type, + 'domain': settings.get('domain', ''), + 'site_title': settings.get('site_title', 'CloudEdge'), + 'backend': settings.get('backend', 'none'), + 'backend_port': settings.get('backend_port', DEFAULT_BACKEND_PORT), + 'vpn_path': settings.get('vpn_path', DEFAULT_VPN_PATH), + 'tls_mode': settings.get('tls_mode', 'internal'), + 'site_url': site_url, + 'mask_enabled': settings.get('backend') in ('xray', 'telemt'), + } + + # ===================== INSTALL / UPDATE / REMOVE ===================== + + def install_protocol( + self, + protocol_type='revproxy', + domain=None, + site_title=None, + backend='none', + backend_port=None, + vpn_path=None, + tls_email=None, + enable_telemt_mask=False, + ): + if not self.check_docker_installed(): + return {'status': 'error', 'message': 'Docker not installed'} + + backend = (backend or 'none').lower() + if backend not in BACKEND_UPSTREAMS: + backend = 'none' + + backend_port = int(backend_port or DEFAULT_BACKEND_PORT) + vpn_path = self._normalize_path(vpn_path or DEFAULT_VPN_PATH) + site_title = (site_title or 'CloudEdge').strip() or 'CloudEdge' + domain = (domain or '').strip().lower() + tls_email = (tls_email or '').strip() + tls_mode = 'acme' if domain else 'internal' + + settings = { + 'domain': domain, + 'site_title': site_title, + 'backend': backend, + 'backend_port': backend_port, + 'vpn_path': vpn_path, + 'tls_email': tls_email, + 'tls_mode': tls_mode, + } + + log = [] + if self.check_protocol_installed(): + log.append('Removing previous reverse proxy container...') + self.remove_container() + + log.append('Ensuring Docker network amnezia-dns-net...') + self._ensure_network() + + log.append('Ensuring docker compose plugin...') + self._ensure_docker_compose() + + log.append('Uploading reverse proxy files...') + self._upload_bundle(settings) + + log.append('Starting Caddy reverse proxy...') + out, err, code = self.ssh.run_sudo_command( + f"sh -c 'cd {self.REMOTE_DIR} && docker compose up -d'", timeout=180 + ) + if code != 0: + out2, err2, code2 = self.ssh.run_sudo_command( + f"sh -c 'cd {self.REMOTE_DIR} && docker-compose up -d'", timeout=180 + ) + if code2 != 0: + return { + 'status': 'error', + 'message': f'Failed to start reverse proxy: {err or err2 or out or out2}', + } + + if backend == 'telemt' and enable_telemt_mask: + log.append('Enabling Telemt site masking (mask → Caddy)...') + self._configure_telemt_mask() + elif backend == 'xray': + self.ssh.run_sudo_command( + 'docker network connect amnezia-dns-net amnezia-xray 2>/dev/null || true' + ) + + site_url = self._build_site_url(settings) + log.append(f'Decoy site: {site_url}') + if backend != 'none': + log.append(f'VPN tunnel path: {vpn_path} → {BACKEND_UPSTREAMS[backend]}:{backend_port}') + log.append('Ensure the backend listens on the internal port, not host 443.') + + return { + 'status': 'success', + 'protocol': 'revproxy', + 'port': 443, + 'domain': domain, + 'site_url': site_url, + 'backend': backend, + 'vpn_path': vpn_path, + 'tls_mode': tls_mode, + 'message': 'Reverse proxy installed', + 'log': log, + } + + def update_settings( + self, + domain=None, + site_title=None, + backend=None, + backend_port=None, + vpn_path=None, + tls_email=None, + enable_telemt_mask=False, + ): + if not self.check_protocol_installed(): + return {'status': 'error', 'message': 'Reverse proxy not installed'} + + current = self._read_settings() + settings = { + 'domain': (domain if domain is not None else current.get('domain', '')).strip().lower(), + 'site_title': (site_title if site_title is not None else current.get('site_title', 'CloudEdge')).strip(), + 'backend': (backend if backend is not None else current.get('backend', 'none')).lower(), + 'backend_port': int(backend_port if backend_port is not None else current.get('backend_port', DEFAULT_BACKEND_PORT)), + 'vpn_path': self._normalize_path(vpn_path if vpn_path is not None else current.get('vpn_path', DEFAULT_VPN_PATH)), + 'tls_email': (tls_email if tls_email is not None else current.get('tls_email', '')).strip(), + 'tls_mode': 'acme' if (domain if domain is not None else current.get('domain')) else 'internal', + } + + if settings['backend'] not in BACKEND_UPSTREAMS: + settings['backend'] = 'none' + + self._write_settings(settings) + self._write_caddyfile(settings) + self._write_site_html(settings) + self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}") + + if settings['backend'] == 'telemt' and enable_telemt_mask: + self._configure_telemt_mask() + + return { + 'status': 'success', + **settings, + 'site_url': self._build_site_url(settings), + } + + def save_server_config(self, protocol_type, config_content): + """Save raw Caddyfile and hot-reload.""" + path = f"{self.REMOTE_DIR}/Caddyfile" + self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), path) + self.ssh.run_sudo_command( + f"docker exec {self.CONTAINER_NAME} caddy reload --config /etc/caddy/Caddyfile 2>/dev/null " + f"|| docker restart {self.CONTAINER_NAME}" + ) + + def remove_container(self, protocol_type='revproxy'): + self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true") + self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true") + self.ssh.run_sudo_command(f"rm -rf {self.REMOTE_DIR}") + return True + + def get_settings(self): + return self._read_settings() + + def get_caddyfile(self): + out, _, code = self.ssh.run_sudo_command(f"cat {self.REMOTE_DIR}/Caddyfile 2>/dev/null") + if code != 0: + return '' + return out + + # ===================== INTERNALS ===================== + + def _ensure_network(self): + script = """ +docker network inspect amnezia-dns-net >/dev/null 2>&1 || \ + docker network create --subnet=172.29.172.0/24 amnezia-dns-net +""" + self.ssh.run_sudo_script(script) + + def _ensure_docker_compose(self): + out, _, code = self.ssh.run_command('docker compose version 2>/dev/null') + if code == 0 and out.strip(): + return + self.ssh.run_sudo_command( + 'apt-get update -y && apt-get install -y docker-compose-plugin 2>/dev/null || true', + timeout=120, + ) + + def _upload_bundle(self, settings): + local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_revproxy') + remote = self.REMOTE_DIR + self.ssh.run_sudo_command(f'mkdir -p {remote}/site') + self.ssh.run_sudo_command(f'chmod -R 755 {remote}') + + with open(os.path.join(local_dir, 'docker-compose.yml'), 'r', encoding='utf-8') as f: + self.ssh.upload_file_sudo(f.read(), f'{remote}/docker-compose.yml') + + self._write_settings(settings) + self._write_caddyfile(settings) + self._write_site_html(settings) + + for fname in ('index.html', 'style.css'): + local_path = os.path.join(local_dir, 'site', fname) + if os.path.exists(local_path) and fname != 'index.html': + with open(local_path, 'r', encoding='utf-8') as f: + self.ssh.upload_file_sudo(f.read(), f'{remote}/site/{fname}') + + def _write_settings(self, settings): + content = json.dumps(settings, indent=2, ensure_ascii=False) + self.ssh.upload_file_sudo(content, f'{self.REMOTE_DIR}/{self.SETTINGS_FILE}') + + def _read_settings(self): + out, _, code = self.ssh.run_sudo_command( + f"cat {self.REMOTE_DIR}/{self.SETTINGS_FILE} 2>/dev/null" + ) + if code != 0 or not out.strip(): + return {} + try: + return json.loads(out) + except json.JSONDecodeError: + return {} + + def _write_site_html(self, settings): + local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_revproxy', 'site') + with open(os.path.join(local_dir, 'index.html'), 'r', encoding='utf-8') as f: + html = f.read() + + title = settings.get('site_title', 'CloudEdge') + domain = settings.get('domain') or self.ssh.host + html = html.replace('{{SITE_TITLE}}', self._escape_html(title)) + html = html.replace('{{SITE_DOMAIN}}', self._escape_html(domain)) + self.ssh.upload_file_sudo(html, f'{self.REMOTE_DIR}/site/index.html') + + style_path = os.path.join(local_dir, 'style.css') + if os.path.exists(style_path): + with open(style_path, 'r', encoding='utf-8') as f: + self.ssh.upload_file_sudo(f.read(), f'{self.REMOTE_DIR}/site/style.css') + + def _write_caddyfile(self, settings): + caddyfile = self._build_caddyfile(settings) + self.ssh.upload_file_sudo(caddyfile, f'{self.REMOTE_DIR}/Caddyfile') + + def _build_caddyfile(self, settings): + domain = settings.get('domain', '') + site_title = settings.get('site_title', 'CloudEdge') + backend = settings.get('backend', 'none') + backend_port = int(settings.get('backend_port', DEFAULT_BACKEND_PORT)) + vpn_path = self._normalize_path(settings.get('vpn_path', DEFAULT_VPN_PATH)) + tls_email = settings.get('tls_email', '') + + site_address = domain if domain else ':443' + global_block = '{\n\tadmin off\n\tservers {\n\t\tprotocols h1 h2 h3\n\t}\n' + if tls_email and domain: + global_block += f'\temail {tls_email}\n' + global_block += '}\n\n' + + tls_line = '' + if not domain: + tls_line = '\n\ttls internal' + + backend_block = '' + if backend != 'none' and BACKEND_UPSTREAMS.get(backend): + upstream = f'{BACKEND_UPSTREAMS[backend]}:{backend_port}' + backend_block = f""" +\t@vpn path {vpn_path}* +\thandle @vpn {{ +\t\treverse_proxy {upstream} {{ +\t\t\theader_up Host {{host}} +\t\t\theader_up X-Real-IP {{remote_host}} +\t\t\theader_up X-Forwarded-For {{remote_host}} +\t\t\theader_up X-Forwarded-Proto {{scheme}} +\t\t\ttransport http {{ +\t\t\t\tkeepalive 30s +\t\t\t\tkeepalive_idle_conns 100 +\t\t\t}} +\t\t}} +\t}} +""" + + return f"""{global_block}{site_address} {{{tls_line} +\troot * /srv/site +\tencode gzip zstd + +\theader {{ +\t\tStrict-Transport-Security "max-age=31536000; includeSubDomains; preload" +\t\tX-Content-Type-Options nosniff +\t\tX-Frame-Options SAMEORIGIN +\t\tReferrer-Policy strict-origin-when-cross-origin +\t\t-Server +\t}} +{backend_block} +\thandle {{ +\t\ttry_files {{path}} {{path}}/ /index.html +\t\tfile_server +\t}} +}} + +:80 {{ +\tredir https://{{host}}{{uri}} permanent +}} +""" + + def _configure_telemt_mask(self): + """Point Telemt mask at the Caddy container on the Docker network.""" + config_path = '/opt/amnezia/telemt/config.toml' + out, _, code = self.ssh.run_sudo_command(f"test -f {config_path} && cat {config_path}") + if code != 0: + return + + config = out + config = re.sub(r'mask\s*=\s*(true|false)', 'mask = true', config) + if 'mask_host' in config: + config = re.sub(r'#?\s*mask_host\s*=\s*".*?"', 'mask_host = "amnezia-revproxy"', config) + else: + config = config.replace('[censorship]', '[censorship]\nmask_host = "amnezia-revproxy"') + + if 'mask_port' in config: + config = re.sub(r'#?\s*mask_port\s*=\s*\d+', 'mask_port = 443', config) + else: + config = config.replace('mask_host = "amnezia-revproxy"', 'mask_host = "amnezia-revproxy"\nmask_port = 443') + + self.ssh.upload_file_sudo(config.replace('\r\n', '\n'), config_path) + self.ssh.run_sudo_command( + 'docker network connect amnezia-dns-net telemt 2>/dev/null || true' + ) + self.ssh.run_sudo_command( + 'docker kill -s HUP telemt 2>/dev/null || docker restart telemt 2>/dev/null || true' + ) + + def _build_site_url(self, settings): + domain = settings.get('domain', '') + if domain: + return f'https://{domain}' + return f'https://{self.ssh.host}' + + @staticmethod + def _normalize_path(path): + path = (path or DEFAULT_VPN_PATH).strip() + if not path.startswith('/'): + path = '/' + path + return path.rstrip('/') or '/' + + @staticmethod + def _escape_html(text): + return ( + str(text) + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .replace('"', '"') + ) diff --git a/managers/socks5_manager.py b/managers/socks5_manager.py new file mode 100644 index 0000000..5add58d --- /dev/null +++ b/managers/socks5_manager.py @@ -0,0 +1,208 @@ +""" +SOCKS5 Proxy Manager — runs 3proxy in a Docker container, modelled after the +official Amnezia client install (client/server_scripts/socks5_proxy/). Holds a +single user (port + username + password); credentials can be edited later from +the panel via update_credentials(). +""" + +import logging +import secrets +import string +import re + +logger = logging.getLogger(__name__) + + +def _generate_password(length=16): + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(length)) + + +class Socks5Manager: + PROTOCOL = 'socks5' + CONTAINER_NAME = 'amnezia-socks5proxy' + IMAGE_NAME = '3proxy/3proxy:0.9.5' + CONFIG_DIR = '/opt/amnezia/socks5proxy' + CONFIG_PATH = '/usr/local/3proxy/conf/3proxy.cfg' + + DEFAULT_PORT = 38080 + DEFAULT_USERNAME = 'proxy_user' + + def __init__(self, ssh): + self.ssh = ssh + + # ===================== STATUS ===================== + + def check_docker_installed(self): + out, _, code = self.ssh.run_command("docker --version 2>/dev/null") + if code != 0: + return False + out2, _, _ = self.ssh.run_command( + "systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null" + ) + return 'active' in out2 or 'running' in out2.lower() + + def check_protocol_installed(self, protocol_type='socks5'): + out, _, _ = self.ssh.run_sudo_command( + f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'" + ) + return self.CONTAINER_NAME in out.strip().split('\n') + + def check_container_running(self): + out, _, _ = self.ssh.run_sudo_command( + f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'" + ) + return 'Up' in out + + def get_server_status(self, protocol_type='socks5'): + exists = self.check_protocol_installed() + running = self.check_container_running() + creds = self.get_credentials() if exists else {} + return { + 'container_exists': exists, + 'container_running': running, + 'port': creds.get('port'), + 'username': creds.get('username'), + 'protocol': protocol_type, + } + + # ===================== CONFIG I/O ===================== + + def _build_config(self, username, password, port): + # Mirrors client/server_scripts/socks5_proxy/configure_container.sh. + # 'auth strong' enforces username/password on every connection; + # 'allow {user}' restricts the ACL to our single user only. + return ( + "#!/bin/3proxy\n" + f"config {self.CONFIG_PATH}\n" + "timeouts 1 5 30 60 180 1800 15 60\n" + f"users {username}:CL:{password}\n" + "log /usr/local/3proxy/logs/3proxy.log\n" + "auth strong\n" + f"allow {username}\n" + f"socks -p{int(port)}\n" + ) + + def _read_config(self): + out, _, code = self.ssh.run_sudo_command( + f"docker exec {self.CONTAINER_NAME} cat {self.CONFIG_PATH} 2>/dev/null" + ) + if code != 0 or not out.strip(): + out, _, code = self.ssh.run_sudo_command( + f"cat {self.CONFIG_DIR}/3proxy.cfg 2>/dev/null" + ) + if code != 0 or not out.strip(): + return '' + return out + + def _write_config(self, config_text): + # Write to host first (so we have a stable copy outside the container), + # then docker cp into the running container at the path 3proxy expects. + self.ssh.run_sudo_command(f"mkdir -p {self.CONFIG_DIR}") + self.ssh.upload_file_sudo(config_text, f"{self.CONFIG_DIR}/3proxy.cfg") + self.ssh.run_sudo_command( + f"docker cp {self.CONFIG_DIR}/3proxy.cfg {self.CONTAINER_NAME}:{self.CONFIG_PATH} 2>/dev/null || true" + ) + + def _parse_credentials(self, config_text): + creds = {'port': None, 'username': None, 'password': None} + if not config_text: + return creds + m_user = re.search(r'^\s*users\s+([^:\s]+):CL:(\S+)', config_text, re.MULTILINE) + if m_user: + creds['username'] = m_user.group(1) + creds['password'] = m_user.group(2) + m_port = re.search(r'^\s*socks\s+-p(\d+)', config_text, re.MULTILINE) + if m_port: + creds['port'] = int(m_port.group(1)) + return creds + + def get_credentials(self): + return self._parse_credentials(self._read_config()) + + # ===================== INSTALL / UPDATE / REMOVE ===================== + + def install_protocol(self, protocol_type='socks5', port=None, username=None, password=None): + if not self.check_docker_installed(): + return {'status': 'error', 'message': 'Docker not installed'} + + port = int(port or self.DEFAULT_PORT) + username = (username or self.DEFAULT_USERNAME).strip() or self.DEFAULT_USERNAME + password = (password or _generate_password()).strip() or _generate_password() + + # Pull image (idempotent — fast no-op if cached) + self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}") + + # Wipe any prior install, including the bind-mounted config dir, before + # writing a fresh config — leftover state would leak old credentials. + if self.check_protocol_installed(): + self.remove_container() + + config_text = self._build_config(username, password, port) + self.ssh.run_sudo_command(f"mkdir -p {self.CONFIG_DIR}") + self.ssh.upload_file_sudo(config_text, f"{self.CONFIG_DIR}/3proxy.cfg") + + # Bind-mount our config in place of the image's default. 3proxy reads + # /usr/local/3proxy/conf/3proxy.cfg by convention. + run_cmd = ( + f"docker run -d --restart always " + f"--name {self.CONTAINER_NAME} " + f"-p {port}:{port}/tcp " + f"-v {self.CONFIG_DIR}/3proxy.cfg:{self.CONFIG_PATH}:ro " + f"{self.IMAGE_NAME} {self.CONFIG_PATH}" + ) + _, err, code = self.ssh.run_sudo_command(run_cmd) + if code != 0: + return {'status': 'error', 'message': f'Failed to start container: {err}'} + + return { + 'status': 'success', + 'protocol': 'socks5', + 'port': port, + 'username': username, + 'password': password, + 'message': 'SOCKS5 proxy installed', + 'log': [ + f'SOCKS5 proxy listening on port {port}/TCP', + f'Username: {username}', + f'Password: {password}', + 'Save these credentials — the password can also be viewed later via "Change settings".', + ], + } + + def update_credentials(self, port=None, username=None, password=None): + """Apply new connection settings: regenerates the config file and + restarts the container so the new port mapping takes effect.""" + if not self.check_protocol_installed(): + return {'status': 'error', 'message': 'SOCKS5 not installed'} + + current = self.get_credentials() + new_port = int(port if port is not None else (current.get('port') or self.DEFAULT_PORT)) + new_user = (username or current.get('username') or self.DEFAULT_USERNAME).strip() + new_pass = (password or current.get('password') or _generate_password()).strip() + + old_port = current.get('port') + + # If the port changed we must recreate the container — `docker run -p` + # mappings are immutable on existing containers. + if old_port and new_port != old_port: + return self.install_protocol( + port=new_port, username=new_user, password=new_pass + ) + + config_text = self._build_config(new_user, new_pass, new_port) + self._write_config(config_text) + self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}") + + return { + 'status': 'success', + 'port': new_port, + 'username': new_user, + 'password': new_pass, + } + + def remove_container(self, protocol_type='socks5'): + self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true") + self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true") + self.ssh.run_sudo_command(f"rm -rf {self.CONFIG_DIR}") + return True diff --git a/managers/ssh_manager.py b/managers/ssh_manager.py new file mode 100644 index 0000000..437e52e --- /dev/null +++ b/managers/ssh_manager.py @@ -0,0 +1,227 @@ +""" +SSH Manager - manages SSH connections to VPN servers. +Replicates the ServerController logic from the AmneziaVPN client. +""" + +import paramiko +import io +import time +import logging + +logger = logging.getLogger(__name__) + + +class SSHManager: + """Manages SSH connections and command execution on remote servers.""" + + def __init__(self, host, port, username, password=None, private_key=None): + self.host = host + self.port = int(port) + self.username = username + self.password = password + self.private_key = private_key + self.client = None + self._is_root = (username == 'root') + + def connect(self): + """Establish SSH connection to the server.""" + self.client = paramiko.SSHClient() + self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + kwargs = { + 'hostname': self.host, + 'port': self.port, + 'username': self.username, + 'timeout': 15, + 'allow_agent': False, + 'look_for_keys': False, + } + + if self.private_key: + key_file = io.StringIO(self.private_key) + try: + pkey = paramiko.RSAKey.from_private_key(key_file) + except paramiko.ssh_exception.SSHException: + key_file.seek(0) + try: + pkey = paramiko.Ed25519Key.from_private_key(key_file) + except paramiko.ssh_exception.SSHException: + key_file.seek(0) + pkey = paramiko.ECDSAKey.from_private_key(key_file) + kwargs['pkey'] = pkey + elif self.password: + kwargs['password'] = self.password + + self.client.connect(**kwargs) + return True + + def disconnect(self): + """Close SSH connection.""" + if self.client: + self.client.close() + self.client = None + + def run_command(self, command, timeout=60): + """Execute command on remote server.""" + if not self.client: + raise ConnectionError("Not connected to server") + + logger.info(f"Running command: {command[:100]}...") + stdin, stdout, stderr = self.client.exec_command(command, timeout=timeout) + + # Crucial: set timeout on the channel to prevent hanging indefinitely + stdout.channel.settimeout(timeout) + stderr.channel.settimeout(timeout) + + try: + exit_code = stdout.channel.recv_exit_status() + out = stdout.read().decode('utf-8', errors='replace').strip() + err = stderr.read().decode('utf-8', errors='replace').strip() + except Exception as e: + logger.error(f"Command timed out or failed to read: {e}") + out, err, exit_code = "", str(e), -1 + + if exit_code != 0: + logger.warning(f"Command exited with code {exit_code}: {err}") + + return out, err, exit_code + + def _sudo_prefix(self): + """Get the sudo command prefix with password handling.""" + if self._is_root: + return '' + if self.password: + # Use sudo -S to read password from stdin + escaped_pass = self.password.replace("'", "'\\''") + return f"echo '{escaped_pass}' | sudo -S " + return 'sudo ' + + def run_sudo_command(self, command, timeout=60): + """ + Execute command with sudo, automatically handling password. + Strips 'sudo ' from the beginning of command if present, + and re-adds it with password piping. + """ + # Remove existing sudo prefix if present + clean_cmd = command + if clean_cmd.strip().startswith('sudo '): + clean_cmd = clean_cmd.strip()[5:] + + if self._is_root: + return self.run_command(clean_cmd, timeout=timeout) + + if self.password: + escaped_pass = self.password.replace("'", "'\\''") + # Pipe password directly to sudo -S, preserving original command quoting + # 2>/dev/null on echo suppresses '[sudo] password for...' prompt noise + full_cmd = f"echo '{escaped_pass}' | sudo -S -p '' {clean_cmd}" + else: + full_cmd = f"sudo {clean_cmd}" + + return self.run_command(full_cmd, timeout=timeout) + + def run_sudo_script(self, script, timeout=120): + """ + Execute a multi-line script with sudo/root privileges. + Writes script to /tmp via SFTP, then runs with sudo bash. + """ + if self._is_root: + return self.run_script(script, timeout=timeout) + + # Write script to temp file via SFTP (avoids heredoc/pipe conflicts) + import hashlib + script_hash = hashlib.md5(script.encode()).hexdigest()[:8] + tmp_script = f"/tmp/_amnz_script_{script_hash}.sh" + self.upload_file(script, tmp_script) + + # Run with sudo + if self.password: + escaped_pass = self.password.replace("'", "'\\''") + full_cmd = f"echo '{escaped_pass}' | sudo -S -p '' bash {tmp_script}; rm -f {tmp_script}" + else: + full_cmd = f"sudo bash {tmp_script}; rm -f {tmp_script}" + + return self.run_command(full_cmd, timeout=timeout) + + def run_script(self, script, timeout=120): + """Execute a multi-line script on remote server.""" + return self.run_command(script, timeout=timeout) + + def upload_file(self, content, remote_path): + """Upload text content to a remote file via SFTP.""" + if not self.client: + raise ConnectionError("Not connected to server") + + # Normalize line endings (Windows CRLF -> Unix LF) + content = content.replace('\r\n', '\n') + + sftp = self.client.open_sftp() + try: + with sftp.file(remote_path, 'w') as f: + f.write(content) + finally: + sftp.close() + + def upload_file_sudo(self, content, remote_path): + """ + Upload text content to a remote file that requires root access. + Uses SFTP to write to /tmp, then sudo mv to the target path. + Also normalizes line endings to Unix-style (LF). + """ + if not self.client: + raise ConnectionError("Not connected to server") + + # Normalize line endings (Windows CRLF -> Unix LF) + content = content.replace('\r\n', '\n') + + # Write to temp file via SFTP (no sudo needed for /tmp) + import hashlib + tmp_name = f"/tmp/_amnz_{hashlib.md5(remote_path.encode()).hexdigest()[:8]}" + self.upload_file(content, tmp_name) + + # Move to target with sudo + self.run_sudo_command(f"mv {tmp_name} {remote_path}") + self.run_sudo_command(f"chmod 644 {remote_path}") + return True + + def download_file(self, remote_path): + """Download text content from a remote file.""" + if not self.client: + raise ConnectionError("Not connected to server") + + sftp = self.client.open_sftp() + try: + with sftp.file(remote_path, 'r') as f: + return f.read().decode('utf-8', errors='replace') + finally: + sftp.close() + + def file_exists(self, remote_path): + """Check if a remote file exists.""" + if not self.client: + raise ConnectionError("Not connected to server") + + sftp = self.client.open_sftp() + try: + sftp.stat(remote_path) + return True + except FileNotFoundError: + return False + finally: + sftp.close() + + def test_connection(self): + """Test SSH connection and return server info.""" + out, err, code = self.run_command("uname -sr && cat /etc/os-release 2>/dev/null | head -2") + return out + + def write_file(self, remote_path, content): + """Write content to a remote file with sudo.""" + return self.upload_file_sudo(content, remote_path) + + def __enter__(self): + self.connect() + return self + + def __exit__(self, *args): + self.disconnect() diff --git a/managers/telemt_manager.py b/managers/telemt_manager.py new file mode 100644 index 0000000..63df326 --- /dev/null +++ b/managers/telemt_manager.py @@ -0,0 +1,532 @@ +import json +import logging +import uuid +import re +import os +import secrets +from datetime import datetime +from .ssh_manager import SSHManager + +logger = logging.getLogger(__name__) + +class TelemtManager: + CONTAINER_NAME = "telemt" + API_URL = "http://127.0.0.1:9091" + + def __init__(self, ssh_manager: SSHManager): + self.ssh = ssh_manager + + def _api_request(self, method, path, data=None): + """Execute a curl request inside the docker container.""" + cmd = f"docker exec {self.CONTAINER_NAME} curl -s -X {method} {self.API_URL}{path}" + if data: + js_data = json.dumps(data).replace('"', '\\"') + cmd += f" -H 'Content-Type: application/json' -d \"{js_data}\"" + + out, err, code = self.ssh.run_sudo_command(cmd) + if code != 0: + return None + + try: + return json.loads(out) + except json.JSONDecodeError: + return None + + def check_docker_installed(self): + out, _, _ = self.ssh.run_command("docker --version 2>/dev/null") + return bool(out.strip()) + + def check_protocol_installed(self): + out, _, _ = self.ssh.run_command(f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'") + return out.strip() == self.CONTAINER_NAME + + def get_server_status(self, protocol_type): + exists = self.check_protocol_installed() + out, _, _ = self.ssh.run_command(f"docker inspect -f '{{{{.State.Running}}}}' {self.CONTAINER_NAME} 2>/dev/null") + is_running = out.strip().lower() == 'true' + + status = { + 'container_exists': exists, + 'container_running': is_running, + } + + if is_running: + # get external docker port mapping for 443 + out, _, _ = self.ssh.run_command(f"docker port {self.CONTAINER_NAME} 443 2>/dev/null") + if out: + port = out.split(':')[-1].strip() + status['port'] = port + else: + status['port'] = None + + config = self._get_server_config() + status['awg_params'] = self._parse_telemt_params(config) + + # Count connections from API + clients = self.get_clients(protocol_type) + status['clients_count'] = len(clients) + + return status + + def _ensure_docker_compose(self): + """Make sure `docker compose` is available, installing the plugin if needed. + + Why: `docker-buildx-plugin` and `docker-compose-plugin` only ship in Docker's + official apt/yum repo. When Docker was installed from distro packages + (e.g. `docker.io` on Ubuntu), that repo is not configured and a plain + `apt-get install docker-compose-plugin` fails. So we add the repo, + refresh package lists, then install. + """ + out, _, code = self.ssh.run_command("docker compose version 2>/dev/null") + if code == 0 and out.strip(): + return + + script = r""" +if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -y || true + apt-get install -y ca-certificates curl gnupg || exit 1 + install -m 0755 -d /etc/apt/keyrings + . /etc/os-release + DOCKER_DISTRO="$ID" + case "$ID" in + linuxmint|pop|elementary|zorin) DOCKER_DISTRO="ubuntu" ;; + kali|parrot) DOCKER_DISTRO="debian" ;; + esac + if [ ! -s /etc/apt/keyrings/docker.asc ]; then + curl -fsSL "https://download.docker.com/linux/${DOCKER_DISTRO}/gpg" -o /etc/apt/keyrings/docker.asc || exit 1 + chmod a+r /etc/apt/keyrings/docker.asc + fi + CODENAME="${UBUNTU_CODENAME:-$VERSION_CODENAME}" + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${DOCKER_DISTRO} ${CODENAME} stable" > /etc/apt/sources.list.d/docker.list + apt-get update -y || exit 1 + apt-get install -y docker-buildx-plugin docker-compose-plugin || exit 1 +elif command -v dnf >/dev/null 2>&1; then + dnf install -y dnf-plugins-core || exit 1 + . /etc/os-release + dnf config-manager --add-repo "https://download.docker.com/linux/${ID}/docker-ce.repo" \ + || dnf config-manager --add-repo "https://download.docker.com/linux/centos/docker-ce.repo" \ + || exit 1 + dnf makecache || true + dnf install -y docker-buildx-plugin docker-compose-plugin || exit 1 +elif command -v yum >/dev/null 2>&1; then + yum install -y yum-utils || exit 1 + . /etc/os-release + yum-config-manager --add-repo "https://download.docker.com/linux/${ID}/docker-ce.repo" \ + || yum-config-manager --add-repo "https://download.docker.com/linux/centos/docker-ce.repo" \ + || exit 1 + yum makecache || true + yum install -y docker-buildx-plugin docker-compose-plugin || exit 1 +else + echo "Unsupported package manager" >&2 + exit 1 +fi +docker compose version +""" + out, err, code = self.ssh.run_sudo_script(script, timeout=300) + if code != 0: + raise RuntimeError(f"Failed to install docker compose plugin: {err or out}") + + def install_protocol(self, protocol_type='telemt', port='443', tls_emulation=True, tls_domain="", max_connections=0): + results = [] + if not self.check_docker_installed(): + results.append("Installing Docker...") + self.ssh.run_sudo_command("curl -fsSL https://get.docker.com | sh", timeout=300) + + if self.check_protocol_installed(): + self.ssh.run_sudo_command(f"docker rm -f {self.CONTAINER_NAME}") + + results.append("Ensuring docker compose plugin...") + self._ensure_docker_compose() + + results.append("Uploading Telemt files...") + local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_telemt') + remote_dir = "/opt/amnezia/telemt" + self.ssh.run_sudo_command(f"mkdir -p {remote_dir}") + self.ssh.run_sudo_command(f"chmod 755 {remote_dir}") + + # Read and patch config.toml + with open(os.path.join(local_dir, 'config.toml'), 'r', encoding='utf-8') as f: + config_content = f.read() + + tls_emul_str = "true" if tls_emulation else "false" + config_content = re.sub(r'tls_emulation\s*=\s*(true|false|True|False)', f'tls_emulation = {tls_emul_str}', config_content) + + if tls_emulation and tls_domain: + config_content = re.sub(r'tls_domain\s*=\s*".*?"', f'tls_domain = "{tls_domain}"', config_content) + + if max_connections is not None and max_connections > 0: + config_content = re.sub(r'max_connections\s*=\s*\d+', f'max_connections = {max_connections}', config_content) + + # Patch public_host and public_port for links + if "public_host =" in config_content or "# public_host =" in config_content: + config_content = re.sub(r'#?\s*public_host\s*=\s*".*?"', f'public_host = "{self.ssh.host}"', config_content) + else: + config_content = config_content.replace('[general.links]', f'[general.links]\npublic_host = "{self.ssh.host}"') + + config_content = re.sub(r'public_port\s*=\s*\d+', f'public_port = {port}', config_content) + + # Remove default hello user + config_content = re.sub(r'^hello\s*=\s*".*?"', '', config_content, flags=re.MULTILINE) + + self.ssh.upload_file_sudo(config_content, f"{remote_dir}/config.toml") + + # Patch docker-compose.yml with proper port + with open(os.path.join(local_dir, 'docker-compose.yml'), 'r', encoding='utf-8') as f: + compose_content = f.read() + + compose_content = re.sub(r'"443:443"', f'"{port}:443"', compose_content) + self.ssh.upload_file_sudo(compose_content, f"{remote_dir}/docker-compose.yml") + + # Upload Dockerfile + with open(os.path.join(local_dir, 'Dockerfile'), 'r', encoding='utf-8') as f: + dockerfile = f.read() + self.ssh.upload_file_sudo(dockerfile, f"{remote_dir}/Dockerfile") + + results.append("Starting Telemt container...") + out, err, code = self.ssh.run_sudo_command(f"sh -c 'cd {remote_dir} && docker compose up -d --build'", timeout=600) + if code != 0: + self.ssh.run_sudo_command(f"sh -c 'cd {remote_dir} && docker-compose up -d --build'", timeout=600) + + return { + "status": "success", + "host": "", + "port": port, + "log": results + } + + def _get_server_config(self): + out, _, code = self.ssh.run_sudo_command(f"cat /opt/amnezia/telemt/config.toml") + if code != 0: return "" + return out + + def save_server_config(self, protocol_type, config_content): + self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), "/opt/amnezia/telemt/config.toml") + # Use SIGHUP (HUP) to reload MTProxy config without restarting the process/container. + # This keeps the traffic statistics (octets) in memory. + self.ssh.run_sudo_command(f"docker kill -s HUP {self.CONTAINER_NAME} || docker restart {self.CONTAINER_NAME}") + + def _parse_telemt_params(self, config_text): + params = {} + m = re.search(r'tls_emulation\s*=\s*(true|false)', config_text, re.IGNORECASE) + if m: params['tls_emulation'] = m.group(1).lower() == 'true' + + m = re.search(r'tls_domain\s*=\s*"([^"]+)"', config_text) + if m: params['tls_domain'] = m.group(1) + + m = re.search(r'max_connections\s*=\s*(\d+)', config_text) + if m: params['max_connections'] = int(m.group(1)) + + return params + + def remove_container(self, protocol_type=None): + self.ssh.run_sudo_command(f"docker rm -f {self.CONTAINER_NAME}") + self.ssh.run_sudo_command("rm -rf /opt/amnezia/telemt") + + def get_clients(self, protocol_type): + api_data = {} + resp = self._api_request("GET", "/v1/users") + if resp and resp.get('ok'): + for u in resp.get('data', []): + api_data[u.get('username')] = u + + config_text = self._get_server_config() + users = self._parse_users_from_config(config_text) + + clients = [] + needs_update = False + for username, secret in users.items(): + user_stats = api_data.get(username.lstrip('#').strip(), {}) + links = user_stats.get('links', {}) + tg_link = "" + if links.get('tls'): tg_link = links['tls'][0] + elif links.get('secure'): tg_link = links['secure'][0] + elif links.get('classic'): tg_link = links['classic'][0] + + enabled = not username.startswith('#') + clean_name = username.lstrip('#').strip() + + total_octets = user_stats.get('total_octets', 0) + quota = user_stats.get('data_quota_bytes') + + # AUTO-DISABLE IF QUOTA REACHED + if enabled and quota and total_octets >= quota: + logger.info(f"Auto-disabling client {clean_name} - quota reached: {total_octets} >= {quota}") + # We will trigger a toggle after we finish this loop to avoid re-reading config inside loop + enabled = False + needs_update = True + + clients.append({ + "clientId": clean_name, + "clientName": clean_name, + "enabled": enabled, + "creationDate": "", + "userData": { + "clientName": clean_name, + "token": secret, + "tg_link": tg_link, + "total_octets": total_octets, + "current_connections": user_stats.get('current_connections', 0), + "active_ips": user_stats.get('active_unique_ips', 0), + "quota": quota, + "expiry": user_stats.get('expiration_rfc3339') + } + }) + + if needs_update: + # Re-read and update config strictly at the end + for c in clients: + if not c['enabled']: + self.toggle_client(protocol_type, c['clientId'], False, restart=False) + self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}") + + return clients + + def _parse_users_from_config(self, config_text): + users = {} + lines = config_text.split('\n') + in_section = False + for line in lines: + stripped = line.strip() + if stripped == '[access.users]': + in_section = True + continue + if in_section and stripped.startswith('['): + break + if in_section and stripped: + commented = stripped.startswith('#') + content = stripped.lstrip('#').strip() + if '=' in content: + if content.lower().startswith('format:'): continue + name, secret = content.split('=', 1) + name = name.strip().strip('"').strip() + secret = secret.strip().strip('"').strip() + full_name = ("# " + name) if commented else name + users[full_name] = secret + return users + + def add_client(self, protocol_type, name, host='', port='', **kwargs): + username = re.sub(r'[^a-zA-Z0-9_.-]', '', name.replace(' ', '_')) + if not username: username = "user_" + uuid.uuid4().hex[:8] + + config_text = self._get_server_config() + current_users = self._parse_users_from_config(config_text) + idx = 1 + base_username = username + while any(u.lstrip('#').strip() == username for u in current_users): + username = f"{base_username}_{idx}" + idx += 1 + + secret = kwargs.get('secret') or secrets.token_hex(16) + + # 1. Update config file for persistence (but don't restart yet) + config_text = self._insert_into_section(config_text, "access.users", f'{username} = "{secret}"') + + api_payload = { + "username": username, + "secret": secret + } + + if kwargs.get('telemt_quota'): + val = int(kwargs['telemt_quota']) + config_text = self._insert_into_section(config_text, "access.user_data_quota", f'{username} = {val}') + api_payload['data_quota_bytes'] = val + + if kwargs.get('telemt_max_ips'): + val = int(kwargs['telemt_max_ips']) + config_text = self._insert_into_section(config_text, "access.user_max_unique_ips", f'{username} = {val}') + api_payload['max_unique_ips'] = val + + if kwargs.get('telemt_expiry'): + val = kwargs['telemt_expiry'] + config_text = self._insert_into_section(config_text, "access.user_expirations", f'{username} = "{val}"') + api_payload['expiration_rfc3339'] = val + + if kwargs.get('user_ad_tag'): + val = kwargs['user_ad_tag'] + config_text = self._insert_into_section(config_text, "access.user_ad_tags", f'{username} = "{val}"') + api_payload['user_ad_tag'] = val + + if kwargs.get('max_tcp_conns'): + val = int(kwargs['max_tcp_conns']) + config_text = self._insert_into_section(config_text, "access.user_max_tcp_conns", f'{username} = {val}') + api_payload['max_tcp_conns'] = val + + # Save config to host + self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), "/opt/amnezia/telemt/config.toml") + + # 2. Call API for immediate effect + self._api_request("POST", "/v1/users", data=api_payload) + + # Fetch the official link from API (it includes TLS emulation padding like 'ee...' if enabled) + link = self.get_client_config(protocol_type, username, host, port) + + # Extreme fallback if API is slow or 404 + if link == "Not found": + link = f"tg://proxy?server={host}&port={port}&secret={secret}" + + return { + "client_id": username, + "config": link, + "vpn_link": link + } + + def edit_client(self, protocol_type, client_id, new_params): + """Update existing client parameters via API and in config.""" + config_text = self._get_server_config() + api_payload = {} + + if 'telemt_quota' in new_params: + val = int(new_params['telemt_quota']) if new_params['telemt_quota'] else None + config_text = self._update_line_in_section(config_text, "access.user_data_quota", client_id, val) + api_payload['data_quota_bytes'] = val + + if 'telemt_max_ips' in new_params: + val = int(new_params['telemt_max_ips']) if new_params['telemt_max_ips'] else None + config_text = self._update_line_in_section(config_text, "access.user_max_unique_ips", client_id, val) + api_payload['max_unique_ips'] = val + + if 'telemt_expiry' in new_params: + val = new_params['telemt_expiry'] + quoted_val = f'"{val}"' if val else None + config_text = self._update_line_in_section(config_text, "access.user_expirations", client_id, quoted_val) + api_payload['expiration_rfc3339'] = val + + if 'secret' in new_params: + val = new_params['secret'] + quoted_val = f'"{val}"' if val else None + config_text = self._update_line_in_section(config_text, "access.users", client_id, quoted_val) + api_payload['secret'] = val + + if 'user_ad_tag' in new_params: + val = new_params['user_ad_tag'] + quoted_val = f'"{val}"' if val else None + config_text = self._update_line_in_section(config_text, "access.user_ad_tags", client_id, quoted_val) + api_payload['user_ad_tag'] = val + + if 'max_tcp_conns' in new_params: + val = int(new_params['max_tcp_conns']) if new_params['max_tcp_conns'] else None + config_text = self._update_line_in_section(config_text, "access.user_max_tcp_conns", client_id, val) + api_payload['max_tcp_conns'] = val + + # Save config to host + self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), "/opt/amnezia/telemt/config.toml") + + # API call + self._api_request("PATCH", f"/v1/users/{client_id}", data=api_payload) + return {"status": "success"} + + def _update_line_in_section(self, config_text, section_name, client_id, value): + lines = config_text.split('\n') + section_start = -1 + section_end = -1 + for i, line in enumerate(lines): + if line.strip() == f"[{section_name}]": + section_start = i + elif section_start != -1 and line.strip().startswith('['): + section_end = i + break + + if section_end == -1: section_end = len(lines) + if section_start == -1: + if value is not None: + lines.append(f"[{section_name}]") + lines.append(f'{client_id} = {value}') + lines.append("") + return '\n'.join(lines) + + found = False + for i in range(section_start + 1, section_end): + line = lines[i].strip().lstrip('#').strip() + if line.startswith(f"{client_id} ") or line.startswith(f"{client_id}="): + if value is None: lines.pop(i) + else: lines[i] = f'{client_id} = {value}' + found = True + break + + if not found and value is not None: + lines.insert(section_start + 1, f'{client_id} = {value}') + + return '\n'.join(lines) + + def _insert_into_section(self, config_text, section_name, line_to_insert): + lines = config_text.split('\n') + section_start = -1 + for i, line in enumerate(lines): + if line.strip() == f"[{section_name}]": + section_start = i + break + if section_start == -1: + lines.append(f"[{section_name}]") + lines.append(line_to_insert) + lines.append("") + else: + lines.insert(section_start + 1, line_to_insert) + return '\n'.join(lines) + + def remove_client(self, protocol_type, client_id): + # 1. API + self._api_request("DELETE", f"/v1/users/{client_id}") + + # 2. Config + config_text = self._get_server_config() + lines = config_text.split('\n') + new_lines = [] + for line in lines: + stripped = line.strip().lstrip('#').strip() + if stripped.startswith(f"{client_id} ") or stripped.startswith(f"{client_id}="): + continue + new_lines.append(line) + self.ssh.upload_file_sudo('\n'.join(new_lines).replace('\r\n', '\n'), "/opt/amnezia/telemt/config.toml") + + def toggle_client(self, protocol_type, client_id, enable, restart=True): + # API doesn't have a direct "toggle", so we either set a huge quota or remove/re-add + # But for Telemt, commenting out in config is the persistent way. + # We'll use HUP after toggling in config. + + config_text = self._get_server_config() + lines = config_text.split('\n') + new_lines = [] + in_access_section = False + for line in lines: + stripped = line.strip() + if stripped.startswith('[access.'): in_access_section = True + elif stripped.startswith('['): in_access_section = False + + if in_access_section: + base_line = line.lstrip('#').strip() + if base_line.startswith(f"{client_id} ") or base_line.startswith(f"{client_id}="): + line = base_line if enable else f"# {base_line}" + new_lines.append(line) + + self.ssh.upload_file_sudo('\n'.join(new_lines).replace('\r\n', '\n'), "/opt/amnezia/telemt/config.toml") + + if enable: + # If enabling, we re-add via API since it might have been deleted from memory + secret = "" + users = self._parse_users_from_config('\n'.join(new_lines)) + secret = users.get(client_id, "") + if secret: + self._api_request("POST", "/v1/users", data={"username": client_id, "secret": secret}) + else: + # If disabling, we just delete from memory + self._api_request("DELETE", f"/v1/users/{client_id}") + + if restart: + self.ssh.run_sudo_command(f"docker kill -s HUP {self.CONTAINER_NAME} || docker restart {self.CONTAINER_NAME}") + + def get_client_config(self, protocol_type, client_id, host='', port=''): + resp = self._api_request("GET", f"/v1/users/{client_id}") + if resp and resp.get('ok'): + user = resp.get('data', {}) + links = user.get('links', {}) + if links.get('tls'): return links['tls'][0] + if links.get('secure'): return links['secure'][0] + if links.get('classic'): return links['classic'][0] + + clients = self.get_clients(protocol_type) + c = next((c for c in clients if c['clientId'] == client_id), None) + if c: + secret = c.get('userData', {}).get('token', '') + if secret: return f"tg://proxy?server={host}&port={port}&secret={secret}" + return "Not found" \ No newline at end of file diff --git a/managers/wireguard_manager.py b/managers/wireguard_manager.py new file mode 100644 index 0000000..b646cd3 --- /dev/null +++ b/managers/wireguard_manager.py @@ -0,0 +1,823 @@ +""" +WireGuard Protocol Manager - handles standard WireGuard protocol +installation, configuration, and client management on remote servers. + +Follows the same architecture as awg_manager.py, using: +- client/server_scripts/wireguard/ scripts as reference +- Docker container: amneziavpn/amnezia-wg (same as AWG Legacy) +- Standard wg/wg-quick tools +""" + +import json +import re +import secrets +import logging +from base64 import b64encode +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from cryptography.hazmat.primitives import serialization + +logger = logging.getLogger(__name__) + +WG_DEFAULTS = { + 'port': '51820', + 'mtu': '1420', + 'subnet_address': '10.8.2.0', + 'subnet_cidr': '24', + 'subnet_ip': '10.8.2.1', + 'dns1': '1.1.1.1', + 'dns2': '1.0.0.1', +} + + +def generate_wg_keypair(): + """Generate a WireGuard X25519 keypair (private, public) as base64 strings.""" + private_key = X25519PrivateKey.generate() + private_bytes = private_key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption() + ) + public_bytes = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw + ) + return b64encode(private_bytes).decode(), b64encode(public_bytes).decode() + + +def generate_psk(): + """Generate a WireGuard preshared key.""" + return b64encode(secrets.token_bytes(32)).decode() + + +class WireGuardManager: + """Manages standard WireGuard protocol installation and client management.""" + + PROTOCOL = 'wireguard' + CONTAINER_NAME = 'amnezia-wireguard' + DOCKER_IMAGE = 'amneziavpn/amnezia-wg:latest' + CONFIG_PATH = '/opt/amnezia/wireguard/wg0.conf' + KEY_DIR = '/opt/amnezia/wireguard' + CLIENTS_TABLE_PATH = '/opt/amnezia/wireguard/clientsTable' + INTERFACE = 'wg0' + + def __init__(self, ssh_manager): + self.ssh = ssh_manager + + # ===================== INSTALLATION ===================== + + def check_docker_installed(self): + """Check if Docker is installed and running.""" + out, err, code = self.ssh.run_command("docker --version 2>/dev/null") + if code != 0: + return False + out2, _, code2 = self.ssh.run_command( + "systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null" + ) + return 'active' in out2 or 'running' in out2.lower() + + def install_docker(self): + """Install Docker on the server.""" + script = r""" +if which apt-get > /dev/null 2>&1; then pm=$(which apt-get); silent_inst="-yq install"; check_pkgs="-yq update"; docker_pkg="docker.io"; dist="debian"; +elif which dnf > /dev/null 2>&1; then pm=$(which dnf); silent_inst="-yq install"; check_pkgs="-yq check-update"; docker_pkg="docker"; dist="fedora"; +elif which yum > /dev/null 2>&1; then pm=$(which yum); silent_inst="-y -q install"; check_pkgs="-y -q check-update"; docker_pkg="docker"; dist="centos"; +else echo "Packet manager not found"; exit 1; fi; +if [ "$dist" = "debian" ]; then export DEBIAN_FRONTEND=noninteractive; fi; +if ! command -v docker > /dev/null 2>&1; then + $pm $check_pkgs; $pm $silent_inst $docker_pkg; + sleep 5; systemctl enable --now docker; sleep 5; +fi; +if [ "$(systemctl is-active docker)" != "active" ]; then + $pm $check_pkgs; $pm $silent_inst $docker_pkg; + sleep 5; systemctl start docker; sleep 5; +fi; +docker --version +""" + out, err, code = self.ssh.run_sudo_script(script, timeout=180) + if code != 0: + raise RuntimeError(f"Failed to install Docker: {err}") + return out + + def check_container_running(self): + """Check if WireGuard container is running.""" + out, _, code = self.ssh.run_sudo_command( + f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'" + ) + return 'Up' in out + + def check_protocol_installed(self): + """Check if protocol is installed (container exists).""" + out, _, code = self.ssh.run_sudo_command( + f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'" + ) + return self.CONTAINER_NAME in out.strip().split('\n') + + def prepare_host(self): + """Prepare host for container.""" + dockerfile_folder = f"/opt/amnezia/{self.CONTAINER_NAME}" + script = f""" +mkdir -p {dockerfile_folder} +mkdir -p {self.KEY_DIR} +if ! docker network ls | grep -q amnezia-dns-net; then + docker network create --driver bridge --subnet=172.29.172.0/24 --opt com.docker.network.bridge.name=amn0 amnezia-dns-net +fi +""" + out, err, code = self.ssh.run_sudo_script(script) + if code != 0: + logger.warning(f"prepare_host warning: {err}") + return True + + def setup_firewall(self): + """Setup host firewall.""" + script = """ +sysctl -w net.ipv4.ip_forward=1 +iptables -C INPUT -p icmp --icmp-type echo-request -j DROP 2>/dev/null || iptables -A INPUT -p icmp --icmp-type echo-request -j DROP +iptables -C FORWARD -j DOCKER-USER 2>/dev/null || iptables -A FORWARD -j DOCKER-USER 2>/dev/null +""" + self.ssh.run_sudo_script(script) + return True + + def install_protocol(self, port=None): + """ + Full installation of WireGuard protocol. + Steps: install docker -> prepare host -> build container -> + configure container -> run container -> setup firewall + """ + if port is None: + port = WG_DEFAULTS['port'] + + results = [] + + # Step 1: Install Docker + if not self.check_docker_installed(): + results.append("Installing Docker...") + self.install_docker() + results.append("Docker installed successfully") + else: + results.append("Docker already installed") + + # Step 2: Prepare host + results.append("Preparing host...") + self.prepare_host() + results.append("Host prepared") + + # Step 3: Remove old container if exists + if self.check_protocol_installed(): + results.append("Removing old container...") + self.remove_container() + results.append("Old container removed") + + # Step 4: Build container + results.append("Building Docker image...") + dockerfile_folder = f"/opt/amnezia/{self.CONTAINER_NAME}" + + dockerfile_content = ( + f"FROM {self.DOCKER_IMAGE}\n" + f"\n" + f'LABEL maintainer="AmneziaVPN"\n' + f"\n" + f"RUN apk add --no-cache curl wireguard-tools dumb-init iptables bash\n" + f"RUN apk --update upgrade --no-cache\n" + f"\n" + f"RUN mkdir -p /opt/amnezia\n" + f'RUN echo "#!/bin/bash" > /opt/amnezia/start.sh && ' + f'echo "tail -f /dev/null" >> /opt/amnezia/start.sh\n' + f"RUN chmod a+x /opt/amnezia/start.sh\n" + f"\n" + f'ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]\n' + ) + self.ssh.run_sudo_command(f"mkdir -p {dockerfile_folder}") + self.ssh.upload_file_sudo(dockerfile_content, f"{dockerfile_folder}/Dockerfile") + + out, err, code = self.ssh.run_sudo_command( + f"docker build --no-cache --pull -t {self.CONTAINER_NAME} {dockerfile_folder}", + timeout=300 + ) + if code != 0: + raise RuntimeError(f"Failed to build container: {err}") + results.append("Docker image built successfully") + + # Step 5: Run container + results.append("Starting container...") + run_cmd = f"""docker run -d \ +--restart always \ +--privileged \ +--cap-add=NET_ADMIN \ +--cap-add=SYS_MODULE \ +-p {port}:{port}/udp \ +-v /lib/modules:/lib/modules \ +--sysctl="net.ipv4.conf.all.src_valid_mark=1" \ +--name {self.CONTAINER_NAME} \ +{self.CONTAINER_NAME}""" + + out, err, code = self.ssh.run_sudo_command(run_cmd) + if code != 0: + raise RuntimeError(f"Failed to run container: {err}") + + # Connect to DNS network + self.ssh.run_sudo_command(f"docker network connect amnezia-dns-net {self.CONTAINER_NAME}") + + # Wait for container + results.append("Waiting for container to start...") + self._wait_container_running() + results.append("Container started") + + # Step 6: Configure container + results.append("Configuring WireGuard...") + self._configure_container(port) + results.append("WireGuard configured") + + # Step 7: Upload start script + results.append("Starting WireGuard service...") + self._upload_start_script(port) + results.append("WireGuard service started") + + # Step 8: Setup firewall + results.append("Setting up firewall...") + self.setup_firewall() + results.append("Firewall configured") + + return { + 'status': 'success', + 'protocol': self.PROTOCOL, + 'port': port, + 'log': results, + } + + def _wait_container_running(self, timeout=30): + """Wait for a container to be in 'running' state.""" + import time + last_status = 'unknown' + for i in range(timeout // 2): + out, _, _ = self.ssh.run_sudo_command( + f"docker inspect --format='{{{{.State.Status}}}}' {self.CONTAINER_NAME}" + ) + last_status = out.strip().strip("'\"") + if last_status == 'running': + time.sleep(1) + return True + time.sleep(2) + + logs_out, _, _ = self.ssh.run_sudo_command( + f"docker logs --tail 50 {self.CONTAINER_NAME} 2>&1" + ) + raise RuntimeError( + f"Container {self.CONTAINER_NAME} did not start within {timeout}s " + f"(status: {last_status}). Logs:\n{logs_out}" + ) + + def _configure_container(self, port): + """Configure the WireGuard container (generate keys and server config).""" + subnet_ip = WG_DEFAULTS['subnet_ip'] + subnet_cidr = WG_DEFAULTS['subnet_cidr'] + + config_script = f""" +mkdir -p {self.KEY_DIR} +cd {self.KEY_DIR} +WIREGUARD_SERVER_PRIVATE_KEY=$(wg genkey) +echo $WIREGUARD_SERVER_PRIVATE_KEY > {self.KEY_DIR}/wireguard_server_private_key.key + +WIREGUARD_SERVER_PUBLIC_KEY=$(echo $WIREGUARD_SERVER_PRIVATE_KEY | wg pubkey) +echo $WIREGUARD_SERVER_PUBLIC_KEY > {self.KEY_DIR}/wireguard_server_public_key.key + +WIREGUARD_PSK=$(wg genpsk) +echo $WIREGUARD_PSK > {self.KEY_DIR}/wireguard_psk.key + +cat > {self.CONFIG_PATH} </dev/null + +# Start WireGuard +if [ -f {self.CONFIG_PATH} ]; then wg-quick up {self.CONFIG_PATH}; fi + +# Allow traffic on the TUN interface +iptables -A INPUT -i {self.INTERFACE} -j ACCEPT +iptables -A FORWARD -i {self.INTERFACE} -j ACCEPT +iptables -A OUTPUT -o {self.INTERFACE} -j ACCEPT + +iptables -A FORWARD -i {self.INTERFACE} -o eth0 -s {subnet_ip}/{subnet_cidr} -j ACCEPT +iptables -A FORWARD -i {self.INTERFACE} -o eth1 -s {subnet_ip}/{subnet_cidr} -j ACCEPT + +iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT + +iptables -t nat -A POSTROUTING -s {subnet_ip}/{subnet_cidr} -o eth0 -j MASQUERADE +iptables -t nat -A POSTROUTING -s {subnet_ip}/{subnet_cidr} -o eth1 -j MASQUERADE + +tail -f /dev/null +""" + self.ssh.upload_file(start_script, "/tmp/_wg_start.sh") + self.ssh.run_sudo_command(f"docker cp /tmp/_wg_start.sh {self.CONTAINER_NAME}:/opt/amnezia/start.sh") + self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} chmod +x /opt/amnezia/start.sh") + self.ssh.run_command("rm -f /tmp/_wg_start.sh") + + self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}") + import time + time.sleep(5) + + def remove_container(self): + """Remove WireGuard container.""" + self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME}") + self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME}") + self.ssh.run_sudo_command(f"docker rmi {self.CONTAINER_NAME}") + return True + + # ===================== CLIENT MANAGEMENT ===================== + + def _get_clients_table(self): + """Get the clients table from the server.""" + out, err, code = self.ssh.run_sudo_command( + f"docker exec -i {self.CONTAINER_NAME} cat {self.CLIENTS_TABLE_PATH} 2>/dev/null" + ) + if code != 0 or not out.strip(): + return [] + try: + data = json.loads(out) + if isinstance(data, list): + return data + return [] + except json.JSONDecodeError: + return [] + + def _save_clients_table(self, clients_table): + """Save the clients table to the server.""" + content = json.dumps(clients_table, indent=2) + self.ssh.upload_file(content, "/tmp/_wg_clients.json") + self.ssh.run_sudo_command( + f"docker cp /tmp/_wg_clients.json {self.CONTAINER_NAME}:{self.CLIENTS_TABLE_PATH}" + ) + self.ssh.run_command("rm -f /tmp/_wg_clients.json") + + def _get_server_config(self): + """Get the server WireGuard config.""" + out, err, code = self.ssh.run_sudo_command( + f"docker exec -i {self.CONTAINER_NAME} cat {self.CONFIG_PATH}" + ) + if code != 0: + raise RuntimeError(f"Failed to get server config: {err}") + return out + + def save_server_config(self, config_content): + """Save the server WireGuard config and restart container.""" + self.ssh.upload_file(config_content.replace('\r\n', '\n'), "/tmp/_wg_edit_config.conf") + self.ssh.run_sudo_command(f"docker cp /tmp/_wg_edit_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}") + self.ssh.run_command("rm -f /tmp/_wg_edit_config.conf") + self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}") + + def _get_server_public_key(self): + """Get server public key.""" + out, err, code = self.ssh.run_sudo_command( + f"docker exec -i {self.CONTAINER_NAME} cat {self.KEY_DIR}/wireguard_server_public_key.key" + ) + if code != 0: + raise RuntimeError(f"Failed to get server public key: {err}") + return out.strip() + + def _get_server_psk(self): + """Get server preshared key.""" + out, err, code = self.ssh.run_sudo_command( + f"docker exec -i {self.CONTAINER_NAME} cat {self.KEY_DIR}/wireguard_psk.key" + ) + if code != 0: + raise RuntimeError(f"Failed to get PSK: {err}") + return out.strip() + + def _get_listen_port(self): + """Extract ListenPort from server config.""" + config = self._get_server_config() + for line in config.split('\n'): + if line.strip().startswith('ListenPort'): + return line.split('=', 1)[1].strip() + return WG_DEFAULTS['port'] + + def _get_used_ips(self): + """Get list of IPs already assigned in the config.""" + config = self._get_server_config() + ips = [] + for line in config.split('\n'): + line = line.strip() + if line.startswith('AllowedIPs'): + match = re.search(r'(\d+\.\d+\.\d+\.\d+)', line) + if match: + ips.append(match.group(1)) + elif line.startswith('Address'): + match = re.search(r'(\d+\.\d+\.\d+\.\d+)', line) + if match: + ips.append(match.group(1)) + return ips + + def _get_next_ip(self): + """Calculate the next available IP for a new client.""" + used_ips = self._get_used_ips() + if not used_ips: + base = WG_DEFAULTS['subnet_address'] + parts = base.split('.') + parts[3] = '2' + return '.'.join(parts) + + last_ip = used_ips[-1] + parts = last_ip.split('.') + last_octet = int(parts[3]) + next_octet = last_octet + 1 + if next_octet > 254: + next_octet = 2 + parts[3] = str(next_octet) + return '.'.join(parts) + + def _parse_peers_from_config(self): + """Parse [Peer] sections from WireGuard server config.""" + try: + config = self._get_server_config() + except Exception: + return {} + peers = {} + current_key = None + for line in config.split('\n'): + line = line.strip() + if line == '[Peer]': + current_key = None + elif current_key is None and line.startswith('PublicKey'): + current_key = line.split('=', 1)[1].strip() + peers[current_key] = {'allowedIps': ''} + elif current_key and line.startswith('AllowedIPs'): + peers[current_key]['allowedIps'] = line.split('=', 1)[1].strip() + return peers + + def _parse_bytes(self, size_str): + """Parse human readable size string into bytes.""" + try: + parts = size_str.strip().split() + if len(parts) != 2: + return 0 + val, unit = float(parts[0]), parts[1] + units = {'B': 1, 'KiB': 1024, 'MiB': 1024**2, 'GiB': 1024**3, 'TiB': 1024**4} + return int(val * units.get(unit, 1)) + except Exception: + return 0 + + def _wg_show(self): + """Run 'wg show all' and parse output.""" + out, err, code = self.ssh.run_sudo_command( + f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg show all'" + ) + if code != 0 or not out.strip(): + return {} + + result = {} + current_peer = None + + for line in out.split('\n'): + line = line.strip() + if line.startswith('peer:'): + current_peer = line.split(':', 1)[1].strip() + result[current_peer] = {} + elif current_peer and ':' in line: + key, value = line.split(':', 1) + key = key.strip() + value = value.strip() + if key == 'latest handshake': + result[current_peer]['latestHandshake'] = value + elif key == 'transfer': + parts = value.split(',') + if len(parts) == 2: + received = parts[0].strip().replace(' received', '') + sent = parts[1].strip().replace(' sent', '') + result[current_peer]['dataReceived'] = received + result[current_peer]['dataSent'] = sent + result[current_peer]['dataReceivedBytes'] = self._parse_bytes(received) + result[current_peer]['dataSentBytes'] = self._parse_bytes(sent) + elif key == 'allowed ips': + result[current_peer]['allowedIps'] = value + return result + + def get_clients(self): + """Get list of all clients with live traffic data.""" + clients_table = self._get_clients_table() + + try: + wg_show_data = self._wg_show() + except Exception: + wg_show_data = {} + + known_ids = set() + for client in clients_table: + client_id = client.get('clientId', '') + known_ids.add(client_id) + if client_id in wg_show_data: + show_data = wg_show_data[client_id] + user_data = client.get('userData', {}) + user_data['latestHandshake'] = show_data.get('latestHandshake', '') + user_data['dataReceived'] = show_data.get('dataReceived', '') + user_data['dataSent'] = show_data.get('dataSent', '') + user_data['dataReceivedBytes'] = show_data.get('dataReceivedBytes', 0) + user_data['dataSentBytes'] = show_data.get('dataSentBytes', 0) + user_data['allowedIps'] = show_data.get('allowedIps', '') + client['userData'] = user_data + + # Pick up peers from conf not in clientsTable (native app clients) + try: + conf_peers = self._parse_peers_from_config() + for pub_key, peer_info in conf_peers.items(): + if pub_key in known_ids: + continue + show_data = wg_show_data.get(pub_key, {}) + allowed_ip = peer_info.get('allowedIps', '') or show_data.get('allowedIps', '') + ip_part = '' + if allowed_ip: + m = re.search(r'(\d+\.\d+\.\d+\.\d+)', allowed_ip) + if m: + ip_part = m.group(1) + display_name = f'External ({ip_part})' if ip_part else 'External (native app)' + clients_table.append({ + 'clientId': pub_key, + 'userData': { + 'clientName': display_name, + 'clientPrivateKey': '', + 'externalClient': True, + 'clientIp': ip_part, + 'latestHandshake': show_data.get('latestHandshake', ''), + 'dataReceived': show_data.get('dataReceived', ''), + 'dataSent': show_data.get('dataSent', ''), + 'dataReceivedBytes': show_data.get('dataReceivedBytes', 0), + 'dataSentBytes': show_data.get('dataSentBytes', 0), + 'allowedIps': allowed_ip, + } + }) + except Exception as e: + logger.warning(f'get_clients: failed to parse conf peers: {e}') + + return clients_table + + def add_client(self, client_name, server_host): + """ + Add a new client/peer to the WireGuard config. + Returns the client config string. + """ + # Generate client keys + client_priv_key, client_pub_key = generate_wg_keypair() + + # Get server info + server_pub_key = self._get_server_public_key() + psk = self._get_server_psk() + port = self._get_listen_port() + + # Get next available IP + client_ip = self._get_next_ip() + + dns1 = WG_DEFAULTS['dns1'] + dns2 = WG_DEFAULTS['dns2'] + + # Check if AmneziaDNS is installed + out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'") + if 'amnezia-dns' in out: + dns1 = '172.29.172.254' + + mtu = WG_DEFAULTS['mtu'] + + # Append peer to server config + peer_section = f""" +[Peer] +PublicKey = {client_pub_key} +PresharedKey = {psk} +AllowedIPs = {client_ip}/32 + +""" + escaped_peer = peer_section.replace("'", "'\\''") + self.ssh.run_sudo_command( + f"docker exec -i {self.CONTAINER_NAME} bash -c 'echo \"{escaped_peer}\" >> {self.CONFIG_PATH}'" + ) + + # Sync config without restart + self.ssh.run_sudo_command( + f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg syncconf {self.INTERFACE} <(wg-quick strip {self.CONFIG_PATH})'" + ) + + # Update clients table + clients_table = self._get_clients_table() + new_client = { + 'clientId': client_pub_key, + 'userData': { + 'clientName': client_name, + 'creationDate': __import__('datetime').datetime.now().isoformat(), + 'clientPrivateKey': client_priv_key, + 'clientIp': client_ip, + 'psk': psk, + 'enabled': True, + } + } + clients_table.append(new_client) + self._save_clients_table(clients_table) + + # Build client config + client_config = f"""[Interface] +Address = {client_ip}/32 +DNS = {dns1}, {dns2} +PrivateKey = {client_priv_key} +MTU = {mtu} + +[Peer] +PublicKey = {server_pub_key} +PresharedKey = {psk} +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = {server_host}:{port} +PersistentKeepalive = 25 +""" + return { + 'client_name': client_name, + 'client_id': client_pub_key, + 'client_ip': client_ip, + 'config': client_config, + } + + def get_client_config(self, client_id, server_host): + """Reconstruct client config from stored data.""" + clients_table = self._get_clients_table() + client = next((c for c in clients_table if c.get('clientId') == client_id), None) + if not client: + raise RuntimeError(f"Client {client_id} not found") + + ud = client.get('userData', {}) + client_priv_key = ud.get('clientPrivateKey', '') + client_ip = ud.get('clientIp', '') + psk = ud.get('psk', '') + + if not client_priv_key: + raise RuntimeError("Client private key not stored. Config cannot be reconstructed.") + + server_pub_key = self._get_server_public_key() + if not psk: + psk = self._get_server_psk() + + port = self._get_listen_port() + + dns1 = WG_DEFAULTS['dns1'] + dns2 = WG_DEFAULTS['dns2'] + + # Check if AmneziaDNS is installed + out, _, _ = self.ssh.run_sudo_command("docker ps -a --filter name=^amnezia-dns$ --format '{{.Names}}'") + if 'amnezia-dns' in out: + dns1 = '172.29.172.254' + + mtu = WG_DEFAULTS['mtu'] + + config = f"""[Interface] +Address = {client_ip}/32 +DNS = {dns1}, {dns2} +PrivateKey = {client_priv_key} +MTU = {mtu} + +[Peer] +PublicKey = {server_pub_key} +PresharedKey = {psk} +AllowedIPs = 0.0.0.0/0, ::/0 +Endpoint = {server_host}:{port} +PersistentKeepalive = 25 +""" + return config + + def toggle_client(self, client_id, enable): + """Enable or disable a client by adding/removing their [Peer] from server config.""" + if enable: + clients_table = self._get_clients_table() + client = next((c for c in clients_table if c.get('clientId') == client_id), None) + if not client: + raise RuntimeError(f"Client {client_id} not found") + + ud = client.get('userData', {}) + psk = ud.get('psk', '') or self._get_server_psk() + client_ip = ud.get('clientIp', '') + + peer_section = f""" +[Peer] +PublicKey = {client_id} +PresharedKey = {psk} +AllowedIPs = {client_ip}/32 + +""" + escaped_peer = peer_section.replace("'", "'\\''") + self.ssh.run_sudo_command( + f"docker exec -i {self.CONTAINER_NAME} bash -c 'echo \"{escaped_peer}\" >> {self.CONFIG_PATH}'" + ) + else: + # Remove peer from server config + config = self._get_server_config() + sections = config.split('[') + new_sections = [s for s in sections if s.strip() and client_id not in s] + new_config = '[' + '['.join(new_sections) + + self.ssh.upload_file(new_config, "/tmp/_wg_config.conf") + self.ssh.run_sudo_command( + f"docker cp /tmp/_wg_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}" + ) + self.ssh.run_command("rm -f /tmp/_wg_config.conf") + + # Sync config + self.ssh.run_sudo_command( + f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg syncconf {self.INTERFACE} <(wg-quick strip {self.CONFIG_PATH})'" + ) + + # Update enabled status in clients table + clients_table = self._get_clients_table() + for c in clients_table: + if c.get('clientId') == client_id: + c.setdefault('userData', {})['enabled'] = enable + break + self._save_clients_table(clients_table) + + def remove_client(self, client_id): + """Remove a client from WireGuard config.""" + config = self._get_server_config() + sections = config.split('[') + new_sections = [s for s in sections if s.strip() and client_id not in s] + new_config = '[' + '['.join(new_sections) + + self.ssh.upload_file(new_config, "/tmp/_wg_config.conf") + self.ssh.run_sudo_command( + f"docker cp /tmp/_wg_config.conf {self.CONTAINER_NAME}:{self.CONFIG_PATH}" + ) + self.ssh.run_command("rm -f /tmp/_wg_config.conf") + + # Sync config + self.ssh.run_sudo_command( + f"docker exec -i {self.CONTAINER_NAME} bash -c 'wg syncconf {self.INTERFACE} <(wg-quick strip {self.CONFIG_PATH})'" + ) + + # Update clients table + clients_table = self._get_clients_table() + clients_table = [c for c in clients_table if c.get('clientId') != client_id] + self._save_clients_table(clients_table) + return True + + def get_server_status(self): + """Get detailed status of the WireGuard server.""" + info = { + 'container_exists': self.check_protocol_installed(), + 'container_running': False, + 'protocol': self.PROTOCOL, + } + + if info['container_exists']: + info['container_running'] = self.check_container_running() + if info['container_running']: + try: + config = self._get_server_config() + for line in config.split('\n'): + if 'ListenPort' in line: + info['port'] = line.split('=')[1].strip() + break + info['clients_count'] = len(self._get_clients_table()) + except Exception as e: + info['error'] = str(e) + + return info + + def get_traffic_stats(self): + """Get aggregate traffic stats for all clients.""" + try: + wg_data = self._wg_show() + except Exception: + return {} + + total_rx = 0 + total_tx = 0 + active_peers = 0 + active_ips = [] + + for peer_key, data in wg_data.items(): + rx = data.get('dataReceivedBytes', 0) + tx = data.get('dataSentBytes', 0) + total_rx += rx + total_tx += tx + if rx > 0 or tx > 0: + active_peers += 1 + allowed = data.get('allowedIps', '') + if allowed: + m = re.search(r'(\d+\.\d+\.\d+\.\d+)', allowed) + if m: + active_ips.append(m.group(1)) + + return { + 'total_rx_bytes': total_rx, + 'total_tx_bytes': total_tx, + 'active_connections': active_peers, + 'active_ips': active_ips, + } diff --git a/managers/xray_manager.py b/managers/xray_manager.py new file mode 100644 index 0000000..a7d6d87 --- /dev/null +++ b/managers/xray_manager.py @@ -0,0 +1,774 @@ +import json +import os +import uuid +import logging +import base64 +import shlex +from datetime import datetime +import urllib.parse + +logger = logging.getLogger(__name__) + +class XrayManager: + """Manages Xray (VLESS-Reality) protocol installation and client management.""" + + PROTOCOL = 'xray' + CONTAINER_NAME = 'amnezia-xray' + IMAGE_NAME = 'amneziavpn/amnezia-xray' # or we can build it + + def __init__(self, ssh_manager): + self.ssh = ssh_manager + + def _config_dir(self): + return '/opt/amnezia/xray' + + def _config_path(self): + return f'{self._config_dir()}/server.json' + + def _list_xray_files(self): + """List filenames in the on-disk Xray config directory.""" + out, _, code = self.ssh.run_sudo_command(f"ls -1 {self._config_dir()} 2>/dev/null") + if code != 0: + return [] + return [f for f in out.strip().split('\n') if f] + + def _detect_layout(self): + """Pick which on-disk layout this installation uses. + + 'native' — official Amnezia client layout: xray_private.key, + xray_public.key, xray_short_id.key, xray_uuid.key plus a clientsTable + file without an extension. + 'panel' — legacy web-panel layout: meta.json + clientsTable.json. + + On a fresh node with no Xray files yet, defaults to 'native' so new + installs produce the same artifacts as the official client. + """ + if hasattr(self, '_cached_layout'): + return self._cached_layout + files = set(self._list_xray_files()) + if {'xray_private.key', 'xray_public.key'} & files: + layout = 'native' + elif 'meta.json' in files: + layout = 'panel' + else: + layout = 'native' + self._cached_layout = layout + return layout + + def _clients_table_filename(self): + return 'clientsTable' if self._detect_layout() == 'native' else 'clientsTable.json' + + def _clients_table_path(self): + return f'{self._config_dir()}/{self._clients_table_filename()}' + + def _read_remote_file(self, path): + """Read a remote text file, preferring the running container's view.""" + out, _, code = self.ssh.run_sudo_command( + f"docker exec {self.CONTAINER_NAME} cat {path} 2>/dev/null" + ) + if code != 0 or not out: + out, _, code = self.ssh.run_sudo_command(f"cat {path} 2>/dev/null") + if code != 0 or not out.strip(): + return None + return out + + def _derive_pubkey_from_priv(self, priv_key): + """Derive the Reality public key from a private key via the xray binary. + Used as a fallback when xray_public.key is missing or unreadable. + """ + if not priv_key: + return '' + out, _, code = self.ssh.run_sudo_command( + f"docker exec {self.CONTAINER_NAME} /usr/bin/xray x25519 -i {priv_key}" + ) + if code != 0 or not out.strip(): + out, _, code = self.ssh.run_sudo_command( + f"docker run --rm --entrypoint=\"\" {self.IMAGE_NAME} /usr/bin/xray x25519 -i {priv_key}" + ) + if code != 0 or not out: + return '' + for line in out.split('\n'): + if 'Public' in line and ':' in line: + return line.split(':', 1)[1].strip() + return '' + + def _get_default_xray_uuid(self): + """UUID of the install-time default client (xray_uuid.key) — meaningful only + for native-layout installs. Imports skip this UUID, mirroring the official + Amnezia client behaviour (see usersController.cpp::getXrayClients). + """ + if self._detect_layout() != 'native': + return '' + out = self._read_remote_file(f"{self._config_dir()}/xray_uuid.key") + return (out or '').strip() + + # ===================== INSTALLATION ===================== + + def check_docker_installed(self): + out, err, code = self.ssh.run_command("docker --version 2>/dev/null") + if code != 0: return False + out2, _, code2 = self.ssh.run_command("systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null") + return 'active' in out2 or 'running' in out2.lower() + + def check_container_running(self): + out, _, _ = self.ssh.run_sudo_command( + f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'" + ) + return 'Up' in out + + def check_protocol_installed(self): + out, _, _ = self.ssh.run_sudo_command( + f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'" + ) + return self.CONTAINER_NAME in out.strip().split('\n') + + def get_server_status(self, protocol): + exists = self.check_protocol_installed() + running = self.check_container_running() + clients = self.get_clients() if exists else [] + meta = self._get_meta_json() if exists else {} + return { + 'container_exists': exists, + 'container_running': running, + 'clients_count': len(clients), + 'port': meta.get('port') + } + + def install_protocol(self, port=443, site_name='yahoo.com'): + """Full installation of Xray.""" + results = [] + + if not self.check_docker_installed(): + results.append("Installing Docker...") + # Using same install method as AWGManager or assume it's installed + pass + + results.append("Removing old container if exists...") + if self.check_protocol_installed(): + self.remove_container() + + results.append("Building Docker image...") + dockerfile_folder = f"/opt/amnezia/{self.CONTAINER_NAME}" + dockerfile_content = """FROM alpine:3.15 +RUN apk add --no-cache curl unzip bash openssl netcat-openbsd dumb-init rng-tools xz iptables ip6tables +RUN apk --update upgrade --no-cache +RUN mkdir -p /opt/amnezia/xray +RUN curl -L -H "Cache-Control: no-cache" -o /root/xray.zip "https://github.com/XTLS/Xray-core/releases/download/v26.3.27/Xray-linux-64.zip" && \\ + unzip /root/xray.zip -d /usr/bin/ && \\ + chmod a+x /usr/bin/xray && \\ + rm /root/xray.zip + +# Tune network +RUN echo "fs.file-max = 51200" >> /etc/sysctl.conf && \\ + echo "net.core.rmem_max = 67108864" >> /etc/sysctl.conf && \\ + echo "net.core.wmem_max = 67108864" >> /etc/sysctl.conf && \\ + echo "net.core.netdev_max_backlog = 250000" >> /etc/sysctl.conf && \\ + echo "net.core.somaxconn = 4096" >> /etc/sysctl.conf && \\ + echo "net.ipv4.tcp_syncookies = 1" >> /etc/sysctl.conf && \\ + echo "net.ipv4.tcp_tw_reuse = 1" >> /etc/sysctl.conf && \\ + echo "net.ipv4.tcp_tw_recycle = 0" >> /etc/sysctl.conf && \\ + echo "net.ipv4.tcp_fin_timeout = 30" >> /etc/sysctl.conf && \\ + echo "net.ipv4.tcp_keepalive_time = 1200" >> /etc/sysctl.conf && \\ + echo "net.ipv4.ip_local_port_range = 10000 65000" >> /etc/sysctl.conf && \\ + echo "net.ipv4.tcp_max_syn_backlog = 8192" >> /etc/sysctl.conf && \\ + echo "net.ipv4.tcp_max_tw_buckets = 5000" >> /etc/sysctl.conf && \\ + echo "net.ipv4.tcp_fastopen = 3" >> /etc/sysctl.conf && \\ + echo "net.ipv4.tcp_mem = 25600 51200 102400" >> /etc/sysctl.conf && \\ + echo "net.ipv4.tcp_rmem = 4096 87380 67108864" >> /etc/sysctl.conf && \\ + echo "net.ipv4.tcp_wmem = 4096 65536 67108864" >> /etc/sysctl.conf && \\ + echo "net.ipv4.tcp_mtu_probing = 1" >> /etc/sysctl.conf && \\ + echo "net.ipv4.tcp_congestion_control = hybla" >> /etc/sysctl.conf + +RUN mkdir -p /etc/security && \\ + echo "* soft nofile 51200" >> /etc/security/limits.conf && \\ + echo "* hard nofile 51200" >> /etc/security/limits.conf + +RUN echo '#!/bin/bash' > /opt/amnezia/start.sh && \\ + echo 'sysctl -p /etc/sysctl.conf 2>/dev/null' >> /opt/amnezia/start.sh && \\ + echo '/usr/bin/xray -config /opt/amnezia/xray/server.json' >> /opt/amnezia/start.sh && \\ + chmod a+x /opt/amnezia/start.sh + +ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ] +""" + self.ssh.run_sudo_command(f"mkdir -p {dockerfile_folder}") + self.ssh.upload_file_sudo(dockerfile_content, f"{dockerfile_folder}/Dockerfile") + + _, err, code = self.ssh.run_sudo_command( + f"docker build --no-cache -t {self.IMAGE_NAME} {dockerfile_folder}", timeout=300 + ) + if code != 0: raise RuntimeError(f"Failed to build container: {err}") + + results.append("Generating keys and config...") + # We generate a base config using a temp container or directly if host has openssl + + # Xray keypair generation using a temporary run overriding the entrypoint + keypair_cmd = f"docker run --rm --entrypoint=\"\" {self.IMAGE_NAME} /usr/bin/xray x25519" + out_kp, err_kp, code_kp = self.ssh.run_sudo_command(keypair_cmd) + if code_kp != 0: raise RuntimeError(f"Failed to generate x25519 keys: {err_kp}") + + priv_key = "" + pub_key = "" + for line in out_kp.split('\n'): + if "Private" in line: priv_key = line.split(":", 1)[1].strip() + if "Public" in line: pub_key = line.split(":", 1)[1].strip() + + short_id_cmd = f"docker run --rm --entrypoint=\"\" {self.IMAGE_NAME} openssl rand -hex 8" + out_sid, _, _ = self.ssh.run_sudo_command(short_id_cmd) + short_id = out_sid.strip() + + # Generate initial server.json with Stats and API enabled + server_json = { + "log": {"loglevel": "error"}, + "stats": {}, + "api": { + "services": ["StatsService", "LoggerService", "HandlerService"], + "tag": "api" + }, + "policy": { + "levels": { + "0": {"statsUserUplink": True, "statsUserDownlink": True} + }, + "system": { + "statsInboundUplink": True, "statsInboundDownlink": True, + "statsOutboundUplink": True, "statsOutboundDownlink": True + } + }, + "inbounds": [ + { + "port": int(port), + "protocol": "vless", + "tag": "proxy", + "settings": { + "clients": [], + "decryption": "none" + }, + "streamSettings": { + "network": "tcp", + "security": "reality", + "realitySettings": { + "dest": f"{site_name}:443", + "serverNames": [site_name], + "privateKey": priv_key, + "shortIds": [short_id] + } + } + }, + { + "listen": "127.0.0.1", + "port": 10085, + "protocol": "dokodemo-door", + "settings": {"address": "127.0.0.1"}, + "tag": "api" + } + ], + "outbounds": [{"protocol": "freedom"}], + "routing": { + "rules": [ + { + "inboundTag": ["api"], + "outboundTag": "api", + "type": "field" + } + ] + } + } + + self.ssh.run_sudo_command("mkdir -p /opt/amnezia/xray") + self.ssh.upload_file_sudo(json.dumps(server_json, indent=2), "/opt/amnezia/xray/server.json") + # Native layout — separate key files matching the official Amnezia client install. + # See client/server_scripts/xray/configure_container.sh for the canonical layout. + self.ssh.upload_file_sudo(priv_key + '\n', "/opt/amnezia/xray/xray_private.key") + self.ssh.upload_file_sudo(pub_key + '\n', "/opt/amnezia/xray/xray_public.key") + self.ssh.upload_file_sudo(short_id + '\n', "/opt/amnezia/xray/xray_short_id.key") + # xray_uuid.key marks the install-time "default" client whose ID gets skipped on + # auto-import. Panel installs do not reserve such a client, so we leave it empty. + self.ssh.upload_file_sudo('\n', "/opt/amnezia/xray/xray_uuid.key") + self.ssh.upload_file_sudo("[]", "/opt/amnezia/xray/clientsTable") + + results.append("Starting container...") + run_cmd = f"""docker run -d \\ +--restart always \\ +--privileged \\ +--cap-add=NET_ADMIN \\ +-p {port}:{port}/tcp \\ +-p {port}:{port}/udp \\ +-v /opt/amnezia/xray:/opt/amnezia/xray \\ +--name {self.CONTAINER_NAME} \\ +{self.IMAGE_NAME}""" + + _, err, code = self.ssh.run_sudo_command(run_cmd) + if code != 0: raise RuntimeError(f"Failed to run container: {err}") + + # Try to connect to network if needed + self.ssh.run_sudo_command(f"docker network connect amnezia-dns-net {self.CONTAINER_NAME} || true") + + results.append("Xray configured and running") + return {'status': 'success', 'protocol': 'xray', 'port': port, 'log': results} + + def remove_container(self): + self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME}") + self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME}") + self.ssh.run_sudo_command(f"docker rmi {self.IMAGE_NAME}") + return True + + # ===================== CLIENT MANAGEMENT ===================== + + def _get_server_json(self): + """Read server.json — tries inside container first, falls back to host path.""" + out, _, code = self.ssh.run_sudo_command( + f"docker exec {self.CONTAINER_NAME} cat {self._config_path()}" + ) + if code != 0: + out, _, code = self.ssh.run_sudo_command(f"cat {self._config_path()}") + if code != 0 or not out.strip(): + return None + return json.loads(out) + + def _save_server_json(self, data): + return self._write_server_json(data, restart=True) + + def _write_server_json(self, data, restart=True): + """Write server.json into container via docker cp AND sync to host path.""" + tmp_file = "/tmp/_xray_server.json" + self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file) + self.ssh.run_sudo_command( + f"docker cp {tmp_file} {self.CONTAINER_NAME}:{self._config_path()}" + ) + # Also keep host copy in sync (handles both volume-mount and no-mount installs) + self.ssh.run_sudo_command( + f"cp {tmp_file} {self._config_path()} 2>/dev/null || true" + ) + if restart: + self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}") + + def _get_vless_inbound(self, config): + for inbound in config.get('inbounds', []): + if inbound.get('protocol') == 'vless': + return inbound + return None + + def _get_vless_inbound_tag(self, config): + inbound = self._get_vless_inbound(config) + return inbound.get('tag') if inbound else None + + def _run_xray_api_json(self, subcommand, payload): + tmp_name = f"/tmp/_xray_api_{uuid.uuid4().hex}.json" + container_tmp = tmp_name + try: + self.ssh.upload_file_sudo(json.dumps(payload, indent=2), tmp_name) + _, err, code = self.ssh.run_sudo_command( + f"docker cp {tmp_name} {self.CONTAINER_NAME}:{container_tmp}" + ) + if code != 0: + return False, err + out, err, code = self.ssh.run_sudo_command( + f"docker exec -i {self.CONTAINER_NAME} /usr/bin/xray api {subcommand} " + f"-server=127.0.0.1:10085 {container_tmp}" + ) + return code == 0, err or out + finally: + self.ssh.run_sudo_command(f"rm -f {tmp_name}") + self.ssh.run_sudo_command(f"docker exec -i {self.CONTAINER_NAME} rm -f {container_tmp} 2>/dev/null || true") + + def _xray_api_add_user(self, config, client): + tag = self._get_vless_inbound_tag(config) + if not tag: + return False + payload = { + "inbounds": [{ + "tag": tag, + "protocol": "vless", + "settings": { + "clients": [client], + "decryption": "none", + } + }] + } + ok, message = self._run_xray_api_json('adu', payload) + if not ok: + logger.warning(f"Xray API add user failed: {message}") + return False + return True + + def _xray_api_remove_user(self, config, client_id): + tag = self._get_vless_inbound_tag(config) + if not tag: + return False + cmd = ( + f"docker exec -i {self.CONTAINER_NAME} /usr/bin/xray api rmu " + f"-server=127.0.0.1:10085 " + f"-tag={shlex.quote(tag)} {shlex.quote(client_id)}" + ) + out, err, code = self.ssh.run_sudo_command(cmd) + if code != 0: + logger.warning(f"Xray API remove user failed: {err or out}") + return False + return True + + def _get_meta_json(self): + """Read protocol metadata. Supports both layouts. + + Native layout pulls keys from xray_*.key files. Panel layout reads + meta.json. Either way, port and site_name come from server.json since + that is the authoritative runtime config — meta.json may go stale if + the user edits server.json directly via the panel. + """ + config = self._get_server_json() or {} + + port = None + site_name = None + rs = {} + try: + ib = next(b for b in config.get('inbounds', []) if b.get('protocol') == 'vless') + port = ib.get('port') + rs = ib.get('streamSettings', {}).get('realitySettings', {}) or {} + names = rs.get('serverNames') or [] + if names: + site_name = names[0] + except StopIteration: + pass + + if self._detect_layout() == 'native': + priv = (self._read_remote_file(f"{self._config_dir()}/xray_private.key") or '').strip() + pub = (self._read_remote_file(f"{self._config_dir()}/xray_public.key") or '').strip() + sid = (self._read_remote_file(f"{self._config_dir()}/xray_short_id.key") or '').strip() + if not priv: + priv = rs.get('privateKey', '') + if not sid: + sids = rs.get('shortIds') or [] + sid = sids[0] if sids else '' + if not pub: + pub = self._derive_pubkey_from_priv(priv) + return { + 'private_key': priv, + 'public_key': pub, + 'short_id': sid, + 'port': port, + 'site_name': site_name or 'yahoo.com', + } + + # Panel (legacy) layout + out = self._read_remote_file(f"{self._config_dir()}/meta.json") + meta = {} + if out: + try: + meta = json.loads(out) + except Exception: + meta = {} + if port is not None: + meta['port'] = port + if site_name: + meta['site_name'] = site_name + if not meta.get('private_key'): + meta['private_key'] = rs.get('privateKey', '') + if not meta.get('short_id'): + sids = rs.get('shortIds') or [] + if sids: + meta['short_id'] = sids[0] + if not meta.get('public_key') and meta.get('private_key'): + meta['public_key'] = self._derive_pubkey_from_priv(meta['private_key']) + return meta + + def _get_clients_table(self): + """Read clientsTable, trying both layout filenames.""" + layout = self._detect_layout() + primary = 'clientsTable' if layout == 'native' else 'clientsTable.json' + fallback = 'clientsTable.json' if layout == 'native' else 'clientsTable' + for fname in (primary, fallback): + out = self._read_remote_file(f"{self._config_dir()}/{fname}") + if not out or not out.strip(): + continue + try: + return json.loads(out) + except Exception: + continue + return [] + + def _save_clients_table(self, data): + """Write clientsTable to the file matching the current layout, in both + the container and the host bind-mount. + """ + path = self._clients_table_path() + tmp_file = "/tmp/_xray_clients.json" + self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file) + self.ssh.run_sudo_command( + f"docker cp {tmp_file} {self.CONTAINER_NAME}:{path}" + ) + self.ssh.run_sudo_command( + f"cp {tmp_file} {path} 2>/dev/null || true" + ) + + def _upgrade_config_for_stats(self, config, restart=True): + """Injects API and Stats blocks into older Xray configs transparently.""" + dirty = False + if 'stats' not in config: + config['stats'] = {} + dirty = True + if 'api' not in config: + config['api'] = {"services": ["StatsService", "LoggerService", "HandlerService"], "tag": "api"} + dirty = True + else: + services = config['api'].setdefault('services', []) + if 'HandlerService' not in services: + services.append('HandlerService') + dirty = True + if 'policy' not in config: + config['policy'] = { + "levels": {"0": {"statsUserUplink": True, "statsUserDownlink": True}}, + "system": {"statsInboundUplink": True, "statsInboundDownlink": True, "statsOutboundUplink": True, "statsOutboundDownlink": True} + } + dirty = True + if 'routing' not in config: + config['routing'] = {"rules": [{"inboundTag": ["api"], "outboundTag": "api", "type": "field"}]} + dirty = True + + has_api_inbound = any(ib.get('tag') == 'api' for ib in config.get('inbounds', [])) + if not has_api_inbound: + config.setdefault('inbounds', []).append({ + "listen": "127.0.0.1", + "port": 10085, + "protocol": "dokodemo-door", + "settings": {"address": "127.0.0.1"}, + "tag": "api" + }) + dirty = True + + for ib in config.get('inbounds', []): + if ib.get('protocol') == 'vless': + if not ib.get('tag'): + ib['tag'] = 'proxy' + dirty = True + for c in ib.get('settings', {}).get('clients', []): + if 'email' not in c: + c['email'] = c['id'] + dirty = True + + if dirty: + self._write_server_json(config, restart=restart) + return dirty + + def _query_xray_stats(self): + """Query Xray API for traffic stats using xray api command.""" + out, _, code = self.ssh.run_sudo_command( + f"docker exec -i {self.CONTAINER_NAME} /usr/bin/xray api statsquery -server=127.0.0.1:10085" + ) + if code != 0 or not out.strip(): + return {} + + try: + stats_raw = json.loads(out) + except Exception: + return {} + + results = {} + # Output format: {"stat": [{"name": "user>>>uid>>>traffic>>>downlink", "value": "123"}, ...]} + for item in stats_raw.get('stat', []): + name_parts = item.get('name', '').split('>>>') + if len(name_parts) == 4 and name_parts[0] == 'user': + uid = name_parts[1] + t_type = name_parts[3] # uplink or downlink + val = int(item.get('value', 0)) + + if uid not in results: + results[uid] = {'rx': 0, 'tx': 0} + + if t_type == 'downlink': + results[uid]['rx'] = val + elif t_type == 'uplink': + results[uid]['tx'] = val + + return results + + def _format_bytes(self, size): + # Format bytes to string like AWG (e.g., 1.50 MiB) + power = 2**10 + n = 0 + powers = {0: 'B', 1: 'KiB', 2: 'MiB', 3: 'GiB', 4: 'TiB'} + while size > power: + size /= power + n += 1 + v = round(size, 2) + if v == int(v): + v = int(v) + return f"{v} {powers.get(n, 'B')}" + + def get_clients(self, protocol=None): + config = self._get_server_json() + if not config: + return [] + + self._upgrade_config_for_stats(config, restart=False) + + # Collect all client IDs currently registered in the Xray server config + xray_clients = [] + for ib in config.get('inbounds', []): + if ib.get('protocol') == 'vless': + xray_clients.extend(ib.get('settings', {}).get('clients', [])) + + clients_table = self._get_clients_table() + table_ids = {c['clientId'] for c in clients_table} + + # Auto-import clients present in server.json but missing from clientsTable + # (e.g. added via the native Amnezia phone/desktop app). Skip the install-time + # default UUID for native-layout installs — the official client treats it as + # the device of the user who installed the server, not a manageable client. + default_uuid = self._get_default_xray_uuid() + updated = False + for xc in xray_clients: + uid = xc.get('id') + if not uid or uid in table_ids or uid == default_uuid: + continue + clients_table.append({ + 'clientId': uid, + 'userData': { + 'clientName': f'Imported_{uid[:8]}', + 'creationDate': datetime.now().isoformat(), + 'enabled': True + } + }) + table_ids.add(uid) + updated = True + logger.info(f"Auto-imported Xray client {uid[:8]} from server.json") + + if updated: + self._save_clients_table(clients_table) + + stats = self._query_xray_stats() + + for c in clients_table: + uid = c.get('clientId', '') + if uid in stats: + user_data = c.setdefault('userData', {}) + rx = stats[uid]['rx'] + tx = stats[uid]['tx'] + if rx > 0 or tx > 0: + user_data['dataReceived'] = self._format_bytes(rx) + user_data['dataSent'] = self._format_bytes(tx) + user_data['dataReceivedBytes'] = rx + user_data['dataSentBytes'] = tx + + return clients_table + + def get_client_config(self, protocol, client_id, server_host, port): + clients = self._get_clients_table() + client = next((c for c in clients if c['clientId'] == client_id), None) + if not client: return None + + meta = self._get_meta_json() + if not meta: return None + + config = self._get_server_json() + sni = meta.get('site_name', 'yahoo.com') + if config: + try: + sni = config['inbounds'][0]['streamSettings']['realitySettings']['serverNames'][0] + except Exception: + pass + + # Format URL + # vless://{id}@{host}:{port}?type=tcp&security=reality&pbk={public_key}&sni={sni}&fp=chrome&sid={short_id}&spx=%2F&flow=xtls-rprx-vision#{name} + + name = client.get('userData', {}).get('clientName', 'vpn') + encoded_name = urllib.parse.quote(name) + + url = ( + f"vless://{client_id}@{server_host}:{meta.get('port', port)}" + f"?type=tcp&security=reality&pbk={meta['public_key']}" + f"&sni={sni}&fp=chrome&sid={meta['short_id']}" + f"&spx=%2F&flow=xtls-rprx-vision#{encoded_name}" + ) + return url + + def add_client(self, protocol, client_name, server_host, port): + client_id = str(uuid.uuid4()) + + config = self._get_server_json() + if not config: raise RuntimeError("Xray server config not found.") + + self._upgrade_config_for_stats(config, restart=False) + + inbound = self._get_vless_inbound(config) + if not inbound: + raise RuntimeError("Xray VLESS inbound not found.") + + # Ensure clients structure exists + clients_node = inbound.setdefault('settings', {}).setdefault('clients', []) + client = { + "id": client_id, + "flow": "xtls-rprx-vision", + "email": client_id + } + if not self._xray_api_add_user(config, client): + raise RuntimeError( + "Xray runtime API is not available for hot user updates. " + "The server config was upgraded, but the container must be restarted once to enable HandlerService. " + "Restart the Xray container manually and try again." + ) + clients_node.append(client) + self._write_server_json(config, restart=False) + + # Update table + clients_table = self._get_clients_table() + clients_table.append({ + 'clientId': client_id, + 'userData': { + 'clientName': client_name, + 'creationDate': datetime.now().isoformat(), + 'enabled': True + } + }) + self._save_clients_table(clients_table) + + return { + 'client_id': client_id, + 'config': self.get_client_config(protocol, client_id, server_host, port) + } + + def toggle_client(self, protocol, client_id, enable): + config = self._get_server_json() + self._upgrade_config_for_stats(config, restart=False) + inbound = self._get_vless_inbound(config) + if not inbound: + raise RuntimeError("Xray VLESS inbound not found.") + clients_node = inbound.setdefault('settings', {}).setdefault('clients', []) + + # If toggling on and not present, we can re-add it from clientsTable + if enable: + if not any(c['id'] == client_id for c in clients_node): + client = { + "id": client_id, + "flow": "xtls-rprx-vision", + "email": client_id + } + if not self._xray_api_add_user(config, client): + raise RuntimeError("Xray runtime API failed to enable the client without restarting the container.") + clients_node.append(client) + else: + if not self._xray_api_remove_user(config, client_id): + raise RuntimeError("Xray runtime API failed to disable the client without restarting the container.") + inbound['settings']['clients'] = [c for c in clients_node if c['id'] != client_id] + + self._write_server_json(config, restart=False) + + clients_table = self._get_clients_table() + for c in clients_table: + if c['clientId'] == client_id: + c.setdefault('userData', {})['enabled'] = enable + self._save_clients_table(clients_table) + + def remove_client(self, protocol, client_id): + config = self._get_server_json() + self._upgrade_config_for_stats(config, restart=False) + inbound = self._get_vless_inbound(config) + if not inbound: + raise RuntimeError("Xray VLESS inbound not found.") + clients_node = inbound.setdefault('settings', {}).setdefault('clients', []) + if not self._xray_api_remove_user(config, client_id): + raise RuntimeError("Xray runtime API failed to remove the client without restarting the container.") + inbound['settings']['clients'] = [c for c in clients_node if c['id'] != client_id] + self._write_server_json(config, restart=False) + + clients_table = self._get_clients_table() + clients_table = [c for c in clients_table if c['clientId'] != client_id] + self._save_clients_table(clients_table) + return True diff --git a/protocol_revproxy/docker-compose.yml b/protocol_revproxy/docker-compose.yml new file mode 100644 index 0000000..8206415 --- /dev/null +++ b/protocol_revproxy/docker-compose.yml @@ -0,0 +1,34 @@ +services: + revproxy: + image: caddy:2-alpine + container_name: amnezia-revproxy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./site:/srv/site:ro + - caddy_data:/data + - caddy_config:/config + networks: + - amnezia-dns-net + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + security_opt: + - no-new-privileges:true + ulimits: + nofile: + soft: 65536 + hard: 65536 + +volumes: + caddy_data: + caddy_config: + +networks: + amnezia-dns-net: + external: true diff --git a/protocol_revproxy/site/index.html b/protocol_revproxy/site/index.html new file mode 100644 index 0000000..735fca8 --- /dev/null +++ b/protocol_revproxy/site/index.html @@ -0,0 +1,94 @@ + + + + + + + + {{SITE_TITLE}} + + + + +
+
+ + +
+
+ +
+
+
+

Trusted by teams worldwide

+

Reliable cloud infrastructure
for your business

+

We deliver secure hosting, CDN acceleration, and 24/7 monitoring — so you can focus on building products.

+ +
+
+ +
+
+

Our services

+
+
+
🚀
+

Managed hosting

+

High-availability clusters with automatic failover and daily backups.

+
+
+
🔒
+

Security

+

TLS everywhere, WAF protection, and compliance-ready infrastructure.

+
+
+
📊
+

Analytics

+

Real-time metrics, uptime dashboards, and actionable alerts.

+
+
+
+
+ +
+
+
+

Built for uptime

+

Since 2018 we have operated edge nodes across multiple regions. Our platform is designed for low latency, horizontal scaling, and predictable billing.

+
    +
  • 99.9% SLA on production workloads
  • +
  • ISO 27001 aligned practices
  • +
  • Human support, not ticket bots
  • +
+
+
+
40+Regions
+
2M+Requests/day
+
12msAvg latency
+
+
+
+ +
+
+

Contact us

+

hello@{{SITE_DOMAIN}}

+

Business hours: Mon–Fri, 9:00–18:00 UTC

+
+
+
+ +
+
+

© 2026 {{SITE_TITLE}}. All rights reserved.

+
+
+ + diff --git a/protocol_revproxy/site/style.css b/protocol_revproxy/site/style.css new file mode 100644 index 0000000..ba2e3e4 --- /dev/null +++ b/protocol_revproxy/site/style.css @@ -0,0 +1,167 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0b1220; + --surface: #111827; + --text: #e5e7eb; + --muted: #9ca3af; + --accent: #3b82f6; + --accent-hover: #2563eb; + --border: rgba(255,255,255,0.08); +} + +html { scroll-behavior: smooth; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; +} + +.container { max-width: 1100px; margin: 0 auto; padding: 0 24px; } + +.header { + position: sticky; + top: 0; + z-index: 10; + background: rgba(11, 18, 32, 0.85); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); +} + +.header-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 64px; +} + +.logo { font-weight: 700; font-size: 1.1rem; letter-spacing: -0.02em; } + +.nav { display: flex; gap: 24px; } +.nav a { color: var(--muted); text-decoration: none; font-size: 0.95rem; transition: color 0.2s; } +.nav a:hover { color: var(--text); } + +.hero { + padding: 96px 0 80px; + background: radial-gradient(ellipse 80% 60% at 50% -20%, rgba(59,130,246,0.25), transparent); +} + +.hero-badge { + display: inline-block; + padding: 6px 14px; + border-radius: 999px; + background: rgba(59,130,246,0.15); + color: #93c5fd; + font-size: 0.85rem; + margin-bottom: 20px; +} + +.hero h1 { + font-size: clamp(2rem, 5vw, 3.25rem); + line-height: 1.15; + letter-spacing: -0.03em; + margin-bottom: 20px; +} + +.hero-lead { + font-size: 1.15rem; + color: var(--muted); + max-width: 560px; + margin-bottom: 32px; +} + +.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; } + +.btn { + display: inline-flex; + align-items: center; + padding: 12px 24px; + border-radius: 10px; + font-weight: 600; + text-decoration: none; + transition: background 0.2s, transform 0.15s; +} + +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); } + +.btn-secondary { + background: rgba(255,255,255,0.06); + color: var(--text); + border: 1px solid var(--border); +} +.btn-secondary:hover { background: rgba(255,255,255,0.1); } + +.section { padding: 72px 0; } +.section-muted { background: var(--surface); } + +.section h2 { font-size: 1.75rem; margin-bottom: 32px; letter-spacing: -0.02em; } + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 24px; +} + +.card { + padding: 28px; + border-radius: 16px; + background: rgba(255,255,255,0.03); + border: 1px solid var(--border); +} + +.card-icon { font-size: 2rem; margin-bottom: 12px; } +.card h3 { margin-bottom: 8px; font-size: 1.1rem; } +.card p { color: var(--muted); font-size: 0.95rem; } + +.about { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 48px; + align-items: center; +} + +.about p { color: var(--muted); margin-bottom: 20px; } + +.checklist { list-style: none; } +.checklist li { + padding: 8px 0 8px 28px; + position: relative; + color: var(--muted); +} +.checklist li::before { + content: "✓"; + position: absolute; + left: 0; + color: #34d399; + font-weight: 700; +} + +.stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + text-align: center; +} +.stats strong { display: block; font-size: 1.75rem; color: var(--text); } +.stats span { font-size: 0.85rem; color: var(--muted); } + +.contact { text-align: center; } +.contact p { font-size: 1.1rem; margin-bottom: 8px; } +.muted { color: var(--muted); font-size: 0.9rem; } + +.footer { + padding: 32px 0; + border-top: 1px solid var(--border); + text-align: center; + color: var(--muted); + font-size: 0.85rem; +} + +@media (max-width: 768px) { + .nav { display: none; } + .about { grid-template-columns: 1fr; } + .stats { grid-template-columns: 1fr; } +} diff --git a/protocol_telemt/Dockerfile b/protocol_telemt/Dockerfile new file mode 100644 index 0000000..11dd9d6 --- /dev/null +++ b/protocol_telemt/Dockerfile @@ -0,0 +1,108 @@ +# syntax=docker/dockerfile:1 + +ARG TELEMT_REPOSITORY=telemt/telemt +ARG TELEMT_VERSION=latest + +# ========================== +# Minimal Image +# ========================== +FROM debian:12-slim AS minimal + +ARG TARGETARCH +ARG TELEMT_REPOSITORY +ARG TELEMT_VERSION + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + binutils \ + ca-certificates \ + curl \ + tar; \ + rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) ASSET="telemt-x86_64-linux-musl.tar.gz" ;; \ + arm64) ASSET="telemt-aarch64-linux-musl.tar.gz" ;; \ + *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + VERSION="${TELEMT_VERSION#refs/tags/}"; \ + if [ -z "${VERSION}" ] || [ "${VERSION}" = "latest" ]; then \ + BASE_URL="https://github.com/${TELEMT_REPOSITORY}/releases/latest/download"; \ + else \ + BASE_URL="https://github.com/${TELEMT_REPOSITORY}/releases/download/${VERSION}"; \ + fi; \ + curl -fL \ + --retry 5 \ + --retry-delay 3 \ + --connect-timeout 10 \ + --max-time 120 \ + -o "/tmp/${ASSET}" \ + "${BASE_URL}/${ASSET}"; \ + curl -fL \ + --retry 5 \ + --retry-delay 3 \ + --connect-timeout 10 \ + --max-time 120 \ + -o "/tmp/${ASSET}.sha256" \ + "${BASE_URL}/${ASSET}.sha256"; \ + cd /tmp; \ + sha256sum -c "${ASSET}.sha256"; \ + tar -xzf "${ASSET}" -C /tmp; \ + test -f /tmp/telemt; \ + install -m 0755 /tmp/telemt /telemt; \ + strip --strip-unneeded /telemt || true; \ + rm -f "/tmp/${ASSET}" "/tmp/${ASSET}.sha256" /tmp/telemt + +# ========================== +# Debug Image +# ========================== +FROM debian:12-slim AS debug + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + tzdata \ + curl \ + iproute2 \ + busybox; \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=minimal /telemt /app/telemt +COPY config.toml /app/config.toml + +EXPOSE 443 9090 9091 + +ENTRYPOINT ["/app/telemt"] +CMD ["config.toml"] + +# ========================== +# Production Distroless on MUSL +# ========================== +#FROM gcr.io/distroless/static-debian12 AS prod +FROM debian:12-slim AS prod + +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + tzdata \ + bash \ + curl; \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=minimal /telemt /app/telemt +COPY config.toml /app/config.toml + +#USER nonroot:nonroot + +EXPOSE 443 9090 9091 + +ENTRYPOINT ["/app/telemt"] +CMD ["config.toml"] diff --git a/protocol_telemt/config.toml b/protocol_telemt/config.toml new file mode 100644 index 0000000..b0b91f4 --- /dev/null +++ b/protocol_telemt/config.toml @@ -0,0 +1,62 @@ +### Telemt Based Config.toml +# We believe that these settings are sufficient for most scenarios +# where cutting-egde methods and parameters or special solutions are not needed + +# === General Settings === +[general] +use_middle_proxy = true +# Global ad_tag fallback when user has no per-user tag in [access.user_ad_tags] +# ad_tag = "00000000000000000000000000000000" +# Per-user ad_tag in [access.user_ad_tags] (32 hex from @MTProxybot) + +# === Log Level === +# Log level: debug | verbose | normal | silent +# Can be overridden with --silent or --log-level CLI flags +# RUST_LOG env var takes absolute priority over all of these +log_level = "normal" + +[general.modes] +classic = false +secure = false +tls = true + +[general.links] +show = "*" +# show = ["alice", "bob"] # Only show links for alice and bob +# show = "*" # Show links for all users +# public_host = "proxy.example.com" # Host (IP or domain) for tg:// links +public_port = 443 # Port for tg:// links (default: server.port) + +# === Server Binding === +[server] +port = 443 +# proxy_protocol = false # Enable if behind HAProxy/nginx with PROXY protocol +metrics_port = 9090 +metrics_listen = "0.0.0.0:9090" # Listen address for metrics (overrides metrics_port) +metrics_whitelist = ["127.0.0.1", "::1", "0.0.0.0/0"] +max_connections = 0 # 0 - unlimited, 10000 - default + +[server.api] +enabled = true +listen = "0.0.0.0:9091" +whitelist = ["127.0.0.0/8"] +minimal_runtime_enabled = false +minimal_runtime_cache_ttl_ms = 1000 + +# Listen on multiple interfaces/IPs - IPv4 +[[server.listeners]] +ip = "0.0.0.0" + +# === Anti-Censorship & Masking === +[censorship] +tls_domain = "petrovich.ru" +mask = false +tls_emulation = true # Fetch real cert lengths and emulate TLS records +tls_front_dir = "tlsfront" # Cache directory for TLS emulation +# mask_host = "172.17.0.1" # Docker host bridge IP +# mask_port = 8443 # Caddy порт +# mask_shape_hardening = true # anti-fingerprint + +[access.users] +# format: "username" = "32_hex_chars_secret" +hello = "00000000000000000000000000000000" diff --git a/protocol_telemt/docker-compose.yml b/protocol_telemt/docker-compose.yml new file mode 100644 index 0000000..d2bf5d5 --- /dev/null +++ b/protocol_telemt/docker-compose.yml @@ -0,0 +1,31 @@ +services: + telemt: + image: ghcr.io/telemt/telemt:latest + build: . + container_name: telemt + restart: unless-stopped + ports: + - "443:443" + - "127.0.0.1:9090:9090" + - "127.0.0.1:9091:9091" + # Allow caching 'proxy-secret' in read-only container + working_dir: /app/conf + volumes: + - .:/app/conf:rw + tmpfs: + - /run/telemt:rw,mode=1777,size=1m + environment: + - RUST_LOG=info + # Uncomment this line if you want to use host network for IPv6, but bridge is default and usually better + # network_mode: host + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE # allow binding to port 443 + read_only: false + security_opt: + - no-new-privileges:true + ulimits: + nofile: + soft: 65536 + hard: 65536 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cb937fd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,39 @@ +annotated-types==0.7.0 +anyio==4.12.1 +bcrypt==5.0.0 +blinker==1.9.0 +certifi==2026.2.25 +cffi==2.0.0 +click==8.3.1 +colorama==0.4.6 +cryptography==44.0.0 +fastapi==0.115.12 +Flask==3.1.0 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.25.2 +idna==3.11 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +multicolorcaptcha==1.2.0 +paramiko==3.5.1 +pillow==12.1.1 +pycparser==3.0 +pydantic==2.12.5 +pydantic_core==2.41.5 +PyNaCl==1.6.2 +python-dotenv==1.2.2 +python-multipart==0.0.20 +python-telegram-bot==20.7 +PyYAML==6.0.3 +sniffio==1.3.1 +starlette==0.46.2 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +uvicorn==0.34.0 +watchfiles==1.1.1 +websockets==16.0 +Werkzeug==3.1.6 + diff --git a/screen/panel1-2.png b/screen/panel1-2.png new file mode 100644 index 0000000..486d58a Binary files /dev/null and b/screen/panel1-2.png differ diff --git a/screen/panel1-3.png b/screen/panel1-3.png new file mode 100644 index 0000000..1df0f64 Binary files /dev/null and b/screen/panel1-3.png differ diff --git a/screen/panel1.png b/screen/panel1.png new file mode 100644 index 0000000..910b5be Binary files /dev/null and b/screen/panel1.png differ diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..565c49c --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,1868 @@ +/* ======================================== + AmneziaVPN Web Panel — Design System + ======================================== */ + +/* Google Font */ +/* @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); */ + +/* ===== CSS Variables / Tokens ===== */ +:root { + /* Color palette — deep dark with neon violet accent */ + --bg-primary: #0a0a0f; + --bg-secondary: #12121a; + --bg-card: #1a1a26; + --bg-card-hover: #22223a; + --bg-input: #16162240; + --bg-modal: rgba(10, 10, 15, 0.92); + + --border-color: rgba(255, 255, 255, 0.06); + --border-hover: rgba(255, 255, 255, 0.12); + --border-focus: rgba(138, 92, 246, 0.5); + + --text-primary: #f0f0f5; + --text-secondary: #8b8ba0; + --text-muted: #5a5a70; + --text-accent: #a78bfa; + + --accent: #8b5cf6; + --accent-light: #a78bfa; + --accent-dark: #7c3aed; + --accent-glow: rgba(139, 92, 246, 0.25); + --accent-gradient: linear-gradient(135deg, #7c3aed, #2d2f71); + + --success: #22c55e; + --success-bg: rgba(34, 197, 94, 0.12); + --success-border: rgba(34, 197, 94, 0.25); + + --danger: #ef4444; + --danger-bg: rgba(239, 68, 68, 0.12); + --danger-border: rgba(239, 68, 68, 0.25); + + --warning: #f59e0b; + --warning-bg: rgba(245, 158, 11, 0.12); + + --info: #3b82f6; + --info-bg: rgba(59, 130, 246, 0.12); + + /* Spacing */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 16px; + --space-lg: 24px; + --space-xl: 32px; + --space-2xl: 48px; + --space-3xl: 64px; + + /* Radius */ + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5); + --shadow-glow: 0 0 30px rgba(139, 92, 246, 0.15); + --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.04); + + /* Transitions */ + --transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 0.25s cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); + + /* Font */ + --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +/* ===== Light Theme ===== */ +[data-theme="light"] { + --bg-primary: #f5f5f9; + --bg-secondary: #eeeef3; + --bg-card: #ffffff; + --bg-card-hover: #f8f8fc; + --bg-input: rgba(0, 0, 0, 0.03); + --bg-modal: rgba(255, 255, 255, 0.92); + + --border-color: rgba(0, 0, 0, 0.08); + --border-hover: rgba(0, 0, 0, 0.15); + --border-focus: rgba(138, 92, 246, 0.4); + + --text-primary: #1a1a2e; + --text-secondary: #5a5a78; + --text-muted: #9090a8; + --text-accent: #7c3aed; + + --accent: #7c3aed; + --accent-light: #8b5cf6; + --accent-dark: #6d28d9; + --accent-glow: rgba(124, 58, 237, 0.15); + --accent-gradient: linear-gradient(135deg, #7c3aed, #2d2f71); + + --success: #16a34a; + --success-bg: rgba(22, 163, 74, 0.08); + --success-border: rgba(22, 163, 74, 0.2); + + --danger: #dc2626; + --danger-bg: rgba(220, 38, 38, 0.06); + --danger-border: rgba(220, 38, 38, 0.15); + + --warning: #d97706; + --warning-bg: rgba(217, 119, 6, 0.08); + + --info: #2563eb; + --info-bg: rgba(37, 99, 235, 0.06); + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1); + --shadow-glow: 0 0 20px rgba(124, 58, 237, 0.08); + --shadow-card: 0 2px 12px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.03); +} + +[data-theme="light"] body::before { + background: + radial-gradient(ellipse 600px 400px at 20% 10%, rgba(124, 58, 237, 0.04), transparent), + radial-gradient(ellipse 500px 500px at 80% 80%, rgba(99, 102, 241, 0.03), transparent), + radial-gradient(ellipse 800px 300px at 50% 50%, rgba(139, 92, 246, 0.02), transparent); +} + +[data-theme="light"] ::-webkit-scrollbar-thumb { + background: rgba(124, 58, 237, 0.2); +} + +[data-theme="light"] .logo-text { + background: linear-gradient(135deg, #1a1a2e, #7c3aed); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +[data-theme="light"] .theme-toggle { + background: rgba(0, 0, 0, 0.04); +} + +[data-theme="light"] .theme-toggle:hover { + background: rgba(0, 0, 0, 0.08); +} + +[data-theme="light"] .header-nav { + background: #ffffff; +} + +/* ===== Theme Toggle ===== */ +.theme-toggle { + width: 36px; + height: 36px; + border: 1px solid var(--border-color); + border-radius: var(--radius-full); + background: rgba(255, 255, 255, 0.04); + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + transition: all var(--transition-base); + flex-shrink: 0; +} + +.theme-toggle:hover { + color: var(--text-primary); + border-color: var(--border-hover); + background: rgba(255, 255, 255, 0.08); + transform: rotate(15deg); +} + +/* ===== Reset & Base ===== */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-family); + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; + overflow-x: hidden; +} + +/* Animated mesh gradient background */ +body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: + radial-gradient(ellipse 600px 400px at 20% 10%, rgba(99, 102, 241, 0.08), transparent), + radial-gradient(ellipse 500px 500px at 80% 80%, rgba(139, 92, 246, 0.06), transparent), + radial-gradient(ellipse 800px 300px at 50% 50%, rgba(79, 70, 229, 0.04), transparent); + pointer-events: none; + z-index: 0; +} + +a { + color: var(--accent-light); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--accent); +} + +::selection { + background: rgba(139, 92, 246, 0.3); + color: #fff; +} + +/* ===== Scrollbar ===== */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(139, 92, 246, 0.3); + border-radius: var(--radius-full); +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(139, 92, 246, 0.5); +} + +/* ===== Layout ===== */ +.app-container { + position: relative; + z-index: 1; + max-width: 1100px; + margin: 0 auto; + padding: var(--space-xl) var(--space-lg); +} + +/* ===== Header ===== */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-lg) 0; + margin-bottom: var(--space-xl); + border-bottom: 1px solid var(--border-color); +} + +.app-logo { + display: flex; + align-items: center; + gap: var(--space-md); +} + +.logo-icon { + width: 44px; + height: 44px; + background: var(--accent-gradient); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + box-shadow: var(--shadow-glow); +} + +.logo-text { + font-size: 1.35rem; + font-weight: 700; + letter-spacing: -0.02em; + background: linear-gradient(135deg, #f0f0f5, #a78bfa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.logo-subtitle { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 400; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +/* ===== Buttons ===== */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: 10px 20px; + border: none; + border-radius: var(--radius-md); + font-family: var(--font-family); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-base); + position: relative; + overflow: hidden; + white-space: nowrap; +} + +.btn::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), transparent); + opacity: 0; + transition: opacity var(--transition-fast); +} + +.btn:hover::before { + opacity: 1; +} + +.btn-primary { + background: var(--accent-gradient); + color: #fff; + box-shadow: 0 2px 12px rgba(139, 92, 246, 0.3); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4); +} + +.btn-primary:active { + transform: translateY(0); +} + +.btn-secondary { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + border-color: var(--border-hover); + background: var(--bg-card-hover); +} + +.btn-danger { + background: var(--danger-bg); + color: var(--danger); + border: 1px solid var(--danger-border); +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.2); +} + +.btn-success { + background: var(--success-bg); + color: var(--success); + border: 1px solid var(--success-border); +} + +.btn-sm { + padding: 6px 14px; + font-size: 0.8rem; + border-radius: var(--radius-sm); +} + +.btn-icon { + width: 36px; + height: 36px; + padding: 0; + border-radius: var(--radius-sm); + font-size: 1.1rem; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.btn .spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +/* ===== Cards ===== */ +.card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: var(--space-lg); + box-shadow: var(--shadow-card); + transition: all var(--transition-base); +} + +.card-hover:hover { + border-color: var(--border-hover); + box-shadow: var(--shadow-card), var(--shadow-glow); + transform: translateY(-2px); +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-lg); +} + +.card-title { + font-size: 1.1rem; + font-weight: 600; +} + +.card-subtitle { + font-size: 0.85rem; + color: var(--text-secondary); + margin-top: var(--space-xs); +} + +/* ===== Section ===== */ +.section-title { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: var(--space-lg); + display: flex; + align-items: center; + gap: var(--space-md); +} + +.section-title .icon { + font-size: 1.3rem; +} + +/* ===== Server Card ===== */ +.server-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: var(--space-lg); + margin-bottom: var(--space-xl); +} + +.server-card { + position: relative; + cursor: grab; +} + +.server-card:active { + cursor: grabbing; +} + +/* While being dragged: dim the card so the user sees their selection. */ +.server-card.dragging { + opacity: 0.45; + cursor: grabbing; +} + +/* Drop target hint: highlighted border + slight lift. */ +.server-card.drag-over { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent), 0 8px 20px rgba(139, 92, 246, 0.25); + transform: translateY(-2px); +} + +/* Ping indicator dot to the left of the server name. */ +.ping-dot { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; + background: #888; + transition: background-color 0.25s ease, box-shadow 0.25s ease; + flex-shrink: 0; +} + +.ping-dot.ping-pending { + background: #94a3b8; + animation: ping-pulse 1.2s ease-in-out infinite; +} + +.ping-dot.ping-ok { + background: var(--success); + box-shadow: 0 0 6px rgba(34, 197, 94, 0.55); +} + +.ping-dot.ping-fail { + background: var(--danger, #ef4444); +} + +.ping-ms { + font-size: 0.72rem; + margin-left: 6px; + color: var(--text-muted); + font-family: 'SF Mono', 'Fira Code', monospace; +} + +@keyframes ping-pulse { + 0%, 100% { opacity: 0.35; } + 50% { opacity: 1; } +} + +.server-icon { + width: 48px; + height: 48px; + background: var(--accent-gradient); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + flex-shrink: 0; + box-shadow: 0 2px 12px rgba(139, 92, 246, 0.2); +} + +.server-card .server-meta { + display: flex; + align-items: center; + gap: var(--space-md); + margin-bottom: var(--space-md); +} + +.server-card .server-name { + font-size: 1.05rem; + font-weight: 600; +} + +.server-card .server-host { + font-size: 0.8rem; + color: var(--text-muted); + font-family: 'SF Mono', 'Fira Code', monospace; +} + +.server-card .server-protocols { + display: flex; + gap: var(--space-sm); + flex-wrap: wrap; + margin-top: var(--space-sm); +} + +.server-card .server-actions { + display: flex; + gap: var(--space-sm); + margin-top: var(--space-md); + padding-top: var(--space-md); + border-top: 1px solid var(--border-color); +} + +/* ===== Badge ===== */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: var(--radius-full); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.badge-success { + background: var(--success-bg); + color: var(--success); + border: 1px solid var(--success-border); +} + +.badge-danger { + background: var(--danger-bg); + color: var(--danger); + border: 1px solid var(--danger-border); +} + +.badge-info { + background: var(--info-bg); + color: var(--info); + border: 1px solid rgba(59, 130, 246, 0.25); +} + +.badge-warn { + background: var(--warning-bg); + color: var(--warning); + border: 1px solid rgba(245, 158, 11, 0.25); +} + +.badge-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +/* ===== Form Elements ===== */ +.form-group { + margin-bottom: var(--space-md); +} + +.form-label { + display: block; + font-size: 0.82rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: var(--space-xs); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 11px 16px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: var(--font-family); + font-size: 0.9rem; + transition: all var(--transition-fast); + outline: none; +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + border-color: var(--border-focus); + box-shadow: 0 0 0 3px var(--accent-glow); + background: var(--bg-secondary); +} + +.form-input::placeholder { + color: var(--text-muted); +} + +.form-textarea { + min-height: 100px; + resize: vertical; + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.82rem; +} + +.form-select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238b8ba0' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + padding-right: 36px; + cursor: pointer; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-md); +} + +.form-hint { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: var(--space-xs); +} + +/* ===== Modal ===== */ +.modal-backdrop { + position: fixed; + inset: 0; + background: var(--bg-modal); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: all var(--transition-base); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.modal-backdrop.active { + opacity: 1; + visibility: visible; +} + +.modal { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-xl); + width: 90%; + max-width: 520px; + max-height: 90vh; + overflow-y: auto; + padding: var(--space-xl); + box-shadow: var(--shadow-lg); + transform: translateY(20px) scale(0.97); + transition: transform var(--transition-base); +} + +.modal-backdrop.active .modal { + transform: translateY(0) scale(1); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-lg); +} + +.modal-title { + font-size: 1.15rem; + font-weight: 700; +} + +.modal-close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: var(--radius-sm); + font-size: 1.3rem; + transition: all var(--transition-fast); +} + +.modal-close:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.05); +} + +.modal-footer { + display: flex; + gap: var(--space-sm); + justify-content: flex-end; + margin-top: var(--space-lg); + padding-top: var(--space-lg); + border-top: 1px solid var(--border-color); +} + +/* ===== Protocol Card ===== */ +.protocol-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: var(--space-lg); + margin-bottom: var(--space-xl); +} + +.protocol-card { + position: relative; + overflow: hidden; +} + +.protocol-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--accent-gradient); + opacity: 0; + transition: opacity var(--transition-base); +} + +.protocol-card:hover::before { + opacity: 1; +} + +.protocol-icon { + width: 56px; + height: 56px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.6rem; + margin-bottom: var(--space-md); +} + +.protocol-awg .protocol-icon { + background: linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(99, 102, 241, 0.1)); + color: var(--accent-light); +} + +.protocol-legacy .protocol-icon { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(99, 102, 241, 0.1)); + color: var(--info); +} + +.protocol-xray .protocol-icon { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.2), rgba(245, 158, 11, 0.1)); + color: var(--warning); +} + +.protocol-telemt .protocol-icon { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(16, 185, 129, 0.1)); + color: #38bdf8; +} + +.protocol-dns .protocol-icon { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.1)); + color: var(--success); +} + +.protocol-wireguard .protocol-icon { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.2), rgba(52, 211, 153, 0.1)); + color: #10b981; +} + +.protocol-adguard .protocol-icon { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(132, 204, 22, 0.1)); + color: #65a30d; +} + +.protocol-socks5 .protocol-icon { + background: linear-gradient(135deg, rgba(168, 85, 247, 0.2), rgba(217, 70, 239, 0.1)); + color: #c084fc; +} + +.protocol-revproxy .protocol-icon { + background: linear-gradient(135deg, rgba(251, 146, 60, 0.2), rgba(244, 63, 94, 0.1)); + color: #fb923c; +} + +/* ============================================================ + Promo blocks — locked teasers for not-yet-shipped features. + They live inside .protocol-cards (a CSS grid) and span every + column via `grid-column: 1 / -1`, so they always stretch the + full width without hard-coding the column count. + ============================================================ */ +.promo-block { + grid-column: 1 / -1; + position: relative; + overflow: hidden; + padding: 22px 28px; + border-radius: var(--radius-lg); + color: #fff; + cursor: not-allowed; + isolation: isolate; /* keep blend modes / blurred orbs from leaking out */ + border: 1px solid rgba(255, 255, 255, 0.12); + transition: transform 0.35s ease, box-shadow 0.35s ease; + user-select: none; +} + +.promo-block:hover { + transform: translateY(-2px); +} + +/* Slow shimmer that sweeps diagonally across the block — pure CSS, no JS. */ +.promo-block::after { + content: ""; + position: absolute; + inset: -50% -10%; + background: linear-gradient(115deg, transparent 35%, rgba(255, 255, 255, 0.18) 50%, transparent 65%); + transform: translateX(-100%); + animation: promo-shimmer 6s ease-in-out infinite; + pointer-events: none; + z-index: 1; +} + +@keyframes promo-shimmer { + 0%, 20% { transform: translateX(-100%); } + 50%, 70% { transform: translateX(100%); } + 100% { transform: translateX(100%); } +} + +/* Floating blurred orbs that drift in the background. */ +.promo-orbs { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; + overflow: hidden; +} + +.promo-orb { + position: absolute; + border-radius: 50%; + filter: blur(48px); + opacity: 0.55; + animation: promo-orb-drift 14s ease-in-out infinite; +} + +.promo-orb:nth-child(1) { top: -40px; left: 8%; width: 220px; height: 220px; animation-delay: 0s; } +.promo-orb:nth-child(2) { bottom: -60px; left: 38%; width: 180px; height: 180px; animation-delay: -5s; } +.promo-orb:nth-child(3) { top: 10%; right: 8%; width: 200px; height: 200px; animation-delay: -9s; } + +@keyframes promo-orb-drift { + 0%, 100% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(20px, -16px) scale(1.12); } + 66% { transform: translate(-12px, 14px) scale(0.92); } +} + +.promo-lock-badge { + position: absolute; + top: 14px; + right: 16px; + z-index: 3; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.18); + color: #fff; +} + +.promo-content { + position: relative; + z-index: 2; + display: flex; + align-items: center; + gap: 20px; + flex-wrap: wrap; +} + +.promo-icon { + flex-shrink: 0; + width: 56px; + height: 56px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.9rem; + background: rgba(255, 255, 255, 0.12); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18), 0 8px 24px rgba(0, 0, 0, 0.25); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + animation: promo-icon-float 4.5s ease-in-out infinite; +} + +@keyframes promo-icon-float { + 0%, 100% { transform: translateY(0) rotate(0); } + 50% { transform: translateY(-3px) rotate(2deg); } +} + +.promo-text { + flex: 1; + min-width: 0; +} + +.promo-title { + font-size: 1.35rem; + font-weight: 700; + letter-spacing: -0.01em; + margin-bottom: 2px; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); +} + +.promo-subtitle { + font-size: 0.88rem; + color: rgba(255, 255, 255, 0.85); + line-height: 1.4; + max-width: 70ch; +} + +.promo-cta { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 16px; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 600; + text-decoration: none; + color: #fff; + background: rgba(255, 255, 255, 0.18); + border: 1px solid rgba(255, 255, 255, 0.28); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + transition: background 0.2s ease, transform 0.2s ease; + cursor: pointer; + pointer-events: auto; /* block-level cursor:not-allowed shouldn't kill the CTA link */ +} + +.promo-cta:hover { + background: rgba(255, 255, 255, 0.32); + transform: translateY(-1px); +} + +/* ----- Theme: AIVPN (purple/cyan, neural feel) ----- */ +.promo-aivpn { + background: + radial-gradient(circle at 20% 0%, rgba(99, 102, 241, 0.6), transparent 60%), + radial-gradient(circle at 80% 100%, rgba(236, 72, 153, 0.5), transparent 55%), + linear-gradient(135deg, #312e81 0%, #6d28d9 50%, #be185d 100%); + box-shadow: 0 16px 40px -10px rgba(109, 40, 217, 0.5); +} + +.promo-aivpn .promo-orb:nth-child(1) { background: #06b6d4; } +.promo-aivpn .promo-orb:nth-child(2) { background: #d946ef; } +.promo-aivpn .promo-orb:nth-child(3) { background: #6366f1; } + +.promo-aivpn .promo-icon { + background: linear-gradient(135deg, rgba(99, 102, 241, 0.5), rgba(236, 72, 153, 0.4)); + text-shadow: 0 0 16px rgba(168, 85, 247, 0.6); +} + +/* ----- Theme: Reverse Proxy (amber/red, shield/firewall feel) ----- */ +.promo-revproxy { + background: + radial-gradient(circle at 100% 0%, rgba(251, 146, 60, 0.55), transparent 55%), + radial-gradient(circle at 0% 100%, rgba(244, 63, 94, 0.45), transparent 55%), + linear-gradient(135deg, #7c2d12 0%, #b91c1c 45%, #9d174d 100%); + box-shadow: 0 16px 40px -10px rgba(185, 28, 28, 0.5); +} + +.promo-revproxy .promo-orb:nth-child(1) { background: #fb923c; } +.promo-revproxy .promo-orb:nth-child(2) { background: #f43f5e; } +.promo-revproxy .promo-orb:nth-child(3) { background: #f59e0b; } + +.promo-revproxy .promo-icon { + background: linear-gradient(135deg, rgba(251, 146, 60, 0.5), rgba(244, 63, 94, 0.4)); + text-shadow: 0 0 16px rgba(251, 146, 60, 0.55); +} + +/* Faint isometric grid overlay — gives the reverse-proxy block a "network" feel. + Uses a tiled SVG-less approach via repeating linear gradients to keep CSS-only. */ +.promo-grid-bg { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 0.18; + background-image: + linear-gradient(rgba(255, 255, 255, 0.5) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.5) 1px, transparent 1px); + background-size: 28px 28px; + mask-image: radial-gradient(ellipse at center, #000 30%, transparent 75%); + -webkit-mask-image: radial-gradient(ellipse at center, #000 30%, transparent 75%); + animation: promo-grid-pan 20s linear infinite; +} + +@keyframes promo-grid-pan { + from { background-position: 0 0; } + to { background-position: 56px 56px; } +} + +/* ----- Responsive: stack content on narrow screens ----- */ +@media (max-width: 720px) { + .promo-content { gap: 14px; } + .promo-cta { width: 100%; justify-content: center; margin-left: 0; } + .promo-subtitle { font-size: 0.82rem; } +} + +/* Respect users who turn off motion. */ +@media (prefers-reduced-motion: reduce) { + .promo-block::after, + .promo-orb, + .promo-icon, + .promo-grid-bg { + animation: none !important; + } +} + +.protocol-name { + font-size: 1.1rem; + font-weight: 700; + margin-bottom: var(--space-xs); +} + +.protocol-desc { + font-size: 0.82rem; + color: var(--text-secondary); + margin-bottom: var(--space-md); + line-height: 1.5; +} + +.protocol-status { + display: flex; + align-items: center; + gap: var(--space-sm); + margin-bottom: var(--space-md); +} + +.protocol-info { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-sm); + margin-bottom: var(--space-md); +} + +.protocol-info-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.protocol-info-label { + font-size: 0.72rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.protocol-info-value { + font-size: 0.88rem; + font-weight: 500; + font-family: 'SF Mono', 'Fira Code', monospace; +} + +/* ===== Clients Table ===== */ +.clients-section { + margin-top: var(--space-xl); +} + +.clients-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-lg); +} + +.clients-list { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.client-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-md) var(--space-lg); + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + transition: all var(--transition-fast); +} + +.client-item:hover { + border-color: var(--border-hover); + background: var(--bg-card-hover); +} + +.client-info { + display: flex; + align-items: center; + gap: var(--space-md); + flex: 1; + min-width: 0; +} + +.client-avatar { + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + background: var(--accent-gradient); + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + font-weight: 700; + color: #fff; + flex-shrink: 0; +} + +.client-name { + font-weight: 600; + font-size: 0.92rem; +} + +.client-meta { + font-size: 0.78rem; + color: var(--text-muted); + display: flex; + gap: var(--space-md); + margin-top: 2px; + flex-wrap: wrap; + align-items: center; +} + +.client-actions { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-shrink: 0; + margin-left: var(--space-md); + flex-wrap: wrap; + max-width: 140px; +} + +/* ===== Config Display ===== */ +.config-display { + position: relative; + margin-top: var(--space-md); +} + +.config-text { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: var(--space-md); + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.78rem; + line-height: 1.7; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-all; + max-height: 300px; + overflow-y: auto; +} + +.config-actions { + display: flex; + gap: var(--space-sm); + margin-top: var(--space-sm); +} + +/* ===== Toast Notifications ===== */ +.toast-container { + position: fixed; + top: var(--space-lg); + right: var(--space-lg); + z-index: 2000; + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.toast { + padding: var(--space-md) var(--space-lg); + border-radius: var(--radius-md); + font-size: 0.85rem; + font-weight: 500; + box-shadow: var(--shadow-lg); + animation: toast-in 0.3s ease-out; + display: flex; + align-items: center; + gap: var(--space-sm); + max-width: 400px; +} + +.toast-success { + background: #0d2818; + border: 1px solid var(--success-border); + color: var(--success); +} + +.toast-error { + background: #2d1212; + border: 1px solid var(--danger-border); + color: var(--danger); +} + +.toast-info { + background: #0d1a2d; + border: 1px solid rgba(59, 130, 246, 0.25); + color: var(--info); +} + +@keyframes toast-in { + from { + transform: translateX(100%); + opacity: 0; + } + + to { + transform: translateX(0); + opacity: 1; + } +} + +/* ===== Progress / Loading ===== */ +.progress-bar { + height: 4px; + background: var(--bg-secondary); + border-radius: var(--radius-full); + overflow: hidden; + margin: var(--space-md) 0; +} + +.progress-fill { + height: 100%; + background: var(--accent-gradient); + border-radius: var(--radius-full); + transition: width 0.5s ease; + position: relative; +} + +.progress-fill::after { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 40px; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3)); + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { + transform: translateX(-40px); + } + + 100% { + transform: translateX(40px); + } +} + +.loading-overlay { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: var(--space-md); + padding: var(--space-3xl) 0; + color: var(--text-muted); +} + +.loading-spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border-color); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ===== Log Output ===== */ +.log-output { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: var(--space-md); + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.78rem; + line-height: 1.8; + max-height: 250px; + overflow-y: auto; + color: var(--text-muted); +} + +.log-line { + padding: 2px 0; +} + +.log-line.success { + color: var(--success); +} + +.log-line.error { + color: var(--danger); +} + +.log-line.info { + color: var(--text-secondary); +} + +/* ===== Empty State ===== */ +.empty-state { + text-align: center; + padding: var(--space-3xl) var(--space-xl); + color: var(--text-muted); +} + +.empty-state .empty-icon { + font-size: 3.5rem; + margin-bottom: var(--space-lg); + opacity: 0.5; +} + +.empty-state .empty-title { + font-size: 1.2rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: var(--space-sm); +} + +.empty-state .empty-desc { + font-size: 0.88rem; + max-width: 360px; + margin: 0 auto var(--space-lg); +} + +/* ===== Tabs ===== */ +.tabs { + display: flex; + gap: 2px; + background: var(--bg-secondary); + border-radius: var(--radius-md); + padding: 3px; + margin-bottom: var(--space-lg); +} + +.tab { + flex: 1; + padding: 10px 16px; + border: none; + background: transparent; + color: var(--text-muted); + font-family: var(--font-family); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); + text-align: center; +} + +.tab:hover { + color: var(--text-secondary); +} + +.tab.active { + background: var(--bg-card); + color: var(--text-primary); + box-shadow: var(--shadow-sm); +} + +/* ===== Back Link ===== */ +.back-link { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + color: var(--text-muted); + font-size: 0.85rem; + margin-bottom: var(--space-lg); + transition: color var(--transition-fast); +} + +.back-link:hover { + color: var(--text-primary); +} + +/* ===== Divider ===== */ +.divider { + height: 1px; + background: var(--border-color); + margin: var(--space-lg) 0; +} + +/* ===== Utility ===== */ +.flex { + display: flex; +} + +.flex-col { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-sm { + gap: var(--space-sm); +} + +.gap-md { + gap: var(--space-md); +} + +.mt-md { + margin-top: var(--space-md); +} + +.mt-lg { + margin-top: var(--space-lg); +} + +.text-muted { + color: var(--text-muted); +} + +.text-sm { + font-size: 0.82rem; +} + +.font-mono { + font-family: 'SF Mono', 'Fira Code', monospace; +} + +.hidden { + display: none !important; +} + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + .app-container { + padding: var(--space-md) var(--space-md); + } + + .server-grid { + grid-template-columns: 1fr; + } + + .protocol-cards { + grid-template-columns: 1fr; + } + + .form-row { + grid-template-columns: 1fr; + } + + .modal { + width: 95%; + padding: var(--space-lg); + } + + .app-header { + flex-wrap: wrap; + gap: var(--space-md); + } +} + +@media (max-width: 480px) { + .protocol-info { + grid-template-columns: 1fr; + } + + .stats-grid { + grid-template-columns: 1fr 1fr; + } +} + +/* ===== Server Stats (inline pills) ===== */ +.stat-pill { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 3px 8px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-full); + font-size: 0.68rem; + font-weight: 500; + font-family: 'SF Mono', 'Fira Code', monospace; + color: var(--text-secondary); + white-space: nowrap; + cursor: default; + transition: all var(--transition-fast); +} + +.stat-pill:hover { + border-color: var(--border-hover); + color: var(--text-primary); +} + +/* ===== Config Tabs ===== */ +.config-tabs { + display: flex; + gap: 2px; + background: var(--bg-secondary); + border-radius: var(--radius-sm); + padding: 3px; + margin-bottom: var(--space-md); +} + +.config-tab { + flex: 1; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--text-muted); + font-family: var(--font-family); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + border-radius: 6px; + transition: all var(--transition-fast); + text-align: center; + white-space: nowrap; +} + +.config-tab:hover { + color: var(--text-secondary); +} + +.config-tab.active { + background: var(--bg-card); + color: var(--text-primary); + box-shadow: var(--shadow-sm); +} + +.config-panel { + display: none; +} + +.config-panel.active { + display: block; +} + +/* ===== QR Code ===== */ +.qr-container { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-md); + padding: var(--space-lg); +} + +.qr-container canvas { + border-radius: var(--radius-md); + box-shadow: 0 0 30px rgba(139, 92, 246, 0.15); +} + +.qr-hint { + font-size: 0.8rem; + color: var(--text-muted); + text-align: center; +} + +/* ===== VPN Link ===== */ +.vpn-link-box { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: var(--space-md); + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.72rem; + line-height: 1.6; + color: var(--accent-light); + word-break: break-all; + max-height: 200px; + overflow-y: auto; + user-select: all; +} + +/* ===== Traffic Badge ===== */ +.traffic-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: var(--radius-full); + font-size: 0.7rem; + font-weight: 500; + font-family: 'SF Mono', 'Fira Code', monospace; +} + +.traffic-down { + background: rgba(34, 197, 94, 0.1); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.2); +} + +.traffic-up { + background: rgba(59, 130, 246, 0.1); + color: #3b82f6; + border: 1px solid rgba(59, 130, 246, 0.2); +} + +/* ===== Header Navigation ===== */ +.header-nav { + display: flex; + align-items: center; + gap: 4px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-full); + padding: 5px; + box-shadow: var(--shadow-sm); + transition: all var(--transition-base); +} + +.nav-link { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 20px; + border-radius: var(--radius-full); + font-size: 0.85rem; + font-weight: 500; + color: var(--text-secondary); + transition: all var(--transition-base); + text-decoration: none; + white-space: nowrap; + position: relative; + letter-spacing: -0.01em; +} + +.nav-link:hover { + color: var(--text-primary); + background: rgba(139, 92, 246, 0.08); +} + +.nav-link.active { + color: #fff; + background: var(--accent-gradient); + box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3); + font-weight: 600; +} + +.nav-user { + display: flex; + align-items: center; + gap: var(--space-md); + padding-left: var(--space-lg); + border-left: 1px solid var(--border-color); + margin-left: var(--space-sm); + transition: border-color var(--transition-base); +} + +/* Global Transition for Theme Switching */ +body, +.card, +.app-header, +.header-nav, +.nav-link, +.btn, +.form-input, +.form-select, +.modal, +.modal-backdrop, +.nav-user { + transition: background-color var(--transition-slow), + border-color var(--transition-slow), + color var(--transition-slow), + box-shadow var(--transition-slow), + transform var(--transition-base); +} + +.nav-username { + font-size: 0.82rem; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: -0.01em; +} + +.nav-user .badge { + padding: 2px 10px; + font-size: 0.65rem; + letter-spacing: 0.05em; + font-weight: 700; +} + +.nav-user .btn { + padding: 6px 14px; + font-size: 0.8rem; + font-weight: 600; + border-radius: var(--radius-sm); +} + +@media (max-width: 900px) { + .header-nav { + gap: 2px; + padding: 4px; + } + + .nav-link { + padding: 6px 12px; + font-size: 0.8rem; + } +} + +@media (max-width: 768px) { + .app-header { + flex-direction: column; + gap: var(--space-md); + align-items: stretch; + } + + .header-nav { + border-radius: var(--radius-md); + justify-content: center; + } + + .nav-user { + border-left: none; + margin-left: 0; + padding-left: 0; + padding-top: var(--space-sm); + border-top: 1px solid var(--border-color); + justify-content: center; + } +} + +/* ===== Badges ===== */ +.badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.badge-success { + background: var(--success-bg); + color: var(--success); + border: 1px solid var(--success-border); +} + +.badge-warn { + background: var(--warning-bg); + color: var(--warning); + border: 1px solid rgba(245, 158, 11, 0.25); +} + +.badge-danger { + background: var(--danger-bg); + color: var(--danger); + border: 1px solid var(--danger-border); +} + +.badge-info { + background: var(--info-bg); + color: var(--info); + border: 1px solid rgba(59, 130, 246, 0.25); +} + +.badge-secondary { + background: rgba(255, 255, 255, 0.06); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +/* Additional layout helpers */ +.hidden { + display: none !important; +} + +.gap-sm { + gap: var(--space-sm); +} + +.gap-md { + gap: var(--space-md); +} \ No newline at end of file diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..f543356 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/static/js/qrcode.min.js b/static/js/qrcode.min.js new file mode 100644 index 0000000..993e88f --- /dev/null +++ b/static/js/qrcode.min.js @@ -0,0 +1 @@ +var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); \ No newline at end of file diff --git a/telegram_bot.py b/telegram_bot.py new file mode 100644 index 0000000..f6feb89 --- /dev/null +++ b/telegram_bot.py @@ -0,0 +1,404 @@ +""" +Telegram bot for Amnezia Web Panel. +Uses raw Telegram Bot API via httpx — no library version conflicts. +Runs as a background asyncio task alongside the FastAPI app. +""" +import asyncio +import logging +from typing import Optional, Callable + +import httpx + +logger = logging.getLogger(__name__) + +# ----------------------------------------------------------------------- # +# Global state +# ----------------------------------------------------------------------- # +_bot_task: Optional[asyncio.Task] = None + + +def is_running() -> bool: + return _bot_task is not None and not _bot_task.done() + + +def launch_bot(token: str, load_data_fn: Callable, generate_vpn_link_fn: Callable): + global _bot_task + _bot_task = asyncio.create_task( + _run_bot(token, load_data_fn, generate_vpn_link_fn), + name="telegram_bot", + ) + return _bot_task + + +async def stop_bot(): + global _bot_task + if _bot_task and not _bot_task.done(): + _bot_task.cancel() + try: + await _bot_task + except asyncio.CancelledError: + pass + _bot_task = None + logger.info("Telegram bot stopped.") + + +# ----------------------------------------------------------------------- # +# Low-level Telegram API helpers +# ----------------------------------------------------------------------- # +class TelegramAPI: + def __init__(self, token: str, client: httpx.AsyncClient): + self.base = f"https://api.telegram.org/bot{token}" + self.client = client + + async def call(self, method: str, **params) -> dict: + r = await self.client.post(f"{self.base}/{method}", json=params, timeout=30) + return r.json() + + async def get_updates(self, offset: int = 0, timeout: int = 25) -> list: + r = await self.client.post( + f"{self.base}/getUpdates", + json={"offset": offset, "timeout": timeout, "allowed_updates": ["message", "callback_query"]}, + timeout=timeout + 10, + ) + data = r.json() + if data.get("ok"): + return data["result"] + return [] + + async def send_message(self, chat_id, text: str, reply_markup=None, parse_mode="HTML") -> dict: + import json + params = {"chat_id": chat_id, "text": text, "parse_mode": parse_mode} + if reply_markup: + params["reply_markup"] = json.dumps(reply_markup) + return (await self.call("sendMessage", **params)) + + async def edit_message(self, chat_id, message_id, text: str, reply_markup=None, parse_mode="HTML"): + import json + params = {"chat_id": chat_id, "message_id": message_id, "text": text, "parse_mode": parse_mode} + if reply_markup: + params["reply_markup"] = json.dumps(reply_markup) + await self.call("editMessageText", **params) + + async def answer_callback(self, callback_query_id: str, text: str = ""): + await self.call("answerCallbackQuery", callback_query_id=callback_query_id, text=text) + + async def send_document(self, chat_id, filename: str, content: bytes, caption: str = ""): + files = {"document": (filename, content, "text/plain")} + data = {"chat_id": str(chat_id), "caption": caption} + r = await self.client.post(f"{self.base}/sendDocument", data=data, files=files, timeout=30) + return r.json() + + +# ----------------------------------------------------------------------- # +# Helpers +# ----------------------------------------------------------------------- # +def _find_user(load_data_fn: Callable, tg_id: str): + data = load_data_fn() + tg_id_clean = str(tg_id).lstrip("@") + for u in data.get("users", []): + stored = str(u.get("telegramId", "") or "").lstrip("@") + if stored and stored == tg_id_clean: + return u + return None + + +def _build_connections_keyboard(conns: list, data: dict) -> dict: + """Build inline keyboard where each button = one connection.""" + rows = [] + servers = data.get("servers", []) + for c in conns: + sid = c.get("server_id", 0) + server_name = "Unknown" + if sid < len(servers): + srv = servers[sid] + server_name = srv.get("name") or srv.get("host", "Unknown")[:20] + proto = c.get("protocol", "").upper() + name = c.get("name", "Connection") + label = f"🔐 {name} · {proto} · {server_name}" + # callback_data must be ≤ 64 bytes — use short prefix + rows.append([{"text": label, "callback_data": f"cfg:{c['id']}"}]) + rows.append([{"text": "🔄 Обновить список", "callback_data": "refresh"}]) + return {"inline_keyboard": rows} + + +# ----------------------------------------------------------------------- # +# /start handler — shows connections list immediately +# ----------------------------------------------------------------------- # +async def _handle_start(api: TelegramAPI, msg: dict, load_data_fn: Callable): + chat_id = msg["chat"]["id"] + tg_id = str(msg["from"]["id"]) + first_name = msg["from"].get("first_name", "") + + panel_user = _find_user(load_data_fn, tg_id) + + if not panel_user: + await api.send_message( + chat_id, + f"👋 Hi, {first_name}!\n\n" + "Your Telegram account is not linked to any panel user.\n" + "Please contact your administrator — they need to add your Telegram ID to your profile.\n\n" + f"Your Telegram ID: {tg_id}", + ) + return + + data = load_data_fn() + conns = [c for c in data.get("user_connections", []) if c["user_id"] == panel_user["id"]] + + if not conns: + await api.send_message( + chat_id, + f"👋 Hi, {first_name}!\n\n" + f"You are registered as {panel_user['username']}.\n\n" + "You have no connections yet. Please contact your administrator.", + ) + return + + kb = _build_connections_keyboard(conns, data) + await api.send_message( + chat_id, + f"👋 Hi, {first_name}!\n\n" + f"You are registered as {panel_user['username']}.\n\n" + f"Your connections ({len(conns)}) — tap to get config:", + reply_markup=kb, + ) + + +# ----------------------------------------------------------------------- # +# Refresh — edit existing message with updated list +# ----------------------------------------------------------------------- # +async def _handle_refresh( + api: TelegramAPI, chat_id: int, message_id: int, callback_id: str, tg_id: str, load_data_fn: Callable +): + await api.answer_callback(callback_id, "Updated!") + panel_user = _find_user(load_data_fn, tg_id) + if not panel_user: + await api.edit_message(chat_id, message_id, "❌ Access denied.") + return + data = load_data_fn() + conns = [c for c in data.get("user_connections", []) if c["user_id"] == panel_user["id"]] + if not conns: + await api.edit_message(chat_id, message_id, "You have no connections.") + return + kb = _build_connections_keyboard(conns, data) + await api.edit_message( + chat_id, message_id, + f"Your connections ({len(conns)}) — tap to get config:", + reply_markup=kb, + ) + + +# ----------------------------------------------------------------------- # +# Get config — send multiple messages with different formats +# ----------------------------------------------------------------------- # +async def _handle_get_config( + api: TelegramAPI, + chat_id: int, + message_id: int, + callback_id: str, + conn_id: str, + tg_id: str, + load_data_fn: Callable, + generate_vpn_link_fn: Callable, +): + await api.answer_callback(callback_id, "Fetching config...") + + panel_user = _find_user(load_data_fn, tg_id) + if not panel_user: + await api.send_message(chat_id, "❌ Access denied.") + return + + data = load_data_fn() + conn = next( + (c for c in data.get("user_connections", []) + if c["id"] == conn_id and c["user_id"] == panel_user["id"]), + None, + ) + if not conn: + await api.send_message(chat_id, "❌ Connection not found.") + return + + sid = conn["server_id"] + servers = data.get("servers", []) + if sid >= len(servers): + await api.send_message(chat_id, "❌ Server not found.") + return + + server = servers[sid] + proto = conn.get("protocol", "awg") + conn_name = conn.get("name", "Connection") + + # Send "Loading..." as new message + loading_result = await api.send_message(chat_id, f"⏳ Fetching config for {conn_name}...") + loading_msg_id = loading_result.get("result", {}).get("message_id") + + try: + import sys, os + sys.path.insert(0, os.path.dirname(__file__)) + from managers.ssh_manager import SSHManager + from managers.awg_manager import AWGManager + from managers.xray_manager import XrayManager + + ssh = SSHManager( + server["host"], + server.get("ssh_port", 22), + server["username"], + server.get("password", ""), + server.get("private_key", ""), + ) + + proto_info = server.get("protocols", {}).get(proto, {}) + port = proto_info.get("port", "55424") + + def _get_cfg(): + ssh.connect() + from managers.wireguard_manager import WireGuardManager + from managers.telemt_manager import TelemtManager + + if proto == "xray": + mgr = XrayManager(ssh) + cfg = mgr.get_client_config(proto, conn["client_id"], server["host"], port) + elif proto == "wireguard": + mgr = WireGuardManager(ssh) + cfg = mgr.get_client_config(conn["client_id"], server["host"]) + elif proto == "telemt": + mgr = TelemtManager(ssh) + cfg = mgr.get_client_config(proto, conn["client_id"], server["host"], port) + else: + # awg, awg2, awg_legacy + mgr = AWGManager(ssh) + cfg = mgr.get_client_config(proto, conn["client_id"], server["host"], port) + ssh.disconnect() + return cfg + + config = await asyncio.to_thread(_get_cfg) + + if not config: + if loading_msg_id: + await api.edit_message(chat_id, loading_msg_id, "❌ Failed to retrieve configuration.") + return + + vpn_link = generate_vpn_link_fn(config) if config else "" + + # Delete loading message + if loading_msg_id: + await api.call("deleteMessage", chat_id=chat_id, message_id=loading_msg_id) + + # ------- 1. Header ------- + server_name = server.get("name") or server.get("host", "Unknown") + await api.send_message( + chat_id, + f"✅ {conn_name}\n" + f"🌐 Server: {server_name}\n" + f"🔌 Protocol: {proto.upper()}", + ) + + # ------- 2. Send config (format depends on protocol) ------- + # Protocols that return a link/URI rather than an INI-style config file + is_link_proto = proto in ("xray", "telemt") + + if is_link_proto: + # Show as a tappable link — no .conf file needed + await api.send_message( + chat_id, + f"🔗 Connection link (tap to copy):\n{config}", + ) + else: + # AWG / WireGuard — INI config text + MAX_LEN = 4000 + if len(config) <= MAX_LEN: + await api.send_message(chat_id, f"📄 Configuration:\n
{config}
") + else: + chunks = [config[i:i+MAX_LEN] for i in range(0, len(config), MAX_LEN)] + for i, chunk in enumerate(chunks, 1): + await api.send_message(chat_id, f"📄 Configuration (part {i}/{len(chunks)}):\n
{chunk}
") + + # VPN deep-link (vpn:// base64 URI for the Amnezia app) + vpn_link = generate_vpn_link_fn(config) + if vpn_link: + await api.send_message( + chat_id, + f"🔗 VPN Link (tap to copy):\n{vpn_link}", + ) + + # .conf file attachment + filename = f"{conn_name.replace(' ', '_')}.conf" + await api.send_document( + chat_id, + filename=filename, + content=config.encode("utf-8"), + caption=f"📁 Config file: {conn_name}", + ) + + except Exception as e: + logger.exception("Bot: error getting config") + if loading_msg_id: + await api.edit_message(chat_id, loading_msg_id, f"❌ Error: {e}") + else: + await api.send_message(chat_id, f"❌ Error: {e}") + + +# ----------------------------------------------------------------------- # +# Main polling loop +# ----------------------------------------------------------------------- # +async def _run_bot(token: str, load_data_fn: Callable, generate_vpn_link_fn: Callable): + offset = 0 + logger.info("Telegram bot started (raw httpx polling).") + + async with httpx.AsyncClient() as client: + api = TelegramAPI(token, client) + + me = await api.call("getMe") + if not me.get("ok"): + logger.error(f"Telegram bot: invalid token or API error: {me}") + return + logger.info(f"Telegram bot logged in as @{me['result']['username']}") + + while True: + try: + updates = await api.get_updates(offset=offset, timeout=25) + except asyncio.CancelledError: + logger.info("Telegram bot polling cancelled.") + return + except Exception as e: + logger.warning(f"Telegram bot polling error: {e}") + await asyncio.sleep(5) + continue + + for update in updates: + offset = update["update_id"] + 1 + try: + await _dispatch(api, update, load_data_fn, generate_vpn_link_fn) + except asyncio.CancelledError: + return + except Exception as e: + logger.exception(f"Telegram bot: error handling update {update['update_id']}: {e}") + + +async def _dispatch(api: TelegramAPI, update: dict, load_data_fn: Callable, generate_vpn_link_fn: Callable): + # --- Text messages --- + if "message" in update: + msg = update["message"] + text = msg.get("text", "") + if text.startswith("/start"): + await _handle_start(api, msg, load_data_fn) + elif text.startswith("/connections"): + # Alias for /start + await _handle_start(api, msg, load_data_fn) + + # --- Inline button callbacks --- + elif "callback_query" in update: + cq = update["callback_query"] + callback_id = cq["id"] + data_str = cq.get("data", "") + chat_id = cq["message"]["chat"]["id"] + message_id = cq["message"]["message_id"] + tg_id = str(cq["from"]["id"]) + + if data_str == "refresh": + await _handle_refresh(api, chat_id, message_id, callback_id, tg_id, load_data_fn) + elif data_str.startswith("cfg:"): + conn_id = data_str[4:] + await _handle_get_config( + api, chat_id, message_id, callback_id, + conn_id, tg_id, load_data_fn, generate_vpn_link_fn + ) diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..695c326 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,269 @@ + + + + + + + + {{ site_settings.title or 'Amnezia Panel' }} {% block title_extra %}{% endblock %} + + + + + {% block head_extra %}{% endblock %} + + + + +
+ +
+
+ + + {% if current_user %} + + + + {% else %} + + {% endif %} +
+ +
+ {% block content %}{% endblock %} +
+
+ + + + + + + {% block scripts %}{% endblock %} + + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..2d30a7d --- /dev/null +++ b/templates/index.html @@ -0,0 +1,453 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

+ 🖥 + {{ _('nav_servers') }} +

+ +
+ + {% if servers %} +
+ {% for server in servers %} +
+
+
🖥
+
+
+ + {{ server.name }} + +
+
{{ server.host }}:{{ server.ssh_port }}
+
+
+ +
+ {% if server.protocols %} + {% for proto_key, proto_val in server.protocols.items() %} + {% if proto_val.get('installed') %} + + + {{ 'AWG' if proto_key == 'awg' else ('AWG-Legacy' if proto_key == 'awg_legacy' else ('Xray' if + proto_key == 'xray' else proto_key | upper)) }} + + {% endif %} + {% endfor %} + {% else %} + + + {{ _('no_protocols') }} + + {% endif %} +
+ +
+ + {{ _('manage') }} + + + +
+
+ {% endfor %} +
+ {% else %} +
+
🛡
+
{{ _('no_servers') }}
+
+ {{ _('add_server_desc') }} +
+ +
+ {% endif %} +
+ + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..268a14d --- /dev/null +++ b/templates/login.html @@ -0,0 +1,285 @@ + + + + + + + {{ site_settings.title or 'Amnezia Panel' }} — {{ _('login') }} + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/my_connections.html b/templates/my_connections.html new file mode 100644 index 0000000..8f6faeb --- /dev/null +++ b/templates/my_connections.html @@ -0,0 +1,162 @@ +{% extends "base.html" %} + +{% block title_extra %} — {{ _('my_connections_title') }}{% endblock %} + +{% block content %} +
+

+ 🔗 + {{ _('my_connections_title') }} +

+ + {% if connections %} +
+ {% for c in connections %} +
+
+
🔗
+
+
{{ c.name or 'VPN Connection' }}
+
+ 🖥 {{ c.server_name }} + + {{ 'AmneziaWG' if c.protocol == 'awg' else ('AmneziaWG 2.0' if c.protocol == 'awg2' else ('AWG Legacy' if c.protocol == 'awg_legacy' else + ('Xray' if c.protocol == 'xray' else c.protocol | upper))) }} + + {% if c.created_at %} + 📅 {{ c.created_at[:10] }} + {% endif %} +
+
+
+
+ +
+
+ {% endfor %} +
+ {% else %} +
+
🔗
+
{{ _('no_connections') }}
+
{{ _('no_connections_user_desc') }}
+
+ {% endif %} +
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/server.html b/templates/server.html new file mode 100644 index 0000000..7664621 --- /dev/null +++ b/templates/server.html @@ -0,0 +1,1818 @@ +{% extends "base.html" %} + +{% block title_extra %} — {{ server.name }}{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +{{ _('back_to_servers') }} + + +
+
+
+
🖥
+
+

{{ server.name }}

+
{{ server.host }}:{{ server.ssh_port }} • {{ server.username + }}
+
+
+
+ + +
+
+ +
+ + +
+ + + +
+ +
+ +
+
+
+
+
+
AmneziaWG 2.0 NEW +
+
+ {{ _('awg_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+ + +
+
+
🔮
+
+
+
AmneziaWG
+
+ {{ _('awg_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+ + +
+
+
📡
+
+
+
AmneziaWG Legacy
+
+ {{ _('awg_legacy_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+ + +
+
+
+
+
+
Xray (VLESS-Reality)
+
+ {{ _('xray_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+ + +
+
+
+
+
+
Telemt (Telegram Proxy)
+
+ {{ _('telemt_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+ + +
+
+
🔒
+
+
+
WireGuard
+
+ {{ _('wireguard_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+ + +
+ + 🔒 {{ _('coming_soon') }} +
+ +
+
AIVPN
+
{{ _('aivpn_subtitle') }}
+
+ + ⭐ {{ _('promo_star_cta') }} + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..320d30e --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,1057 @@ +{% extends "base.html" %} + +{% block title_extra %} — {{ _('nav_settings') }}{% endblock %} + +{% block content %} +
+ ⚙️ + {{ _('settings_title') }} +
+ +
+ +
+ +
+

{{ _('appearance') }}

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

{{ _('captcha_title') }}

+
+
+ +
+
+
+ + +
+

{{ _('telegram_bot_title') }}

+
+
+ + +
+
+
+ {{ _('bot_status') }}: + {% if bot_running %} + {{ _('bot_running') }} + {% else %} + {{ _('bot_stopped') }} + {% endif %} +
+ +
+

+ {{ _('bot_hint') }} +

+
+
+ + + + + +
+

🌍 {{ _('tunnels_title') }}

+
+
+
+ {{ _('local_server') }} + {{ _('active') }} +
+
+ {{ request.url.scheme }}://{{ request.url.netloc }} + +
+
+ +
+
+ Cloudflare Quick Tunnel + {{ _('not_installed') }} +
+
{{ _('tunnel_no_public_url') }}
+
+ + + +
+
+ +
+
+ ngrok Tunnel + Authtoken + {{ _('not_installed') }} +
+
+ +
+
{{ _('tunnel_no_public_url') }}
+
+ + + +
+
+
+

{{ _('tunnels_hint') }}

+
+ + +
+

🔒 {{ _('ssl_title') }}

+
+
+ +
+
+ + +
+
+
+ + +
+ +
+ + +
+ + +
+
+ + +

{{ _('cf_token_hint') }}

+
+
+ + +
+
+ +
+ +
+ + +

{{ _('cf_issue_hint') }}

+
+ + +
+
+
+ + +
+
+ + +
+
+ +
{{ _('or_paste_text') }}
+ +
+ + +
+
+ + +
+
+ +

{{ _('ssl_hint') }}

+
+
+
+
+ + +
+ +
+

{{ _('import_users_title') }}

+
+
+ +
+ + + + + +
+
+ +
+
+ + +
+
+ + +
+ +
+ +
+ {{ _('sync_hint') }} +
+
+ + +
+
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+

📤 {{ _('backup_title') }}

+
+ +
+
+ + +
+
+
+

+ {{ _('restore_confirm') }} +

+
+ + +
+
+

🔑 {{ _('api_tokens_title') }}

+ +
+

+ {{ _('api_tokens_hint') }} +

+
+
{{ _('api_tokens_empty') }}
+
+
+ + +
+

ℹ️ {{ _('about_title') }}

+
+
+ {{ _('current_version') }} + {{ current_version }} +
+ + + +
+ + +
+
+
+
+
+ +
+ +
+ + + + + + + + +{% endblock %} diff --git a/templates/user_share.html b/templates/user_share.html new file mode 100644 index 0000000..af26f5c --- /dev/null +++ b/templates/user_share.html @@ -0,0 +1,249 @@ +{% extends "base.html" %} + +{% block title_extra %} — {{ _('share_title') }}{% endblock %} + +{% block content %} +
+
+

{{ _('vpn_access') }}

+

{{ _('user_label_share') }}: {{ + share_user.username }} +

+
+ + {% if need_password %} +
+
🔐
+

{{ _('share_protected_desc') }} +

+ +
+
+ +
+ +
+
+ {% else %} +
+
+
+

{{ _('loading_share_conns') }}

+
+ + +
+ {% endif %} +
+ + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/users.html b/templates/users.html new file mode 100644 index 0000000..164953c --- /dev/null +++ b/templates/users.html @@ -0,0 +1,1012 @@ +{% extends "base.html" %} + +{% block title_extra %} — {{ _('users_title') }}{% endblock %} + +{% block content %} +
+
+

+ 👥 + {{ _('users_title') }} +

+ +
+ + 🔍 +
+ + {% if current_user.role == 'admin' %} + + {% endif %} +
+ + + +
+
+

{{ _('loading_users') }}

+
+ + +
+ + + + + + + + + + + + + + + +{% endblock %} + +{% block scripts %} + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 0000000..2fde0cc --- /dev/null +++ b/translations/en.json @@ -0,0 +1,401 @@ +{ + "nav_servers": "Servers", + "nav_users": "Users", + "nav_settings": "Settings", + "nav_connections": "Connections", + "nav_logout": "Logout", + "theme_dark": "Dark", + "theme_light": "Light", + "back_to_servers": "← Back to servers", + "server_check": "Check", + "server_checking": "Checking...", + "protocols": "Protocols", + "install": "Install", + "uninstall": "Uninstall", + "run": "Running", + "stop": "Stopped", + "not_installed": "Not installed", + "installed": "Installed", + "add_server": "Add Server", + "no_servers": "No servers added", + "name": "Name", + "host": "Host", + "port": "Port", + "status": "Status", + "actions": "Actions", + "edit": "Edit", + "delete": "Delete", + "save": "Save", + "cancel": "Cancel", + "loading": "Loading...", + "copy": "Copy", + "download": "Download", + "config": "Configuration", + "qr_code": "QR code", + "vpn_key": "VPN key", + "clients": "Clients", + "traffic": "Traffic", + "limit": "Limit", + "manage": "Manage", + "no_protocols": "No protocols", + "add_server_desc": "Add your first VPN server to get started. You'll need SSH credentials to connect.", + "ssh_host": "Host / IP", + "ssh_port": "SSH Port", + "ssh_user": "User", + "ssh_password": "Password", + "ssh_key": "SSH Key", + "ssh_key_placeholder": "Private SSH Key", + "ssh_key_hint": "Paste the content of your id_rsa or id_ed25519 file", + "connect": "Connect", + "connecting": "Connecting...", + "server_added": "Server added successfully", + "server_deleted": "Server deleted", + "delete_confirm": "Are you sure you want to delete this server?", + "success": "Success", + "error": "Error", + "info": "Info", + "login_title": "Login to Panel", + "username": "Username", + "password": "Password", + "captcha": "Captcha", + "captcha_placeholder": "Enter characters from image", + "captcha_hint": "Click to refresh image", + "login": "Login", + "logging_in": "Logging in...", + "login_error": "Login failed", + "check_done": "Check complete", + "check_error": "Connection error", + "ssh_ok": "SSH Connection", + "docker_ok": "Docker", + "docker_not_installed": "Docker not installed", + "awg_desc": "A newer version of the protocol based on awg-go. Supports advanced obfuscation with S3, S4 parameters.", + "awg_legacy_desc": "Original AWG version based on WireGuard kernel. Compatible with older client versions.", + "xray_desc": "Modern protocol that masks traffic as regular web traffic (XTLS-Reality). Resistant to deep packet analysis.", + "wireguard_desc": "Standard and fastest VPN protocol. Supported natively on all modern OS, but easily detected by DPI.", + "not_checked": "Not checked", + "connections": "Connections", + "add": "Add", + "loading_connections": "Loading connections...", + "no_connections": "No connections", + "no_connections_desc": "Add your first connection to generate a VPN configuration", + "install_protocol": "Install protocol", + "port_default_hint": "Default port: 55424. Make sure it's not busy", + "port_xray_hint": "Default port: 443 (recommended for Xray). Make sure it's not taken by another web server.", + "reinstall": "Reinstall", + "uninstall_confirm": "Uninstall {}? All connections and configurations will be lost.", + "stop_container_confirm": "Stop container {}?", + "start_container_confirm": "Start container {}?", + "stopping": "Stopping", + "starting": "Starting", + "stopped": "stopped", + "started": "started", + "server_config": "Server configuration", + "add_connection": "Add connection", + "connection_name": "Connection name", + "connection_name_placeholder": "Example: iPhone, Laptop", + "assign_to_user": "Assign to user (optional)", + "no_assign": "— No assignment —", + "create": "Create", + "creating": "Creating...", + "connection_created": "Connection \"{}\" created", + "delete_connection_confirm": "Delete this connection? The configuration will stop working.", + "connection_deleted": "Connection deleted", + "config_tab": "📄 Config", + "vpn_key_tab": "🔑 VPN-key", + "qr_code_tab": "📱 QR-code", + "copy_config": "📋 Copy", + "download_conf": "⬇ Download .conf", + "copy_key": "📋 Copy key", + "vpn_link_hint": "Paste this link into the AmneziaVPN app for automatic configuration", + "qr_hint": "Scan the QR code in the AmneziaVPN app", + "no_data": "No data", + "start_btn": "▶ Start", + "stop_btn": "⏹ Stop", + "config_btn": "⚙️ Config", + "done": "Done", + "installing": "Installing...", + "start_install": "Starting installation...", + "check_docker": "Checking Docker...", + "prepare_host": "Preparing host...", + "build_container": "Building container...", + "install_success": "Protocol installed successfully!", + "install_error": "Installation error: ", + "qr_error": "Failed to generate QR code", + "users_title": "Users", + "search_placeholder": "Search by name, email or Telegram...", + "add_user": "Add User", + "prev_page": "← Back", + "next_page": "Next →", + "page_info": "Page {} of {}", + "loading_users": "Loading users...", + "nothing_found": "Nothing found", + "search_empty_desc": "Try changing your query or create a new user", + "username_label": "Username", + "password_label": "Password", + "role_label": "Role", + "role_user_desc": "User — view only own connections", + "role_support_desc": "Support — server management", + "role_admin_desc": "Admin — full access", + "tg_id_label": "Telegram ID (opt.)", + "email_label": "Email (opt.)", + "traffic_limit_label": "Traffic limit (GB) (opt., 0 = ∞)", + "expiration_date_label": "Expiration date", + "description_label": "Description (opt.)", + "auto_conn_title": "Automatic connection creation (optional)", + "server_label": "Server", + "no_create_conn": "— Do not create connection —", + "protocol_label": "Protocol", + "edit_user_title": "Edit user: {}", + "edit_user_tg": "Telegram ID (optional)", + "edit_user_email": "Email (optional)", + "edit_user_limit": "Traffic limit (GB) (0 = ∞)", + "new_password_hint": "New password (leave empty if no change needed)", + "op_type_label": "Operation type", + "create_new_conn": "Create new connection", + "link_existing_conn": "Link existing", + "select_existing_conn": "Select existing connection", + "existing_conn_hint": "Only connections not linked to users will be shown", + "conn_name_panel": "Connection name (in panel)", + "user_conns_title": "User connections", + "no_free_conns": "No free connections", + "linking": "Linking...", + "conn_linked": "Connection linked", + "enabling": "Enabling", + "disabling": "Disabling", + "user_enabled": "User enabled", + "user_disabled": "User disabled", + "delete_user_confirm": "Delete user? All their connections will be deleted.", + "share_access": "Share access", + "enable_public_access": "Enable public access", + "share_password_label": "Password (optional)", + "share_password_hint": "If set, user must enter it", + "public_link_label": "Public link", + "disabled": "Disabled", + "out_of": "Of", + "settings_title": "Panel Settings", + "appearance": "🎨 Appearance", + "title_label": "Title", + "logo_label": "Logo (Emoji or symbol)", + "subtitle_label": "Subtitle", + "captcha_title": "🔐 Captcha", + "enable_captcha": "Enable captcha (multicolorcaptcha)", + "telegram_bot_title": "🤖 Telegram Bot", + "bot_token_label": "Bot Token (from @BotFather)", + "bot_status": "Status", + "bot_running": "✅ Running", + "bot_stopped": "⏹ Stopped", + "bot_stop_btn": "⏹ Stop", + "bot_start_btn": "▶️ Start", + "bot_hint": "After changing the token, save the settings — the bot will restart automatically. Users must have a Telegram ID in their profile.", + "api_docs_title": "🔌 API Documentation", + "api_docs_hint": "Use these interfaces to explore the panel's API capabilities", + "tunnels_title": "Tunnels", + "local_server": "Local Server", + "tunnel_install_enable": "Install & Enable", + "tunnel_enable": "Enable Tunnel", + "tunnel_running": "Tunnel running", + "tunnel_stop": "Stop", + "tunnel_stopping": "Stopping...", + "tunnel_stopped": "Tunnel stopped", + "tunnel_delete": "Delete", + "tunnel_deleted": "Tunnel deleted", + "tunnel_delete_confirm": "Stop and delete this panel-managed tunnel binary?", + "tunnel_starting": "Starting tunnel...", + "tunnel_started": "Tunnel started", + "tunnel_waiting_url": "Tunnel started. Waiting for public URL...", + "tunnel_no_public_url": "Public URL will appear here after the tunnel starts.", + "tunnels_hint": "Quick tunnels expose this local panel to the internet. Keep admin credentials strong and share public URLs only with trusted users.", + "ngrok_authtoken_placeholder": "ngrok authtoken (optional for some accounts)", + "import_users_title": "📤 Import Users", + "import_source_label": "Import / Sync Source", + "remnawave_url_label": "Remnawave URL", + "api_key_label": "API Key", + "enable_sync": "Enable synchronization", + "sync_hint": "Users will be created, disabled, and deleted automatically based on Remnawave data", + "sync_now_btn": "🔄 Sync now", + "delete_sync_btn": "🗑 Delete synchronization", + "auto_create_conns": "Create connections automatically", + "sync_server_label": "Server for new connections", + "save_changes": "💾 Save changes", + "sync_success": "Sync completed: {} users", + "sync_deleted": "Deleted {} synced users", + "delete_sync_confirm": "Are you sure you want to delete ALL synced users and their connections? This action is irreversible.", + "settings_saved": "Settings saved successfully", + "bot_started": "Bot started", + "bot_stopped_msg": "Bot stopped", + "sync_running": "Syncing...", + "deleting": "Deleting...", + "my_connections_title": "My Connections", + "show_config": "📄 Show config", + "no_connections_user_desc": "Contact administrator to create a VPN connection", + "share_title": "Connection Access", + "vpn_access": "VPN Access", + "user_label_share": "User", + "share_protected_desc": "This access is password protected. Please enter it to continue.", + "auth_success": "Auth success", + "loading_share_conns": "Loading connections...", + "no_active_conns": "You have no active connections yet.", + "show_settings_btn": "🔑 Show settings", + "copied": "Copied!", + "active": "Active", + "site_description": "Amnezia Web Panel — management of VPN servers and protocols AmneziaWG", + "toggle_theme": "Change theme", + "copied_to_clipboard": "Copied to clipboard", + "share_not_found": "404 Not Found", + "share_not_found_desc": "Either the link is incorrect or access has been disabled.", + "invalid_captcha": "Invalid captcha", + "account_disabled": "Account disabled", + "invalid_login": "Invalid login or password", + "user_exists": "User with this name already exists", + "cannot_delete_self": "You cannot delete yourself", + "wrong_share_password": "Wrong password", + "saving": "Saving...", + "select_lang": "Select Language", + "lang_ru": "Русский", + "lang_en": "English", + "lang_fr": "Français", + "lang_zh": "中文 (Chinese)", + "lang_fa": "فارسی (Persian)", + "backup_title": "Simple Backup", + "download_backup": "Download data.json", + "restore_backup": "Restore from file", + "restore_confirm": "Are you sure? Current data will be overwritten and the application will restart.", + "restore_success": "Restore successful! Restarting...", + "invalid_backup_file": "Invalid backup file or structure", + "config_unavailable": "Configuration unavailable", + "config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user's device and cannot be recovered by the server.", + "client_public_key": "Client public key:", + "telemt_desc": "MTProxy-based Telegram proxy with advanced obfuscation and TLS emulation support.", + "tls_emulation": "TLS Emulation", + "tls_domain": "Masking Domain", + "max_connections_limit": "Max Connections", + "max_connections_hint": "0 for unlimited. Limits total concurrent users.", + "traffic_reset_strategy_label": "Traffic reset strategy", + "traffic_reset_never": "Never reset", + "traffic_reset_daily": "Daily reset", + "traffic_reset_weekly": "Weekly reset", + "traffic_reset_monthly": "Monthly reset", + "traffic_reset_hint": "How often should user traffic be reset", + "traffic_used_total": "Total spent", + "traffic_reset_info": "Next reset: {}", + "services": "Services", + "dns_desc": "Personal DNS server to block ads and protect the privacy of your queries.", + "dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.", + "telemt_port_hint": "Port for the Telegram proxy (typically 443).", + "socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.", + "socks5_username": "Username", + "socks5_password": "Password", + "socks5_password_placeholder": "Leave empty to auto-generate", + "socks5_password_hint": "If left empty, a random 16-character password will be generated.", + "socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.", + "socks5_change_settings": "Change settings", + "socks5_settings_title": "SOCKS5 — Connection settings", + "socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.", + "socks5_settings_saved": "SOCKS5 settings saved", + "adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.", + "adguard_mode": "Installation mode", + "adguard_mode_sidebyside": "Side-by-side", + "adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)", + "adguard_mode_replace": "Replace AmneziaDNS", + "adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately", + "adguard_dns_port": "DNS port (53)", + "adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.", + "adguard_web_port": "Web UI port", + "adguard_dot_port": "DoT port", + "adguard_doh_port": "DoH port", + "adguard_expose_web": "Expose to internet", + "adguard_expose": "Expose to host", + "adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.", + "adguard_internal_ip": "Internal IP", + "adguard_web_ui": "Admin URL", + "adguard_open_ui": "Open Web UI", + "edit_server_title": "Edit server", + "edit_keep_credential": "Leave empty to keep the current value", + "server_saved": "Server settings saved", + "servers_reordered": "Servers reordered", + "ping_checking": "Checking connectivity...", + "offline": "offline", + "api_tokens_title": "API tokens", + "api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer ` header. Tokens have admin-equivalent rights and stop working if their owner is disabled or demoted.", + "api_tokens_empty": "No API tokens yet. Create one to integrate the panel with an external service.", + "api_tokens_create": "Create token", + "api_tokens_create_title": "Create API token", + "api_tokens_name_label": "Name", + "api_tokens_name_placeholder": "e.g. Linear integration, CI bot, monitoring", + "api_tokens_name_hint": "Used to identify this token in the list. The token value itself is generated for you.", + "api_tokens_name_required": "Token name is required", + "api_tokens_created_title": "Token created", + "api_tokens_value_label": "Token value", + "api_tokens_warning_title": "This is the only time you'll see this token.", + "api_tokens_warning_body": "Copy it now and store it in your integration's secret manager. The panel keeps only a hash — if you lose this value you'll need to revoke and recreate the token.", + "api_tokens_revoke": "Revoke", + "api_tokens_revoke_confirm": "Revoke token \"{}\"? Any integration using it will stop working.", + "api_tokens_revoked": "Token revoked", + "api_tokens_created": "Created", + "api_tokens_last_used": "Last used", + "api_tokens_owner": "Owner", + "api_tokens_never": "never", + "coming_soon": "Coming soon", + "promo_star_cta": "Star us on GitHub", + "aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.", + "revproxy_title": "Reverse Proxy", + "revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.", + "revproxy_desc": "Caddy reverse proxy with a decoy website on ports 80/443. Masks VPN traffic behind a normal HTTPS site to reduce DPI detection.", + "revproxy_domain": "Domain name", + "revproxy_domain_hint": "Optional. Point DNS A-record to this server for Let's Encrypt TLS. Leave empty for a self-signed certificate.", + "revproxy_site_title": "Site title", + "revproxy_tls_email": "ACME email", + "revproxy_tls_email_hint": "Required for automatic Let's Encrypt certificates when a domain is set.", + "revproxy_backend": "VPN backend", + "revproxy_backend_none": "Decoy site only", + "revproxy_backend_port": "Backend port", + "revproxy_vpn_path": "Secret tunnel path", + "revproxy_vpn_path_hint": "Hidden path proxied to the VPN backend (e.g. /cdn-cgi/challenge).", + "revproxy_telemt_mask": "Enable Telemt site masking via Caddy", + "revproxy_install_hint": "Port 443 is used by the reverse proxy. Reinstall Xray/Telemt on an internal port (e.g. 8443) before enabling backend routing.", + "revproxy_port_hint": "Reverse proxy binds host ports 80 and 443 (HTTPS + HTTP/3).", + "revproxy_site": "Decoy site", + "revproxy_open_site": "Open site", + "revproxy_change_settings": "Settings", + "revproxy_settings_title": "Reverse Proxy Settings", + "management": "Management", + "server_management": "Server Management", + "server_management_desc": "Perform system-level actions on your machine.", + "check_server_services": "Check the server services", + "reboot_server": "Reboot server", + "clear_server": "Clear server", + "remove_server": "Remove server from panel", + "ssl_title": "SSL / HTTPS Settings", + "enable_https": "Enable HTTPS", + "panel_port_label": "Panel Port", + "domain_label": "Domain name", + "cert_path_label": "SSL Certificate path (.pem)", + "key_path_label": "Private Key path (.pem)", + "or_paste_text": "OR paste certificate text", + "cert_text_label": "Certificate content (CRT)", + "key_text_label": "Private Key content (KEY)", + "ssl_hint": "After enabling, the panel will be accessible via https:// on the specified domain. Make sure the file paths or text data are correct.", + "ssl_provider_label": "Certificate source", + "ssl_provider_manual": "Manual (paths / paste)", + "ssl_provider_cloudflare": "Cloudflare (auto Let's Encrypt)", + "cf_token_label": "Cloudflare API Token", + "cf_token_hint": "Create a token with Zone:DNS:Edit and Zone:Zone:Read permissions for your domain's zone. Used for DNS-01 validation only — port 80/443 stays free.", + "acme_email_label": "Email (Let's Encrypt notifications)", + "acme_staging_label": "Use staging (test certificates)", + "cf_issue_btn": "Issue / Renew certificate", + "cf_issue_hint": "Issues a real Let's Encrypt certificate via Cloudflare DNS without binding port 443. Restart the panel afterwards to apply.", + "cf_issuing": "Issuing...", + "cf_issued_ok": "Certificate issued successfully", + "cf_restart_required": "Restart the panel to apply the new certificate", + "cf_need_domain": "Enter a domain first", + "cf_cert_active": "Active", + "cf_cert_none": "No certificate yet", + "cf_days_left": "days left", + "about_title": "About & Updates", + "current_version": "Current version", + "check_updates": "Check for updates", + "checking_updates": "Checking...", + "update_available": "New version available", + "download_update": "Download update", + "up_to_date": "You have the latest version" +} diff --git a/translations/fa.json b/translations/fa.json new file mode 100644 index 0000000..9fdf6be --- /dev/null +++ b/translations/fa.json @@ -0,0 +1,369 @@ +{ + "nav_servers": "سرورها", + "nav_users": "کاربران", + "nav_settings": "تنظیمات", + "nav_connections": "اتصال‌ها", + "nav_logout": "خروج", + "theme_dark": "تاریک", + "theme_light": "روشن", + "back_to_servers": "← بازگشت به سرورها", + "server_check": "بررسی", + "server_checking": "در حال بررسی...", + "protocols": "پروتکل‌ها", + "install": "نصب", + "uninstall": "حذف نصب", + "run": "در حال اجرا", + "stop": "متوقف شده", + "not_installed": "نصب نشده", + "installed": "نصب شده", + "add_server": "افزودن سرور", + "no_servers": "هیچ سروری اضافه نشده است", + "name": "نام", + "host": "میزبان", + "port": "پورت", + "status": "وضعیت", + "actions": "عملکردها", + "edit": "ویرایش", + "delete": "حذف", + "save": "ذخیره", + "cancel": "لغو", + "loading": "در حال بارگذاری...", + "copy": "کپی", + "download": "دانلود", + "config": "پیکربندی", + "qr_code": "کد QR", + "vpn_key": "کلید VPN", + "clients": "کلاینت‌ها", + "traffic": "ترافیک", + "limit": "محدودیت", + "manage": "مدیریت", + "no_protocols": "بدون پروتکل", + "add_server_desc": "اولین سرور VPN خود را اضافه کنید. برای اتصال به اطلاعات SSH نیاز دارید.", + "ssh_host": "میزبان / IP", + "ssh_port": "پورت SSH", + "ssh_user": "نام کاربری", + "ssh_password": "رمز عبور", + "ssh_key": "کلید SSH", + "ssh_key_placeholder": "کلید خصوصی SSH", + "ssh_key_hint": "محتوای فایل id_rsa یا id_ed25519 خود را اینجا قرار دهید", + "connect": "اتصال", + "connecting": "در حال اتصال...", + "server_added": "سرور با موفقیت اضافه شد", + "server_deleted": "سرور حذف شد", + "delete_confirm": "آیا از حذف این سرور مطمئن هستید؟", + "success": "موفقیت", + "error": "خطا", + "info": "اطلاعات", + "login_title": "ورود به پنل", + "username": "نام کاربری", + "password": "رمز عبور", + "captcha": "کد امنیتی", + "captcha_placeholder": "کاراکترهای تصویر را وارد کنید", + "captcha_hint": "برای نوسازی تصویر کلیک کنید", + "login": "ورود", + "logging_in": "در حال ورود...", + "login_error": "ورود ناموفق بود", + "check_done": "بررسی کامل شد", + "check_error": "خطای اتصال", + "ssh_ok": "اتصال SSH برقرار است", + "docker_ok": "داکر نصب است", + "docker_not_installed": "داکر نصب نیست", + "awg_desc": "نسخه جدید پروتکل بر پایه awg-go. پشتیبانی از مبهم‌سازی پیشرفته (S3, S4).", + "awg_legacy_desc": "نسخه اصلی AWG. سازگار با نسخه‌های قدیمی کلاینت.", + "xray_desc": "تغییر ظاهر ترافیک به ترافیک معمولی وب (XTLS-Reality). مقاوم در برابر فیلترینگ شدید.", + "not_checked": "بررسی نشده", + "connections": "اتصال‌ها", + "add": "افزودن", + "loading_connections": "در حال بارگذاری اتصال‌ها...", + "no_connections": "بدون اتصال", + "no_connections_desc": "اولین اتصال خود را برای ایجاد پیکربندی VPN اضافه کنید", + "install_protocol": "نصب پروتکل", + "port_default_hint": "پورت پیش‌فرض: 55424. مطمئن شوید این پورت آزاد است.", + "port_xray_hint": "پورت پیشنهادی: 443. مطمئن شوید توسط وب‌سرور دیگری اشغال نشده باشد.", + "reinstall": "نصب مجدد", + "uninstall_confirm": "حذف نصب {}؟ تمام اتصال‌ها و پیکربندی‌ها از بین خواهند رفت.", + "stop_container_confirm": "توقف کانتینر {}؟", + "start_container_confirm": "شروع کانتینر {}؟", + "stopping": "در حال توقف", + "starting": "در حال شروع", + "stopped": "متوقف شد", + "started": "شروع شد", + "server_config": "پیکربندی سرور", + "add_connection": "افزودن اتصال", + "connection_name": "نام اتصال", + "connection_name_placeholder": "مثال: iPhone, Laptop", + "assign_to_user": "تخصیص به کاربر (اختیاری)", + "no_assign": "— بدون تخصیص —", + "create": "ایجاد", + "creating": "در حال ایجاد...", + "connection_created": "اتصال \"{}\" ایجاد شد", + "delete_connection_confirm": "این اتصال حذف شود؟ پیکربندی دیگر کار نخواهد کرد.", + "connection_deleted": "اتصال حذف شد", + "config_tab": "📄 فایل", + "vpn_key_tab": "🔑 کلید-VPN", + "qr_code_tab": "📱 کد-QR", + "copy_config": "📋 کپی", + "download_conf": "⬇ دانلود .conf", + "copy_key": "📋 کپی کلید", + "vpn_link_hint": "این لینک را برای تنظیم خودکار در برنامه AmneziaVPN وارد کنید", + "qr_hint": "کد QR را در برنامه AmneziaVPN اسکن کنید", + "no_data": "بدون داده", + "start_btn": "▶ شروع", + "stop_btn": "⏹ توقف", + "config_btn": "⚙️ تنظیم", + "done": "انجام شد", + "installing": "در حال نصب...", + "start_install": "شروع نصب...", + "check_docker": "بررسی داکر...", + "prepare_host": "آماده‌سازی میزبان...", + "build_container": "ساخت کانتینر...", + "install_success": "پروتکل با موفقیت نصب شد!", + "install_error": "خطا در نصب: ", + "qr_error": "ایجاد کد QR ناموفق بود", + "users_title": "کاربران", + "search_placeholder": "جستجو با نام، ایمیل یا تلگرام...", + "add_user": "افزودن کاربر", + "prev_page": "← قبلی", + "next_page": "بعدی →", + "page_info": "صفحه {} از {}", + "loading_users": "در حال بارگذاری کاربران...", + "nothing_found": "چیزی یافت نشد", + "search_empty_desc": "جستجوی دیگری را امتحان کنید یا کاربر جدید بسازید", + "username_label": "نام کاربری", + "password_label": "رمز عبور", + "role_label": "نقش", + "role_user_desc": "User — فقط مشاهده اتصال‌های خود", + "role_support_desc": "Support — مدیریت سرورها", + "role_admin_desc": "Admin — دسترسی کامل", + "tg_id_label": "آیدی تلگرام (اختیاری)", + "email_label": "ایمیل (اختیاری)", + "traffic_limit_label": "محدودیت ترافیک (گیگابایت) (0 = نامحدود)", + "description_label": "توضیحات (اختیاری)", + "auto_conn_title": "ایجاد خودکار اتصال (اختیاری)", + "server_label": "سرور", + "no_create_conn": "— عدم ایجاد اتصال —", + "protocol_label": "پروتکل", + "edit_user_title": "ویرایش کاربر: {}", + "edit_user_tg": "آیدی تلگرام (اختیاری)", + "edit_user_email": "ایمیل (اختیاری)", + "edit_user_limit": "محدودیت ترافیک (گیگابایت) (0 = نامحدود)", + "new_password_hint": "رمز عبور جدید (اگر تغییری نمی‌خواهید خالی بگذارید)", + "op_type_label": "نوع عملیات", + "create_new_conn": "ایجاد اتصال جدید", + "link_existing_conn": "اتصال به موجود", + "select_existing_conn": "انتخاب اتصال موجود", + "existing_conn_hint": "فقط اتصال‌های بدون کاربر نمایش داده می‌شوند", + "conn_name_panel": "نام اتصال (در پنل)", + "user_conns_title": "اتصال‌های کاربر", + "no_free_conns": "اتصال آزاد موجود نیست", + "linking": "در حال اتصال...", + "conn_linked": "اتصال برقرار شد", + "enabling": "در حال فعال‌سازی", + "disabling": "در حال غیرفعال‌سازی", + "user_enabled": "کاربر فعال شد", + "user_disabled": "کاربر غیرفعال شد", + "delete_user_confirm": "کاربر حذف شود؟ تمام اتصال‌های او حذف خواهند شد.", + "share_access": "اشتراک‌گذاری دسترسی", + "enable_public_access": "فعال‌سازی دسترسی عمومی", + "share_password_label": "رمز عبور (اختیاری)", + "share_password_hint": "در صورت تنظیم، کاربر باید آن را وارد کند", + "public_link_label": "لینک عمومی", + "disabled": "غیرفعال", + "out_of": "از", + "settings_title": "تنظیمات پنل", + "appearance": "🎨 ظاهر", + "title_label": "عنوان (Title)", + "logo_label": "لوگو (ایموجی یا نماد)", + "subtitle_label": "زیرعنوان (Subtitle)", + "captcha_title": "🔐 کد امنیتی", + "enable_captcha": "فعال‌سازی کد امنیتی", + "telegram_bot_title": "🤖 ربات تلگرام", + "bot_token_label": "توکن ربات (از @BotFather)", + "bot_status": "وضعیت", + "bot_running": "✅ در حال اجرا", + "bot_stopped": "⏹ متوقف شده", + "bot_stop_btn": "⏹ توقف", + "bot_start_btn": "▶️ شروع", + "bot_hint": "پس از تغییر توکن تنظیمات را ذخیره کنید تا ربات بازراه‌اندازی شود.", + "api_docs_title": "🔌 مستندات API", + "api_docs_hint": "از این رابط‌ها برای بررسی قابلیت‌های API استفاده کنید", + "tunnels_title": "Tunnels", + "local_server": "سرور محلی", + "tunnel_install_enable": "نصب و فعال‌سازی", + "tunnel_enable": "فعال‌سازی تونل", + "tunnel_running": "تونل در حال اجرا است", + "tunnel_stop": "توقف", + "tunnel_stopping": "در حال توقف...", + "tunnel_stopped": "تونل متوقف شد", + "tunnel_delete": "حذف", + "tunnel_deleted": "تونل حذف شد", + "tunnel_delete_confirm": "این فایل اجرایی تونل مدیریت‌شده توسط پنل متوقف و حذف شود؟", + "tunnel_starting": "در حال شروع تونل...", + "tunnel_started": "تونل شروع شد", + "tunnel_waiting_url": "تونل شروع شد. در انتظار آدرس عمومی...", + "tunnel_no_public_url": "آدرس عمومی پس از شروع تونل اینجا نمایش داده می‌شود.", + "tunnels_hint": "تونل‌های سریع این پنل محلی را در اینترنت منتشر می‌کنند. رمز مدیر را قوی نگه دارید و آدرس‌های عمومی را فقط با کاربران مورد اعتماد به اشتراک بگذارید.", + "ngrok_authtoken_placeholder": "ngrok authtoken (برای برخی حساب‌ها اختیاری است)", + "import_users_title": "📤 وارد کردن کاربران", + "import_source_label": "منبع وارد کردن / همگام‌سازی", + "remnawave_url_label": "آدرس Remnawave", + "api_key_label": "کلید API", + "enable_sync": "فعال‌سازی همگام‌سازی", + "sync_hint": "کاربران طبق داده‌های Remnawave به‌طور خودکار مدیریت می‌شوند", + "sync_now_btn": "🔄 همگام‌سازی اکنون", + "delete_sync_btn": "🗑 حذف همگام‌سازی", + "auto_create_conns": "ایجاد خودکار اتصال‌ها", + "sync_server_label": "سرور برای اتصال‌های جدید", + "save_changes": "💾 ذخیره تغییرات", + "sync_success": "همگام‌سازی انجام شد: {} کاربر", + "sync_deleted": "{} کاربر همگام‌سازی شده حذف شدند", + "delete_sync_confirm": "آیا از حذف تمام کاربران همگام‌سازی شده مطمئن هستید؟", + "settings_saved": "تنظیمات با موفقیت ذخیره شد", + "bot_started": "ربات شروع به کار کرد", + "bot_stopped_msg": "ربات متوقف شد", + "sync_running": "در حال همگام‌سازی...", + "deleting": "در حال حذف...", + "my_connections_title": "اتصال‌های من", + "show_config": "📄 نمایش پیکربندی", + "no_connections_user_desc": "برای ایجاد اتصال VPN با مدیر تماس بگیرید", + "share_title": "دسترسی به اتصال", + "vpn_access": "دسترسی VPN", + "user_label_share": "کاربر", + "share_protected_desc": "این دسترسی با رمز عبور محافظت شده است.", + "auth_success": "ورود موفقیت‌آمیز", + "loading_share_conns": "در حال بارگذاری...", + "no_active_conns": "هنوز هیچ اتصال فعالی ندارید.", + "show_settings_btn": "🔑 نمایش تنظیمات", + "copied": "کپی شد!", + "active": "فعال", + "site_description": "پنل وب آمنزیا — مدیریت سرورها و پروتکل‌های AmneziaWG", + "toggle_theme": "تغییر تم", + "copied_to_clipboard": "در حافظه کپی شد", + "share_not_found": "404 یافت نشد", + "share_not_found_desc": "لینک اشتباه است یا دسترسی غیرفعال شده است.", + "invalid_captcha": "کد امنیتی اشتباه است", + "account_disabled": "حساب کاربری غیرفعال است", + "invalid_login": "نام کاربری یا رمز عبور اشتباه است", + "user_exists": "کاربری با این نام قبلاً ثبت شده است", + "cannot_delete_self": "شما نمی‌توانید خودتان را حذف کنید", + "wrong_share_password": "رمز عبور اشتباه است", + "saving": "در حال ذخیره...", + "select_lang": "انتخاب زبان", + "lang_ru": "Русский", + "lang_en": "English", + "lang_fr": "Français", + "lang_zh": "中文 (Chinese)", + "lang_fa": "فارسی (Persian)", + "backup_title": "پشتیبان‌گیری ساده", + "download_backup": "دانلود data.json", + "restore_backup": "بازیابی از فایل", + "restore_confirm": "آیا مطمئن هستید؟ داده‌های فعلی بازنویسی می‌شوند و برنامه دوباره راه‌اندازی خواهد شد.", + "restore_success": "بازیابی موفقیت‌آمیز بود! در حال راه‌اندازی مجدد...", + "invalid_backup_file": "فایل پشتیبان یا ساختار نامعتبر است", + "config_unavailable": "پیکربندی در دسترس نیست", + "config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره می‌شود و توسط سرور قابل بازیابی نیست.", + "client_public_key": "کلید عمومی کلاینت:", + "management": "مدیریت", + "server_management": "مدیریت سرور", + "server_management_desc": "انجام اقدامات در سطح سیستم روی دستگاه شما.", + "check_server_services": "بررسی سرویس‌های سرور", + "reboot_server": "راه‌اندازی مجدد سرور", + "clear_server": "پاک‌سازی سرور", + "remove_server": "حذف سرور از پنل", + "telemt_desc": "پروکسی تلگرام بر پایه‌ی MTProxy با پوشش‌های پیشرفته.", + "dns_desc": "سرور DNS شخصی برای مسدود کردن تبلیغات و حفظ حریم خصوصی.", + "dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.", + "telemt_port_hint": "Port for the Telegram proxy (typically 443).", + "socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.", + "socks5_username": "Username", + "socks5_password": "Password", + "socks5_password_placeholder": "Leave empty to auto-generate", + "socks5_password_hint": "If left empty, a random 16-character password will be generated.", + "socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.", + "socks5_change_settings": "Change settings", + "socks5_settings_title": "SOCKS5 — Connection settings", + "socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.", + "socks5_settings_saved": "SOCKS5 settings saved", + "adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.", + "adguard_mode": "Installation mode", + "adguard_mode_sidebyside": "Side-by-side", + "adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)", + "adguard_mode_replace": "Replace AmneziaDNS", + "adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately", + "adguard_dns_port": "DNS port (53)", + "adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.", + "adguard_web_port": "Web UI port", + "adguard_dot_port": "DoT port", + "adguard_doh_port": "DoH port", + "adguard_expose_web": "Expose to internet", + "adguard_expose": "Expose to host", + "adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.", + "adguard_internal_ip": "Internal IP", + "adguard_web_ui": "Admin URL", + "adguard_open_ui": "Open Web UI", + "edit_server_title": "Edit server", + "edit_keep_credential": "Leave empty to keep the current value", + "server_saved": "Server settings saved", + "servers_reordered": "Servers reordered", + "ping_checking": "Checking connectivity...", + "offline": "offline", + "api_tokens_title": "API tokens", + "api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer ` header. Tokens have admin-equivalent rights and stop working if their owner is disabled or demoted.", + "api_tokens_empty": "No API tokens yet. Create one to integrate the panel with an external service.", + "api_tokens_create": "Create token", + "api_tokens_create_title": "Create API token", + "api_tokens_name_label": "Name", + "api_tokens_name_placeholder": "e.g. Linear integration, CI bot, monitoring", + "api_tokens_name_hint": "Used to identify this token in the list. The token value itself is generated for you.", + "api_tokens_name_required": "Token name is required", + "api_tokens_created_title": "Token created", + "api_tokens_value_label": "Token value", + "api_tokens_warning_title": "This is the only time you'll see this token.", + "api_tokens_warning_body": "Copy it now and store it in your integration's secret manager. The panel keeps only a hash — if you lose this value you'll need to revoke and recreate the token.", + "api_tokens_revoke": "Revoke", + "api_tokens_revoke_confirm": "Revoke token \"{}\"? Any integration using it will stop working.", + "api_tokens_revoked": "Token revoked", + "api_tokens_created": "Created", + "api_tokens_last_used": "Last used", + "api_tokens_owner": "Owner", + "api_tokens_never": "never", + "coming_soon": "Coming soon", + "promo_star_cta": "Star us on GitHub", + "aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.", + "revproxy_title": "Reverse Proxy", + "revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.", + "revproxy_desc": "Caddy reverse proxy with a decoy website on ports 80/443. Masks VPN traffic behind a normal HTTPS site to reduce DPI detection.", + "revproxy_domain": "Domain name", + "revproxy_domain_hint": "Optional. Point DNS A-record to this server for Let's Encrypt TLS. Leave empty for a self-signed certificate.", + "revproxy_site_title": "Site title", + "revproxy_tls_email": "ACME email", + "revproxy_tls_email_hint": "Required for automatic Let's Encrypt certificates when a domain is set.", + "revproxy_backend": "VPN backend", + "revproxy_backend_none": "Decoy site only", + "revproxy_backend_port": "Backend port", + "revproxy_vpn_path": "Secret tunnel path", + "revproxy_vpn_path_hint": "Hidden path proxied to the VPN backend (e.g. /cdn-cgi/challenge).", + "revproxy_telemt_mask": "Enable Telemt site masking via Caddy", + "revproxy_install_hint": "Port 443 is used by the reverse proxy. Reinstall Xray/Telemt on an internal port (e.g. 8443) before enabling backend routing.", + "revproxy_port_hint": "Reverse proxy binds host ports 80 and 443 (HTTPS + HTTP/3).", + "revproxy_site": "Decoy site", + "revproxy_open_site": "Open site", + "revproxy_change_settings": "Settings", + "revproxy_settings_title": "Reverse Proxy Settings", + "ssl_provider_label": "Certificate source", + "ssl_provider_manual": "Manual (paths / paste)", + "ssl_provider_cloudflare": "Cloudflare (auto Let's Encrypt)", + "cf_token_label": "Cloudflare API Token", + "cf_token_hint": "Create a token with Zone:DNS:Edit and Zone:Zone:Read permissions. Used for DNS-01 validation only — port 80/443 stays free.", + "acme_email_label": "Email (Let's Encrypt notifications)", + "acme_staging_label": "Use staging (test certificates)", + "cf_issue_btn": "Issue / Renew certificate", + "cf_issue_hint": "Issues a real Let's Encrypt certificate via Cloudflare DNS without binding port 443. Restart the panel afterwards to apply.", + "cf_issuing": "Issuing...", + "cf_issued_ok": "Certificate issued successfully", + "cf_restart_required": "Restart the panel to apply the new certificate", + "cf_need_domain": "Enter a domain first", + "cf_cert_active": "Active", + "cf_cert_none": "No certificate yet", + "cf_days_left": "days left" +} diff --git a/translations/fr.json b/translations/fr.json new file mode 100644 index 0000000..b09f033 --- /dev/null +++ b/translations/fr.json @@ -0,0 +1,369 @@ +{ + "nav_servers": "Serveurs", + "nav_users": "Utilisateurs", + "nav_settings": "Paramètres", + "nav_connections": "Connexions", + "nav_logout": "Déconnexion", + "theme_dark": "Sombre", + "theme_light": "Clair", + "back_to_servers": "← Retour aux serveurs", + "server_check": "Vérifier", + "server_checking": "Vérification...", + "protocols": "Protocoles", + "install": "Installer", + "uninstall": "Désinstaller", + "run": "En cours", + "stop": "Arrêté", + "not_installed": "Pas installé", + "installed": "Installé", + "add_server": "Ajouter un serveur", + "no_servers": "Aucun serveur ajouté", + "name": "Nom", + "host": "Hôte", + "port": "Port", + "status": "Statut", + "actions": "Actions", + "edit": "Modifier", + "delete": "Supprimer", + "save": "Enregistrer", + "cancel": "Annuler", + "loading": "Chargement...", + "copy": "Copier", + "download": "Télécharger", + "config": "Configuration", + "qr_code": "Code QR", + "vpn_key": "Clé VPN", + "clients": "Clients", + "traffic": "Trafic", + "limit": "Limite", + "manage": "Gérer", + "no_protocols": "Aucun protocole", + "add_server_desc": "Ajoutez votre premier serveur VPN pour commencer. Identifiants SSH requis.", + "ssh_host": "Hôte / IP", + "ssh_port": "Port SSH", + "ssh_user": "Utilisateur", + "ssh_password": "Mot de passe", + "ssh_key": "Clé SSH", + "ssh_key_placeholder": "Clé SSH privée", + "ssh_key_hint": "Collez le contenu de votre fichier id_rsa ou id_ed25519", + "connect": "Connecter", + "connecting": "Connexion...", + "server_added": "Serveur ajouté avec succès", + "server_deleted": "Serveur supprimé", + "delete_confirm": "Voulez-vous vraiment supprimer ce serveur ?", + "success": "Succès", + "error": "Erreur", + "info": "Info", + "login_title": "Connexion au Panneau", + "username": "Nom d'utilisateur", + "password": "Mot de passe", + "captcha": "Captcha", + "captcha_placeholder": "Entrez les caractères", + "captcha_hint": "Cliquez pour rafraîchir", + "login": "Connexion", + "logging_in": "Connexion...", + "login_error": "Échec de la connexion", + "check_done": "Vérification terminée", + "check_error": "Erreur de connexion", + "ssh_ok": "Connexion SSH", + "docker_ok": "Docker", + "docker_not_installed": "Docker non installé", + "awg_desc": "Version moderne basée sur awg-go. Obfuscation avancée (S3, S4).", + "awg_legacy_desc": "Version AWG originale. Compatible avec les anciens clients.", + "xray_desc": "Masque le trafic en trafic web normal (XTLS-Reality). Résiste au DPI.", + "not_checked": "Non vérifié", + "connections": "Connexions", + "add": "Ajouter", + "loading_connections": "Chargement des connexions...", + "no_connections": "Aucune connexion", + "no_connections_desc": "Ajoutez votre première connexion pour générer un fichier VPN", + "install_protocol": "Installer le protocole", + "port_default_hint": "Port par défaut : 55424. Assurez-vous qu'il est libre.", + "port_xray_hint": "Port recommandé : 443. Assurez-vous qu'il n'est pas utilisé par un serveur web.", + "reinstall": "Réinstaller", + "uninstall_confirm": "Désinstaller {} ? Toutes les données seront perdues.", + "stop_container_confirm": "Arrêter le conteneur {} ?", + "start_container_confirm": "Démarrer le conteneur {} ?", + "stopping": "Arrêt", + "starting": "Démarrage", + "stopped": "arrêté", + "started": "démarré", + "server_config": "Configuration du serveur", + "add_connection": "Ajouter une connexion", + "connection_name": "Nom de la connexion", + "connection_name_placeholder": "Ex: iPhone, Laptop", + "assign_to_user": "Assigner à l'utilisateur (optionnel)", + "no_assign": "— Aucune assignation —", + "create": "Créer", + "creating": "Création...", + "connection_created": "Connexion \"{}\" créée", + "delete_connection_confirm": "Supprimer cette connexion ? Elle cessera de fonctionner.", + "connection_deleted": "Connexion supprimée", + "config_tab": "📄 Config", + "vpn_key_tab": "🔑 Clé-VPN", + "qr_code_tab": "📱 Code-QR", + "copy_config": "📋 Copier", + "download_conf": "⬇ Télécharger .conf", + "copy_key": "📋 Copier la clé", + "vpn_link_hint": "Collez ce lien dans AmneziaVPN pour une config automatique", + "qr_hint": "Scannez le code QR dans l'application AmneziaVPN", + "no_data": "Aucune donnée", + "start_btn": "▶ Start", + "stop_btn": "⏹ Stop", + "config_btn": "⚙️ Config", + "done": "Terminé", + "installing": "Installation...", + "start_install": "Début de l'installation...", + "check_docker": "Vérification de Docker...", + "prepare_host": "Préparation de l'hôte...", + "build_container": "Construction du conteneur...", + "install_success": "Protocole installé avec succès !", + "install_error": "Erreur d'installation : ", + "qr_error": "Échec de génération du QR code", + "users_title": "Utilisateurs", + "search_placeholder": "Recherche par nom, email ou Telegram...", + "add_user": "Ajouter Utilisateur", + "prev_page": "← Retour", + "next_page": "Suivant →", + "page_info": "Page {} sur {}", + "loading_users": "Chargement des utilisateurs...", + "nothing_found": "Rien trouvé", + "search_empty_desc": "Essayez une autre requête ou créez un utilisateur", + "username_label": "Nom d'utilisateur", + "password_label": "Mot de passe", + "role_label": "Rôle", + "role_user_desc": "User — voit ses propres connexions", + "role_support_desc": "Support — gestion des serveurs", + "role_admin_desc": "Admin — accès complet", + "tg_id_label": "ID Telegram (opt.)", + "email_label": "Email (opt.)", + "traffic_limit_label": "Limite trafic (Go) (0 = illimité)", + "description_label": "Description (opt.)", + "auto_conn_title": "Création auto de connexion (optionnel)", + "server_label": "Serveur", + "no_create_conn": "— Ne pas créer —", + "protocol_label": "Protocole", + "edit_user_title": "Modifier utilisateur : {}", + "edit_user_tg": "ID Telegram (optionnel)", + "edit_user_email": "Email (optionnel)", + "edit_user_limit": "Limite trafic (Go) (0 = illimité)", + "new_password_hint": "Nouveau mot de passe (laisser vide si inchangé)", + "op_type_label": "Type d'opération", + "create_new_conn": "Créer nouvelle connexion", + "link_existing_conn": "Lier existante", + "select_existing_conn": "Sélectionner connexion", + "existing_conn_hint": "Seules les connexions libres sont affichées", + "conn_name_panel": "Nom (dans le panneau)", + "user_conns_title": "Connexions utilisateur", + "no_free_conns": "Aucune connexion libre", + "linking": "Liaison...", + "conn_linked": "Connexion liée", + "enabling": "Activation", + "disabling": "Désactivation", + "user_enabled": "Utilisateur activé", + "user_disabled": "Utilisateur désactivé", + "delete_user_confirm": "Supprimer l'utilisateur ? Ses connexions seront supprimées.", + "share_access": "Partager l'accès", + "enable_public_access": "Activer l'accès public", + "share_password_label": "Mot de passe (optionnel)", + "share_password_hint": "Si défini, requis pour l'accès", + "public_link_label": "Lien public", + "disabled": "Désactivé", + "out_of": "sur", + "settings_title": "Paramètres du Panneau", + "appearance": "🎨 Apparence", + "title_label": "Titre", + "logo_label": "Logo (Emoji ou symbole)", + "subtitle_label": "Sous-titre", + "captcha_title": "🔐 Captcha", + "enable_captcha": "Activer captcha", + "telegram_bot_title": "🤖 Bot Telegram", + "bot_token_label": "Bot Token (@BotFather)", + "bot_status": "Statut", + "bot_running": "✅ En cours", + "bot_stopped": "⏹ Arrêté", + "bot_stop_btn": "⏹ Arrêter", + "bot_start_btn": "▶️ Démarrer", + "bot_hint": "Sauvegardez après modification du token. Le bot redémarrera.", + "api_docs_title": "🔌 Documentation API", + "api_docs_hint": "Utilisez ces interfaces pour explorer l'API", + "tunnels_title": "Tunnels", + "local_server": "Serveur local", + "tunnel_install_enable": "Installer et activer", + "tunnel_enable": "Activer le tunnel", + "tunnel_running": "Tunnel en cours", + "tunnel_stop": "Arrêter", + "tunnel_stopping": "Arrêt...", + "tunnel_stopped": "Tunnel arrêté", + "tunnel_delete": "Supprimer", + "tunnel_deleted": "Tunnel supprimé", + "tunnel_delete_confirm": "Arrêter et supprimer ce binaire de tunnel géré par le panneau ?", + "tunnel_starting": "Démarrage du tunnel...", + "tunnel_started": "Tunnel démarré", + "tunnel_waiting_url": "Tunnel démarré. En attente de l'URL publique...", + "tunnel_no_public_url": "L'URL publique apparaîtra ici après le démarrage du tunnel.", + "tunnels_hint": "Les tunnels rapides exposent ce panneau local sur Internet. Gardez des identifiants administrateur robustes et partagez les URL publiques uniquement avec des utilisateurs de confiance.", + "ngrok_authtoken_placeholder": "ngrok authtoken (optionnel pour certains comptes)", + "import_users_title": "📤 Import Utilisateurs", + "import_source_label": "Source Import / Sync", + "remnawave_url_label": "URL Remnawave", + "api_key_label": "Clé API", + "enable_sync": "Activer synchronisation", + "sync_hint": "Synchro automatique avec Remnawave", + "sync_now_btn": "🔄 Sync maintenant", + "delete_sync_btn": "🗑 Supprimer synchro", + "auto_create_conns": "Créer connexions auto", + "sync_server_label": "Serveur pour nouvelles connexions", + "save_changes": "💾 Enregistrer changements", + "sync_success": "Sync terminée : {} utilisateurs", + "sync_deleted": "Supprimé {} utilisateurs sync", + "delete_sync_confirm": "Supprimer TOUS les utilisateurs synchronisés ? Irréversible.", + "settings_saved": "Paramètres enregistrés", + "bot_started": "Bot démarré", + "bot_stopped_msg": "Bot arrêté", + "sync_running": "Synchronisation...", + "deleting": "Suppression...", + "my_connections_title": "Mes Connexions", + "show_config": "📄 Voir config", + "no_connections_user_desc": "Contactez l'admin pour une connexion", + "share_title": "Accès Connexion", + "vpn_access": "Accès VPN", + "user_label_share": "Utilisateur", + "share_protected_desc": "Accès protégé par mot de passe.", + "auth_success": "Succès Auth", + "loading_share_conns": "Chargement...", + "no_active_conns": "Aucune connexion active.", + "show_settings_btn": "🔑 Voir paramètres", + "copied": "Copié !", + "active": "Actif", + "site_description": "Panneau Web Amnezia — gestion serveurs VPN et AmneziaWG", + "toggle_theme": "Changer thème", + "copied_to_clipboard": "Copié dans le presse-papier", + "share_not_found": "404 Introuvable", + "share_not_found_desc": "Lien invalide ou accès désactivé.", + "invalid_captcha": "Captcha invalide", + "account_disabled": "Compte désactivé", + "invalid_login": "Login ou password incorrect", + "user_exists": "L'utilisateur existe déjà", + "cannot_delete_self": "Vous ne pouvez pas vous supprimer", + "wrong_share_password": "Mauvais mot de passe", + "saving": "Enregistrement...", + "select_lang": "Choisir la langue", + "lang_ru": "Русский", + "lang_en": "English", + "lang_fr": "Français", + "lang_zh": "中文 (Chinese)", + "lang_fa": "فارسی (Persian)", + "backup_title": "Sauvegarde Simple", + "download_backup": "Télécharger data.json", + "restore_backup": "Restaurer depuis un fichier", + "restore_confirm": "Êtes-vous sûr ? Les données actuelles seront écrasées et l'application redémarrera.", + "restore_success": "Restauration réussie ! Redémarrage...", + "invalid_backup_file": "Fichier de sauvegarde ou structure invalide", + "config_unavailable": "Configuration indisponible", + "config_unavailable_desc": "Ce client a été créé via l'application native Amnezia.\\nLa clé privée est stockée uniquement sur l'appareil de l'utilisateur et ne peut pas être récupérée par le serveur.", + "client_public_key": "Clé publique du client :", + "management": "Gestion", + "server_management": "Gestion du serveur", + "server_management_desc": "Effectuer des actions au niveau du système sur votre machine.", + "check_server_services": "Vérifier les services du serveur", + "reboot_server": "Redémarrer le serveur", + "clear_server": "Réinitialiser le serveur", + "remove_server": "Retirer le serveur du panneau", + "telemt_desc": "Proxy Telegram basé sur MTProxy avec obfuscation avancée.", + "dns_desc": "Serveur DNS personnel pour bloquer les publicités et protéger la vie privée.", + "dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.", + "telemt_port_hint": "Port for the Telegram proxy (typically 443).", + "socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.", + "socks5_username": "Username", + "socks5_password": "Password", + "socks5_password_placeholder": "Leave empty to auto-generate", + "socks5_password_hint": "If left empty, a random 16-character password will be generated.", + "socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.", + "socks5_change_settings": "Change settings", + "socks5_settings_title": "SOCKS5 — Connection settings", + "socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.", + "socks5_settings_saved": "SOCKS5 settings saved", + "adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.", + "adguard_mode": "Installation mode", + "adguard_mode_sidebyside": "Side-by-side", + "adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)", + "adguard_mode_replace": "Replace AmneziaDNS", + "adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately", + "adguard_dns_port": "DNS port (53)", + "adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.", + "adguard_web_port": "Web UI port", + "adguard_dot_port": "DoT port", + "adguard_doh_port": "DoH port", + "adguard_expose_web": "Expose to internet", + "adguard_expose": "Expose to host", + "adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.", + "adguard_internal_ip": "Internal IP", + "adguard_web_ui": "Admin URL", + "adguard_open_ui": "Open Web UI", + "edit_server_title": "Edit server", + "edit_keep_credential": "Leave empty to keep the current value", + "server_saved": "Server settings saved", + "servers_reordered": "Servers reordered", + "ping_checking": "Checking connectivity...", + "offline": "offline", + "api_tokens_title": "API tokens", + "api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer ` header. Tokens have admin-equivalent rights and stop working if their owner is disabled or demoted.", + "api_tokens_empty": "No API tokens yet. Create one to integrate the panel with an external service.", + "api_tokens_create": "Create token", + "api_tokens_create_title": "Create API token", + "api_tokens_name_label": "Name", + "api_tokens_name_placeholder": "e.g. Linear integration, CI bot, monitoring", + "api_tokens_name_hint": "Used to identify this token in the list. The token value itself is generated for you.", + "api_tokens_name_required": "Token name is required", + "api_tokens_created_title": "Token created", + "api_tokens_value_label": "Token value", + "api_tokens_warning_title": "This is the only time you'll see this token.", + "api_tokens_warning_body": "Copy it now and store it in your integration's secret manager. The panel keeps only a hash — if you lose this value you'll need to revoke and recreate the token.", + "api_tokens_revoke": "Revoke", + "api_tokens_revoke_confirm": "Revoke token \"{}\"? Any integration using it will stop working.", + "api_tokens_revoked": "Token revoked", + "api_tokens_created": "Created", + "api_tokens_last_used": "Last used", + "api_tokens_owner": "Owner", + "api_tokens_never": "never", + "coming_soon": "Coming soon", + "promo_star_cta": "Star us on GitHub", + "aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.", + "revproxy_title": "Reverse Proxy", + "revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.", + "revproxy_desc": "Caddy reverse proxy with a decoy website on ports 80/443. Masks VPN traffic behind a normal HTTPS site to reduce DPI detection.", + "revproxy_domain": "Domain name", + "revproxy_domain_hint": "Optional. Point DNS A-record to this server for Let's Encrypt TLS. Leave empty for a self-signed certificate.", + "revproxy_site_title": "Site title", + "revproxy_tls_email": "ACME email", + "revproxy_tls_email_hint": "Required for automatic Let's Encrypt certificates when a domain is set.", + "revproxy_backend": "VPN backend", + "revproxy_backend_none": "Decoy site only", + "revproxy_backend_port": "Backend port", + "revproxy_vpn_path": "Secret tunnel path", + "revproxy_vpn_path_hint": "Hidden path proxied to the VPN backend (e.g. /cdn-cgi/challenge).", + "revproxy_telemt_mask": "Enable Telemt site masking via Caddy", + "revproxy_install_hint": "Port 443 is used by the reverse proxy. Reinstall Xray/Telemt on an internal port (e.g. 8443) before enabling backend routing.", + "revproxy_port_hint": "Reverse proxy binds host ports 80 and 443 (HTTPS + HTTP/3).", + "revproxy_site": "Decoy site", + "revproxy_open_site": "Open site", + "revproxy_change_settings": "Settings", + "revproxy_settings_title": "Reverse Proxy Settings", + "ssl_provider_label": "Certificate source", + "ssl_provider_manual": "Manual (paths / paste)", + "ssl_provider_cloudflare": "Cloudflare (auto Let's Encrypt)", + "cf_token_label": "Cloudflare API Token", + "cf_token_hint": "Create a token with Zone:DNS:Edit and Zone:Zone:Read permissions. Used for DNS-01 validation only — port 80/443 stays free.", + "acme_email_label": "Email (Let's Encrypt notifications)", + "acme_staging_label": "Use staging (test certificates)", + "cf_issue_btn": "Issue / Renew certificate", + "cf_issue_hint": "Issues a real Let's Encrypt certificate via Cloudflare DNS without binding port 443. Restart the panel afterwards to apply.", + "cf_issuing": "Issuing...", + "cf_issued_ok": "Certificate issued successfully", + "cf_restart_required": "Restart the panel to apply the new certificate", + "cf_need_domain": "Enter a domain first", + "cf_cert_active": "Active", + "cf_cert_none": "No certificate yet", + "cf_days_left": "days left" +} diff --git a/translations/ru.json b/translations/ru.json new file mode 100644 index 0000000..adf71c1 --- /dev/null +++ b/translations/ru.json @@ -0,0 +1,401 @@ +{ + "nav_servers": "Серверы", + "nav_users": "Пользователи", + "nav_settings": "Настройки", + "nav_connections": "Подключения", + "nav_logout": "Выход", + "theme_dark": "Темная", + "theme_light": "Светлая", + "back_to_servers": "← Назад к серверам", + "server_check": "Проверить", + "server_checking": "Проверка...", + "protocols": "Протоколы", + "install": "Установить", + "uninstall": "Удалить", + "run": "Работает", + "stop": "Остановлен", + "not_installed": "Не установлен", + "installed": "Установлен", + "add_server": "Добавить сервер", + "no_servers": "Серверы не добавлены", + "name": "Название", + "host": "Адрес", + "port": "Порт", + "status": "Статус", + "actions": "Действия", + "edit": "Редактировать", + "delete": "Удалить", + "save": "Сохранить", + "cancel": "Отмена", + "loading": "Загрузка...", + "copy": "Копировать", + "download": "Скачать", + "config": "Конфигурация", + "qr_code": "QR-код", + "vpn_key": "VPN-ключ", + "clients": "Клиенты", + "traffic": "Трафик", + "limit": "Limit", + "manage": "Управление", + "no_protocols": "Нет протоколов", + "add_server_desc": "Добавьте свой первый VPN сервер для начала работы. Вам понадобятся SSH-данные для подключения.", + "ssh_host": "Хост / IP", + "ssh_port": "SSH Порт", + "ssh_user": "Пользователь", + "ssh_password": "Пароль", + "ssh_key": "SSH Ключ", + "ssh_key_placeholder": "Приватный SSH ключ", + "ssh_key_hint": "Вставьте содержимое вашего файла id_rsa или id_ed25519", + "connect": "Подключить", + "connecting": "Подключение...", + "server_added": "Сервер успешно добавлен", + "server_deleted": "Сервер удалён", + "delete_confirm": "Вы уверены, что хотите удалить этот сервер?", + "success": "Успешно", + "error": "Ошибка", + "info": "Информация", + "login_title": "Вход в панель", + "username": "Логин", + "password": "Пароль", + "captcha": "Капча", + "captcha_placeholder": "Введите символы с картинки", + "captcha_hint": "Нажмите для обновления картинки", + "login": "Войти", + "logging_in": "Вход...", + "login_error": "Ошибка входа", + "check_done": "Проверка завершена", + "check_error": "Ошибка подключения", + "ssh_ok": "SSH подключение", + "docker_ok": "Docker", + "docker_not_installed": "Docker не установлен", + "awg_desc": "Новая версия протокола на основе awg-go. Поддерживает расширенную обфускацию с параметрами S3, S4.", + "awg_legacy_desc": "Оригинальная версия AWG на базе ядра WireGuard. Совместима с клиентами старых версий.", + "xray_desc": "Современный протокол с маскировкой под обычный веб-трафик (XTLS-Reality). Устойчив к глубокому анализу пакетов.", + "wireguard_desc": "Стандартный и самый быстрый VPN-протокол. Встроен во все современные ОС, но легко блокируется DPI.", + "not_checked": "Не проверено", + "connections": "Подключения", + "add": "Добавить", + "loading_connections": "Загрузка подключений...", + "no_connections": "Нет подключений", + "no_connections_desc": "Добавьте первое подключение для генерации VPN конфигурации", + "install_protocol": "Установить протокол", + "port_default_hint": "Порт по умолчанию: 55424. Убедитесь, что он не занят", + "port_xray_hint": "Порт по умолчанию: 443 (рекомендуется для Xray). Убедитесь, что он не занят другим веб-сервером.", + "reinstall": "Переустановить", + "uninstall_confirm": "Удалить {}? Все подключения и конфигурации будут потеряны.", + "stop_container_confirm": "Остановить контейнер {}?", + "start_container_confirm": "Запустить контейнер {}?", + "stopping": "Остановка", + "starting": "Запуск", + "stopped": "остановлен", + "started": "запущен", + "server_config": "Конфигурация сервера", + "add_connection": "Добавить подключение", + "connection_name": "Имя подключения", + "connection_name_placeholder": "Например: iPhone, Laptop", + "assign_to_user": "Назначить пользователю (опционально)", + "no_assign": "— Без привязки —", + "create": "Создать", + "creating": "Создание...", + "connection_created": "Подключение \"{}\" создано", + "delete_connection_confirm": "Удалить это подключение? Конфигурация перестанет работать.", + "connection_deleted": "Подключение удалено", + "config_tab": "📄 Конфиг", + "vpn_key_tab": "🔑 VPN-ключ", + "qr_code_tab": "📱 QR-код", + "copy_config": "📋 Копировать", + "download_conf": "⬇ Скачать .conf", + "copy_key": "📋 Копировать ключ", + "vpn_link_hint": "Вставьте эту ссылку в приложение AmneziaVPN для автоматической настройки", + "qr_hint": "Отсканируйте QR-код в приложении AmneziaVPN", + "no_data": "Нет данных", + "start_btn": "▶ Старт", + "stop_btn": "⏹ Стоп", + "config_btn": "⚙️ Конфиг", + "done": "Готово", + "installing": "Установка...", + "start_install": "Начинаем установку...", + "check_docker": "Проверка Docker...", + "prepare_host": "Подготовка хоста...", + "build_container": "Сборка контейнера...", + "install_success": "Протокол установлен успешно!", + "install_error": "Ошибка установки: ", + "qr_error": "Не удалось сгенерировать QR-код", + "users_title": "Пользователи", + "search_placeholder": "Поиск по имени, email или Telegram...", + "add_user": "Добавить пользователя", + "prev_page": "← Назад", + "next_page": "Вперёд →", + "page_info": "Страница {} из {}", + "loading_users": "Загрузка пользователей...", + "nothing_found": "Ничего не найдено", + "search_empty_desc": "Попробуйте изменить запрос или создайте нового пользователя", + "username_label": "Имя пользователя", + "password_label": "Пароль", + "role_label": "Роль", + "role_user_desc": "User — только просмотр своих подключений", + "role_support_desc": "Support — управление серверами", + "role_admin_desc": "Admin — полный доступ", + "tg_id_label": "Telegram ID (опц.)", + "email_label": "Email (опц.)", + "traffic_limit_label": "Лимит трафика (ГБ) (опц., 0 = ∞)", + "expiration_date_label": "Срок действия (до)", + "description_label": "Описание (опц.)", + "auto_conn_title": "Автоматическое создание подключения (опционально)", + "server_label": "Сервер", + "no_create_conn": "— Не создавать подключение —", + "protocol_label": "Протокол", + "edit_user_title": "Редактировать пользователя: {}", + "edit_user_tg": "Telegram ID (опционально)", + "edit_user_email": "Email (опционально)", + "edit_user_limit": "Лимит трафика (ГБ) (0 = ∞)", + "new_password_hint": "Новый пароль (оставьте пустым, если не нужно менять)", + "op_type_label": "Тип операции", + "create_new_conn": "Создать новое подключение", + "link_existing_conn": "Привязать существующее", + "select_existing_conn": "Выберите существующее подключение", + "existing_conn_hint": "Будут показаны только не привязанные к пользователям подключения", + "conn_name_panel": "Имя подключения (в панели)", + "user_conns_title": "Подключения пользователя", + "no_free_conns": "Нет свободных подключений", + "linking": "Привязка...", + "conn_linked": "Подключение привязано", + "enabling": "Включение", + "disabling": "Отключение", + "user_enabled": "Пользователь включён", + "user_disabled": "Пользователь отключён", + "delete_user_confirm": "Удалить пользователя? Все его подключения будут удалены.", + "share_access": "Поделиться доступом", + "enable_public_access": "Включить публичный доступ", + "share_password_label": "Пароль (опционально)", + "share_password_hint": "Если установлен, пользователь должен будет его ввести", + "public_link_label": "Публичная ссылка", + "disabled": "Отключён", + "out_of": "Из", + "settings_title": "Настройки панели", + "appearance": "🎨 Внешний вид", + "title_label": "Заголовок (Title)", + "logo_label": "Логотип (Emoji или символ)", + "subtitle_label": "Подзаголовок (Subtitle)", + "captcha_title": "🔐 Капча", + "enable_captcha": "Включить капчу (multicolorcaptcha)", + "telegram_bot_title": "🤖 Telegram Bot", + "bot_token_label": "Bot Token (от @BotFather)", + "bot_status": "Статус", + "bot_running": "✅ Запущен", + "bot_stopped": "⏹ Остановлен", + "bot_stop_btn": "⏹ Остановить", + "bot_start_btn": "▶️ Запустить", + "bot_hint": "После изменения токена сохраните настройки — бот перезапустится автоматически. Пользователи должны иметь Telegram ID в профиле.", + "api_docs_title": "🔌 API Документация", + "api_docs_hint": "Используйте эти интерфейсы для изучения возможностей API панели", + "tunnels_title": "Tunnels", + "local_server": "Local Server", + "tunnel_install_enable": "Install & Enable", + "tunnel_enable": "Enable Tunnel", + "tunnel_running": "Туннель запущен", + "tunnel_stop": "Остановить", + "tunnel_stopping": "Остановка...", + "tunnel_stopped": "Туннель остановлен", + "tunnel_delete": "Удалить", + "tunnel_deleted": "Туннель удалён", + "tunnel_delete_confirm": "Остановить и удалить бинарный файл туннеля, установленный панелью?", + "tunnel_starting": "Запуск туннеля...", + "tunnel_started": "Туннель запущен", + "tunnel_waiting_url": "Туннель запущен. Ждем публичный адрес...", + "tunnel_no_public_url": "Публичный адрес появится здесь после запуска туннеля.", + "tunnels_hint": "Быстрые туннели публикуют локальную панель в интернете. Используйте надежный пароль администратора и передавайте публичные адреса только доверенным пользователям.", + "ngrok_authtoken_placeholder": "ngrok authtoken (для некоторых аккаунтов необязательно)", + "import_users_title": "📤 Импорт пользователей", + "import_source_label": "Источник импорта / Синхронизации", + "remnawave_url_label": "Remnawave URL", + "api_key_label": "API Key", + "enable_sync": "Включить синхронизацию", + "sync_hint": "Пользователи будут создаваться, отключаться и удаляться автоматически на основе данных из Remnawave", + "sync_now_btn": "🔄 Синхронизировать сейчас", + "delete_sync_btn": "🗑 Удалить синхронизацию", + "auto_create_conns": "Создавать подключения автоматически", + "sync_server_label": "Сервер для новых подключений", + "save_changes": "💾 Сохранить изменения", + "sync_success": "Синхронизация завершена: {} пользователей", + "sync_deleted": "Удалено {} синхронизированных пользователей", + "delete_sync_confirm": "Вы уверены, что хотите удалить ВСЕХ синхронизированных пользователей и их подключения? Это действие необратимо.", + "settings_saved": "Настройки успешно сохранены", + "bot_started": "Бот запущен", + "bot_stopped_msg": "Бот остановлен", + "sync_running": "Синхронизация...", + "deleting": "Удаление...", + "my_connections_title": "Мои подключения", + "show_config": "📄 Показать конфиг", + "no_connections_user_desc": "Обратитесь к администратору для создания VPN подключения", + "share_title": "Доступ к подключениям", + "vpn_access": "VPN Доступ", + "user_label_share": "Пользователь", + "share_protected_desc": "Этот доступ защищен паролем. Пожалуйста, введите его для продолжения.", + "auth_success": "Вход выполнен", + "loading_share_conns": "Загрузка подключений...", + "no_active_conns": "У вас пока нет активных подключений.", + "show_settings_btn": "🔑 Показать настройки", + "copied": "Скопировано!", + "active": "Активен", + "site_description": "Amnezia Web Panel — управление VPN серверами и протоколами AmneziaWG", + "toggle_theme": "Сменить тему", + "copied_to_clipboard": "Скопировано в буфер обмена", + "share_not_found": "404 Не Найдено", + "share_not_found_desc": "Либо ссылка неверна, либо доступ был отключен.", + "invalid_captcha": "Неверная капча", + "account_disabled": "Аккаунт отключён", + "invalid_login": "Неверный логин или пароль", + "user_exists": "Пользователь с таким именем уже существует", + "cannot_delete_self": "Нельзя удалить самого себя", + "wrong_share_password": "Неверный пароль", + "saving": "Сохранение...", + "select_lang": "Выберите язык", + "lang_ru": "Русский", + "lang_en": "English", + "lang_fr": "Français", + "lang_zh": "中文 (Chinese)", + "lang_fa": "فارسی (Persian)", + "backup_title": "Резервное копирование", + "download_backup": "Скачать data.json", + "restore_backup": "Восстановить из файла", + "restore_confirm": "Вы уверены? Текущие данные будут перезаписаны, и приложение перезагрузится.", + "restore_success": "Восстановление успешно! Перезагрузка...", + "invalid_backup_file": "Неверный файл резервной копии или структура", + "config_unavailable": "Конфигурация недоступна", + "config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.", + "client_public_key": "Публичный ключ клиента:", + "telemt_desc": "Прокси для Telegram на базе MTProxy с продвинутой обфускацией и эмуляцией TLS.", + "tls_emulation": "TLS-эмуляция", + "tls_domain": "Домен маскировки", + "max_connections_limit": "Макс. соединений", + "max_connections_hint": "0 — без ограничений. Ограничивает одновременных пользователей.", + "traffic_reset_strategy_label": "Стратегия сброса трафика", + "traffic_reset_never": "Никогда не сбрасывать", + "traffic_reset_daily": "Сброс ежедневно", + "traffic_reset_weekly": "Сброс еженедельно", + "traffic_reset_monthly": "Сброс ежемесячно", + "traffic_reset_hint": "Как часто следует сбрасывать трафик пользователя", + "traffic_used_total": "Всего потрачено", + "traffic_reset_info": "Следующий сброс: {}", + "services": "Сервисы", + "dns_desc": "Персональный DNS-сервер для блокировки рекламы и защиты приватности ваших запросов.", + "dns_internal_hint": "Сервис будет запущен во внутренней сети (172.29.172.254) без привязки к портам хоста.", + "telemt_port_hint": "Порт для Telegram-прокси (обычно 443).", + "socks5_desc": "SOCKS5-прокси (3proxy) с одной учётной записью и аутентификацией по логину/паролю.", + "socks5_username": "Логин", + "socks5_password": "Пароль", + "socks5_password_placeholder": "Оставьте пустым для автогенерации", + "socks5_password_hint": "Если поле пустое, будет сгенерирован случайный пароль на 16 символов.", + "socks5_port_hint": "TCP-порт, на котором будет слушать прокси. В официальном клиенте Amnezia по умолчанию 38080.", + "socks5_change_settings": "Изменить настройки", + "socks5_settings_title": "SOCKS5 — Настройки подключения", + "socks5_port_change_hint": "Смена порта пересоздаёт контейнер — текущие соединения оборвутся.", + "socks5_settings_saved": "Настройки SOCKS5 сохранены", + "adguard_desc": "AdGuard Home — DNS-блокировщик рекламы с веб-интерфейсом администратора.", + "adguard_mode": "Режим установки", + "adguard_mode_sidebyside": "Рядом с AmneziaDNS", + "adguard_mode_sidebyside_hint": "поднимается параллельно AmneziaDNS на отдельном внутреннем IP (172.29.172.253)", + "adguard_mode_replace": "Заменить AmneziaDNS", + "adguard_mode_replace_hint": "удаляет контейнер amnezia-dns и забирает его IP — VPN-клиенты сразу пойдут в AdGuard", + "adguard_dns_port": "DNS-порт (53)", + "adguard_dns_port_hint": "Внутренний DNS-порт, на который ходят VPN-клиенты через docker-сеть.", + "adguard_web_port": "Порт Web UI", + "adguard_dot_port": "Порт DoT", + "adguard_doh_port": "Порт DoH", + "adguard_expose_web": "Опубликовать наружу", + "adguard_expose": "Опубликовать наружу", + "adguard_install_hint": "Первоначальная настройка AdGuard проходит через мастер на веб-интерфейсе после установки.", + "adguard_internal_ip": "Внутренний IP", + "adguard_web_ui": "Адрес панели", + "adguard_open_ui": "Открыть Web UI", + "edit_server_title": "Изменить сервер", + "edit_keep_credential": "Оставьте пустым, чтобы сохранить текущее значение", + "server_saved": "Настройки сервера сохранены", + "servers_reordered": "Порядок серверов изменён", + "ping_checking": "Проверяем соединение...", + "offline": "недоступен", + "api_tokens_title": "API-токены", + "api_tokens_hint": "Bearer-токены для внешних интеграций. Передавайте токен в заголовке `Authorization: Bearer `. Токены имеют права администратора и перестают работать, если их владелец отключён или понижен в роли.", + "api_tokens_empty": "Пока нет ни одного API-токена. Создайте, чтобы подключить панель к внешнему сервису.", + "api_tokens_create": "Создать токен", + "api_tokens_create_title": "Создание API-токена", + "api_tokens_name_label": "Название", + "api_tokens_name_placeholder": "напр. Linear, CI-бот, мониторинг", + "api_tokens_name_hint": "Нужно, чтобы отличать токен в списке. Сам токен сгенерируется автоматически.", + "api_tokens_name_required": "Укажите название токена", + "api_tokens_created_title": "Токен создан", + "api_tokens_value_label": "Значение токена", + "api_tokens_warning_title": "Это единственный раз, когда вы увидите этот токен.", + "api_tokens_warning_body": "Скопируйте его сейчас и сохраните в менеджере секретов вашей интеграции. Панель хранит только хэш — если потеряете значение, токен придётся отозвать и создать заново.", + "api_tokens_revoke": "Отозвать", + "api_tokens_revoke_confirm": "Отозвать токен «{}»? Любая интеграция, использующая его, перестанет работать.", + "api_tokens_revoked": "Токен отозван", + "api_tokens_created": "Создан", + "api_tokens_last_used": "Последнее использование", + "api_tokens_owner": "Владелец", + "api_tokens_never": "никогда", + "coming_soon": "Скоро", + "promo_star_cta": "Поставить звезду на GitHub", + "aivpn_subtitle": "ИИ-подбор протокола, который сам выбирает правильный туннель под текущие условия. Поможете звездой — выйдет быстрее.", + "revproxy_title": "Reverse Proxy", + "revproxy_subtitle": "Высокопроизводительная маскировка трафика: прячем VPN за обычным сайтом, снижаем заметность для DPI.", + "revproxy_desc": "Reverse proxy на Caddy с декой-сайтом на портах 80/443. Маскирует VPN-трафик под обычный HTTPS-сайт для снижения детекции DPI.", + "revproxy_domain": "Домен", + "revproxy_domain_hint": "Необязательно. Направьте A-запись DNS на этот сервер для Let's Encrypt. Пусто — самоподписанный сертификат.", + "revproxy_site_title": "Название сайта", + "revproxy_tls_email": "Email для ACME", + "revproxy_tls_email_hint": "Нужен для автоматического получения сертификата Let's Encrypt при указании домена.", + "revproxy_backend": "VPN-бэкенд", + "revproxy_backend_none": "Только декой-сайт", + "revproxy_backend_port": "Порт бэкенда", + "revproxy_vpn_path": "Секретный путь туннеля", + "revproxy_vpn_path_hint": "Скрытый путь, проксируемый на VPN-бэкенд (напр. /cdn-cgi/challenge).", + "revproxy_telemt_mask": "Включить маскировку Telemt через Caddy", + "revproxy_install_hint": "Порт 443 занимает reverse proxy. Переустановите Xray/Telemt на внутренний порт (напр. 8443) перед маршрутизацией бэкенда.", + "revproxy_port_hint": "Reverse proxy слушает порты 80 и 443 (HTTPS + HTTP/3).", + "revproxy_site": "Декой-сайт", + "revproxy_open_site": "Открыть сайт", + "revproxy_change_settings": "Настройки", + "revproxy_settings_title": "Настройки Reverse Proxy", + "management": "Управление", + "server_management": "Управление сервером", + "server_management_desc": "Выполнение системных действий на вашей машине.", + "check_server_services": "Проверить сервисы сервера", + "reboot_server": "Перезагрузить сервер", + "clear_server": "Очистить сервер", + "remove_server": "Удалить сервер из панели", + "ssl_title": "SSL / HTTPS Настройки", + "enable_https": "Включить HTTPS", + "panel_port_label": "Порт панели", + "domain_label": "Доменное имя", + "cert_path_label": "Путь к SSL сертификату (.pem)", + "key_path_label": "Путь к приватному ключу (.pem)", + "or_paste_text": "ИЛИ вставьте текст сертификатов", + "cert_text_label": "Содержимое сертификата (CRT)", + "key_text_label": "Содержимое ключа (KEY)", + "ssl_hint": "После включения панель будет доступна по https:// на указанном домене. Убедитесь, что пути к файлам или текстовые данные верны.", + "ssl_provider_label": "Источник сертификата", + "ssl_provider_manual": "Вручную (пути / вставка)", + "ssl_provider_cloudflare": "Cloudflare (авто Let's Encrypt)", + "cf_token_label": "API-токен Cloudflare", + "cf_token_hint": "Создайте токен с правами Zone:DNS:Edit и Zone:Zone:Read для зоны вашего домена. Используется только для DNS-01 проверки — порты 80/443 остаются свободными.", + "acme_email_label": "Email (уведомления Let's Encrypt)", + "acme_staging_label": "Тестовый режим (staging-сертификаты)", + "cf_issue_btn": "Выпустить / Обновить сертификат", + "cf_issue_hint": "Выпускает настоящий сертификат Let's Encrypt через DNS Cloudflare без занятия порта 443. После выпуска перезапустите панель.", + "cf_issuing": "Выпускаем...", + "cf_issued_ok": "Сертификат успешно выпущен", + "cf_restart_required": "Перезапустите панель, чтобы применить новый сертификат", + "cf_need_domain": "Сначала укажите домен", + "cf_cert_active": "Активен", + "cf_cert_none": "Сертификат ещё не выпущен", + "cf_days_left": "дн. осталось", + "about_title": "О программе и обновления", + "current_version": "Текущая версия", + "check_updates": "Проверить обновления", + "checking_updates": "Проверка...", + "update_available": "Доступна новая версия", + "download_update": "Скачать обновление", + "up_to_date": "У вас установлена последняя версия" +} diff --git a/translations/zh.json b/translations/zh.json new file mode 100644 index 0000000..6b0406f --- /dev/null +++ b/translations/zh.json @@ -0,0 +1,369 @@ +{ + "nav_servers": "服务器", + "nav_users": "用户列表", + "nav_settings": "设置", + "nav_connections": "连接管理", + "nav_logout": "登出", + "theme_dark": "深色模式", + "theme_light": "浅色模式", + "back_to_servers": "← 返回服务器列表", + "server_check": "检查状态", + "server_checking": "正在检查...", + "protocols": "协议", + "install": "安装", + "uninstall": "卸载", + "run": "运行中", + "stop": "已停止", + "not_installed": "未安装", + "installed": "已安装", + "add_server": "添加服务器", + "no_servers": "尚未添加服务器", + "name": "名称", + "host": "地址", + "port": "端口", + "status": "状态", + "actions": "操作", + "edit": "编辑", + "delete": "删除", + "save": "保存", + "cancel": "取消", + "loading": "加载中...", + "copy": "复制", + "download": "下载", + "config": "配置", + "qr_code": "二维码", + "vpn_key": "VPN 密钥", + "clients": "客户端", + "traffic": "流量", + "limit": "限制", + "manage": "管理", + "no_protocols": "无协议", + "add_server_desc": "添加您的第一台 VPN 服务器。需要 SSH 凭据进行连接。", + "ssh_host": "主机 / IP", + "ssh_port": "SSH 端口", + "ssh_user": "用户名", + "ssh_password": "密码", + "ssh_key": "SSH 密钥", + "ssh_key_placeholder": "私钥 (Private Key)", + "ssh_key_hint": "粘贴 id_rsa 或 id_ed25519 文件的内容", + "connect": "连接", + "connecting": "正在连接...", + "server_added": "服务器添加成功", + "server_deleted": "服务器已删除", + "delete_confirm": "确定要删除此服务器吗?", + "success": "成功", + "error": "错误", + "info": "信息", + "login_title": "登录管理面板", + "username": "用户名", + "password": "密码", + "captcha": "验证码", + "captcha_placeholder": "输入图中字符", + "captcha_hint": "点击刷新验证码", + "login": "登录", + "logging_in": "登录中...", + "login_error": "登录失败", + "check_done": "检查完成", + "check_error": "连接错误", + "ssh_ok": "SSH 连接正常", + "docker_ok": "Docker 正常", + "docker_not_installed": "Docker 未安装", + "awg_desc": "基于 awg-go 的新版协议。支持 S3, S4 高级混淆。", + "awg_legacy_desc": "原始 AWG 版本。兼容旧版客户端。", + "xray_desc": "将流量伪装成普通网页流量 (XTLS-Reality),抗封锁能力强。", + "not_checked": "未检查", + "connections": "连接", + "add": "添加", + "loading_connections": "正在加载连接...", + "no_connections": "无连接", + "no_connections_desc": "添加首个连接以生成 VPN 配置文件", + "install_protocol": "安装协议", + "port_default_hint": "默认端口: 55424。请确保端口未被占用。", + "port_xray_hint": "推荐端口: 443。请确保未被其他 Web 服务器使用。", + "reinstall": "重新安装", + "uninstall_confirm": "确定卸载 {}?所有连接和配置都将丢失。", + "stop_container_confirm": "停止容器 {}?", + "start_container_confirm": "启动容器 {}?", + "stopping": "停止中", + "starting": "启动中", + "stopped": "已停止", + "started": "已启动", + "server_config": "服务端配置", + "add_connection": "添加连接", + "connection_name": "连接名称", + "connection_name_placeholder": "例如: iPhone, 笔记本电脑", + "assign_to_user": "分配给用户 (可选)", + "no_assign": "— 不分配 —", + "create": "创建", + "creating": "创建中...", + "connection_created": "连接 \"{}\" 已创建", + "delete_connection_confirm": "确定删除此连接?配置将失效。", + "connection_deleted": "连接已删除", + "config_tab": "📄 配置文件", + "vpn_key_tab": "🔑 VPN 密钥", + "qr_code_tab": "📱 二维码", + "copy_config": "📋 复制配置", + "download_conf": "⬇ 下载 .conf 文件", + "copy_key": "📋 复制密钥", + "vpn_link_hint": "将此链接粘贴到 AmneziaVPN 应用中即可自动配置", + "qr_hint": "使用 AmneziaVPN 应用扫描二维码", + "no_data": "无数据", + "start_btn": "▶ 启动", + "stop_btn": "⏹ 停止", + "config_btn": "⚙️ 配置", + "done": "完成", + "installing": "安装中...", + "start_install": "开始安装...", + "check_docker": "检查 Docker...", + "prepare_host": "准备环境...", + "build_container": "构建容器...", + "install_success": "协议安装成功!", + "install_error": "安装失败: ", + "qr_error": "无法生成二维码", + "users_title": "用户管理", + "search_placeholder": "搜索用户名、邮箱或 Telegram ID...", + "add_user": "添加用户", + "prev_page": "← 上一页", + "next_page": "下一页 →", + "page_info": "第 {} 页,共 {} 页", + "loading_users": "正在加载用户...", + "nothing_found": "未找到匹配项", + "search_empty_desc": "请尝试更改查询条件或创建新用户", + "username_label": "用户名", + "password_label": "密码", + "role_label": "角色", + "role_user_desc": "User — 仅查看自己的连接", + "role_support_desc": "Support — 服务器管理权限", + "role_admin_desc": "Admin — 全部权限", + "tg_id_label": "Telegram ID (可选)", + "email_label": "邮箱 (可选)", + "traffic_limit_label": "流量限制 (GB) (0 = 不限制)", + "description_label": "备注 (可选)", + "auto_conn_title": "自动创建连接 (可选)", + "server_label": "服务器", + "no_create_conn": "— 不自动创建 —", + "protocol_label": "协议", + "edit_user_title": "编辑用户: {}", + "edit_user_tg": "Telegram ID (可选)", + "edit_user_email": "邮箱 (可选)", + "edit_user_limit": "流量限制 (GB) (0 = 不限制)", + "new_password_hint": "新密码 (如不修改请留空)", + "op_type_label": "操作类型", + "create_new_conn": "创建新连接", + "link_existing_conn": "关联现有连接", + "select_existing_conn": "选择现有连接", + "existing_conn_hint": "仅显示尚未关联用户的连接", + "conn_name_panel": "连接名称 (面板显示)", + "user_conns_title": "用户连接列表", + "no_free_conns": "无可用空闲连接", + "linking": "关联中...", + "conn_linked": "连接关联成功", + "enabling": "启用中", + "disabling": "禁用中", + "user_enabled": "用户已启用", + "user_disabled": "用户已禁用", + "delete_user_confirm": "确定删除该用户?其所有连接都将被删除。", + "share_access": "分享访问权限", + "enable_public_access": "启用公开访问", + "share_password_label": "密码 (可选)", + "share_password_hint": "如设置,访问者需输入密码", + "public_link_label": "公开链接", + "disabled": "已禁用", + "out_of": "/", + "settings_title": "面板设置", + "appearance": "🎨 外观设置", + "title_label": "页面标题 (Title)", + "logo_label": "图标 (Emoji 或 字符)", + "subtitle_label": "副标题 (Subtitle)", + "captcha_title": "🔐 验证码", + "enable_captcha": "启用登录验证码", + "telegram_bot_title": "🤖 Telegram 机器人", + "bot_token_label": "Bot Token (来自 @BotFather)", + "bot_status": "状态", + "bot_running": "✅ 运行中", + "bot_stopped": "⏹ 已停止", + "bot_stop_btn": "⏹ 停止", + "bot_start_btn": "▶️ 启动", + "bot_hint": "修改 Token 后请保存,机器人将自动重启。用户需在个人资料中填写 Telegram ID。", + "api_docs_title": "🔌 API 文档", + "api_docs_hint": "使用这些接口了解面板功能", + "tunnels_title": "Tunnels", + "local_server": "本地服务器", + "tunnel_install_enable": "安装并启用", + "tunnel_enable": "启用隧道", + "tunnel_running": "隧道运行中", + "tunnel_stop": "停止", + "tunnel_stopping": "正在停止...", + "tunnel_stopped": "隧道已停止", + "tunnel_delete": "删除", + "tunnel_deleted": "隧道已删除", + "tunnel_delete_confirm": "停止并删除由面板管理的隧道二进制文件?", + "tunnel_starting": "正在启动隧道...", + "tunnel_started": "隧道已启动", + "tunnel_waiting_url": "隧道已启动,正在等待公网地址...", + "tunnel_no_public_url": "隧道启动后公网地址会显示在这里。", + "tunnels_hint": "快速隧道会将本地面板暴露到互联网。请使用强管理员密码,并仅与可信用户共享公网地址。", + "ngrok_authtoken_placeholder": "ngrok authtoken(某些账号可选)", + "import_users_title": "📤 导入用户", + "import_source_label": "导入 / 同步源", + "remnawave_url_label": "Remnawave 地址", + "api_key_label": "API 密钥", + "enable_sync": "开启同步", + "sync_hint": "将根据 Remnawave 数据自动创建、禁用和删除用户", + "sync_now_btn": "🔄 立即同步", + "delete_sync_btn": "🗑 删除同步数据", + "auto_create_conns": "自动创建连接", + "sync_server_label": "新连接的服务器", + "save_changes": "💾 保存修改", + "sync_success": "同步完成: {} 个用户已更新", + "sync_deleted": "已删除 {} 个同步用户", + "delete_sync_confirm": "确定要删除所有同步用户及其连接吗?此操作不可逆。", + "settings_saved": "设置已保存", + "bot_started": "机器人已启动", + "bot_stopped_msg": "机器人已停止", + "sync_running": "同步中...", + "deleting": "删除中...", + "my_connections_title": "我的连接", + "show_config": "📄 查看配置", + "no_connections_user_desc": "请联系管理员创建 VPN 连接", + "share_title": "访问连接", + "vpn_access": "VPN 访问", + "user_label_share": "用户", + "share_protected_desc": "此访问受密码保护。请输入密码以继续。", + "auth_success": "身份验证通过", + "loading_share_conns": "正在加载连接...", + "no_active_conns": "您目前没有活跃连接。", + "show_settings_btn": "🔑 显示设置", + "copied": "已复制!", + "active": "活跃", + "site_description": "Amnezia Web Panel — VPN 服务器及 AmneziaWG 协议管理工具", + "toggle_theme": "切换主题", + "copied_to_clipboard": "已复制到剪贴板", + "share_not_found": "404 页面未找到", + "share_not_found_desc": "链接无效或访问已被禁用。", + "invalid_captcha": "验证码错误", + "account_disabled": "账户已被禁用", + "invalid_login": "用户名或密码错误", + "user_exists": "该用户名已存在", + "cannot_delete_self": "您无法删除自己的账户", + "wrong_share_password": "密码错误", + "saving": "正在保存...", + "select_lang": "选择语言", + "lang_ru": "Русский", + "lang_en": "English", + "lang_fr": "Français", + "lang_zh": "中文 (Chinese)", + "lang_fa": "فارسی (Persian)", + "backup_title": "简易备份", + "download_backup": "下载 data.json", + "restore_backup": "从文件恢复", + "restore_confirm": "您确定吗?当前数据将被覆盖,应用程序将重新启动。", + "restore_success": "恢复成功!正在重启...", + "invalid_backup_file": "备份文件无效或结构错误", + "config_unavailable": "配置文件不可用", + "config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。", + "client_public_key": "客户端公钥:", + "management": "管理", + "server_management": "服务器管理", + "server_management_desc": "在您的机器上执行系统级别的操作。", + "check_server_services": "检查服务器服务", + "reboot_server": "重启服务器", + "clear_server": "清除服务器", + "remove_server": "从面板移除服务器", + "telemt_desc": "基于 MTProxy 的 Telegram 代理,支持高级混淆。", + "dns_desc": "防止广告和保护隐私的个人 DNS 服务器。", + "dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.", + "telemt_port_hint": "Port for the Telegram proxy (typically 443).", + "socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.", + "socks5_username": "Username", + "socks5_password": "Password", + "socks5_password_placeholder": "Leave empty to auto-generate", + "socks5_password_hint": "If left empty, a random 16-character password will be generated.", + "socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.", + "socks5_change_settings": "Change settings", + "socks5_settings_title": "SOCKS5 — Connection settings", + "socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.", + "socks5_settings_saved": "SOCKS5 settings saved", + "adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.", + "adguard_mode": "Installation mode", + "adguard_mode_sidebyside": "Side-by-side", + "adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)", + "adguard_mode_replace": "Replace AmneziaDNS", + "adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately", + "adguard_dns_port": "DNS port (53)", + "adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.", + "adguard_web_port": "Web UI port", + "adguard_dot_port": "DoT port", + "adguard_doh_port": "DoH port", + "adguard_expose_web": "Expose to internet", + "adguard_expose": "Expose to host", + "adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.", + "adguard_internal_ip": "Internal IP", + "adguard_web_ui": "Admin URL", + "adguard_open_ui": "Open Web UI", + "edit_server_title": "Edit server", + "edit_keep_credential": "Leave empty to keep the current value", + "server_saved": "Server settings saved", + "servers_reordered": "Servers reordered", + "ping_checking": "Checking connectivity...", + "offline": "offline", + "api_tokens_title": "API tokens", + "api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer ` header. Tokens have admin-equivalent rights and stop working if their owner is disabled or demoted.", + "api_tokens_empty": "No API tokens yet. Create one to integrate the panel with an external service.", + "api_tokens_create": "Create token", + "api_tokens_create_title": "Create API token", + "api_tokens_name_label": "Name", + "api_tokens_name_placeholder": "e.g. Linear integration, CI bot, monitoring", + "api_tokens_name_hint": "Used to identify this token in the list. The token value itself is generated for you.", + "api_tokens_name_required": "Token name is required", + "api_tokens_created_title": "Token created", + "api_tokens_value_label": "Token value", + "api_tokens_warning_title": "This is the only time you'll see this token.", + "api_tokens_warning_body": "Copy it now and store it in your integration's secret manager. The panel keeps only a hash — if you lose this value you'll need to revoke and recreate the token.", + "api_tokens_revoke": "Revoke", + "api_tokens_revoke_confirm": "Revoke token \"{}\"? Any integration using it will stop working.", + "api_tokens_revoked": "Token revoked", + "api_tokens_created": "Created", + "api_tokens_last_used": "Last used", + "api_tokens_owner": "Owner", + "api_tokens_never": "never", + "coming_soon": "Coming soon", + "promo_star_cta": "Star us on GitHub", + "aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.", + "revproxy_title": "Reverse Proxy", + "revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.", + "revproxy_desc": "Caddy reverse proxy with a decoy website on ports 80/443. Masks VPN traffic behind a normal HTTPS site to reduce DPI detection.", + "revproxy_domain": "Domain name", + "revproxy_domain_hint": "Optional. Point DNS A-record to this server for Let's Encrypt TLS. Leave empty for a self-signed certificate.", + "revproxy_site_title": "Site title", + "revproxy_tls_email": "ACME email", + "revproxy_tls_email_hint": "Required for automatic Let's Encrypt certificates when a domain is set.", + "revproxy_backend": "VPN backend", + "revproxy_backend_none": "Decoy site only", + "revproxy_backend_port": "Backend port", + "revproxy_vpn_path": "Secret tunnel path", + "revproxy_vpn_path_hint": "Hidden path proxied to the VPN backend (e.g. /cdn-cgi/challenge).", + "revproxy_telemt_mask": "Enable Telemt site masking via Caddy", + "revproxy_install_hint": "Port 443 is used by the reverse proxy. Reinstall Xray/Telemt on an internal port (e.g. 8443) before enabling backend routing.", + "revproxy_port_hint": "Reverse proxy binds host ports 80 and 443 (HTTPS + HTTP/3).", + "revproxy_site": "Decoy site", + "revproxy_open_site": "Open site", + "revproxy_change_settings": "Settings", + "revproxy_settings_title": "Reverse Proxy Settings", + "ssl_provider_label": "Certificate source", + "ssl_provider_manual": "Manual (paths / paste)", + "ssl_provider_cloudflare": "Cloudflare (auto Let's Encrypt)", + "cf_token_label": "Cloudflare API Token", + "cf_token_hint": "Create a token with Zone:DNS:Edit and Zone:Zone:Read permissions. Used for DNS-01 validation only — port 80/443 stays free.", + "acme_email_label": "Email (Let's Encrypt notifications)", + "acme_staging_label": "Use staging (test certificates)", + "cf_issue_btn": "Issue / Renew certificate", + "cf_issue_hint": "Issues a real Let's Encrypt certificate via Cloudflare DNS without binding port 443. Restart the panel afterwards to apply.", + "cf_issuing": "Issuing...", + "cf_issued_ok": "Certificate issued successfully", + "cf_restart_required": "Restart the panel to apply the new certificate", + "cf_need_domain": "Enter a domain first", + "cf_cert_active": "Active", + "cf_cert_none": "No certificate yet", + "cf_days_left": "days left" +} diff --git a/tunnels_state.json b/tunnels_state.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tunnels_state.json @@ -0,0 +1 @@ +{} \ No newline at end of file