diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2200963 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Panel +APP_PORT=5000 +SECRET_KEY=change-me-to-a-long-random-string + +# PostgreSQL 17 +POSTGRES_USER=amnezia +POSTGRES_PASSWORD=amnezia +POSTGRES_DB=amnezia_panel +POSTGRES_PORT=5432 + +# Used by the panel process (local run or Docker) +# Docker Compose sets this automatically to point at the db service. +DATABASE_URL=postgresql://amnezia:amnezia@localhost:5432/amnezia_panel 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..4564e0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# 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 +data.json.migrated.*.bak +tunnels_state.json +.env +.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/Dockerfile.warp b/Dockerfile.warp new file mode 100644 index 0000000..ab0e2ae --- /dev/null +++ b/Dockerfile.warp @@ -0,0 +1,38 @@ +# syntax=docker/dockerfile:1 + +# Optional image variant with Cloudflare WARP installed inside the container. +# Use with: +# docker compose -f docker-compose.yml -f docker-compose.warp.yml up -d --build + +FROM python:3.14-slim-bookworm + +WORKDIR /app + +ARG CLOUDFLARE_WARP_REPO_CODENAME=bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + gnupg \ + iproute2 \ + iputils-ping \ + procps \ + && mkdir -p /usr/share/keyrings \ + && curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg \ + | gpg --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ ${CLOUDFLARE_WARP_REPO_CODENAME} main" \ + > /etc/apt/sources.list.d/cloudflare-client.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends cloudflare-warp \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt requirements.txt +RUN pip3 install --no-cache-dir -r requirements.txt + +COPY . . +COPY docker-entrypoint-warp.sh /usr/local/bin/docker-entrypoint-warp.sh +RUN chmod +x /usr/local/bin/docker-entrypoint-warp.sh + +ENTRYPOINT ["docker-entrypoint-warp.sh"] +CMD ["python3", "app.py"] 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/app.py b/app.py new file mode 100644 index 0000000..e3fdce9 --- /dev/null +++ b/app.py @@ -0,0 +1,3968 @@ +import os +import sys +import json +import logging + +from dotenv import load_dotenv +load_dotenv() +import base64 +import hashlib +import secrets +import uuid +import asyncio +import platform +import re +import shutil +import shlex +import tempfile +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 starlette.background import BackgroundTask +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 +from managers.backup_manager import BackupManager +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') # legacy JSON; used only for one-shot import / export +CURRENT_VERSION = "v1.5.0" +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')) + +# Panel state lives in PostgreSQL 17 (see db/). load_data/save_data keep the old dict API. +from db import ( # noqa: E402 + clear_tunnel_state, + ensure_db_ready, + export_data_dict, + load_data, + load_tunnel_state, + save_data, + save_tunnel_state, + update_tunnel_state, +) + + +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"\']+') +WARP_CLI_COMMAND = 'warp-cli.exe' if os.name == 'nt' else 'warp-cli' + + + +# ======================== 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 panel DB writes to prevent race conditions during async operations +DATA_LOCK = asyncio.Lock() + + +async def save_data_async(data): + """Saves panel state to PostgreSQL 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_warp_cli_binary(): + found = shutil.which(WARP_CLI_COMMAND) + if found: + return found + if os.name == 'nt': + for base in (os.environ.get('ProgramFiles'), os.environ.get('ProgramFiles(x86)')): + if not base: + continue + candidate = os.path.join(base, 'Cloudflare', 'Cloudflare WARP', 'warp-cli.exe') + if os.path.exists(candidate): + return candidate + return None + + +def is_warp_cli_installed(): + return bool(get_warp_cli_binary()) + + +def _warp_install_hint(): + system = platform.system().lower() + if system == 'windows': + return 'Install Cloudflare WARP from https://1.1.1.1/ or winget install Cloudflare.Warp, then restart this panel.' + if system == 'darwin': + return 'Install Cloudflare WARP from https://1.1.1.1/ or brew install --cask cloudflare-warp, then restart this panel.' + return 'Install the official cloudflare-warp package for your Linux distribution, start warp-svc, then restart this panel.' + + +def run_warp_cli(*args, timeout: int = 12): + binary = get_warp_cli_binary() + if not binary: + raise RuntimeError(_warp_install_hint()) + creationflags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0 + command = [binary] + if '--accept-tos' not in args: + command.append('--accept-tos') + command.extend(args) + result = subprocess.run( + command, + capture_output=True, + text=True, + encoding='utf-8', + errors='replace', + timeout=timeout, + creationflags=creationflags, + ) + output = '\n'.join(part.strip() for part in (result.stdout, result.stderr) if part and part.strip()) + if result.returncode != 0: + raise RuntimeError(output or f'warp-cli exited with code {result.returncode}') + return output + + +def _parse_warp_status(output: str): + lowered = (output or '').lower() + if 'registration missing' in lowered or 'not registered' in lowered or 'not enrolled' in lowered: + return 'not_registered' + if 'disconnect' in lowered or 'disabled' in lowered: + return 'disconnected' + if 'connecting' in lowered: + return 'connecting' + if 'connected' in lowered: + return 'connected' + return 'unknown' + + +def get_warp_status(): + installed = is_warp_cli_installed() + status = { + 'installed': installed, + 'running': False, + 'connected': False, + 'status': 'not_installed' if not installed else 'unknown', + 'raw': '', + 'last_error': '' if installed else _warp_install_hint(), + 'install_hint': _warp_install_hint(), + } + if not installed: + return status + try: + output = run_warp_cli('status') + parsed = _parse_warp_status(output) + status.update({ + 'running': parsed in ('connected', 'connecting'), + 'connected': parsed == 'connected', + 'status': parsed, + 'raw': output, + 'last_error': '', + }) + except Exception as e: + status['last_error'] = str(e) + lowered = str(e).lower() + if 'registration missing' in lowered or 'not registered' in lowered or 'not enrolled' in lowered: + status['status'] = 'not_registered' + elif 'service' in lowered or 'daemon' in lowered or 'warp-svc' in lowered: + status['status'] = 'service_unavailable' + else: + status['status'] = 'error' + return status + + +def enable_warp(): + current = get_warp_status() + if not current.get('installed'): + raise RuntimeError(current.get('install_hint') or _warp_install_hint()) + try: + if current.get('status') == 'not_registered': + run_warp_cli('registration', 'new', timeout=30) + run_warp_cli('mode', 'warp', timeout=15) + run_warp_cli('connect', timeout=30) + except Exception as e: + message = str(e) + if 'registration' in message.lower() or 'not registered' in message.lower() or 'not enrolled' in message.lower(): + run_warp_cli('registration', 'new', timeout=30) + run_warp_cli('mode', 'warp', timeout=15) + run_warp_cli('connect', timeout=30) + else: + raise + time.sleep(1) + return get_warp_status() + + +def disable_warp(): + current = get_warp_status() + if not current.get('installed'): + raise RuntimeError(current.get('install_hint') or _warp_install_hint()) + run_warp_cli('disconnect', timeout=20) + time.sleep(0.5) + return get_warp_status() + + +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 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) + + +BASE_PROTOCOLS = ['awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'dns', 'wireguard', 'socks5', 'adguard', 'nginx'] +MULTI_INSTANCE_PROTOCOLS = {'awg', 'awg2', 'awg_legacy', 'xray', 'telemt', 'socks5'} + + +def protocol_base(protocol: str) -> str: + return str(protocol or 'awg').split('__', 1)[0] + + +def protocol_instance(protocol: str) -> int: + parts = str(protocol or '').split('__', 1) + if len(parts) == 2: + try: + return max(1, int(parts[1])) + except ValueError: + return 1 + return 1 + + +def protocol_key(base: str, instance: int = 1) -> str: + base = protocol_base(base) + return base if int(instance or 1) <= 1 else f'{base}__{int(instance)}' + + +def next_protocol_key(protocols: dict, base: str) -> str: + base = protocol_base(base) + used = {protocol_instance(k) for k in (protocols or {}).keys() if protocol_base(k) == base} + idx = 1 + while idx in used: + idx += 1 + return protocol_key(base, idx) + + +def protocol_display_name(protocol: str) -> str: + base = protocol_base(protocol) + idx = protocol_instance(protocol) + names = { + 'awg': 'AmneziaWG', + 'awg2': 'AmneziaWG 2.0', + 'awg_legacy': 'AmneziaWG Legacy', + 'xray': 'Xray', + 'telemt': 'Telemt', + 'dns': 'AmneziaDNS', + 'wireguard': 'WireGuard', + 'socks5': 'SOCKS5', + 'adguard': 'AdGuard Home', + 'nginx': 'NGINX', + } + name = names.get(base, base) + return name if idx <= 1 else f'{name} #{idx}' + + +def protocol_container_name(protocol: str) -> Optional[str]: + base = protocol_base(protocol) + idx = protocol_instance(protocol) + base_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', + 'nginx': 'amnezia-nginx', + } + name = base_names.get(base) + if not name: + return None + return name if idx <= 1 else f'{name}-{idx}' + + +def is_valid_protocol(protocol: str) -> bool: + return protocol_base(protocol) in BASE_PROTOCOLS + + +def get_protocol_manager(ssh, protocol: str): + base = protocol_base(protocol) + if base == 'xray': + from managers.xray_manager import XrayManager + return XrayManager(ssh, protocol) + elif base == 'telemt': + from managers.telemt_manager import TelemtManager + return TelemtManager(ssh, protocol) + elif base == 'dns': + from managers.dns_manager import DNSManager + return DNSManager(ssh) + elif base == 'wireguard': + from managers.wireguard_manager import WireGuardManager + return WireGuardManager(ssh) + elif base == 'socks5': + from managers.socks5_manager import Socks5Manager + return Socks5Manager(ssh, protocol) + elif base == 'adguard': + from managers.adguard_manager import AdguardManager + return AdguardManager(ssh) + elif base == 'nginx': + from managers.nginx_manager import NginxManager + return NginxManager(ssh, protocol) + from managers.awg_manager import AWGManager + return AWGManager(ssh) + + + +def ensure_docker_installed(ssh): + """Ensure Docker is installed and running before installing any protocol/service.""" + out, _, code = ssh.run_command("docker --version 2>/dev/null") + if code == 0 and out.strip(): + status, _, _ = ssh.run_command("systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null") + if 'active' in status or 'running' in status.lower(): + return 'Docker already installed' + ssh.run_sudo_command("systemctl enable --now docker 2>/dev/null || service docker start 2>/dev/null || true", timeout=60) + status, _, _ = ssh.run_command("systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null") + if 'active' in status or 'running' in status.lower(): + return 'Docker service started' + + script = r""" +set -e +if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -y + apt-get install -y docker.io +elif command -v dnf >/dev/null 2>&1; then + dnf install -y docker +elif command -v yum >/dev/null 2>&1; then + yum install -y docker +elif command -v zypper >/dev/null 2>&1; then + zypper --non-interactive refresh + zypper --non-interactive install docker +elif command -v pacman >/dev/null 2>&1; then + pacman -Sy --noconfirm --noprogressbar docker +else + echo "Packet manager not found" >&2 + exit 1 +fi +(systemctl enable --now docker || service docker start || true) +sleep 3 +docker --version +""" + out, err, code = ssh.run_sudo_script(script, timeout=300) + if code != 0: + raise RuntimeError(f"Failed to install Docker: {err or out}") + status, _, _ = ssh.run_command("systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null") + if 'active' not in status and 'running' not in status.lower(): + raise RuntimeError("Docker installed but service is not running") + return 'Docker installed successfully' + + +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' + install_another: Optional[bool] = False + 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 + # NGINX + nginx_domain: Optional[str] = None + nginx_email: Optional[str] = None + + +class Socks5SettingsRequest(BaseModel): + protocol: str = 'socks5' + port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = 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 NginxSiteSaveRequest(BaseModel): + protocol: str = 'nginx' + html: str = '' + + +class BackupDownloadRequest(BaseModel): + protocol: str + filename: 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 + +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(): + # Init Postgres schema; import legacy data.json once if DB is empty + await asyncio.to_thread(ensure_db_ready, DATA_FILE) + # Optional: import tunnels_state.json into DB if present and empty + if os.path.exists(TUNNEL_STATE_FILE): + try: + existing = await asyncio.to_thread(load_tunnel_state) + if not existing: + with open(TUNNEL_STATE_FILE, 'r', encoding='utf-8') as f: + legacy_tunnels = json.load(f) + if isinstance(legacy_tunnels, dict) and legacy_tunnels: + await asyncio.to_thread(save_tunnel_state, legacy_tunnels) + logger.info("Imported legacy tunnels_state.json into PostgreSQL") + except Exception as e: + logger.warning(f"Could not import tunnels_state.json: {e}") + + 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 + } + changed = True + logger.info("Migrated SSL settings") + + 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, save_data) + + +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'] = {} + + def merge_saved_protocol_status(proto, result=None, error=None): + """Merge live status with saved protocol metadata. + + Multi-instance protocols are source-of-truth in data.json because + they cannot be discovered from BASE_PROTOCOLS alone. A transient + check failure must not delete awg2__2/awg2__3 from the panel. + """ + db_proto = server.get('protocols', {}).get(proto, {}) or {} + merged = dict(result or {}) + merged.setdefault('protocol', proto) + if error: + merged['error'] = error + if not merged.get('port') and db_proto.get('port'): + merged['port'] = db_proto['port'] + if db_proto.get('awg_params') and not merged.get('awg_params'): + merged['awg_params'] = db_proto.get('awg_params') + merged['base_protocol'] = db_proto.get('base_protocol') or protocol_base(proto) + merged['instance'] = db_proto.get('instance') or protocol_instance(proto) + merged['display_name'] = db_proto.get('display_name') or protocol_display_name(proto) + merged['container_name'] = db_proto.get('container_name') or protocol_container_name(proto) + if protocol_base(proto) == 'adguard': + for key in ('web_port', 'mode', 'internal_ip', 'expose_web'): + if db_proto.get(key) not in (None, ''): + if key == 'web_port' and merged.get('web_exposed'): + continue + merged[key] = db_proto[key] + if 'expose_web' in db_proto and 'web_exposed' not in merged: + merged['web_exposed'] = bool(db_proto.get('expose_web')) + if protocol_base(proto) == 'nginx': + for key in ('domain', 'email', 'site_url'): + if db_proto.get(key) not in (None, ''): + merged[key] = db_proto[key] + return merged + + def should_preserve_saved_protocol(proto, result=None, err=None): + """Return True when check must not remove a saved protocol record.""" + db_proto = server.get('protocols', {}).get(proto) + if not db_proto: + return False + # Additional AWG-family instances are only known by their saved + # dynamic keys (awg__2/awg2__2/awg_legacy__2). Keep them unless + # the user explicitly uninstalls them. + if protocol_base(proto) in MULTI_INSTANCE_PROTOCOLS and protocol_instance(proto) > 1: + return True + # Do not delete any saved protocol on command/check errors; only a + # clean live result with container_exists=False may prune base apps. + if err or (result and result.get('error')): + return True + return False + + def check_proto(proto): + try: + p_manager = get_protocol_manager(ssh, proto) + result = _manager_call(p_manager, 'get_server_status', proto) + return proto, merge_saved_protocol_status(proto, result), None + except Exception as e: + return proto, merge_saved_protocol_status(proto, {}, str(e)), str(e) + + protocols_to_check = list(dict.fromkeys(BASE_PROTOCOLS + list(server.get('protocols', {}).keys()))) + # Run checks sequentially. Several managers use the same SSH connection; + # checking them in parallel through one SSH object can produce false + # negatives and previously caused dynamic AWG instances to be removed. + for proto in protocols_to_check: + proto, result, err = check_proto(proto) + status['protocols'][proto] = result + if err: + continue + 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', {}), + 'base_protocol': protocol_base(proto), + 'instance': protocol_instance(proto), + 'display_name': protocol_display_name(proto), + 'container_name': protocol_container_name(proto), + } + if protocol_base(proto) == 'adguard': + server['protocols'][proto].update({ + 'mode': result.get('mode'), + 'internal_ip': result.get('internal_ip'), + 'web_port': result.get('web_port'), + 'expose_web': result.get('web_exposed'), + }) + if protocol_base(proto) == 'nginx': + server['protocols'][proto].update({ + 'domain': result.get('domain'), + 'email': result.get('email'), + 'site_url': result.get('site_url'), + }) + changed = True + else: + if proto in server['protocols']: + if should_preserve_saved_protocol(proto, result, err): + # Keep saved dynamic instances visible as installed but stopped/unchecked. + status['protocols'][proto]['container_exists'] = True + status['protocols'][proto].setdefault('container_running', False) + status['protocols'][proto]['status_preserved'] = True + else: + 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 not is_valid_protocol(req.protocol): + return JSONResponse({'error': 'Invalid protocol type'}, status_code=400) + + server = data['servers'][server_id] + if 'protocols' not in server: + server['protocols'] = {} + base_protocol = protocol_base(req.protocol) + if req.install_another: + if base_protocol not in MULTI_INSTANCE_PROTOCOLS: + return JSONResponse({'error': 'Multiple instances are not supported for this protocol yet'}, status_code=400) + install_protocol = next_protocol_key(server.get('protocols', {}), base_protocol) + else: + install_protocol = req.protocol + install_base = protocol_base(install_protocol) + + ssh = get_ssh(server) + ssh.connect() + docker_install_log = ensure_docker_installed(ssh) + manager = get_protocol_manager(ssh, install_protocol) + + # Pass parameters to installer + if install_base == 'telemt': + result = manager.install_protocol( + protocol_type=install_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 install_base == 'xray': + result = manager.install_protocol(port=req.port) + elif install_base == 'wireguard': + result = manager.install_protocol(port=req.port) + elif install_base == 'socks5': + result = manager.install_protocol( + protocol_type=install_protocol, + port=req.port, + username=req.socks5_username, + password=req.socks5_password, + ) + elif install_base == '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 install_base == 'nginx': + result = manager.install_protocol( + protocol_type='nginx', + port=req.port, + domain=req.nginx_domain, + email=req.nginx_email, + ) + else: + result = manager.install_protocol(install_protocol, port=req.port) + + if not isinstance(result, dict): + result = {'status': 'success', 'message': str(result)} + if docker_install_log: + result.setdefault('log', []) + result['log'].insert(0, docker_install_log) + if result.get('status') == 'error' or result.get('error'): + ssh.disconnect() + return JSONResponse({'error': result.get('message') or result.get('error') or 'Installation failed'}, status_code=400) + + proto_record = { + 'installed': True, + 'port': req.port, + 'awg_params': result.get('awg_params', {}), + } + if install_base == '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 install_base == 'nginx': + proto_record['domain'] = result.get('domain') + proto_record['email'] = result.get('email') + proto_record['site_url'] = result.get('site_url') + proto_record['base_protocol'] = install_base + proto_record['instance'] = protocol_instance(install_protocol) + proto_record['display_name'] = protocol_display_name(install_protocol) + proto_record['container_name'] = protocol_container_name(install_protocol) + server['protocols'][install_protocol] = proto_record + result['protocol'] = install_protocol + result['base_protocol'] = install_base + result['display_name'] = proto_record['display_name'] + result['container_name'] = proto_record['container_name'] + 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, protocol: str = 'socks5'): + """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] + protocol = protocol if is_valid_protocol(protocol) and protocol_base(protocol) == 'socks5' else 'socks5' + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, protocol) + 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] + protocol = req.protocol if is_valid_protocol(req.protocol) and protocol_base(req.protocol) == 'socks5' else 'socks5' + ssh = get_ssh(server) + ssh.connect() + manager = get_protocol_manager(ssh, protocol) + 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(protocol, {}) + srv_proto['port'] = str(result['port']) + srv_proto['installed'] = True + srv_proto['base_protocol'] = protocol_base(protocol) + srv_proto['instance'] = protocol_instance(protocol) + srv_proto['display_name'] = protocol_display_name(protocol) + srv_proto['container_name'] = protocol_container_name(protocol) + save_data(data) + return result + except Exception as e: + logger.exception("Error updating SOCKS5 credentials") + 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) + base = protocol_base(req.protocol) + if base 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', + 'nginx': 'amnezia-nginx', +} + + + +@app.post('/api/servers/{server_id}/backups', tags=["Protocols"]) +async def api_protocol_backups_list(request: Request, server_id: int, req: ProtocolRequest): + """List backups created on the remote server for one protocol.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + if not is_valid_protocol(req.protocol): + return JSONResponse({'error': 'Unknown protocol'}, status_code=400) + ssh = None + 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() + result = BackupManager(ssh).list_backups(req.protocol) + if result.get('status') == 'error': + return JSONResponse({'error': result.get('message', 'Failed to list backups')}, status_code=500) + return result + except Exception as e: + logger.exception("Error listing protocol backups") + return JSONResponse({'error': str(e)}, status_code=500) + finally: + if ssh: + ssh.disconnect() + + +@app.post('/api/servers/{server_id}/backups/create', tags=["Protocols"]) +async def api_protocol_backup_create(request: Request, server_id: int, req: ProtocolRequest): + """Create a protocol backup archive on the remote server.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + if not is_valid_protocol(req.protocol): + return JSONResponse({'error': 'Unknown protocol'}, status_code=400) + ssh = None + try: + data = load_data() + if server_id >= len(data['servers']): + return JSONResponse({'error': 'Server not found'}, status_code=404) + container = protocol_container_name(req.protocol) + if not container: + return JSONResponse({'error': 'Unknown protocol'}, status_code=400) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + result = BackupManager(ssh).create_backup(req.protocol, container) + if result.get('status') == 'error': + return JSONResponse({'error': result.get('message', 'Failed to create backup')}, status_code=500) + return result + except Exception as e: + logger.exception("Error creating protocol backup") + return JSONResponse({'error': str(e)}, status_code=500) + finally: + if ssh: + ssh.disconnect() + + +@app.post('/api/servers/{server_id}/backups/download', tags=["Protocols"]) +async def api_protocol_backup_download(request: Request, server_id: int, req: BackupDownloadRequest): + """Download one remote protocol backup archive through the panel.""" + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + if not is_valid_protocol(req.protocol): + return JSONResponse({'error': 'Unknown protocol'}, status_code=400) + manager = BackupManager(None) + safe_proto = manager.safe_protocol(req.protocol) + filename = manager.safe_filename(req.filename) + if not filename: + return JSONResponse({'error': 'Invalid backup filename'}, status_code=400) + ssh = None + tmp_path = None + tmp_remote = f'/tmp/{filename}' + remote_path = f'{manager.BACKUP_ROOT}/{safe_proto}/{filename}' + 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() + quoted_remote = shlex.quote(remote_path) + quoted_tmp = shlex.quote(tmp_remote) + _, err, code = ssh.run_sudo_command( + f"test -f {quoted_remote} && cp {quoted_remote} {quoted_tmp} && chmod 0644 {quoted_tmp}" + ) + if code != 0: + return JSONResponse({'error': err or 'Backup not found'}, status_code=404) + fd, tmp_path = tempfile.mkstemp(prefix='amnezia-backup-', suffix='.tar.gz') + os.close(fd) + sftp = ssh.client.open_sftp() + try: + sftp.get(tmp_remote, tmp_path) + finally: + sftp.close() + ssh.run_sudo_command(f"rm -f {quoted_tmp}") + ssh.disconnect() + ssh = None + return FileResponse( + tmp_path, + media_type='application/gzip', + filename=filename, + background=BackgroundTask(lambda p=tmp_path: os.path.exists(p) and os.remove(p)), + ) + except Exception as e: + logger.exception("Error downloading protocol backup") + if tmp_path and os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except Exception: + pass + return JSONResponse({'error': str(e)}, status_code=500) + finally: + if ssh: + ssh.disconnect() + + +@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 = protocol_container_name(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 protocol_base(req.protocol) == 'xray': + from managers.xray_manager import XrayManager + mgr = XrayManager(ssh, req.protocol) + 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 protocol_base(req.protocol) == 'telemt': + from managers.telemt_manager import TelemtManager + mgr = TelemtManager(ssh, req.protocol) + config = mgr._get_server_config() + elif protocol_base(req.protocol) == 'wireguard': + from managers.wireguard_manager import WireGuardManager + mgr = WireGuardManager(ssh) + config = mgr._get_server_config() + elif protocol_base(req.protocol) == 'nginx': + from managers.nginx_manager import NginxManager + mgr = NginxManager(ssh, req.protocol) + config = mgr._get_server_config(req.protocol) + 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 protocol_base(req.protocol) == 'xray': + from managers.xray_manager import XrayManager + mgr = XrayManager(ssh, req.protocol) + 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 protocol_base(req.protocol) == 'telemt': + from managers.telemt_manager import TelemtManager + mgr = TelemtManager(ssh, req.protocol) + mgr.save_server_config(req.protocol, req.config) + elif protocol_base(req.protocol) == 'wireguard': + from managers.wireguard_manager import WireGuardManager + mgr = WireGuardManager(ssh) + mgr.save_server_config(req.config) + elif protocol_base(req.protocol) == 'nginx': + from managers.nginx_manager import NginxManager + mgr = NginxManager(ssh, req.protocol) + mgr.save_server_config(req.protocol, 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.post('/api/servers/{server_id}/nginx/site', tags=["Protocols"]) +async def api_nginx_site_get(request: Request, server_id: int, req: ProtocolRequest): + """Return editable NGINX site index.html.""" + 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 protocol_base(req.protocol) != 'nginx': + return JSONResponse({'error': 'Invalid protocol type'}, status_code=400) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + from managers.nginx_manager import NginxManager + mgr = NginxManager(ssh, req.protocol) + html = mgr.get_site_index(req.protocol) + ssh.disconnect() + return {'status': 'success', 'html': html} + except Exception as e: + logger.exception("Error getting NGINX site") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/servers/{server_id}/nginx/site/save', tags=["Protocols"]) +async def api_nginx_site_save(request: Request, server_id: int, req: NginxSiteSaveRequest): + """Save editable NGINX site index.html.""" + 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 protocol_base(req.protocol) != 'nginx': + return JSONResponse({'error': 'Invalid protocol type'}, status_code=400) + server = data['servers'][server_id] + ssh = get_ssh(server) + ssh.connect() + from managers.nginx_manager import NginxManager + mgr = NginxManager(ssh, req.protocol) + mgr.save_site_index(req.protocol, req.html) + ssh.disconnect() + return {'status': 'success'} + except Exception as e: + logger.exception("Error saving NGINX site") + 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 protocol_base(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 protocol_base(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 protocol_base(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 protocol_base(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() + + req_fields = getattr(req, 'model_fields_set', getattr(req, '__fields_set__', set())) + if 'expiration_date' in req_fields: + 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 protocol_base(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'), + 'warp': get_warp_status(), + } + + +@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/warp/connect', tags=["Settings"]) +async def api_warp_connect(request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + return await asyncio.to_thread(enable_warp) + except Exception as e: + logger.exception("Error connecting Cloudflare WARP") + return JSONResponse({'error': str(e)}, status_code=500) + + +@app.post('/api/settings/warp/disconnect', tags=["Settings"]) +async def api_warp_disconnect(request: Request): + if not _check_admin(request): + return JSONResponse({'error': 'Forbidden'}, status_code=403) + try: + return await asyncio.to_thread(disable_warp) + except Exception as e: + logger.exception("Error disconnecting Cloudflare WARP") + 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() + data['settings']['ssl'] = payload.ssl.dict() + 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, save_data) + 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()} + + +@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, save_data) + 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) + payload = await asyncio.to_thread(export_data_dict) + content = json.dumps(payload, indent=2, ensure_ascii=False).encode('utf-8') + return StreamingResponse( + io.BytesIO(content), + media_type='application/json', + headers={'Content-Disposition': 'attachment; 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) + + +if __name__ == '__main__': + 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/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..d237706 --- /dev/null +++ b/db/__init__.py @@ -0,0 +1,29 @@ +"""Database package for Amnezia Web Panel (PostgreSQL 17).""" + +from .connection import close_pool, get_database_url, init_schema +from .store import ( + clear_tunnel_state, + ensure_db_ready, + export_data_dict, + import_from_json_file, + load_data, + load_tunnel_state, + save_data, + save_tunnel_state, + update_tunnel_state, +) + +__all__ = [ + 'clear_tunnel_state', + 'close_pool', + 'ensure_db_ready', + 'export_data_dict', + 'get_database_url', + 'import_from_json_file', + 'init_schema', + 'load_data', + 'load_tunnel_state', + 'save_data', + 'save_tunnel_state', + 'update_tunnel_state', +] diff --git a/db/connection.py b/db/connection.py new file mode 100644 index 0000000..a53ddc5 --- /dev/null +++ b/db/connection.py @@ -0,0 +1,82 @@ +"""PostgreSQL connection pool for Amnezia Web Panel.""" + +from __future__ import annotations + +import logging +import os +import threading +from pathlib import Path + +from dotenv import load_dotenv + +load_dotenv() + +logger = logging.getLogger(__name__) + +DEFAULT_DATABASE_URL = 'postgresql://amnezia:amnezia@localhost:5432/amnezia_panel' + +_pool = None +_pool_lock = threading.Lock() +_schema_ready = False + + +def get_database_url() -> str: + return os.environ.get('DATABASE_URL', DEFAULT_DATABASE_URL).strip() + + +def get_pool(): + global _pool + if _pool is not None: + return _pool + with _pool_lock: + if _pool is not None: + return _pool + from psycopg.rows import dict_row + from psycopg_pool import ConnectionPool + + url = get_database_url() + logger.info('Connecting to PostgreSQL…') + _pool = ConnectionPool( + conninfo=url, + min_size=1, + max_size=10, + kwargs={ + 'autocommit': False, + 'row_factory': dict_row, + }, + open=True, + ) + return _pool + + +def close_pool(): + global _pool, _schema_ready + with _pool_lock: + if _pool is not None: + _pool.close() + _pool = None + _schema_ready = False + + +def init_schema(): + """Create tables if they do not exist.""" + global _schema_ready + if _schema_ready: + return + schema_path = Path(__file__).with_name('schema.sql') + sql = schema_path.read_text(encoding='utf-8') + pool = get_pool() + with pool.connection() as conn: + with conn.cursor() as cur: + # psycopg3 executes one statement per execute() + for raw in sql.split(';'): + lines = [ + ln for ln in raw.splitlines() + if ln.strip() and not ln.strip().startswith('--') + ] + stmt = '\n'.join(lines).strip() + if stmt: + cur.execute(stmt) + conn.commit() + _schema_ready = True + logger.info('PostgreSQL schema ready') diff --git a/db/migrate_json.py b/db/migrate_json.py new file mode 100644 index 0000000..237e066 --- /dev/null +++ b/db/migrate_json.py @@ -0,0 +1,58 @@ +"""One-shot CLI: import legacy data.json into PostgreSQL. + +Usage: + python -m db.migrate_json [path/to/data.json] +""" + +from __future__ import annotations + +import logging +import os +import sys + +from dotenv import load_dotenv + +load_dotenv() + +logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') +logger = logging.getLogger('migrate_json') + + +def main(argv: list[str] | None = None) -> int: + argv = list(argv if argv is not None else sys.argv[1:]) + force = '--force' in argv + paths = [a for a in argv if not a.startswith('-')] + + if getattr(sys, 'frozen', False): + app_path = os.path.dirname(sys.executable) + else: + app_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + data_file = paths[0] if paths else os.path.join(app_path, 'data.json') + + from db.store import import_from_json_file, is_database_empty, load_data + + if not os.path.exists(data_file): + logger.error('File not found: %s', data_file) + return 1 + + if not is_database_empty(): + logger.warning('Database is not empty — import will overwrite panel tables.') + if not force: + logger.error('Re-run with --force to overwrite existing data.') + return 2 + + import_from_json_file(data_file, backup=True) + data = load_data() + logger.info( + 'Done. servers=%s users=%s connections=%s tokens=%s', + len(data['servers']), + len(data['users']), + len(data['user_connections']), + len(data['api_tokens']), + ) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..d48a1a1 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,75 @@ +-- Amnezia Web Panel — PostgreSQL 17 schema + +CREATE TABLE IF NOT EXISTS servers ( + position INTEGER PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + host TEXT NOT NULL DEFAULT '', + ssh_port INTEGER NOT NULL DEFAULT 22, + username TEXT NOT NULL DEFAULT '', + password TEXT, + private_key TEXT, + server_info JSONB NOT NULL DEFAULT '{}'::jsonb, + protocols JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'user', + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ, + telegram_id TEXT, + email TEXT, + description TEXT, + traffic_limit BIGINT NOT NULL DEFAULT 0, + traffic_used BIGINT NOT NULL DEFAULT 0, + traffic_total BIGINT NOT NULL DEFAULT 0, + traffic_reset_strategy TEXT NOT NULL DEFAULT 'never', + last_reset_at TIMESTAMPTZ, + expiration_date TIMESTAMPTZ, + remnawave_uuid TEXT, + share_enabled BOOLEAN NOT NULL DEFAULT FALSE, + share_token TEXT, + share_password_hash TEXT +); + +CREATE TABLE IF NOT EXISTS user_connections ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + server_id INTEGER NOT NULL DEFAULT 0, + protocol TEXT NOT NULL DEFAULT '', + client_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ, + last_bytes BIGINT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_user_connections_user_id ON user_connections(user_id); +CREATE INDEX IF NOT EXISTS idx_user_connections_server_id ON user_connections(server_id); + +CREATE TABLE IF NOT EXISTS api_tokens ( + id UUID PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL DEFAULT '', + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); + +CREATE TABLE IF NOT EXISTS settings ( + id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + data JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE TABLE IF NOT EXISTS tunnel_state ( + provider TEXT PRIMARY KEY, + data JSONB NOT NULL DEFAULT '{}'::jsonb +); + +INSERT INTO settings (id, data) VALUES (1, '{}'::jsonb) +ON CONFLICT (id) DO NOTHING; diff --git a/db/store.py b/db/store.py new file mode 100644 index 0000000..29a8304 --- /dev/null +++ b/db/store.py @@ -0,0 +1,413 @@ +"""Panel data store backed by PostgreSQL 17. + +Preserves the same dict shape that used to live in data.json so existing +FastAPI handlers keep working without a full rewrite. +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional +from uuid import UUID + +from psycopg.types.json import Jsonb + +from .connection import get_pool, init_schema + +logger = logging.getLogger(__name__) + +DEFAULT_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', + }, +} + + +def _parse_ts(value: Any) -> Optional[datetime]: + if value is None or value == '': + return None + if isinstance(value, datetime): + return value + text = str(value).strip() + if not text: + return None + if text.endswith('Z'): + text = text[:-1] + '+00:00' + try: + return datetime.fromisoformat(text) + except ValueError: + return None + + +def _ts_iso(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + return str(value) + + +def _as_uuid(value: Any) -> UUID: + return UUID(str(value)) + + +def _merge_settings(raw: Optional[dict]) -> dict: + settings = json.loads(json.dumps(DEFAULT_SETTINGS)) + if not isinstance(raw, dict): + return settings + for section, defaults in DEFAULT_SETTINGS.items(): + incoming = raw.get(section) + if isinstance(incoming, dict) and isinstance(defaults, dict): + merged = dict(defaults) + merged.update(incoming) + settings[section] = merged + elif section in raw: + settings[section] = raw[section] + for key, value in raw.items(): + if key not in settings: + settings[key] = value + return settings + + +def _row_to_server(row) -> dict: + return { + 'name': row['name'] or '', + 'host': row['host'] or '', + 'ssh_port': int(row['ssh_port'] or 22), + 'username': row['username'] or '', + 'password': row['password'], + 'private_key': row['private_key'], + 'server_info': dict(row['server_info'] or {}), + 'protocols': dict(row['protocols'] or {}), + } + + +def _row_to_user(row) -> dict: + return { + 'id': str(row['id']), + 'username': row['username'], + 'password_hash': row['password_hash'] or '', + 'role': row['role'] or 'user', + 'enabled': bool(row['enabled']), + 'created_at': _ts_iso(row['created_at']), + 'telegramId': row['telegram_id'], + 'email': row['email'], + 'description': row['description'], + 'traffic_limit': int(row['traffic_limit'] or 0), + 'traffic_used': int(row['traffic_used'] or 0), + 'traffic_total': int(row['traffic_total'] or 0), + 'traffic_reset_strategy': row['traffic_reset_strategy'] or 'never', + 'last_reset_at': _ts_iso(row['last_reset_at']), + 'expiration_date': _ts_iso(row['expiration_date']), + 'remnawave_uuid': row['remnawave_uuid'], + 'share_enabled': bool(row['share_enabled']), + 'share_token': row['share_token'], + 'share_password_hash': row['share_password_hash'], + } + + +def _row_to_connection(row) -> dict: + return { + 'id': str(row['id']), + 'user_id': str(row['user_id']), + 'server_id': int(row['server_id'] or 0), + 'protocol': row['protocol'] or '', + 'client_id': row['client_id'] or '', + 'name': row['name'] or '', + 'created_at': _ts_iso(row['created_at']), + 'last_bytes': int(row['last_bytes'] or 0), + } + + +def _row_to_token(row) -> dict: + return { + 'id': str(row['id']), + 'name': row['name'] or '', + 'token_hash': row['token_hash'] or '', + 'token_prefix': row['token_prefix'] or '', + 'user_id': str(row['user_id']), + 'created_at': _ts_iso(row['created_at']), + 'last_used_at': _ts_iso(row['last_used_at']), + } + + +def load_data() -> dict: + init_schema() + pool = get_pool() + with pool.connection() as conn: + with conn.cursor() as cur: + cur.execute( + 'SELECT position, name, host, ssh_port, username, password, ' + 'private_key, server_info, protocols ' + 'FROM servers ORDER BY position ASC' + ) + servers = [_row_to_server(r) for r in cur.fetchall()] + + cur.execute( + 'SELECT id, username, password_hash, role, enabled, created_at, ' + 'telegram_id, email, description, traffic_limit, traffic_used, ' + 'traffic_total, traffic_reset_strategy, last_reset_at, ' + 'expiration_date, remnawave_uuid, share_enabled, share_token, ' + 'share_password_hash FROM users ORDER BY created_at NULLS LAST, username' + ) + users = [_row_to_user(r) for r in cur.fetchall()] + + cur.execute( + 'SELECT id, user_id, server_id, protocol, client_id, name, ' + 'created_at, last_bytes FROM user_connections ' + 'ORDER BY created_at NULLS LAST, id' + ) + user_connections = [_row_to_connection(r) for r in cur.fetchall()] + + cur.execute( + 'SELECT id, name, token_hash, token_prefix, user_id, ' + 'created_at, last_used_at FROM api_tokens ' + 'ORDER BY created_at NULLS LAST, id' + ) + api_tokens = [_row_to_token(r) for r in cur.fetchall()] + + cur.execute('SELECT data FROM settings WHERE id = 1') + settings_row = cur.fetchone() + settings = _merge_settings(settings_row['data'] if settings_row else None) + + return { + 'servers': servers, + 'users': users, + 'user_connections': user_connections, + 'api_tokens': api_tokens, + 'settings': settings, + } + + +def save_data(data: dict) -> None: + """Replace panel state in a single transaction (same semantics as rewriting data.json).""" + init_schema() + servers = data.get('servers') or [] + users = data.get('users') or [] + connections = data.get('user_connections') or [] + tokens = data.get('api_tokens') or [] + settings = _merge_settings(data.get('settings')) + + # Keep only connections whose user still exists + user_ids = {str(u.get('id')) for u in users if u.get('id')} + connections = [c for c in connections if str(c.get('user_id')) in user_ids] + tokens = [t for t in tokens if str(t.get('user_id')) in user_ids] + + pool = get_pool() + with pool.connection() as conn: + with conn.cursor() as cur: + # Order matters for FKs: children first on delete, parents first on insert + cur.execute('DELETE FROM api_tokens') + cur.execute('DELETE FROM user_connections') + cur.execute('DELETE FROM users') + cur.execute('DELETE FROM servers') + + for pos, server in enumerate(servers): + cur.execute( + 'INSERT INTO servers (position, name, host, ssh_port, username, ' + 'password, private_key, server_info, protocols) ' + 'VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)', + ( + pos, + server.get('name') or '', + server.get('host') or '', + int(server.get('ssh_port') or 22), + server.get('username') or '', + server.get('password'), + server.get('private_key'), + Jsonb(server.get('server_info') or {}), + Jsonb(server.get('protocols') or {}), + ), + ) + + for user in users: + cur.execute( + 'INSERT INTO users (' + 'id, username, password_hash, role, enabled, created_at, ' + 'telegram_id, email, description, traffic_limit, traffic_used, ' + 'traffic_total, traffic_reset_strategy, last_reset_at, ' + 'expiration_date, remnawave_uuid, share_enabled, share_token, ' + 'share_password_hash' + ') VALUES (' + '%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s' + ')', + ( + _as_uuid(user['id']), + user.get('username') or '', + user.get('password_hash') or '', + user.get('role') or 'user', + bool(user.get('enabled', True)), + _parse_ts(user.get('created_at')), + str(user['telegramId']) if user.get('telegramId') is not None else None, + user.get('email'), + user.get('description'), + int(user.get('traffic_limit') or 0), + int(user.get('traffic_used') or 0), + int(user.get('traffic_total') or 0), + user.get('traffic_reset_strategy') or 'never', + _parse_ts(user.get('last_reset_at')), + _parse_ts(user.get('expiration_date')), + user.get('remnawave_uuid'), + bool(user.get('share_enabled', False)), + user.get('share_token'), + user.get('share_password_hash'), + ), + ) + + for conn_row in connections: + cur.execute( + 'INSERT INTO user_connections (' + 'id, user_id, server_id, protocol, client_id, name, created_at, last_bytes' + ') VALUES (%s, %s, %s, %s, %s, %s, %s, %s)', + ( + _as_uuid(conn_row['id']), + _as_uuid(conn_row['user_id']), + int(conn_row.get('server_id') or 0), + conn_row.get('protocol') or '', + conn_row.get('client_id') or '', + conn_row.get('name') or '', + _parse_ts(conn_row.get('created_at')), + int(conn_row.get('last_bytes') or 0), + ), + ) + + for token in tokens: + cur.execute( + 'INSERT INTO api_tokens (' + 'id, name, token_hash, token_prefix, user_id, created_at, last_used_at' + ') VALUES (%s, %s, %s, %s, %s, %s, %s)', + ( + _as_uuid(token['id']), + token.get('name') or '', + token.get('token_hash') or '', + token.get('token_prefix') or '', + _as_uuid(token['user_id']), + _parse_ts(token.get('created_at')), + _parse_ts(token.get('last_used_at')), + ), + ) + + cur.execute( + 'INSERT INTO settings (id, data) VALUES (1, %s) ' + 'ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data', + (Jsonb(settings),), + ) + conn.commit() + + +def export_data_dict() -> dict: + """Export current DB state as the legacy data.json document.""" + return load_data() + + +def is_database_empty() -> bool: + init_schema() + pool = get_pool() + with pool.connection() as conn: + with conn.cursor() as cur: + cur.execute('SELECT COUNT(*) AS n FROM users') + users = cur.fetchone()['n'] + cur.execute('SELECT COUNT(*) AS n FROM servers') + servers = cur.fetchone()['n'] + return users == 0 and servers == 0 + + +def import_from_json_file(path: str | os.PathLike, *, backup: bool = True) -> bool: + """Import legacy data.json into Postgres. Returns True if import ran.""" + path = Path(path) + if not path.exists(): + return False + + with path.open('r', encoding='utf-8') as f: + data = json.load(f) + + if not isinstance(data, dict): + raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}') + + data.setdefault('servers', []) + data.setdefault('users', []) + data.setdefault('user_connections', []) + data.setdefault('api_tokens', []) + data.setdefault('settings', {}) + + save_data(data) + + if backup: + stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + backup_path = path.with_name(f'data.json.migrated.{stamp}.bak') + try: + shutil.copy2(path, backup_path) + logger.info('Backed up legacy data.json to %s', backup_path) + except OSError as e: + logger.warning('Could not backup data.json: %s', e) + + logger.info( + 'Imported data.json → PostgreSQL (%s servers, %s users, %s connections)', + len(data.get('servers', [])), + len(data.get('users', [])), + len(data.get('user_connections', [])), + ) + return True + + +def load_tunnel_state() -> dict: + init_schema() + pool = get_pool() + with pool.connection() as conn: + with conn.cursor() as cur: + cur.execute('SELECT provider, data FROM tunnel_state') + rows = cur.fetchall() + return {row['provider']: dict(row['data'] or {}) for row in rows} + + +def save_tunnel_state(state: dict) -> None: + init_schema() + pool = get_pool() + with pool.connection() as conn: + with conn.cursor() as cur: + cur.execute('DELETE FROM tunnel_state') + for provider, payload in (state or {}).items(): + cur.execute( + 'INSERT INTO tunnel_state (provider, data) VALUES (%s, %s)', + (str(provider), Jsonb(payload or {})), + ) + conn.commit() + + +def update_tunnel_state(provider: str, **updates) -> None: + 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) -> None: + state = load_tunnel_state() + if provider in state: + state.pop(provider) + save_tunnel_state(state) + + +def ensure_db_ready(legacy_data_file: Optional[str] = None) -> None: + """Init schema and one-shot import from data.json when DB is empty.""" + init_schema() + if legacy_data_file and is_database_empty() and os.path.exists(legacy_data_file): + logger.info('Empty database — importing legacy %s', legacy_data_file) + import_from_json_file(legacy_data_file) diff --git a/docker-compose.warp.yml b/docker-compose.warp.yml new file mode 100644 index 0000000..62246a8 --- /dev/null +++ b/docker-compose.warp.yml @@ -0,0 +1,28 @@ +# Optional Docker Compose override for running Cloudflare WARP inside the panel container. +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.warp.yml up -d --build +# +# Why the extra privileges: +# WARP needs a TUN device and NET_ADMIN to create/manage its tunnel interface. + +services: + amnezia_panel: + build: + context: . + dockerfile: Dockerfile.warp + image: amnezia-panel:warp-local + + cap_add: + - NET_ADMIN + - SYS_MODULE + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + net.ipv4.conf.all.src_valid_mark: "1" + + volumes: + - amnezia_data:/app/data + - cloudflare_warp:/var/lib/cloudflare-warp + +volumes: + cloudflare_warp: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9eab514 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +version: '3.8' + +services: + db: + image: postgres:17 + container_name: amnezia_panel_db + environment: + POSTGRES_USER: ${POSTGRES_USER:-amnezia} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia} + POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel} + volumes: + - amnezia_pgdata:/var/lib/postgresql/data + ports: + - "${POSTGRES_PORT:-5432}:5432" + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-amnezia} -d ${POSTGRES_DB:-amnezia_panel}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + amnezia_panel: + build: + context: . + dockerfile: Dockerfile + image: amnezia-panel:local + container_name: amnezia_panel + depends_on: + db: + condition: service_healthy + ports: + - "${APP_PORT:-5000}:5000" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel} + SECRET_KEY: ${SECRET_KEY:-} + APP_PORT: ${APP_PORT:-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: + amnezia_pgdata: diff --git a/docker-entrypoint-warp.sh b/docker-entrypoint-warp.sh new file mode 100644 index 0000000..352d9a1 --- /dev/null +++ b/docker-entrypoint-warp.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +# Start Cloudflare WARP daemon inside the container when present. +# The panel controls it through warp-cli from the Settings page. +if command -v warp-svc >/dev/null 2>&1; then + mkdir -p /var/lib/cloudflare-warp + warp-svc >/tmp/warp-svc.log 2>&1 & + echo "$!" >/tmp/warp-svc.pid + + # Best-effort short wait: app can still start even if WARP service needs more time. + i=0 + while [ "$i" -lt 10 ]; do + if warp-cli --accept-tos status >/tmp/warp-cli-status.log 2>&1; then + break + fi + i=$((i + 1)) + sleep 1 + done +fi + +exec "$@" 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..e5152ef --- /dev/null +++ b/managers/adguard_manager.py @@ -0,0 +1,264 @@ +""" +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 _container_web_port(self): + """Read the AdGuard HTTP address configured inside the container. + During the first-run wizard AdGuard writes web.listen_addresses to + AdGuardHome.yaml. If the wizard has not been completed yet, the + container is started with --web-addr 0.0.0.0: and this method can + read that port from docker inspect.""" + grep_cmd = "grep -E '^[[:space:]]*-?[[:space:]]*[0-9.]+:[0-9]+$' /opt/adguardhome/conf/AdGuardHome.yaml 2>/dev/null | head -n1" + out, _, _ = self.ssh.run_sudo_command( + f'docker exec {self.CONTAINER_NAME} sh -c "{grep_cmd}"' + ) + line = out.strip().split('\n')[0].strip().lstrip('-').strip() if out.strip() else '' + if ':' in line: + try: + return int(line.rsplit(':', 1)[1]) + except ValueError: + pass + + out, _, _ = self.ssh.run_sudo_command( + f"docker inspect -f '{{{{json .Config.Cmd}}}}' {self.CONTAINER_NAME} 2>/dev/null" + ) + marker = '--web-addr' + if marker in out: + parts = out.replace('[', ' ').replace(']', ' ').replace(',', ' ').replace('\"', ' ').split() + for i, part in enumerate(parts): + if part == marker and i + 1 < len(parts) and ':' in parts[i + 1]: + try: + return int(parts[i + 1].rsplit(':', 1)[1]) + except ValueError: + pass + return None + + def _exposed_web_port(self, container_port=None): + """Reads back the host->container port mapping for the web UI port, + so the panel can show the correct admin URL after install.""" + container_port = int(container_port or self.DEFAULT_WEB_PORT) + for port in (container_port, self.DEFAULT_WEB_PORT): + out, _, _ = self.ssh.run_sudo_command( + f"docker port {self.CONTAINER_NAME} {port}/tcp 2>/dev/null" + ) + if not out.strip(): + continue + # 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: + continue + 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 '' + container_web_port = self._container_web_port() if running else None + exposed_port = self._exposed_web_port(container_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) — use the actual container web port + # when known, otherwise fall back to AdGuard's default :3000. + return { + 'container_exists': exists, + 'container_running': running, + 'mode': mode, + 'internal_ip': ip, + 'web_port': exposed_port or container_web_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}:{web_port}/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} --web-addr 0.0.0.0:{web_port}" + ) + _, 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..f0125f1 --- /dev/null +++ b/managers/awg_manager.py @@ -0,0 +1,1378 @@ +""" +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 _base_protocol(self, protocol_type): + """Return base protocol for instance keys like awg__2.""" + return str(protocol_type or self.AWG).split('__', 1)[0] + + def _instance_index(self, protocol_type): + parts = str(protocol_type or '').split('__', 1) + if len(parts) == 2: + try: + return max(1, int(parts[1])) + except ValueError: + return 1 + return 1 + + def _container_name(self, protocol_type): + """Get Docker container name for protocol type/instance. + First instances keep legacy names; additional instances get -N suffix. + """ + base = self._base_protocol(protocol_type) + idx = self._instance_index(protocol_type) + if base == self.AWG_LEGACY: + name = 'amnezia-awg-legacy' + elif base == self.AWG2: + name = 'amnezia-awg2' + else: + name = 'amnezia-awg' + return name if idx <= 1 else f'{name}-{idx}' + + def _config_path(self, protocol_type): + """Get server config path inside container.""" + if self._base_protocol(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 self._base_protocol(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 self._base_protocol(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 self._base_protocol(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 self._base_protocol(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 self._base_protocol(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=(self._base_protocol(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 self._base_protocol(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 _extract_ipv4(self, value): + """Extract the first IPv4 address from AllowedIPs/clientIp-like values.""" + if not value: + return '' + match = re.search(r'(\d+\.\d+\.\d+\.\d+)', str(value)) + return match.group(1) if match else '' + + def _client_ip_from_userdata(self, user_data): + """Return a valid client IP from stored userData, tolerating native Amnezia records.""" + return ( + self._extract_ipv4(user_data.get('clientIp')) + or self._extract_ipv4(user_data.get('allowedIps')) + or self._extract_ipv4(user_data.get('allowed_ip')) + ) + + 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 self._base_protocol(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 self._base_protocol(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) + clients_table = self._get_clients_table(protocol_type) + table_changed = False + + if enable: + # Re-add peer to server config. Native Amnezia clients may not have + # userData.clientIp in clientsTable, so recover it from allowedIps + # before falling back to a new free address. + 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.setdefault('userData', {}) + psk = ud.get('psk', '') + client_ip = self._client_ip_from_userdata(ud) + if not client_ip: + client_ip = self._get_next_ip(protocol_type) + logger.warning( + "Client %s had no saved AWG IP/AllowedIPs; assigning next free IP %s", + client_id, + client_ip, + ) + + ud['clientIp'] = client_ip + ud['allowedIps'] = f'{client_ip}/32' + table_changed = True + + if not psk: + psk = self._get_server_psk(protocol_type) + ud['psk'] = psk + table_changed = True + + 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, but first persist its current + # AllowedIPs so native/external clients can be enabled later. + config = self._get_server_config(protocol_type) + conf_peers = self._parse_peers_from_config(protocol_type) + allowed_ips = conf_peers.get(client_id, {}).get('allowedIps', '') + client_ip = self._extract_ipv4(allowed_ips) + client = None + for c in clients_table: + if c.get('clientId') == client_id: + client = c + break + if client is None: + client = { + 'clientId': client_id, + 'userData': { + 'clientName': f'External ({client_ip})' if client_ip else 'External (native app)', + 'externalClient': True, + } + } + clients_table.append(client) + table_changed = True + + ud = client.setdefault('userData', {}) + if client_ip: + ud['clientIp'] = client_ip + ud['allowedIps'] = allowed_ips or f'{client_ip}/32' + table_changed = True + if not ud.get('psk'): + ud['psk'] = self._get_server_psk(protocol_type) + table_changed = True + + 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 + for c in clients_table: + if c.get('clientId') == client_id: + c.setdefault('userData', {})['enabled'] = enable + table_changed = True + break + if table_changed: + 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/backup_manager.py b/managers/backup_manager.py new file mode 100644 index 0000000..35305ed --- /dev/null +++ b/managers/backup_manager.py @@ -0,0 +1,170 @@ +import json +import re +import shlex + + +class BackupManager: + """Create/list downloadable protocol backups on the remote server. + + Backups are intentionally allowlisted per protocol and include only files + needed to recreate protocol state: host config directories, matching + container config directories when present, and docker inspect metadata. + """ + + BACKUP_ROOT = '/opt/amnezia/backups' + + def __init__(self, ssh_manager): + self.ssh = ssh_manager + + @staticmethod + def proto_base(protocol): + return str(protocol or '').split('__', 1)[0] + + @staticmethod + def proto_instance(protocol): + match = re.search(r'__(\d+)$', str(protocol or '')) + return int(match.group(1)) if match else 1 + + @staticmethod + def safe_protocol(protocol): + safe = re.sub(r'[^a-zA-Z0-9_.-]+', '_', str(protocol or '').replace('__', '-')) + return safe.strip('._-') or 'protocol' + + @staticmethod + def safe_filename(filename): + name = str(filename or '') + if not re.fullmatch(r'[A-Za-z0-9_.-]+\.tar\.gz', name): + return None + return name + + def _paths_for(self, protocol, container_name): + base = self.proto_base(protocol) + idx = self.proto_instance(protocol) + paths = { + 'host': [], + 'container': [], + } + + def inst_path(path, suffix_fmt='-{idx}'): + return path if idx <= 1 else f"{path}{suffix_fmt.format(idx=idx)}" + + if base in ('awg', 'awg2', 'awg_legacy'): + paths['host'] = ['/opt/amnezia/awg', f'/opt/amnezia/{container_name}'] + paths['container'] = ['/opt/amnezia/awg', '/opt/amnezia/start.sh'] + elif base == 'wireguard': + paths['host'] = ['/opt/amnezia/wireguard', f'/opt/amnezia/{container_name}'] + paths['container'] = ['/opt/amnezia/wireguard', '/opt/amnezia/start.sh'] + elif base == 'xray': + config_dir = inst_path('/opt/amnezia/xray') + paths['host'] = [config_dir, f'/opt/amnezia/{container_name}'] + paths['container'] = [config_dir] + elif base == 'telemt': + remote_dir = inst_path('/opt/amnezia/telemt') + paths['host'] = [remote_dir] + paths['container'] = [remote_dir] + elif base == 'dns': + paths['host'] = ['/opt/amnezia/dns'] + paths['container'] = ['/opt/amnezia/dns'] + elif base == 'adguard': + paths['host'] = ['/opt/amnezia/adguard'] + paths['container'] = ['/opt/adguardhome/conf', '/opt/adguardhome/work'] + elif base == 'socks5': + config_dir = inst_path('/opt/amnezia/socks5proxy') + paths['host'] = [config_dir] + paths['container'] = ['/etc/3proxy'] + elif base == 'nginx': + paths['host'] = ['/opt/amnezia/nginx'] + paths['container'] = ['/etc/nginx/conf.d', '/usr/share/nginx/html'] + else: + paths['host'] = [f'/opt/amnezia/{base}'] + paths['container'] = [f'/opt/amnezia/{base}'] + + return paths + + def list_backups(self, protocol): + safe_proto = self.safe_protocol(protocol) + backup_dir = f'{self.BACKUP_ROOT}/{safe_proto}' + cmd = ( + f"mkdir -p {shlex.quote(backup_dir)} && " + f"find {shlex.quote(backup_dir)} -maxdepth 1 -type f -name '*.tar.gz' " + "-printf '%f|%s|%T@\\n' 2>/dev/null | sort -t '|' -k3,3nr" + ) + out, err, code = self.ssh.run_sudo_command(cmd) + if code != 0: + return {'status': 'error', 'message': err or out or 'Failed to list backups'} + backups = [] + for line in (out or '').splitlines(): + parts = line.split('|') + if len(parts) != 3: + continue + name, size, mtime = parts + backups.append({ + 'name': name, + 'size': int(float(size or 0)), + 'mtime': float(mtime or 0), + }) + return {'status': 'success', 'protocol': protocol, 'backups': backups} + + def create_backup(self, protocol, container_name): + safe_proto = self.safe_protocol(protocol) + backup_dir = f'{self.BACKUP_ROOT}/{safe_proto}' + paths = self._paths_for(protocol, container_name) + host_paths = ' '.join(shlex.quote(p) for p in paths['host']) + container_paths = ' '.join(shlex.quote(p) for p in paths['container']) + protocol_q = shlex.quote(str(protocol)) + safe_proto_q = shlex.quote(safe_proto) + container_q = shlex.quote(str(container_name or '')) + backup_dir_q = shlex.quote(backup_dir) + + script = f""" +set -eu +umask 077 +protocol={protocol_q} +safe_proto={safe_proto_q} +container={container_q} +backup_dir={backup_dir_q} +timestamp=$(date -u +%Y%m%dT%H%M%SZ) +work_dir=$(mktemp -d /tmp/amnezia-backup-${{safe_proto}}.XXXXXX) +cleanup() {{ rm -rf "$work_dir"; }} +trap cleanup EXIT +mkdir -p "$backup_dir" "$work_dir/host" "$work_dir/container" "$work_dir/docker" +cat > "$work_dir/backup-info.json" </dev/null 2>&1; then + mkdir -p "$work_dir/container$(dirname "$src")" + docker cp "$container:$src" "$work_dir/container$src" >/dev/null 2>&1 || true + fi +}} +for p in {host_paths}; do copy_host_path "$p"; done +for p in {container_paths}; do copy_container_path "$p"; done +if [ -n "$container" ] && docker inspect "$container" >/dev/null 2>&1; then + docker inspect "$container" > "$work_dir/docker/inspect.json" 2>/dev/null || true + docker logs --tail 300 "$container" > "$work_dir/docker/logs-tail.txt" 2>&1 || true +fi +archive="$backup_dir/${{safe_proto}}-${{timestamp}}.tar.gz" +tar -C "$work_dir" -czf "$archive" . +chmod 0644 "$archive" +printf '%s\n' "$archive" +""".strip() + + out, err, code = self.ssh.run_sudo_command(script) + if code != 0: + return {'status': 'error', 'message': err or out or 'Failed to create backup'} + path = (out or '').strip().splitlines()[-1] if (out or '').strip() else '' + name = path.rsplit('/', 1)[-1] if path else '' + return {'status': 'success', 'protocol': protocol, 'backup': {'name': name, 'path': path}} 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/nginx_manager.py b/managers/nginx_manager.py new file mode 100644 index 0000000..0a98414 --- /dev/null +++ b/managers/nginx_manager.py @@ -0,0 +1,383 @@ +""" +NGINX Web Server Manager. + +Runs an NGINX container with a single editable site (index.html) and a +Let's Encrypt certificate issued through the webroot HTTP-01 challenge. +The panel owns: + - nginx.conf: editable through the Config button + - index.html: editable through the Site button +""" + +import json +import logging +import re +import shlex + +logger = logging.getLogger(__name__) + + +def _q(value): + return shlex.quote(str(value)) + + +class NginxManager: + PROTOCOL = 'nginx' + CONTAINER_NAME = 'amnezia-nginx' + IMAGE_NAME = 'nginx:alpine' + CERTBOT_IMAGE = 'certbot/certbot:latest' + CERTBOT_CONTAINER_NAME = 'amnezia-nginx-certbot' + BASE_DIR = '/opt/amnezia/nginx' + CONF_DIR = f'{BASE_DIR}/conf' + HTML_DIR = f'{BASE_DIR}/html' + CERTBOT_WWW_DIR = f'{BASE_DIR}/certbot/www' + LETSENCRYPT_DIR = f'{BASE_DIR}/letsencrypt' + META_PATH = f'{BASE_DIR}/metadata.json' + NGINX_CONF_PATH = f'{CONF_DIR}/default.conf' + INDEX_PATH = f'{HTML_DIR}/index.html' + DEFAULT_PORT = 443 + + def __init__(self, ssh, protocol='nginx'): + self.ssh = ssh + self.protocol = protocol or self.PROTOCOL + + # ===================== 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='nginx'): + 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, protocol_type='nginx'): + out, _, _ = self.ssh.run_sudo_command( + f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'" + ) + return 'Up' in out + + def check_certbot_running(self): + out, _, _ = self.ssh.run_sudo_command( + f"docker ps --filter name=^{self.CERTBOT_CONTAINER_NAME}$ --format '{{{{.Status}}}}'" + ) + return 'Up' in out + + def get_server_status(self, protocol_type='nginx'): + exists = self.check_protocol_installed(protocol_type) + running = self.check_container_running(protocol_type) + meta = self._read_metadata() if exists else {} + config = self._get_server_config(protocol_type) if exists else '' + domain = meta.get('domain') or self._parse_domain(config) + port = int(meta.get('port') or self.DEFAULT_PORT) + return { + 'container_exists': exists, + 'container_running': running, + 'port': port, + 'domain': domain, + 'email': meta.get('email'), + 'site_url': self._site_url(domain, port) if domain else None, + 'protocol': protocol_type, + 'base_protocol': self.PROTOCOL, + 'instance': 1, + 'container_name': self.CONTAINER_NAME, + 'certbot_container_name': self.CERTBOT_CONTAINER_NAME, + 'certbot_running': self.check_certbot_running() if exists else False, + } + + # ===================== CONFIG BUILDERS ===================== + + def _validate_domain(self, domain): + domain = (domain or '').strip().lower() + if not domain or len(domain) > 253: + raise ValueError('Domain is required') + if not re.match(r'^(?!-)[a-z0-9.-]+(? + + + + + {domain} + + + +
+

NGINX is running

+

{domain}

+
+ + +""" + + def _parse_domain(self, config_text): + m = re.search(r'^\s*server_name\s+([^;\s]+)', config_text or '', re.MULTILINE) + return m.group(1) if m else None + + # ===================== FILE I/O ===================== + + def _read_file(self, path): + out, _, code = self.ssh.run_sudo_command(f"cat {_q(path)} 2>/dev/null") + return out if code == 0 else '' + + def _write_file(self, path, content): + parent = path.rsplit('/', 1)[0] + self.ssh.run_sudo_command(f"mkdir -p {_q(parent)}") + self.ssh.upload_file_sudo(content, path) + + def _read_metadata(self): + raw = self._read_file(self.META_PATH) + if not raw.strip(): + return {} + try: + return json.loads(raw) + except Exception: + return {} + + def _write_metadata(self, meta): + self._write_file(self.META_PATH, json.dumps(meta, indent=2, ensure_ascii=False)) + + def _get_server_config(self, protocol_type='nginx'): + config = self._read_file(self.NGINX_CONF_PATH) + if config: + return config + out, _, code = self.ssh.run_sudo_command( + f"docker exec {self.CONTAINER_NAME} cat /etc/nginx/conf.d/default.conf 2>/dev/null" + ) + return out if code == 0 else '' + + def save_server_config(self, protocol_type='nginx', config_text=''): + old_config = self._get_server_config(protocol_type) + self._write_file(self.NGINX_CONF_PATH, config_text or '') + if self.check_container_running(protocol_type): + _, err, code = self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -t", timeout=30) + if code != 0: + self._write_file(self.NGINX_CONF_PATH, old_config) + self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -s reload 2>/dev/null || true") + raise RuntimeError(f'Invalid NGINX config: {err}') + self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -s reload") + return True + + def get_site_index(self, protocol_type='nginx'): + return self._read_file(self.INDEX_PATH) + + def save_site_index(self, protocol_type='nginx', html=''): + self._write_file(self.INDEX_PATH, html or '') + return True + + # ===================== INSTALL / REMOVE ===================== + + def _run_nginx_container(self, port): + run_cmd = ( + f"docker run -d --restart always " + f"--name {self.CONTAINER_NAME} " + f"-p 80:80/tcp " + f"-p {int(port)}:443/tcp " + f"-v {self.CONF_DIR}:/etc/nginx/conf.d:ro " + f"-v {self.HTML_DIR}:/usr/share/nginx/html:ro " + f"-v {self.CERTBOT_WWW_DIR}:/var/www/certbot:ro " + f"-v {self.LETSENCRYPT_DIR}:/etc/letsencrypt:ro " + f"{self.IMAGE_NAME}" + ) + return self.ssh.run_sudo_command(run_cmd, timeout=60) + + def _run_certbot_renewer(self, domain, email): + loop_script = f""" +set -eu +if [ ! -f /etc/letsencrypt/live/{domain}/fullchain.pem ]; then + certbot certonly --webroot --webroot-path /var/www/certbot --email {_q(email)} --agree-tos --no-eff-email -d {_q(domain)} +fi +kill -HUP 1 2>/dev/null || true +while :; do + certbot renew --webroot --webroot-path /var/www/certbot --quiet --deploy-hook 'kill -HUP 1 2>/dev/null || true' + sleep 12h & wait $! +done +""" + self.ssh.run_sudo_command(f"docker rm -fv {self.CERTBOT_CONTAINER_NAME} || true") + run_cmd = ( + f"docker run -d --restart always " + f"--name {self.CERTBOT_CONTAINER_NAME} " + f"--pid=container:{self.CONTAINER_NAME} " + f"-v {self.CERTBOT_WWW_DIR}:/var/www/certbot " + f"-v {self.LETSENCRYPT_DIR}:/etc/letsencrypt " + f"--entrypoint /bin/sh " + f"{self.CERTBOT_IMAGE} -c {_q(loop_script)}" + ) + return self.ssh.run_sudo_command(run_cmd, timeout=60) + + def _wait_for_certificate(self, domain): + wait_script = f""" +for i in $(seq 1 60); do + if [ -s {self.LETSENCRYPT_DIR}/live/{domain}/fullchain.pem ] && [ -s {self.LETSENCRYPT_DIR}/live/{domain}/privkey.pem ]; then + exit 0 + fi + if ! docker ps --filter name=^{self.CERTBOT_CONTAINER_NAME}$ --format '{{{{.Names}}}}' | grep -qx {self.CERTBOT_CONTAINER_NAME}; then + docker logs --tail 120 {self.CERTBOT_CONTAINER_NAME} 2>&1 || true + exit 1 + fi + sleep 5 +done +docker logs --tail 120 {self.CERTBOT_CONTAINER_NAME} 2>&1 || true +exit 1 +""" + return self.ssh.run_sudo_command(f"sh -c {_q(wait_script)}", timeout=330) + + def install_protocol(self, protocol_type='nginx', port=None, email=None, domain=None): + if not self.check_docker_installed(): + return {'status': 'error', 'message': 'Docker not installed'} + + port = int(port or self.DEFAULT_PORT) + if port == 80: + return {'status': 'error', 'message': 'Port 80 is reserved for Let\'s Encrypt validation'} + domain = self._validate_domain(domain) + email = self._validate_email(email) + + self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}", timeout=180) + self.ssh.run_sudo_command(f"docker pull {self.CERTBOT_IMAGE}", timeout=180) + + if self.check_protocol_installed(protocol_type): + self.remove_container(protocol_type) + + self.ssh.run_sudo_command( + f"mkdir -p {self.CONF_DIR} {self.HTML_DIR} {self.CERTBOT_WWW_DIR} {self.LETSENCRYPT_DIR}" + ) + self._write_file(self.NGINX_CONF_PATH, self._build_initial_config(domain)) + self._write_file(self.INDEX_PATH, self._default_index(domain)) + self._write_metadata({'domain': domain, 'email': email, 'port': port}) + + _, err, code = self._run_nginx_container(port) + if code != 0: + return {'status': 'error', 'message': f'Failed to start NGINX container: {err}'} + + _, err, code = self._run_certbot_renewer(domain, email) + if code != 0: + self.ssh.run_sudo_command(f"docker logs --tail 100 {self.CONTAINER_NAME} 2>/dev/null || true") + 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") + return {'status': 'error', 'message': f'Failed to start certbot renewer container: {err}'} + + out, err, code = self._wait_for_certificate(domain) + if code != 0: + self.ssh.run_sudo_command(f"docker logs --tail 120 {self.CERTBOT_CONTAINER_NAME} 2>/dev/null || true") + self.ssh.run_sudo_command(f"docker rm -fv {self.CERTBOT_CONTAINER_NAME} || true") + 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") + return { + 'status': 'error', + 'message': f"Let's Encrypt certificate issue failed. Make sure {domain} points to this server and port 80 is reachable. {err or out}", + } + + self._write_file(self.NGINX_CONF_PATH, self._build_ssl_config(domain, port)) + _, err, code = self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -t", timeout=30) + if code != 0: + return {'status': 'error', 'message': f'Generated NGINX config is invalid: {err}'} + self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -s reload") + + url = self._site_url(domain, port) + return { + 'status': 'success', + 'protocol': protocol_type, + 'base_protocol': self.PROTOCOL, + 'instance': 1, + 'container_name': self.CONTAINER_NAME, + 'certbot_container_name': self.CERTBOT_CONTAINER_NAME, + 'certbot_running': self.check_certbot_running(), + 'port': port, + 'domain': domain, + 'email': email, + 'site_url': url, + 'message': 'NGINX web server installed', + 'log': [ + f'NGINX HTTPS port: {port}/TCP', + 'Port 80/TCP is used for Let\'s Encrypt HTTP-01 validation', + f'Certificate issued for {domain}', + f'Auto-renewal container: {self.CERTBOT_CONTAINER_NAME}', + f'Site URL: {url}', + ], + } + + def remove_container(self, protocol_type='nginx'): + self.ssh.run_sudo_command(f"docker stop {self.CERTBOT_CONTAINER_NAME} || true") + self.ssh.run_sudo_command(f"docker rm -fv {self.CERTBOT_CONTAINER_NAME} || true") + 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.BASE_DIR}") + return True diff --git a/managers/socks5_manager.py b/managers/socks5_manager.py new file mode 100644 index 0000000..7226cb7 --- /dev/null +++ b/managers/socks5_manager.py @@ -0,0 +1,240 @@ +""" +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 = '/etc/3proxy/3proxy.cfg' + + DEFAULT_PORT = 38080 + DEFAULT_USERNAME = 'proxy_user' + + def __init__(self, ssh, protocol='socks5'): + self.ssh = ssh + self.protocol = protocol or self.PROTOCOL + self.instance = self._instance_index(self.protocol) + self.container_name = self._container_name(self.protocol) + self.config_dir = self._config_dir(self.protocol) + + def _instance_index(self, protocol=None): + parts = str(protocol or self.protocol or '').split('__', 1) + if len(parts) == 2: + try: + return max(1, int(parts[1])) + except ValueError: + return 1 + return 1 + + def _container_name(self, protocol=None): + idx = self._instance_index(protocol or self.protocol) + return self.CONTAINER_NAME if idx <= 1 else f'{self.CONTAINER_NAME}-{idx}' + + def _config_dir(self, protocol=None): + idx = self._instance_index(protocol or self.protocol) + return self.CONFIG_DIR if idx <= 1 else f'{self.CONFIG_DIR}-{idx}' + + # ===================== 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(protocol_type)}$ --format '{{{{.Names}}}}'" + ) + return self._container_name(protocol_type) in out.strip().split('\n') + + def check_container_running(self, protocol_type='socks5'): + out, _, _ = self.ssh.run_sudo_command( + f"docker ps --filter name=^{self._container_name(protocol_type)}$ --format '{{{{.Status}}}}'" + ) + return 'Up' in out + + def get_server_status(self, protocol_type='socks5'): + exists = self.check_protocol_installed(protocol_type) + running = self.check_container_running(protocol_type) + 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, + 'base_protocol': self.PROTOCOL, + 'instance': self._instance_index(protocol_type), + 'container_name': self._container_name(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\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. + install_protocol = protocol_type or self.protocol + container_name = self._container_name(install_protocol) + config_dir = self._config_dir(install_protocol) + + if self.check_protocol_installed(install_protocol): + self.remove_container(install_protocol) + + config_text = self._build_config(username, password, port) + self.ssh.run_sudo_command(f"mkdir -p {config_dir}") + self.ssh.upload_file_sudo(config_text, f"{config_dir}/3proxy.cfg") + + # The 3proxy image reads /etc/3proxy/3proxy.cfg by default. + # Do not pass the config path as the container command: Docker would try + # to execute the config file and fail with "permission denied". + run_cmd = ( + f"docker run -d --restart always " + f"--name {container_name} " + f"-p {port}:{port}/tcp " + f"-v {config_dir}:/etc/3proxy:ro " + 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}'} + + return { + 'status': 'success', + 'protocol': install_protocol, + 'base_protocol': self.PROTOCOL, + 'instance': self._instance_index(install_protocol), + 'container_name': container_name, + '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(self.protocol): + 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( + protocol_type=self.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(protocol_type)} || true") + self.ssh.run_sudo_command(f"docker rm -fv {self._container_name(protocol_type)} || true") + self.ssh.run_sudo_command(f"rm -rf {self._config_dir(protocol_type)}") + 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..70d8c89 --- /dev/null +++ b/managers/telemt_manager.py @@ -0,0 +1,571 @@ +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, protocol='telemt'): + self.ssh = ssh_manager + self.protocol = protocol or 'telemt' + self.instance = self._instance_index(self.protocol) + self.container_name = self._container_name(self.protocol) + self.remote_dir = self._remote_dir(self.protocol) + + def _instance_index(self, protocol): + parts = str(protocol or '').split('__', 1) + if len(parts) == 2: + try: + return max(1, int(parts[1])) + except ValueError: + return 1 + return 1 + + def _container_name(self, protocol=None): + idx = self._instance_index(protocol or self.protocol) + base_name = self.CONTAINER_NAME + return base_name if idx <= 1 else f'{base_name}-{idx}' + + def _remote_dir(self, protocol=None): + idx = self._instance_index(protocol or self.protocol) + return '/opt/amnezia/telemt' if idx <= 1 else f'/opt/amnezia/telemt-{idx}' + + def _config_path(self): + return f'{self.remote_dir}/config.toml' + + def _api_host_ports(self): + # Host API mappings are only for diagnostics; panel talks via docker exec. + # Keep first instance backward-compatible, avoid 9090/9091 collisions later. + if self.instance <= 1: + return 9090, 9091 + base = 9090 + (self.instance * 10) + return base, base + 1 + + 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 = self.remote_dir + 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) + compose_content = re.sub(r'container_name:\s*telemt', f'container_name: {self.container_name}', compose_content) + if self.instance > 1: + api_port_9090, api_port_9091 = self._api_host_ports() + compose_content = re.sub(r'"127\.0\.0\.1:9090:9090"', f'"127.0.0.1:{api_port_9090}:9090"', compose_content) + compose_content = re.sub(r'"127\.0\.0\.1:9091:9091"', f'"127.0.0.1:{api_port_9091}:9091"', 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", + "protocol": self.protocol, + "host": "", + "port": port, + "log": results + } + + def _get_server_config(self): + out, _, code = self.ssh.run_sudo_command(f"cat {self._config_path()}") + 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'), f"{self._config_path()}") + # 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(f"rm -rf {self.remote_dir}") + + 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'), f"{self._config_path()}") + + # 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'), f"{self._config_path()}") + + # 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'), f"{self._config_path()}") + + 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'), f"{self._config_path()}") + + 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..3483fa5 --- /dev/null +++ b/managers/xray_manager.py @@ -0,0 +1,800 @@ +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, protocol='xray'): + self.ssh = ssh_manager + self.protocol = protocol or self.PROTOCOL + self.base_protocol = str(self.protocol).split('__', 1)[0] + self.instance = self._instance_index(self.protocol) + self.container_name = self._container_name(self.protocol) + self.image_name = self._image_name(self.protocol) + + def _instance_index(self, protocol): + parts = str(protocol or '').split('__', 1) + if len(parts) == 2: + try: + return max(1, int(parts[1])) + except ValueError: + return 1 + return 1 + + def _container_name(self, protocol=None): + idx = self._instance_index(protocol or self.protocol) + base_name = self.CONTAINER_NAME + return base_name if idx <= 1 else f'{base_name}-{idx}' + + def _image_name(self, protocol=None): + idx = self._instance_index(protocol or self.protocol) + base_name = self.IMAGE_NAME + return base_name if idx <= 1 else f'{base_name}-{idx}' + + def _config_dir(self): + return '/opt/amnezia/xray' if self.instance <= 1 else f'/opt/amnezia/xray-{self.instance}' + + 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...") + config_dir = self._config_dir() + dockerfile_folder = f"/opt/amnezia/{self.container_name}" + dockerfile_content = f"""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 {config_dir} +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 {config_dir}/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(f"mkdir -p {config_dir}") + self.ssh.upload_file_sudo(json.dumps(server_json, indent=2), f"{config_dir}/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 {config_dir}:{config_dir} \\ +--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': self.protocol, '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}") + if self.instance <= 1: + 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_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..ac29a22 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,41 @@ +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 +psycopg[binary]==3.2.9 +psycopg_pool==3.2.6 +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..850cde9 --- /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-nginx .protocol-icon { + background: linear-gradient(135deg, rgba(14, 165, 233, 0.2), rgba(59, 130, 246, 0.1)); + color: #38bdf8; +} + +/* ============================================================ + 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..48fe59b --- /dev/null +++ b/telegram_bot.py @@ -0,0 +1,1137 @@ +""" +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 html +import logging +import os +import shlex +import sys +import time +import uuid +from typing import Optional, Callable + +import httpx + +logger = logging.getLogger(__name__) + +# ----------------------------------------------------------------------- # +# Global state +# ----------------------------------------------------------------------- # +_bot_task: Optional[asyncio.Task] = None +_callback_refs = {} +_pending_inputs = {} + +CLIENT_PROTOCOLS = {"awg", "awg2", "awg_legacy", "xray", "telemt", "wireguard"} +SERVICE_PROTOCOLS = {"dns", "adguard", "socks5", "nginx"} + + +# ----------------------------------------------------------------------- # +# Public lifecycle +# ----------------------------------------------------------------------- # +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, save_data_fn: Optional[Callable] = None): + global _bot_task + _bot_task = asyncio.create_task( + _run_bot(token, load_data_fn, generate_vpn_link_fn, save_data_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() + + +# ----------------------------------------------------------------------- # +# Generic helpers +# ----------------------------------------------------------------------- # +def _e(value) -> str: + return html.escape(str(value if value is not None else "")) + + +def _format_bytes(value) -> str: + try: + value = float(value or 0) + except Exception: + value = 0 + units = ["B", "KB", "MB", "GB", "TB"] + idx = 0 + while value >= 1024 and idx < len(units) - 1: + value /= 1024 + idx += 1 + if idx == 0: + return f"{int(value)} {units[idx]}" + return f"{value:.2f} {units[idx]}" + + +def _proto_base(protocol: str) -> str: + return str(protocol or "awg").split("__", 1)[0] + + +def _protocol_display_name(protocol: str) -> str: + base = _proto_base(protocol) + names = { + "awg": "AmneziaWG", + "awg2": "AmneziaWG 2.0", + "awg_legacy": "AmneziaWG Legacy", + "xray": "Xray", + "telemt": "Telemt", + "dns": "AmneziaDNS", + "wireguard": "WireGuard", + "socks5": "SOCKS5", + "adguard": "AdGuard Home", + "nginx": "NGINX", + } + name = names.get(base, base) + if "__" in str(protocol): + try: + return f"{name} #{int(str(protocol).split('__', 1)[1])}" + except Exception: + return name + return name + + +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 _is_admin(panel_user: dict) -> bool: + return str((panel_user or {}).get("role", "")).lower() == "admin" + + +def _ref(action: str, payload: dict) -> str: + """Short callback_data indirection; Telegram callback_data is limited to 64 bytes.""" + key = uuid.uuid4().hex[:12] + _callback_refs[key] = {"action": action, "payload": payload, "ts": time.time()} + # Opportunistic cleanup. + if len(_callback_refs) > 500: + cutoff = time.time() - 6 * 3600 + for k in [k for k, v in _callback_refs.items() if v.get("ts", 0) < cutoff]: + _callback_refs.pop(k, None) + return f"r:{key}" + + +def _resolve_ref(data_str: str): + if not data_str.startswith("r:"): + return None + return _callback_refs.get(data_str[2:]) + + +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 isinstance(sid, int) and 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}" + rows.append([{"text": label, "callback_data": f"cfg:{c['id']}"}]) + rows.append([{"text": "🔄 Refresh list", "callback_data": "refresh"}]) + return {"inline_keyboard": rows} + + +def _connection_lookup(data: dict, server_id: int, proto: str) -> dict: + return { + c.get("client_id"): c + for c in data.get("user_connections", []) + if c.get("server_id") == server_id and c.get("protocol") == proto and c.get("client_id") + } + + +def _client_display_name(client: dict, conn: Optional[dict] = None) -> str: + if conn and conn.get("name"): + return conn.get("name") + user_data = client.get("userData") or {} + return ( + client.get("name") + or client.get("username") + or user_data.get("clientName") + or user_data.get("name") + or str(client.get("clientId") or client.get("client_id") or client.get("id") or "Connection")[:12] + ) + + +def _user_label(user: dict) -> str: + label = user.get("username") or user.get("id", "user") + role = user.get("role") or "user" + suffix = f" · {role}" + if user.get("telegramId"): + suffix += f" · tg:{user.get('telegramId')}" + if user.get("enabled") is False: + suffix += " · disabled" + return f"{label}{suffix}" + + +def _users_keyboard(data: dict, back_callback: str = "adm:menu") -> dict: + rows = [] + for user in data.get("users", [])[:40]: + rows.append([{"text": f"👤 {_user_label(user)}", "callback_data": _ref("user", {"uid": user.get("id")})}]) + rows.append([{"text": "⬅️ Back", "callback_data": back_callback}]) + return {"inline_keyboard": rows} + + +def _assign_user_keyboard(data: dict, server_id: int, proto: str, name: str) -> dict: + rows = [[{"text": "🚫 Do not assign", "callback_data": _ref("create_client", {"sid": server_id, "proto": proto, "name": name, "user_id": None})}]] + for user in data.get("users", [])[:40]: + rows.append([{"text": f"👤 {_user_label(user)}", "callback_data": _ref("create_client", {"sid": server_id, "proto": proto, "name": name, "user_id": user.get("id")})}]) + rows.append([{"text": "❌ Cancel", "callback_data": _ref("clients", {"sid": server_id, "proto": proto})}]) + return {"inline_keyboard": rows} + + +def _admin_main_keyboard() -> dict: + return { + "inline_keyboard": [ + [{"text": "🖥 Servers", "callback_data": "adm:servers"}], + [{"text": "👤 Users", "callback_data": "adm:users"}], + [{"text": "🔐 My connections", "callback_data": "adm:myconns"}], + [{"text": "➕ How to add a server", "callback_data": "adm:addserver_help"}], + ] + } + + +def _server_keyboard(data: dict) -> dict: + rows = [] + for sid, srv in enumerate(data.get("servers", [])): + name = srv.get("name") or srv.get("host") or f"Server {sid + 1}" + rows.append([{"text": f"🖥 {name}", "callback_data": f"srv:{sid}"}]) + rows.append([{"text": "⬅️ Admin menu", "callback_data": "adm:menu"}]) + return {"inline_keyboard": rows} + + +def _protocol_status_icon(info: dict) -> str: + if info.get("status_error"): + return "⚪" + running = info.get("container_running") + if running is True: + return "🟢" + if running is False: + return "🔴" + return "⚪" + + +def _protocol_status_text(info: dict) -> str: + if info.get("status_error"): + return "unknown ⚪" + running = info.get("container_running") + if running is True: + return "running 🟢" + if running is False: + return "stopped 🔴" + return "unknown ⚪" + + +def _protocols_keyboard(server_id: int, server: dict) -> dict: + rows = [] + protocols = server.get("protocols", {}) or {} + for proto, info in protocols.items(): + installed = "✅" if info.get("installed", True) else "⚪" + rows.append([{"text": f"{installed}{_protocol_status_icon(info)} {_protocol_display_name(proto)}", "callback_data": _ref("proto", {"sid": server_id, "proto": proto})}]) + if not rows: + rows.append([{"text": "No installed protocols", "callback_data": f"noop"}]) + rows.append([{"text": "⬅️ Servers", "callback_data": "adm:servers"}]) + return {"inline_keyboard": rows} + + +def _protocol_keyboard(server_id: int, proto: str, proto_info: dict) -> dict: + base = _proto_base(proto) + rows = [] + if base in CLIENT_PROTOCOLS: + rows.append([{"text": "👥 Connections", "callback_data": _ref("clients", {"sid": server_id, "proto": proto})}]) + rows.append([{"text": "➕ Create connection", "callback_data": _ref("add_client", {"sid": server_id, "proto": proto})}]) + is_running = proto_info.get("container_running") is True + rows.append([{"text": "⏹ Stop" if is_running else "▶️ Start", "callback_data": _ref("toggle_proto", {"sid": server_id, "proto": proto, "start": not is_running})}]) + rows.append([{"text": "⬅️ Protocols", "callback_data": f"srv:{server_id}"}]) + return {"inline_keyboard": rows} + + +def _client_keyboard(server_id: int, proto: str, client: dict) -> dict: + client_id = client.get("clientId") or client.get("client_id") or client.get("id") or "" + enabled = client.get("enabled") + if enabled is None: + enabled = client.get("isEnabled") + enabled = bool(enabled) if enabled is not None else True + return { + "inline_keyboard": [ + [{"text": "📄 Config", "callback_data": _ref("client_cfg", {"sid": server_id, "proto": proto, "client_id": client_id, "name": client.get("name") or client.get("username") or "Connection"})}], + [{"text": "🚫 Disable" if enabled else "✅ Enable", "callback_data": _ref("toggle_client", {"sid": server_id, "proto": proto, "client_id": client_id, "enable": not enabled})}], + [{"text": "🗑 Delete", "callback_data": _ref("remove_client", {"sid": server_id, "proto": proto, "client_id": client_id})}], + [{"text": "⬅️ Connections", "callback_data": _ref("clients", {"sid": server_id, "proto": proto})}], + ] + } + + +def _get_ssh_and_manager(server: dict, proto: str): + 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 + from managers.telemt_manager import TelemtManager + from managers.wireguard_manager import WireGuardManager + from managers.dns_manager import DNSManager + from managers.socks5_manager import Socks5Manager + from managers.adguard_manager import AdguardManager + from managers.nginx_manager import NginxManager + + ssh = SSHManager( + server["host"], + server.get("ssh_port", 22), + server["username"], + server.get("password", ""), + server.get("private_key", ""), + ) + base = _proto_base(proto) + if base == "xray": + manager = XrayManager(ssh, proto) + elif base == "telemt": + manager = TelemtManager(ssh, proto) + elif base == "wireguard": + manager = WireGuardManager(ssh) + elif base == "dns": + manager = DNSManager(ssh) + elif base == "socks5": + manager = Socks5Manager(ssh, proto) + elif base == "adguard": + manager = AdguardManager(ssh) + elif base == "nginx": + manager = NginxManager(ssh, proto) + else: + manager = AWGManager(ssh) + return ssh, manager + + +def _manager_call(manager, method_name: str, proto: str, *args, **kwargs): + method = getattr(manager, method_name) + try: + return method(proto, *args, **kwargs) + except TypeError: + return method(*args, **kwargs) + + +def _refresh_server_protocol_statuses(server: dict) -> dict: + """Refresh saved protocol metadata with live Docker status for Telegram admin views.""" + protocols = server.get("protocols", {}) or {} + if not protocols: + return server + + ssh = None + try: + ssh, _ = _get_ssh_and_manager(server, "awg") + ssh.connect() + for proto, info in protocols.items(): + container = info.get("container_name") + if not container: + try: + _, manager = _get_ssh_and_manager(server, proto) + container = getattr(manager, "container_name", None) or getattr(manager, "CONTAINER_NAME", None) + except Exception: + container = None + if not container: + info["status_error"] = "Container name is unknown" + continue + out, err, code = ssh.run_sudo_command( + f"docker inspect -f '{{{{.State.Running}}}}' {shlex.quote(str(container))} 2>/dev/null" + ) + if code == 0: + info["container_running"] = out.strip().lower() == "true" + info["container_exists"] = True + info.pop("status_error", None) + else: + info["container_running"] = False + info["container_exists"] = False + info["status_error"] = (err or out or "container not found").strip() + except Exception as e: + logger.warning("Telegram bot: failed to refresh protocol statuses for %s: %s", server.get("name") or server.get("host"), e) + for info in protocols.values(): + info["status_error"] = str(e) + finally: + if ssh: + try: + ssh.disconnect() + except Exception: + pass + return server + + +async def _refresh_server_protocol_statuses_async(server: dict) -> dict: + return await asyncio.to_thread(_refresh_server_protocol_statuses, server) + + +# ----------------------------------------------------------------------- # +# /start and user connection handlers +# ----------------------------------------------------------------------- # +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, {_e(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: {_e(tg_id)}", + ) + return + + if _is_admin(panel_user): + await api.send_message( + chat_id, + f"👋 Hi, {_e(first_name)}!\n\n" + f"You are registered as {_e(panel_user.get('username'))} with Admin role.\n" + "Choose an action:", + reply_markup=_admin_main_keyboard(), + ) + return + + await _send_user_connections(api, chat_id, panel_user, load_data_fn, first_name=first_name) + + +async def _send_user_connections(api: TelegramAPI, chat_id: int, panel_user: dict, load_data_fn: Callable, first_name: str = ""): + data = load_data_fn() + conns = [c for c in data.get("user_connections", []) if c.get("user_id") == panel_user.get("id")] + + if not conns: + greeting = f"👋 Hi, {_e(first_name)}!\n\n" if first_name else "" + await api.send_message( + chat_id, + greeting + f"You are registered as {_e(panel_user.get('username'))}.\n\n" + "You have no connections yet. Please contact your administrator.", + ) + return + + kb = _build_connections_keyboard(conns, data) + greeting = f"👋 Hi, {_e(first_name)}!\n\n" if first_name else "" + await api.send_message( + chat_id, + greeting + f"You are registered as {_e(panel_user.get('username'))}.\n\n" + f"Your connections ({len(conns)}) — tap to get config:", + reply_markup=kb, + ) + + +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.get("user_id") == panel_user.get("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) + + +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.get("id") == conn_id and (_is_admin(panel_user) or c.get("user_id") == panel_user.get("id"))), None) + if not conn: + await api.send_message(chat_id, "❌ Connection not found.") + return + + servers = data.get("servers", []) + sid = conn.get("server_id") + if not isinstance(sid, int) or sid >= len(servers): + await api.send_message(chat_id, "❌ Server not found.") + return + + await _send_config_by_client(api, chat_id, servers[sid], conn.get("protocol", "awg"), conn.get("client_id"), conn.get("name", "Connection"), generate_vpn_link_fn) + + +async def _send_config_by_client(api: TelegramAPI, chat_id: int, server: dict, proto: str, client_id: str, conn_name: str, generate_vpn_link_fn: Callable): + loading_result = await api.send_message(chat_id, f"⏳ Fetching config for {_e(conn_name)}...") + loading_msg_id = loading_result.get("result", {}).get("message_id") + try: + proto_info = server.get("protocols", {}).get(proto, {}) + port = proto_info.get("port", "55424") + + def _get_cfg(): + ssh, manager = _get_ssh_and_manager(server, proto) + try: + ssh.connect() + return _manager_call(manager, "get_client_config", proto, client_id, server["host"], port) + finally: + ssh.disconnect() + + 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 + + if loading_msg_id: + await api.call("deleteMessage", chat_id=chat_id, message_id=loading_msg_id) + + server_name = server.get("name") or server.get("host", "Unknown") + await api.send_message(chat_id, f"✅ {_e(conn_name)}\n🌐 Server: {_e(server_name)}\n🔌 Protocol: {_e(proto.upper())}") + + is_link_proto = _proto_base(proto) in ("xray", "telemt") + if is_link_proto: + await api.send_message(chat_id, f"🔗 Connection link (tap to copy):\n{_e(config)}") + else: + MAX_LEN = 4000 + if len(config) <= MAX_LEN: + await api.send_message(chat_id, f"📄 Configuration:\n
{_e(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
{_e(chunk)}
") + + vpn_link = generate_vpn_link_fn(config) if config else "" + if vpn_link: + await api.send_message(chat_id, f"🔗 VPN Link (tap to copy):\n{_e(vpn_link)}") + filename = f"{str(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(e)}") + else: + await api.send_message(chat_id, f"❌ Error: {_e(e)}") + + +# ----------------------------------------------------------------------- # +# Admin handlers +# ----------------------------------------------------------------------- # +def _require_admin(load_data_fn: Callable, tg_id: str): + user = _find_user(load_data_fn, tg_id) + if not user or not _is_admin(user): + return None + return user + + +async def _handle_add_server_command(api: TelegramAPI, msg: dict, load_data_fn: Callable, save_data_fn: Optional[Callable]): + chat_id = msg["chat"]["id"] + tg_id = str(msg["from"]["id"]) + if not _require_admin(load_data_fn, tg_id): + await api.send_message(chat_id, "❌ Access denied.") + return + if not save_data_fn: + await api.send_message(chat_id, "❌ Saving is not available for this bot instance.") + return + + text = msg.get("text", "") + parts = text.split(maxsplit=5) + if len(parts) < 4: + await api.send_message( + chat_id, + "Usage:\n" + "/addserver host username password [ssh_port] [name]\n\n" + "Example:\n" + "/addserver 203.0.113.10 root myPassword 22 Prod VPS\n\n" + "⚠️ Telegram messages are not a secrets manager. Prefer adding servers in the web panel if possible.", + ) + return + + host = parts[1] + username = parts[2] + password = parts[3] + ssh_port = 22 + name = host + if len(parts) >= 5: + try: + ssh_port = int(parts[4]) + except Exception: + name = parts[4] + if len(parts) >= 6: + name = parts[5] or host + + data = load_data_fn() + data.setdefault("servers", []).append({ + "name": name, + "host": host, + "ssh_port": ssh_port, + "username": username, + "password": password, + "private_key": "", + "protocols": {}, + "created_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + }) + save_data_fn(data) + await api.send_message(chat_id, f"✅ Server added: {_e(name)}\nHost: {_e(host)}") + + +async def _admin_servers(api: TelegramAPI, chat_id: int, message_id: Optional[int], load_data_fn: Callable): + data = load_data_fn() + servers = data.get("servers", []) + text = f"🖥 Servers ({len(servers)})\n\nChoose a server:" + if message_id: + await api.edit_message(chat_id, message_id, text, reply_markup=_server_keyboard(data)) + else: + await api.send_message(chat_id, text, reply_markup=_server_keyboard(data)) + + +async def _admin_users(api: TelegramAPI, chat_id: int, message_id: int, load_data_fn: Callable): + data = load_data_fn() + users = data.get("users", []) + await api.edit_message(chat_id, message_id, f"👤 Users ({len(users)})\n\nChoose a user:", reply_markup=_users_keyboard(data)) + + +async def _admin_user_detail(api: TelegramAPI, chat_id: int, message_id: int, user_id: str, load_data_fn: Callable): + data = load_data_fn() + user = next((u for u in data.get("users", []) if u.get("id") == user_id), None) + if not user: + await api.edit_message(chat_id, message_id, "❌ User not found.", reply_markup={"inline_keyboard": [[{"text": "⬅️ Users", "callback_data": "adm:users"}]]}) + return + conns = [c for c in data.get("user_connections", []) if c.get("user_id") == user_id] + lines = [ + f"👤 {_e(user.get('username'))}", + f"Role: {_e(user.get('role', 'user'))}", + f"Enabled: {'yes ✅' if user.get('enabled', True) else 'no 🚫'}", + f"Telegram ID: {_e(user.get('telegramId') or '-')}", + f"Email: {_e(user.get('email') or '-')}", + f"Connections: {len(conns)}", + ] + if user.get("description"): + lines.append(f"Description: {_e(user.get('description'))}") + rows = [] + servers = data.get("servers", []) + for c in conns[:20]: + sid = c.get("server_id") + server_name = "Unknown" + if isinstance(sid, int) and sid < len(servers): + server_name = servers[sid].get("name") or servers[sid].get("host") or "Unknown" + rows.append([{"text": f"🔐 {c.get('name', 'Connection')} · {c.get('protocol', '').upper()} · {server_name}", "callback_data": f"cfg:{c.get('id')}"}]) + rows.append([{"text": "⬅️ Users", "callback_data": "adm:users"}]) + rows.append([{"text": "⬅️ Admin menu", "callback_data": "adm:menu"}]) + await api.edit_message(chat_id, message_id, "\n".join(lines), reply_markup={"inline_keyboard": rows}) + + +async def _admin_server_detail(api: TelegramAPI, chat_id: int, message_id: int, server_id: int, load_data_fn: Callable): + data = load_data_fn() + servers = data.get("servers", []) + if server_id < 0 or server_id >= len(servers): + await api.edit_message(chat_id, message_id, "❌ Server not found.") + return + server = await _refresh_server_protocol_statuses_async(servers[server_id]) + protocols = server.get("protocols", {}) or {} + text = ( + f"🖥 {_e(server.get('name') or server.get('host'))}\n" + f"Host: {_e(server.get('host'))}\n" + f"SSH: {_e(server.get('username'))}@{_e(server.get('host'))}:{_e(server.get('ssh_port', 22))}\n\n" + f"Protocols ({len(protocols)}):" + ) + await api.edit_message(chat_id, message_id, text, reply_markup=_protocols_keyboard(server_id, server)) + + +async def _admin_protocol_detail(api: TelegramAPI, chat_id: int, message_id: int, server_id: int, proto: str, load_data_fn: Callable): + data = load_data_fn() + servers = data.get("servers", []) + if server_id < 0 or server_id >= len(servers): + await api.edit_message(chat_id, message_id, "❌ Server not found.") + return + server = await _refresh_server_protocol_statuses_async(servers[server_id]) + info = (server.get("protocols", {}) or {}).get(proto) + if not info: + await api.edit_message(chat_id, message_id, "❌ Protocol not found.") + return + lines = [ + f"🔌 {_e(_protocol_display_name(proto))}", + f"Server: {_e(server.get('name') or server.get('host'))}", + f"Status: {_protocol_status_text(info)}", + ] + for key in ("port", "container_name", "domain", "site_url", "web_port", "mode"): + if info.get(key) not in (None, ""): + lines.append(f"{_e(key)}: {_e(info.get(key))}") + if info.get("status_error"): + lines.append(f"status_error: {_e(info.get('status_error'))}") + await api.edit_message(chat_id, message_id, "\n".join(lines), reply_markup=_protocol_keyboard(server_id, proto, info)) + + +async def _admin_toggle_protocol(api: TelegramAPI, chat_id: int, message_id: int, server_id: int, proto: str, start: bool, load_data_fn: Callable): + await api.edit_message(chat_id, message_id, "⏳ Updating protocol container...") + + def _toggle(): + data = load_data_fn() + server = data["servers"][server_id] + ssh, manager = _get_ssh_and_manager(server, proto) + try: + ssh.connect() + container = (server.get("protocols", {}).get(proto, {}) or {}).get("container_name") + if not container: + # fallback: most managers expose CONTAINER_NAME for base/first instances + container = getattr(manager, "CONTAINER_NAME", None) + if not container: + raise RuntimeError("Container name is unknown") + action = "start" if start else "stop" + out, err, code = ssh.run_sudo_command(f"docker {action} {container}") + if code != 0: + raise RuntimeError(err or out or f"docker {action} failed") + return data + finally: + ssh.disconnect() + + try: + await asyncio.to_thread(_toggle) + await _admin_protocol_detail(api, chat_id, message_id, server_id, proto, load_data_fn) + except Exception as e: + logger.exception("Bot admin: protocol toggle failed") + await api.edit_message(chat_id, message_id, f"❌ Error: {_e(e)}", reply_markup={"inline_keyboard": [[{"text": "⬅️ Protocol", "callback_data": _ref("proto", {"sid": server_id, "proto": proto})}]]}) + + +async def _admin_clients(api: TelegramAPI, chat_id: int, message_id: int, server_id: int, proto: str, load_data_fn: Callable): + await api.edit_message(chat_id, message_id, "⏳ Loading connections...") + + def _load_clients(): + data = load_data_fn() + server = data["servers"][server_id] + ssh, manager = _get_ssh_and_manager(server, proto) + try: + ssh.connect() + return data, _manager_call(manager, "get_clients", proto) + finally: + ssh.disconnect() + + try: + data, clients = await asyncio.to_thread(_load_clients) + if not clients: + await api.edit_message(chat_id, message_id, "👥 No connections.", reply_markup={"inline_keyboard": [[{"text": "➕ Create connection", "callback_data": _ref("add_client", {"sid": server_id, "proto": proto})}], [{"text": "⬅️ Protocol", "callback_data": _ref("proto", {"sid": server_id, "proto": proto})}]]}) + return + rows = [] + conn_by_client = _connection_lookup(data, server_id, proto) + users_by_id = {u.get("id"): u for u in data.get("users", [])} + for c in clients[:40]: + client_id = c.get("clientId") or c.get("client_id") or c.get("id") or "" + conn = conn_by_client.get(client_id) + name = _client_display_name(c, conn) + traffic = "" + user_data = c.get("userData") or {} + if user_data: + total = (user_data.get("dataReceivedBytes") or 0) + (user_data.get("dataSentBytes") or 0) + traffic = f" · {_format_bytes(total)}" + assigned = "" + if conn and conn.get("user_id") in users_by_id: + assigned = f" · @{users_by_id[conn.get('user_id')].get('username')}" + c["name"] = name + c["assigned_user_id"] = conn.get("user_id") if conn else None + rows.append([{"text": f"👤 {name}{assigned}{traffic}", "callback_data": _ref("client", {"sid": server_id, "proto": proto, "client_id": client_id, "name": name, "client": c})}]) + rows.append([{"text": "➕ Create connection", "callback_data": _ref("add_client", {"sid": server_id, "proto": proto})}]) + rows.append([{"text": "⬅️ Protocol", "callback_data": _ref("proto", {"sid": server_id, "proto": proto})}]) + await api.edit_message(chat_id, message_id, f"👥 {_e(_protocol_display_name(proto))} connections ({len(clients)})", reply_markup={"inline_keyboard": rows}) + except Exception as e: + logger.exception("Bot admin: load clients failed") + await api.edit_message(chat_id, message_id, f"❌ Error: {_e(e)}", reply_markup={"inline_keyboard": [[{"text": "⬅️ Protocol", "callback_data": _ref("proto", {"sid": server_id, "proto": proto})}]]}) + + +async def _admin_client_detail(api: TelegramAPI, chat_id: int, message_id: int, server_id: int, proto: str, client: dict): + client_id = client.get("clientId") or client.get("client_id") or client.get("id") or "" + name = _client_display_name(client) + user_data = client.get("userData") or {} + rx = user_data.get("dataReceivedBytes") or 0 + tx = user_data.get("dataSentBytes") or 0 + enabled = client.get("enabled") + if enabled is None: + enabled = client.get("isEnabled") + enabled_text = "enabled ✅" if (enabled is None or enabled) else "disabled 🚫" + text = ( + f"👤 {_e(name)}\n" + f"Protocol: {_e(_protocol_display_name(proto))}\n" + f"Client ID: {_e(client_id)}\n" + f"Status: {enabled_text}" + ) + if user_data: + text += f"\nTraffic: {_format_bytes(rx + tx)}\nRX: {_format_bytes(rx)} · TX: {_format_bytes(tx)}" + await api.edit_message(chat_id, message_id, text, reply_markup=_client_keyboard(server_id, proto, client)) + + +async def _admin_add_client(api: TelegramAPI, chat_id: int, message_id: int, server_id: int, proto: str, panel_user: dict, load_data_fn: Callable, save_data_fn: Optional[Callable], generate_vpn_link_fn: Callable): + if not save_data_fn: + await api.edit_message(chat_id, message_id, "❌ Saving is not available for this bot instance.") + return + _pending_inputs[str(chat_id)] = { + "kind": "add_client_name", + "sid": server_id, + "proto": proto, + "admin_user_id": panel_user.get("id"), + "ts": time.time(), + } + await api.edit_message( + chat_id, + message_id, + "➕ Create connection\n\n" + f"Server/protocol: {_e(_protocol_display_name(proto))}\n\n" + "Send the connection name in the next message.\n" + "Example: Ivan iPhone\n\n" + "Send /cancel to cancel.", + reply_markup={"inline_keyboard": [[{"text": "❌ Cancel", "callback_data": _ref("clients", {"sid": server_id, "proto": proto})}]]}, + ) + + +async def _admin_choose_client_user(api: TelegramAPI, chat_id: int, name: str, server_id: int, proto: str, load_data_fn: Callable): + data = load_data_fn() + await api.send_message( + chat_id, + "✅ Connection name: {}\n\n" + "Assign this connection to a panel user?".format(_e(name)), + reply_markup=_assign_user_keyboard(data, server_id, proto, name), + ) + + +async def _admin_create_client(api: TelegramAPI, chat_id: int, message_id: int, server_id: int, proto: str, name: str, user_id: Optional[str], load_data_fn: Callable, save_data_fn: Optional[Callable], generate_vpn_link_fn: Callable): + if not save_data_fn: + await api.edit_message(chat_id, message_id, "❌ Saving is not available for this bot instance.") + return + await api.edit_message(chat_id, message_id, "⏳ Creating connection...") + + def _create(): + data = load_data_fn() + server = data["servers"][server_id] + proto_info = (server.get("protocols", {}) or {}).get(proto, {}) + port = proto_info.get("port", "55424") + ssh, manager = _get_ssh_and_manager(server, proto) + try: + ssh.connect() + if _proto_base(proto) == "telemt": + result = manager.add_client(proto, name, server["host"], port) + elif _proto_base(proto) == "wireguard": + result = manager.add_client(name, server["host"]) + else: + result = manager.add_client(proto, name, server["host"], port) + finally: + ssh.disconnect() + client_id = result.get("client_id") or result.get("clientId") + assigned_user = None + if user_id: + assigned_user = next((u for u in data.get("users", []) if u.get("id") == user_id), None) + if user_id and client_id: + data.setdefault("user_connections", []).append({ + "id": str(uuid.uuid4()), + "user_id": user_id, + "server_id": server_id, + "protocol": proto, + "client_id": client_id, + "name": name, + "created_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + }) + save_data_fn(data) + return server, result, client_id, assigned_user + + try: + server, result, client_id, assigned_user = await asyncio.to_thread(_create) + assigned_text = f"\nAssigned to: {_e(assigned_user.get('username'))}" if assigned_user else "\nAssigned: not linked" + await api.edit_message(chat_id, message_id, f"✅ Connection created: {_e(name)}{assigned_text}") + config = result.get("config") + if config: + await _send_config_text(api, chat_id, server, proto, name, config, generate_vpn_link_fn) + elif client_id: + await _send_config_by_client(api, chat_id, server, proto, client_id, name, generate_vpn_link_fn) + except Exception as e: + logger.exception("Bot admin: add client failed") + await api.edit_message(chat_id, message_id, f"❌ Error: {_e(e)}", reply_markup={"inline_keyboard": [[{"text": "⬅️ Protocol", "callback_data": _ref("proto", {"sid": server_id, "proto": proto})}]]}) + + +async def _send_config_text(api: TelegramAPI, chat_id: int, server: dict, proto: str, conn_name: str, config: str, generate_vpn_link_fn: Callable): + await api.send_message(chat_id, f"✅ {_e(conn_name)}\n🌐 Server: {_e(server.get('name') or server.get('host'))}\n🔌 Protocol: {_e(proto.upper())}") + if _proto_base(proto) in ("xray", "telemt"): + await api.send_message(chat_id, f"🔗 Connection link:\n{_e(config)}") + else: + await api.send_message(chat_id, f"📄 Configuration:\n
{_e(config)}
") + vpn_link = generate_vpn_link_fn(config) if config else "" + if vpn_link: + await api.send_message(chat_id, f"🔗 VPN Link:\n{_e(vpn_link)}") + await api.send_document(chat_id, filename=f"{conn_name}.conf", content=config.encode("utf-8"), caption=f"📁 Config file: {conn_name}") + + +async def _admin_toggle_client(api: TelegramAPI, chat_id: int, message_id: int, server_id: int, proto: str, client_id: str, enable: bool, load_data_fn: Callable): + await api.edit_message(chat_id, message_id, "⏳ Updating connection...") + + def _toggle(): + data = load_data_fn() + server = data["servers"][server_id] + ssh, manager = _get_ssh_and_manager(server, proto) + try: + ssh.connect() + return _manager_call(manager, "toggle_client", proto, client_id, enable) + finally: + ssh.disconnect() + + try: + await asyncio.to_thread(_toggle) + await api.edit_message(chat_id, message_id, "✅ Updated.", reply_markup={"inline_keyboard": [[{"text": "⬅️ Connections", "callback_data": _ref("clients", {"sid": server_id, "proto": proto})}]]}) + except Exception as e: + logger.exception("Bot admin: toggle client failed") + await api.edit_message(chat_id, message_id, f"❌ Error: {_e(e)}") + + +async def _admin_remove_client(api: TelegramAPI, chat_id: int, message_id: int, server_id: int, proto: str, client_id: str, load_data_fn: Callable, save_data_fn: Optional[Callable]): + if not save_data_fn: + await api.edit_message(chat_id, message_id, "❌ Saving is not available for this bot instance.") + return + await api.edit_message(chat_id, message_id, "⏳ Removing connection...") + + def _remove(): + data = load_data_fn() + server = data["servers"][server_id] + ssh, manager = _get_ssh_and_manager(server, proto) + try: + ssh.connect() + _manager_call(manager, "remove_client", proto, client_id) + finally: + ssh.disconnect() + data["user_connections"] = [ + c for c in data.get("user_connections", []) + if not (c.get("server_id") == server_id and c.get("protocol") == proto and c.get("client_id") == client_id) + ] + save_data_fn(data) + + try: + await asyncio.to_thread(_remove) + await api.edit_message(chat_id, message_id, "✅ Connection removed.", reply_markup={"inline_keyboard": [[{"text": "⬅️ Connections", "callback_data": _ref("clients", {"sid": server_id, "proto": proto})}]]}) + except Exception as e: + logger.exception("Bot admin: remove client failed") + await api.edit_message(chat_id, message_id, f"❌ Error: {_e(e)}") + + +async def _handle_pending_input(api: TelegramAPI, msg: dict, load_data_fn: Callable, save_data_fn: Optional[Callable], generate_vpn_link_fn: Callable) -> bool: + chat_id = msg["chat"]["id"] + state = _pending_inputs.get(str(chat_id)) + if not state: + return False + + text = (msg.get("text") or "").strip() + if text.lower() in ("/cancel", "cancel"): + _pending_inputs.pop(str(chat_id), None) + await api.send_message(chat_id, "❌ Action cancelled.", reply_markup=_admin_main_keyboard()) + return True + if text.startswith("/"): + _pending_inputs.pop(str(chat_id), None) + return False + + if state.get("kind") == "add_client_name": + panel_user = _require_admin(load_data_fn, str(msg["from"]["id"])) + if not panel_user: + _pending_inputs.pop(str(chat_id), None) + await api.send_message(chat_id, "❌ Access denied.") + return True + name = text[:80].strip() + if not name: + await api.send_message(chat_id, "Name cannot be empty. Send a connection name or /cancel.") + return True + _pending_inputs.pop(str(chat_id), None) + await _admin_choose_client_user(api, chat_id, name, int(state.get("sid", 0)), state.get("proto", "awg"), load_data_fn) + return True + + return False + + +# ----------------------------------------------------------------------- # +# Main polling loop and dispatcher +# ----------------------------------------------------------------------- # +async def _run_bot(token: str, load_data_fn: Callable, generate_vpn_link_fn: Callable, save_data_fn: Optional[Callable] = None): + 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, save_data_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, save_data_fn: Optional[Callable] = None): + if "message" in update: + msg = update["message"] + text = msg.get("text", "") + if await _handle_pending_input(api, msg, load_data_fn, save_data_fn, generate_vpn_link_fn): + return + if text.startswith("/start") or text.startswith("/admin"): + await _handle_start(api, msg, load_data_fn) + elif text.startswith("/connections"): + panel_user = _find_user(load_data_fn, str(msg["from"]["id"])) + if not panel_user: + await api.send_message(msg["chat"]["id"], "❌ Access denied.") + else: + await _send_user_connections(api, msg["chat"]["id"], panel_user, load_data_fn) + elif text.startswith("/servers"): + if _require_admin(load_data_fn, str(msg["from"]["id"])): + await _admin_servers(api, msg["chat"]["id"], None, load_data_fn) + else: + await api.send_message(msg["chat"]["id"], "❌ Access denied.") + elif text.startswith("/addserver"): + await _handle_add_server_command(api, msg, load_data_fn, save_data_fn) + + 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 == "noop": + await api.answer_callback(callback_id) + return + if data_str == "refresh": + await _handle_refresh(api, chat_id, message_id, callback_id, tg_id, load_data_fn) + return + if data_str.startswith("cfg:"): + await _handle_get_config(api, chat_id, message_id, callback_id, data_str[4:], tg_id, load_data_fn, generate_vpn_link_fn) + return + + panel_user = _require_admin(load_data_fn, tg_id) + if not panel_user: + await api.answer_callback(callback_id, "Access denied") + return + + await api.answer_callback(callback_id) + + if data_str == "adm:menu": + await api.edit_message(chat_id, message_id, "Admin menu", reply_markup=_admin_main_keyboard()) + elif data_str == "adm:servers": + await _admin_servers(api, chat_id, message_id, load_data_fn) + elif data_str == "adm:users": + await _admin_users(api, chat_id, message_id, load_data_fn) + elif data_str == "adm:myconns": + await _send_user_connections(api, chat_id, panel_user, load_data_fn) + elif data_str == "adm:addserver_help": + await api.edit_message( + chat_id, + message_id, + "➕ Add server\n\n" + "Use command:\n" + "/addserver host username password [ssh_port] [name]\n\n" + "Example:\n" + "/addserver 203.0.113.10 root myPassword 22 Prod VPS\n\n" + "⚠️ Prefer the web panel for real credentials if possible.", + reply_markup={"inline_keyboard": [[{"text": "⬅️ Admin menu", "callback_data": "adm:menu"}]]}, + ) + elif data_str.startswith("srv:"): + await _admin_server_detail(api, chat_id, message_id, int(data_str.split(":", 1)[1]), load_data_fn) + else: + ref = _resolve_ref(data_str) + if not ref: + await api.edit_message(chat_id, message_id, "❌ Action expired. Use /start again.") + return + action = ref.get("action") + payload = ref.get("payload", {}) + sid = int(payload.get("sid", 0) or 0) + proto = payload.get("proto", "awg") + if action == "user": + await _admin_user_detail(api, chat_id, message_id, payload.get("uid"), load_data_fn) + elif action == "proto": + await _admin_protocol_detail(api, chat_id, message_id, sid, proto, load_data_fn) + elif action == "toggle_proto": + await _admin_toggle_protocol(api, chat_id, message_id, sid, proto, bool(payload.get("start")), load_data_fn) + elif action == "clients": + await _admin_clients(api, chat_id, message_id, sid, proto, load_data_fn) + elif action == "client": + await _admin_client_detail(api, chat_id, message_id, sid, proto, payload.get("client", {})) + elif action == "client_cfg": + data = load_data_fn() + server = data["servers"][sid] + await _send_config_by_client(api, chat_id, server, proto, payload.get("client_id"), payload.get("name", "Connection"), generate_vpn_link_fn) + elif action == "add_client": + await _admin_add_client(api, chat_id, message_id, sid, proto, panel_user, load_data_fn, save_data_fn, generate_vpn_link_fn) + elif action == "create_client": + await _admin_create_client(api, chat_id, message_id, sid, proto, payload.get("name", "Connection"), payload.get("user_id"), load_data_fn, save_data_fn, generate_vpn_link_fn) + elif action == "toggle_client": + await _admin_toggle_client(api, chat_id, message_id, sid, proto, payload.get("client_id"), bool(payload.get("enable")), load_data_fn) + elif action == "remove_client": + await _admin_remove_client(api, chat_id, message_id, sid, proto, payload.get("client_id"), load_data_fn, save_data_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..de0f6c8 --- /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..4a06506 --- /dev/null +++ b/templates/server.html @@ -0,0 +1,2347 @@ +{% 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 + }}
+
+
+
+ + +
+
+ +
+ + + +
+
+ {{ _('checking_server') }} +
+ + +
+ +
+
+
+
+
+
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') }} + +
+
+ + +
+
+
🔍
+
+
+
AmneziaDNS
+
+ {{ _('dns_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+ + +
+
+
🛡️
+
+
+
AdGuard Home
+
+ {{ _('adguard_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+ + +
+
+
🧦
+
+
+
SOCKS5 Proxy
+
+ {{ _('socks5_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+ + + +
+
+
🌐
+
+
+
NGINX
+
+ {{ _('nginx_desc') }} +
+
+ {{ _('not_checked') }} +
+ +
+ +
+
+ + +
+ + + 🔒 {{ _('coming_soon') }} +
+ +
+
{{ _('revproxy_title') }}
+
{{ _('revproxy_subtitle') }}
+
+ + ⭐ {{ _('promo_star_cta') }} + +
+
+
+ +
+ + 🔒 Coming soon +
+ +
+
AIVPN
+
AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.
+
+ + ⭐ Star us on GitHub + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% endblock %} + +{% block scripts %} + + + + + + + + +{% endblock %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..29d4b34 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,1035 @@ +{% 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') }}
+
+ + + +
+
+ + +
+
+ Cloudflare WARP + {{ _('not_installed') }} +
+
{{ _('warp_status_unknown') }}
+
{{ _('warp_hint') }}
+
+ + +
+
+
+

{{ _('tunnels_hint') }}

+
+ + +
+

🔒 {{ _('ssl_title') }}

+
+
+ +
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
{{ _('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..034c8ad --- /dev/null +++ b/templates/users.html @@ -0,0 +1,1084 @@ +{% 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 %} + diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 0000000..cf10d58 --- /dev/null +++ b/translations/en.json @@ -0,0 +1,423 @@ +{ + "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.", + "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.", + "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", + "warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.", + "warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.", + "warp_install_required": "Install WARP first", + "warp_connect": "Connect WARP", + "warp_disconnect": "Disconnect", + "warp_connected": "WARP connected", + "warp_disconnected": "WARP disconnected", + "warp_status_unknown": "WARP status is unknown.", + "warp_status_not_installed": "Cloudflare WARP CLI is not installed on this host.", + "warp_status_connected": "Cloudflare WARP is connected.", + "warp_status_connecting": "Cloudflare WARP is connecting.", + "warp_status_disconnected": "Cloudflare WARP is disconnected.", + "warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.", + "warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.", + "warp_status_error": "Cloudflare WARP returned an error.", + "install_starting": "Starting installation...", + "server_check_complete": "Check completed", + "server_connection_error": "Connection error", + "connections_load_error": "Error loading connections", + "connection_updated": "Connection updated", + "edit_connection": "Edit connection", + "enable_connection_progress": "Enabling connection...", + "disable_connection_progress": "Disabling connection...", + "connection_enabled": "Connection enabled", + "connection_disabled": "Connection disabled", + "gb": "GB", + "marketplace": "Templates", + "open_marketplace": "Open Templates", + "marketplace_desc": "Install protocol and service templates available for this server.", + "installed_apps": "Installed applications", + "installed_apps_hint": "Only installed protocols and services are shown here. Use Templates to add more.", + "no_installed_apps": "No installed applications yet", + "no_installed_apps_desc": "Open Templates to install protocols or services on this server.", + "install_another": "Install another", + "new_instance": "new instance", + "port_next_instance_hint": "Choose a free UDP port for the additional instance.", + "checking_server": "Checking server services...", + "web_servers": "Web servers", + "nginx_desc": "NGINX web server with Let's Encrypt HTTPS certificate and editable index.html.", + "nginx_domain": "Domain", + "nginx_email": "Let's Encrypt email", + "nginx_port_hint": "HTTPS port exposed on the server. Port 80/TCP must be free and reachable for Let's Encrypt validation.", + "nginx_install_hint": "The domain must already point to this server. Port 80/TCP is used temporarily/permanently for HTTP-01 certificate validation.", + "site": "Site", + "site_editor": "Site editor", + "site_saved": "Site saved", + "nginx_dns_hint": "Create this DNS record before installation:", + "backup": "Backup", + "backups": "Backups", + "backup_desc": "Backups are created on the server and contain valuable protocol files needed to restore or recreate it later.", + "create_backup": "Create backup", + "creating_backup": "Creating backup...", + "backup_created": "Backup created", + "no_backups": "No backups yet", + "no_backups_desc": "Create the first backup for this protocol.", + "refresh": "Refresh" +} diff --git a/translations/fa.json b/translations/fa.json new file mode 100644 index 0000000..fd7096c --- /dev/null +++ b/translations/fa.json @@ -0,0 +1,390 @@ +{ + "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.", + "warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.", + "warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.", + "warp_install_required": "Install WARP first", + "warp_connect": "Connect WARP", + "warp_disconnect": "Disconnect", + "warp_connected": "WARP connected", + "warp_disconnected": "WARP disconnected", + "warp_status_unknown": "WARP status is unknown.", + "warp_status_not_installed": "Cloudflare WARP CLI is not installed on this host.", + "warp_status_connected": "Cloudflare WARP is connected.", + "warp_status_connecting": "Cloudflare WARP is connecting.", + "warp_status_disconnected": "Cloudflare WARP is disconnected.", + "warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.", + "warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.", + "warp_status_error": "Cloudflare WARP returned an error.", + "install_starting": "Starting installation...", + "server_check_complete": "Check completed", + "server_connection_error": "Connection error", + "connections_load_error": "Error loading connections", + "connection_updated": "Connection updated", + "edit_connection": "Edit connection", + "enable_connection_progress": "Enabling connection...", + "disable_connection_progress": "Disabling connection...", + "connection_enabled": "Connection enabled", + "connection_disabled": "Connection disabled", + "gb": "GB", + "marketplace": "Templates", + "open_marketplace": "Open Templates", + "marketplace_desc": "Install protocol and service templates available for this server.", + "installed_apps": "Installed applications", + "installed_apps_hint": "Only installed protocols and services are shown here. Use Templates to add more.", + "no_installed_apps": "No installed applications yet", + "no_installed_apps_desc": "Open Templates to install protocols or services on this server.", + "install_another": "Install another", + "new_instance": "new instance", + "port_next_instance_hint": "Choose a free UDP port for the additional instance.", + "web_servers": "Web servers", + "nginx_desc": "NGINX web server with Let's Encrypt HTTPS certificate and editable index.html.", + "nginx_domain": "Domain", + "nginx_email": "Let's Encrypt email", + "nginx_port_hint": "HTTPS port exposed on the server. Port 80/TCP must be free and reachable for Let's Encrypt validation.", + "nginx_install_hint": "The domain must already point to this server. Port 80/TCP is used for HTTP-01 certificate validation.", + "site": "Site", + "site_editor": "Site editor", + "site_saved": "Site saved", + "nginx_dns_hint": "Create this DNS record before installation:", + "backup": "Backup", + "backups": "Backups", + "backup_desc": "Backups are created on the server and contain valuable protocol files needed to restore or recreate it later.", + "create_backup": "Create backup", + "creating_backup": "Creating backup...", + "backup_created": "Backup created", + "no_backups": "No backups yet", + "no_backups_desc": "Create the first backup for this protocol.", + "refresh": "Refresh" +} diff --git a/translations/fr.json b/translations/fr.json new file mode 100644 index 0000000..4f4bdc3 --- /dev/null +++ b/translations/fr.json @@ -0,0 +1,390 @@ +{ + "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.", + "warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.", + "warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.", + "warp_install_required": "Install WARP first", + "warp_connect": "Connect WARP", + "warp_disconnect": "Disconnect", + "warp_connected": "WARP connected", + "warp_disconnected": "WARP disconnected", + "warp_status_unknown": "WARP status is unknown.", + "warp_status_not_installed": "Cloudflare WARP CLI is not installed on this host.", + "warp_status_connected": "Cloudflare WARP is connected.", + "warp_status_connecting": "Cloudflare WARP is connecting.", + "warp_status_disconnected": "Cloudflare WARP is disconnected.", + "warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.", + "warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.", + "warp_status_error": "Cloudflare WARP returned an error.", + "install_starting": "Starting installation...", + "server_check_complete": "Check completed", + "server_connection_error": "Connection error", + "connections_load_error": "Error loading connections", + "connection_updated": "Connection updated", + "edit_connection": "Edit connection", + "enable_connection_progress": "Enabling connection...", + "disable_connection_progress": "Disabling connection...", + "connection_enabled": "Connection enabled", + "connection_disabled": "Connection disabled", + "gb": "GB", + "marketplace": "Templates", + "open_marketplace": "Open Templates", + "marketplace_desc": "Install protocol and service templates available for this server.", + "installed_apps": "Installed applications", + "installed_apps_hint": "Only installed protocols and services are shown here. Use Templates to add more.", + "no_installed_apps": "No installed applications yet", + "no_installed_apps_desc": "Open Templates to install protocols or services on this server.", + "install_another": "Install another", + "new_instance": "new instance", + "port_next_instance_hint": "Choose a free UDP port for the additional instance.", + "web_servers": "Serveurs web", + "nginx_desc": "Serveur web NGINX avec certificat HTTPS Let's Encrypt et index.html modifiable.", + "nginx_domain": "Domaine", + "nginx_email": "Email Let's Encrypt", + "nginx_port_hint": "Port HTTPS exposé sur le serveur. Le port 80/TCP doit être libre et accessible pour la validation Let's Encrypt.", + "nginx_install_hint": "Le domaine doit déjà pointer vers ce serveur. Le port 80/TCP est utilisé pour la validation HTTP-01 du certificat.", + "site": "Site", + "site_editor": "Éditeur du site", + "site_saved": "Site enregistré", + "nginx_dns_hint": "Creez cet enregistrement DNS avant l installation :", + "backup": "Backup", + "backups": "Backups", + "backup_desc": "Backups are created on the server and contain valuable protocol files needed to restore or recreate it later.", + "create_backup": "Create backup", + "creating_backup": "Creating backup...", + "backup_created": "Backup created", + "no_backups": "No backups yet", + "no_backups_desc": "Create the first backup for this protocol.", + "refresh": "Refresh" +} diff --git a/translations/ru.json b/translations/ru.json new file mode 100644 index 0000000..e55b9b5 --- /dev/null +++ b/translations/ru.json @@ -0,0 +1,423 @@ +{ + "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": "Туннели", + "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 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.", + "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:// на указанном домене. Убедитесь, что пути к файлам или текстовые данные верны.", + "about_title": "О программе и обновления", + "current_version": "Текущая версия", + "check_updates": "Проверить обновления", + "checking_updates": "Проверка...", + "update_available": "Доступна новая версия", + "download_update": "Скачать обновление", + "up_to_date": "У вас установлена последняя версия", + "warp_hint": "WARP направляет исходящий трафик этого хоста через Cloudflare. Он не создаёт публичный URL панели как Quick Tunnel или ngrok.", + "warp_install_hint": "Сначала установите официальный клиент Cloudflare WARP, затем перезапустите панель.", + "warp_install_required": "Сначала установите WARP", + "warp_connect": "Подключить WARP", + "warp_disconnect": "Отключить", + "warp_connected": "WARP подключён", + "warp_disconnected": "WARP отключён", + "warp_status_unknown": "Статус WARP неизвестен.", + "warp_status_not_installed": "Cloudflare WARP CLI не установлен на этом хосте.", + "warp_status_connected": "Cloudflare WARP подключён.", + "warp_status_connecting": "Cloudflare WARP подключается.", + "warp_status_disconnected": "Cloudflare WARP отключён.", + "warp_status_not_registered": "Cloudflare WARP установлен, но ещё не зарегистрирован. Подключение попробует зарегистрировать его автоматически.", + "warp_status_service_unavailable": "Сервис Cloudflare WARP недоступен. Убедитесь, что warp-svc запущен.", + "warp_status_error": "Cloudflare WARP вернул ошибку.", + "install_starting": "Начинаем установку...", + "server_check_complete": "Проверка завершена", + "server_connection_error": "Ошибка подключения", + "connections_load_error": "Ошибка загрузки подключений", + "connection_updated": "Подключение обновлено", + "edit_connection": "Редактировать подключение", + "enable_connection_progress": "Включение подключения...", + "disable_connection_progress": "Отключение подключения...", + "connection_enabled": "Подключение включено", + "connection_disabled": "Подключение отключено", + "gb": "ГБ", + "marketplace": "Шаблоны", + "open_marketplace": "Открыть шаблоны", + "marketplace_desc": "Установите доступные шаблоны протоколов и сервисов для этого сервера.", + "installed_apps": "Установленные приложения", + "installed_apps_hint": "Здесь отображаются только установленные протоколы и сервисы. Чтобы добавить новые, откройте шаблоны.", + "no_installed_apps": "Пока нет установленных приложений", + "no_installed_apps_desc": "Откройте шаблоны, чтобы установить протоколы или сервисы на этот сервер.", + "install_another": "Установить ещё один", + "new_instance": "новый экземпляр", + "port_next_instance_hint": "Выберите свободный UDP-порт для дополнительного экземпляра.", + "checking_server": "Проверяем сервисы сервера...", + "web_servers": "Веб-серверы", + "nginx_desc": "NGINX веб-сервер с HTTPS-сертификатом Let's Encrypt и редактируемым index.html.", + "nginx_domain": "Домен", + "nginx_email": "Email для Let's Encrypt", + "nginx_port_hint": "HTTPS-порт на сервере. Порт 80/TCP должен быть свободен и доступен для проверки Let's Encrypt.", + "nginx_install_hint": "Домен уже должен указывать на этот сервер. Порт 80/TCP используется для HTTP-01 проверки сертификата.", + "site": "Сайт", + "site_editor": "Редактор сайта", + "site_saved": "Сайт сохранён", + "nginx_dns_hint": "Перед установкой создайте DNS-запись:", + "backup": "Backup", + "backups": "Бекапы", + "backup_desc": "Бекапы создаются на сервере и содержат ценные файлы протокола, нужные для восстановления или повторного запуска без потерь.", + "create_backup": "Создать бекап", + "creating_backup": "Создание бекапа...", + "backup_created": "Бекап создан", + "no_backups": "Бекапов пока нет", + "no_backups_desc": "Создайте первый бекап для этого протокола.", + "refresh": "Обновить" +} diff --git a/translations/zh.json b/translations/zh.json new file mode 100644 index 0000000..a611051 --- /dev/null +++ b/translations/zh.json @@ -0,0 +1,390 @@ +{ + "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.", + "warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.", + "warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.", + "warp_install_required": "Install WARP first", + "warp_connect": "Connect WARP", + "warp_disconnect": "Disconnect", + "warp_connected": "WARP connected", + "warp_disconnected": "WARP disconnected", + "warp_status_unknown": "WARP status is unknown.", + "warp_status_not_installed": "Cloudflare WARP CLI is not installed on this host.", + "warp_status_connected": "Cloudflare WARP is connected.", + "warp_status_connecting": "Cloudflare WARP is connecting.", + "warp_status_disconnected": "Cloudflare WARP is disconnected.", + "warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.", + "warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.", + "warp_status_error": "Cloudflare WARP returned an error.", + "install_starting": "Starting installation...", + "server_check_complete": "Check completed", + "server_connection_error": "Connection error", + "connections_load_error": "Error loading connections", + "connection_updated": "Connection updated", + "edit_connection": "Edit connection", + "enable_connection_progress": "Enabling connection...", + "disable_connection_progress": "Disabling connection...", + "connection_enabled": "Connection enabled", + "connection_disabled": "Connection disabled", + "gb": "GB", + "marketplace": "Templates", + "open_marketplace": "Open Templates", + "marketplace_desc": "Install protocol and service templates available for this server.", + "installed_apps": "Installed applications", + "installed_apps_hint": "Only installed protocols and services are shown here. Use Templates to add more.", + "no_installed_apps": "No installed applications yet", + "no_installed_apps_desc": "Open Templates to install protocols or services on this server.", + "install_another": "Install another", + "new_instance": "new instance", + "port_next_instance_hint": "Choose a free UDP port for the additional instance.", + "web_servers": "Web servers", + "nginx_desc": "NGINX web server with Let's Encrypt HTTPS certificate and editable index.html.", + "nginx_domain": "Domain", + "nginx_email": "Let's Encrypt email", + "nginx_port_hint": "HTTPS port exposed on the server. Port 80/TCP must be free and reachable for Let's Encrypt validation.", + "nginx_install_hint": "The domain must already point to this server. Port 80/TCP is used for HTTP-01 certificate validation.", + "site": "Site", + "site_editor": "Site editor", + "site_saved": "Site saved", + "nginx_dns_hint": "Create this DNS record before installation:", + "backup": "Backup", + "backups": "Backups", + "backup_desc": "Backups are created on the server and contain valuable protocol files needed to restore or recreate it later.", + "create_backup": "Create backup", + "creating_backup": "Creating backup...", + "backup_created": "Backup created", + "no_backups": "No backups yet", + "no_backups_desc": "Create the first backup for this protocol.", + "refresh": "Refresh" +}