Add Amnezia Web Panel source with PostgreSQL 17 storage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 00:02:18 +03:00
co-authored by Cursor
parent ead1c64dd1
commit 8b70d51c87
50 changed files with 21713 additions and 0 deletions
+13
View File
@@ -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
+102
View File
@@ -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/**/*
+40
View File
@@ -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/
+16
View File
@@ -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"]
+38
View File
@@ -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"]
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+3968
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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',
]
+82
View File
@@ -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')
+58
View File
@@ -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())
+75
View File
@@ -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;
+413
View File
@@ -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)
+28
View File
@@ -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:
+50
View File
@@ -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:
+22
View File
@@ -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 "$@"
+7
View File
@@ -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`).
"""
+264
View File
@@ -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:<port> 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
File diff suppressed because it is too large Load Diff
+170
View File
@@ -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" <<EOF
{{
"protocol": "$protocol",
"container": "$container",
"created_at_utc": "$timestamp",
"backup_format": "amnezia-web-panel-protocol-v1"
}}
EOF
copy_host_path() {{
src="$1"
if [ -e "$src" ]; then
mkdir -p "$work_dir/host$(dirname "$src")"
cp -a "$src" "$work_dir/host$src"
fi
}}
copy_container_path() {{
src="$1"
if [ -n "$container" ] && docker inspect "$container" >/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}}
+83
View File
@@ -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")
+383
View File
@@ -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) or '.' not in domain:
raise ValueError('Invalid domain name')
return domain
def _validate_email(self, email):
email = (email or '').strip()
if not email or '@' not in email:
raise ValueError('Valid Let\'s Encrypt email is required')
return email
def _site_url(self, domain, port):
if not domain:
return None
port = int(port or self.DEFAULT_PORT)
return f'https://{domain}' if port == 443 else f'https://{domain}:{port}'
def _build_initial_config(self, domain):
return f"""server {{
listen 80;
server_name {domain};
location /.well-known/acme-challenge/ {{
root /var/www/certbot;
}}
location / {{
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}}
}}
"""
def _build_ssl_config(self, domain, port):
redirect_target = 'https://$host$request_uri' if int(port) == 443 else f'https://$host:{int(port)}$request_uri'
return f"""server {{
listen 80;
server_name {domain};
location /.well-known/acme-challenge/ {{
root /var/www/certbot;
}}
location / {{
return 301 {redirect_target};
}}
}}
server {{
listen 443 ssl http2;
server_name {domain};
ssl_certificate /etc/letsencrypt/live/{domain}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{domain}/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
root /usr/share/nginx/html;
index index.html;
location / {{
try_files $uri $uri/ /index.html;
}}
}}
"""
def _default_index(self, domain):
return f"""<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>{domain}</title>
<style>
body {{ margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: system-ui, -apple-system, Segoe UI, sans-serif; background: #0f172a; color: #e2e8f0; }}
main {{ text-align: center; padding: 32px; }}
h1 {{ font-size: clamp(2rem, 6vw, 4rem); margin: 0 0 12px; }}
p {{ color: #94a3b8; font-size: 1.1rem; }}
</style>
</head>
<body>
<main>
<h1>NGINX is running</h1>
<p>{domain}</p>
</main>
</body>
</html>
"""
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
+240
View File
@@ -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
+227
View File
@@ -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()
+571
View File
@@ -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"
+823
View File
@@ -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} <<EOF
[Interface]
PrivateKey = $WIREGUARD_SERVER_PRIVATE_KEY
Address = {subnet_ip}/{subnet_cidr}
ListenPort = {port}
EOF
"""
out, err, code = self.ssh.run_sudo_command(
f"docker exec -i {self.CONTAINER_NAME} bash -c '{config_script}'"
)
if code != 0:
raise RuntimeError(f"Failed to configure container: {err}")
def _upload_start_script(self, port):
"""Upload and execute the start script inside the container."""
subnet_ip = WG_DEFAULTS['subnet_ip']
subnet_cidr = WG_DEFAULTS['subnet_cidr']
start_script = f"""#!/bin/bash
echo "WireGuard container startup"
# Kill existing wg-quick if running
wg-quick down {self.CONFIG_PATH} 2>/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,
}
+800
View File
@@ -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
+108
View File
@@ -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"]
+62
View File
@@ -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"
+31
View File
@@ -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
+41
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

+1868
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#8b5cf6"/>
<stop offset="100%" stop-color="#6366f1"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="7" fill="url(#g)"/>
<path d="M16 6C12.5 6 9.5 7.2 8 8v10c0 4.5 3.5 7.5 8 10 4.5-2.5 8-5.5 8-10V8c-1.5-.8-4.5-2-8-2z" fill="none" stroke="rgba(255,255,255,0.9)" stroke-width="1.5"/>
<path d="M16 10c-2.5 0-4.5.8-5.5 1.3v6.7c0 3 2.2 5.2 5.5 7 3.3-1.8 5.5-4 5.5-7v-6.7c-1-.5-3-1.3-5.5-1.3z" fill="rgba(255,255,255,0.15)"/>
</svg>

After

Width:  |  Height:  |  Size: 619 B

+1
View File
File diff suppressed because one or more lines are too long
+1137
View File
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
<!DOCTYPE html>
<html lang="{{ lang }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ _('site_description') }}">
<title>{{ site_settings.title or 'Amnezia Panel' }} {% block title_extra %}{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css">
<script>!function () { var t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t) }()</script>
<script src="/static/js/qrcode.min.js"></script>
{% block head_extra %}{% endblock %}
<style>
.lang-check {
color: var(--success);
font-weight: 700;
}
[dir="rtl"] .lang-check {
margin-right: auto;
}
[dir="ltr"] .lang-check {
margin-left: auto;
}
</style>
</head>
<body>
<div class="toast-container" id="toastContainer"></div>
<div class="app-container">
<header class="app-header">
<a href="/" class="app-logo" style="text-decoration:none">
<div class="logo-icon">{{ site_settings.logo }}</div>
<div>
<div class="logo-text">{{ site_settings.title }}</div>
<div class="logo-subtitle">{{ site_settings.subtitle }}</div>
</div>
</a>
{% if current_user %}
<nav class="header-nav">
{% if current_user.role in ['admin', 'support'] %}
<a href="/" class="nav-link">{{ _('nav_servers') }}</a>
<a href="/users" class="nav-link">{{ _('nav_users') }}</a>
{% endif %}
{% if current_user.role == 'admin' %}
<a href="/settings" class="nav-link">{{ _('nav_settings') }}</a>
{% endif %}
<a href="/my" class="nav-link">{{ _('nav_connections') }}</a>
</nav>
<div class="nav-user">
<button class="theme-toggle" onclick="openModal('langModal')" title="{{ _('select_lang') }}"
style="margin-right: var(--space-sm);">
{% if lang == 'ru' %}🇷🇺{% elif lang == 'fr' %}🇫🇷{% elif lang == 'zh' %}🇨🇳{% elif lang == 'fa'
%}🇮🇷{% else %}🇺🇸{% endif %}
</button>
<button class="theme-toggle" onclick="toggleTheme()" title="{{ _('toggle_theme') }}"
id="themeToggle">🌙</button>
<span class="nav-username">{{ current_user.username }}</span>
<span
class="badge badge-{{ 'success' if current_user.role == 'admin' else ('info' if current_user.role == 'support' else 'warn') }}"
style="font-size:0.65rem;">
{{ current_user.role | upper }}
</span>
<a href="/logout" class="btn btn-secondary btn-sm">{{ _('nav_logout') }}</a>
</div>
{% else %}
<div class="nav-user">
<button class="theme-toggle" onclick="openModal('langModal')" title="{{ _('select_lang') }}"
style="margin-right: var(--space-sm);">
{% if lang == 'ru' %}🇷🇺{% elif lang == 'fr' %}🇫🇷{% elif lang == 'zh' %}🇨🇳{% elif lang == 'fa'
%}🇮🇷{% else %}🇺🇸{% endif %}
</button>
<button class="theme-toggle" onclick="toggleTheme()" title="{{ _('toggle_theme') }}"
id="themeToggle">🌙</button>
</div>
{% endif %}
</header>
<main>
{% block content %}{% endblock %}
</main>
</div>
<!-- ===== Language Modal ===== -->
<div class="modal-backdrop" id="langModal">
<div class="modal" style="max-width: 320px;">
<div class="modal-header">
<h2 class="modal-title">{{ _('select_lang') }}</h2>
<button class="modal-close" onclick="closeModal('langModal')">×</button>
</div>
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
{% set langs = [
('en', '🇺🇸', 'lang_en'),
('ru', '🇷🇺', 'lang_ru'),
('fr', '🇫🇷', 'lang_fr'),
('zh', '🇨🇳', 'lang_zh'),
('fa', '🇮🇷', 'lang_fa')
] %}
{% for l_code, l_flag, l_key in langs %}
<a href="/set_lang/{{ l_code }}" class="btn btn-secondary"
style="justify-content: flex-start; gap: var(--space-md); padding: var(--space-md);">
<span style="font-size: 1.4rem;">{{ l_flag }}</span>
<span style="font-weight: 500;">{{ _(l_key) }}</span>
{% if lang == l_code %}
<span class="lang-check"></span>
{% endif %}
</a>
{% endfor %}
</div>
</div>
</div>
<script>
window.I18N = {{ translations_json | safe }};
window._ = function (key) { return window.I18N[key] || key; };
/* ===== Theme Toggle ===== */
(function () {
const saved = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
const updateUI = () => {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
document.querySelectorAll('.theme-toggle').forEach(btn => {
const isLang = btn.getAttribute('onclick').includes('langModal');
if (!isLang) {
btn.textContent = isLight ? '☀️' : '🌙';
}
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', updateUI);
} else {
updateUI();
}
window.toggleTheme = function () {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
updateUI();
};
})();
/* ===== Active Nav Link ===== */
(function () {
const path = window.location.pathname;
document.querySelectorAll('.nav-link').forEach(link => {
const href = link.getAttribute('href');
if (href === '/' && path === '/') link.classList.add('active');
else if (href !== '/' && path.startsWith(href)) link.classList.add('active');
});
})();
/* ===== Toast Notifications ===== */
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
const icons = { success: '✓', error: '✕', info: '' };
toast.innerHTML = `<span>${icons[type] || ''}</span> <span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
/* ===== Modal Management ===== */
function openModal(id) {
const modal = document.getElementById(id);
if (modal) {
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
}
function closeModal(id) {
const modal = document.getElementById(id);
if (modal) {
modal.classList.remove('active');
document.body.style.overflow = '';
}
}
/* Close modal on backdrop click */
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-backdrop') && e.target.classList.contains('active')) {
e.target.classList.remove('active');
document.body.style.overflow = '';
}
});
/* Close modal on Escape */
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-backdrop.active').forEach(m => {
m.classList.remove('active');
});
document.body.style.overflow = '';
}
});
/* ===== API Helper ===== */
async function apiCall(url, method = 'GET', body = null) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
if (res.status === 401 || res.status === 403) {
window.location.href = '/login';
throw new Error('Session expired');
}
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Unknown error');
}
return data;
}
/* ===== Copy to clipboard ===== */
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
showToast(_('copied_to_clipboard'), 'success');
} catch {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
showToast(_('copied_to_clipboard'), 'success');
}
}
/* ===== Download file ===== */
function downloadFile(content, filename) {
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+453
View File
@@ -0,0 +1,453 @@
{% extends "base.html" %}
{% block content %}
<section>
<div class="flex items-center justify-between" style="margin-bottom: var(--space-lg);">
<h1 class="section-title" style="margin-bottom:0;">
<span class="icon">🖥</span>
{{ _('nav_servers') }}
</h1>
<button class="btn btn-primary" onclick="openModal('addServerModal')">
<span></span> {{ _('add_server') }}
</button>
</div>
{% if servers %}
<div class="server-grid" id="serverGrid">
{% for server in servers %}
<div class="card card-hover server-card" id="server-{{ loop.index0 }}" data-idx="{{ loop.index0 }}"
data-name="{{ server.name }}" data-host="{{ server.host }}" data-port="{{ server.ssh_port }}"
data-username="{{ server.username }}" data-auth="{{ 'key' if server.private_key else 'password' }}"
draggable="true">
<div class="server-meta">
<div class="server-icon">🖥</div>
<div style="min-width: 0; flex: 1;">
<div class="server-name">
<span class="ping-dot ping-pending" id="ping-{{ loop.index0 }}"
title="{{ _('ping_checking') }}"></span>
<span>{{ server.name }}</span>
<span class="ping-ms text-muted" id="ping-ms-{{ loop.index0 }}"></span>
</div>
<div class="server-host">{{ server.host }}:{{ server.ssh_port }}</div>
</div>
</div>
<div class="server-protocols" id="server-protocols-{{ loop.index0 }}">
{% if server.protocols %}
{% for proto_key, proto_val in server.protocols.items() %}
{% if proto_val.get('installed') %}
<span class="badge badge-success">
<span class="badge-dot"></span>
{{ 'AWG' if proto_key == 'awg' else ('AWG-Legacy' if proto_key == 'awg_legacy' else ('Xray' if
proto_key == 'xray' else proto_key | upper)) }}
</span>
{% endif %}
{% endfor %}
{% else %}
<span class="badge badge-warn">
<span class="badge-dot"></span>
{{ _('no_protocols') }}
</span>
{% endif %}
</div>
<div class="server-actions">
<a href="/server/{{ loop.index0 }}" class="btn btn-secondary btn-sm" style="flex:1">
{{ _('manage') }}
</a>
<button class="btn btn-secondary btn-sm btn-icon" onclick="openEditServer(this)"
title="{{ _('edit') }}">
✏️
</button>
<button class="btn btn-danger btn-sm btn-icon" onclick="deleteServer({{ loop.index0 }})"
title="{{ _('delete') }}">
🗑
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">🛡</div>
<div class="empty-title">{{ _('no_servers') }}</div>
<div class="empty-desc">
{{ _('add_server_desc') }}
</div>
<button class="btn btn-primary" onclick="openModal('addServerModal')">
<span></span> {{ _('add_server') }}
</button>
</div>
{% endif %}
</section>
<!-- ===== Edit Server Modal ===== -->
<div class="modal-backdrop" id="editServerModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">{{ _('edit_server_title') }}</h2>
<button class="modal-close" onclick="closeModal('editServerModal')">×</button>
</div>
<form id="editServerForm" onsubmit="return submitEditServer(event)">
<input type="hidden" id="editServerIdx">
<div class="form-group">
<label class="form-label">{{ _('name') }}</label>
<input class="form-input" type="text" id="editServerName">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ _('ssh_host') }}</label>
<input class="form-input" type="text" id="editServerHost" required>
</div>
<div class="form-group">
<label class="form-label">{{ _('ssh_port') }}</label>
<input class="form-input" type="number" id="editServerPort" min="1" max="65535">
</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('ssh_user') }}</label>
<input class="form-input" type="text" id="editServerUser" required>
</div>
<div class="tabs" style="margin-top: var(--space-md)">
<button type="button" class="tab active" onclick="switchEditAuthTab('password', this)">{{ _('ssh_password') }}</button>
<button type="button" class="tab" onclick="switchEditAuthTab('key', this)">{{ _('ssh_key') }}</button>
</div>
<div id="editAuthPassword">
<div class="form-group">
<label class="form-label">{{ _('ssh_password') }}</label>
<input class="form-input" type="password" id="editServerPassword"
placeholder="{{ _('edit_keep_credential') }}">
<div class="form-hint">{{ _('edit_keep_credential') }}</div>
</div>
</div>
<div id="editAuthKey" class="hidden">
<div class="form-group">
<label class="form-label">{{ _('ssh_key') }}</label>
<textarea class="form-textarea" id="editServerKey"
placeholder="{{ _('edit_keep_credential') }}"></textarea>
<div class="form-hint">{{ _('edit_keep_credential') }}</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('editServerModal')">{{ _('cancel')
}}</button>
<button type="submit" class="btn btn-primary" id="editServerBtn">
<span id="editServerBtnText">{{ _('save') }}</span>
<div class="spinner hidden" id="editServerSpinner"></div>
</button>
</div>
</form>
</div>
</div>
<!-- ===== Add Server Modal ===== -->
<div class="modal-backdrop" id="addServerModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">{{ _('add_server') }}</h2>
<button class="modal-close" onclick="closeModal('addServerModal')">×</button>
</div>
<form id="addServerForm" onsubmit="return addServer(event)">
<div class="form-group">
<label class="form-label">{{ _('name') }}</label>
<input class="form-input" type="text" id="serverName" placeholder="My VPN Server">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ _('ssh_host') }}</label>
<input class="form-input" type="text" id="serverHost" placeholder="192.168.1.1" required>
</div>
<div class="form-group">
<label class="form-label">{{ _('ssh_port') }}</label>
<input class="form-input" type="number" id="serverPort" value="22" min="1" max="65535">
</div>
</div>
<div class="form-group">
<label class="form-label">{{ _('ssh_user') }}</label>
<input class="form-input" type="text" id="serverUser" placeholder="root" required>
</div>
<!-- Tabs for auth method -->
<div class="tabs" style="margin-top: var(--space-md)">
<button type="button" class="tab active" onclick="switchAuthTab('password', this)">{{ _('ssh_password')
}}</button>
<button type="button" class="tab" onclick="switchAuthTab('key', this)">{{ _('ssh_key') }}</button>
</div>
<div id="authPassword">
<div class="form-group">
<label class="form-label">{{ _('ssh_password') }}</label>
<input class="form-input" type="password" id="serverPassword" placeholder="••••••••">
</div>
</div>
<div id="authKey" class="hidden">
<div class="form-group">
<label class="form-label">{{ _('ssh_key') }}</label>
<textarea class="form-textarea" id="serverKey"
placeholder="{{ _('ssh_key_placeholder') }}"></textarea>
<div class="form-hint">{{ _('ssh_key_hint') }}</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('addServerModal')">{{ _('cancel')
}}</button>
<button type="submit" class="btn btn-primary" id="addServerBtn">
<span id="addServerBtnText">{{ _('connect') }}</span>
<div class="spinner hidden" id="addServerSpinner"></div>
</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function switchAuthTab(tab, btn) {
document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));
btn.classList.add('active');
document.getElementById('authPassword').classList.toggle('hidden', tab !== 'password');
document.getElementById('authKey').classList.toggle('hidden', tab !== 'key');
}
async function addServer(e) {
e.preventDefault();
const btn = document.getElementById('addServerBtn');
const btnText = document.getElementById('addServerBtnText');
const spinner = document.getElementById('addServerSpinner');
btn.disabled = true;
btnText.textContent = _('connecting');
spinner.classList.remove('hidden');
try {
const data = {
name: document.getElementById('serverName').value,
host: document.getElementById('serverHost').value,
ssh_port: parseInt(document.getElementById('serverPort').value) || 22,
username: document.getElementById('serverUser').value,
password: document.getElementById('serverPassword').value,
private_key: document.getElementById('serverKey').value,
};
const result = await apiCall('/api/servers/add', 'POST', data);
showToast(_('server_added'), 'success');
setTimeout(() => window.location.reload(), 800);
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
btnText.textContent = _('connect');
spinner.classList.add('hidden');
}
}
async function deleteServer(serverId) {
if (!confirm(_('delete_confirm'))) return;
try {
await apiCall(`/api/servers/${serverId}/delete`, 'POST');
showToast(_('server_deleted'), 'success');
setTimeout(() => window.location.reload(), 500);
} catch (err) {
showToast(err.message, 'error');
}
}
/* ========== Edit server ========== */
function switchEditAuthTab(tab, btn) {
const tabs = btn.parentElement.querySelectorAll('.tab');
tabs.forEach(t => t.classList.remove('active'));
btn.classList.add('active');
document.getElementById('editAuthPassword').classList.toggle('hidden', tab !== 'password');
document.getElementById('editAuthKey').classList.toggle('hidden', tab !== 'key');
}
function openEditServer(btn) {
// Prefill from data-* on the parent server-card so we don't have to round-trip.
const card = btn.closest('.server-card');
if (!card) return;
document.getElementById('editServerIdx').value = card.dataset.idx;
document.getElementById('editServerName').value = card.dataset.name || '';
document.getElementById('editServerHost').value = card.dataset.host || '';
document.getElementById('editServerPort').value = card.dataset.port || '22';
document.getElementById('editServerUser').value = card.dataset.username || '';
document.getElementById('editServerPassword').value = '';
document.getElementById('editServerKey').value = '';
// Pre-select the tab matching the server's current auth method.
const authIsKey = card.dataset.auth === 'key';
const tabsEl = document.querySelector('#editServerForm .tabs');
const passTab = tabsEl.children[0];
const keyTab = tabsEl.children[1];
switchEditAuthTab(authIsKey ? 'key' : 'password', authIsKey ? keyTab : passTab);
openModal('editServerModal');
}
async function submitEditServer(e) {
e.preventDefault();
const idx = document.getElementById('editServerIdx').value;
const btn = document.getElementById('editServerBtn');
const btnText = document.getElementById('editServerBtnText');
const spinner = document.getElementById('editServerSpinner');
btn.disabled = true;
btnText.textContent = _('saving') || 'Saving...';
spinner.classList.remove('hidden');
try {
const body = {
name: document.getElementById('editServerName').value,
host: document.getElementById('editServerHost').value,
ssh_port: parseInt(document.getElementById('editServerPort').value) || 22,
username: document.getElementById('editServerUser').value,
// Empty -> null means "leave unchanged" on the backend.
password: document.getElementById('editServerPassword').value || null,
private_key: document.getElementById('editServerKey').value || null,
};
await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
showToast(_('server_saved'), 'success');
setTimeout(() => window.location.reload(), 500);
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
btnText.textContent = _('save');
spinner.classList.add('hidden');
}
return false;
}
/* ========== Ping indicators ========== */
async function pingServer(idx) {
const dot = document.getElementById(`ping-${idx}`);
const ms = document.getElementById(`ping-ms-${idx}`);
if (!dot) return;
try {
const res = await fetch(`/api/servers/${idx}/ping`, { credentials: 'same-origin' });
const data = await res.json();
dot.classList.remove('ping-pending');
if (data.alive) {
dot.classList.add('ping-ok');
dot.title = `${data.ms} ms`;
if (ms) ms.textContent = `${data.ms} ms`;
} else {
dot.classList.add('ping-fail');
dot.title = data.error || _('offline');
if (ms) ms.textContent = _('offline');
}
} catch {
dot.classList.remove('ping-pending');
dot.classList.add('ping-fail');
if (ms) ms.textContent = _('offline');
}
}
function startAllPings() {
// Browsers will fan these out concurrently; the page never blocks waiting
// for results because each card updates independently.
document.querySelectorAll('.server-card').forEach(card => {
const idx = card.dataset.idx;
if (idx !== undefined) pingServer(idx);
});
}
/* ========== Drag-and-drop reorder ========== */
let draggedCard = null;
function setupDragAndDrop() {
const grid = document.getElementById('serverGrid');
if (!grid) return;
grid.addEventListener('dragstart', e => {
const card = e.target.closest('.server-card');
if (!card) return;
// Don't initiate drag from a button/link inside the card — those have
// their own click semantics (Edit/Delete/Manage).
if (e.target.closest('button, a')) {
e.preventDefault();
return;
}
draggedCard = card;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
// Some browsers require this for dragstart to fire with text payload.
try { e.dataTransfer.setData('text/plain', card.dataset.idx); } catch {}
});
grid.addEventListener('dragend', e => {
const card = e.target.closest('.server-card');
if (card) card.classList.remove('dragging');
grid.querySelectorAll('.server-card.drag-over').forEach(c => c.classList.remove('drag-over'));
draggedCard = null;
});
grid.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const card = e.target.closest('.server-card');
if (!card || card === draggedCard) return;
grid.querySelectorAll('.server-card.drag-over').forEach(c => {
if (c !== card) c.classList.remove('drag-over');
});
card.classList.add('drag-over');
});
grid.addEventListener('dragleave', e => {
const card = e.target.closest('.server-card');
if (card && !card.contains(e.relatedTarget)) card.classList.remove('drag-over');
});
grid.addEventListener('drop', async e => {
e.preventDefault();
const target = e.target.closest('.server-card');
grid.querySelectorAll('.server-card.drag-over').forEach(c => c.classList.remove('drag-over'));
if (!target || !draggedCard || target === draggedCard) return;
// Insert before/after target depending on cursor position relative
// to the target's vertical centre, which feels natural in a grid.
const rect = target.getBoundingClientRect();
const insertAfter = (e.clientY - rect.top) > rect.height / 2;
grid.insertBefore(draggedCard, insertAfter ? target.nextSibling : target);
const newOrder = Array.from(grid.querySelectorAll('.server-card'))
.map(c => parseInt(c.dataset.idx, 10));
try {
await apiCall('/api/servers/reorder', 'POST', { order: newOrder });
showToast(_('servers_reordered'), 'success');
// Reload so onclick indices and per-card data-idx attrs realign with
// the new server array — simpler and safer than rewriting every handler.
setTimeout(() => window.location.reload(), 400);
} catch (err) {
showToast(err.message, 'error');
// Rollback the visual move — backend rejected.
window.location.reload();
}
});
}
document.addEventListener('DOMContentLoaded', () => {
startAllPings();
setupDragAndDrop();
});
</script>
{% endblock %}
+285
View File
@@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="{{ lang }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site_settings.title or 'Amnezia Panel' }} — {{ _('login') }}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css">
<script>!function () { var t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t) }()</script>
<style>
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-lg);
background: var(--bg);
}
.login-card {
width: 100%;
max-width: 400px;
}
.login-logo {
text-align: center;
margin-bottom: var(--space-xl);
}
.login-logo .logo-icon {
width: 72px;
height: 72px;
font-size: 2rem;
margin: 0 auto var(--space-md);
}
.login-logo .logo-text {
font-size: 1.6rem;
font-weight: 700;
}
.login-logo .logo-subtitle {
color: var(--text-muted);
font-size: 0.9rem;
}
.login-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
font-size: 0.85rem;
margin-bottom: var(--space-md);
display: none;
}
.login-error.visible {
display: block;
}
.lang-check {
color: var(--success);
font-weight: 700;
}
[dir="rtl"] .lang-check {
margin-right: auto;
}
[dir="ltr"] .lang-check {
margin-left: auto;
}
</style>
</head>
<body>
<button class="theme-toggle" onclick="openModal('langModal')" id="langToggle"
style="position:fixed; top:20px; {% if lang == 'fa' %}left:65px;{% else %}right:65px;{% endif %} z-index:10;"
title="{{ _('select_lang') }}">
{% if lang == 'ru' %}🇷🇺{% elif lang == 'fr' %}🇫🇷{% elif lang == 'zh' %}🇨🇳{% elif lang == 'fa' %}🇮🇷{%
else %}🇺🇸{% endif %}
</button>
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle"
style="position:fixed; top:20px; {% if lang == 'fa' %}left:20px;{% else %}right:20px;{% endif %} z-index:10;"
title="{{ _('toggle_theme') }}">🌙</button>
<div class="login-wrapper">
<div class="login-card">
<div class="login-logo">
<div class="logo-icon">{{ site_settings.logo or '🛡' }}</div>
<div class="logo-text">{{ site_settings.title or 'Amnezia Panel' }}</div>
<div class="logo-subtitle">{{ site_settings.subtitle or 'Web Panel' }}</div>
</div>
<div class="card">
<h2 style="font-size:1.2rem; font-weight:600; margin-bottom:var(--space-lg); text-align:center;">{{
_('login_title') }}</h2>
<div class="login-error" id="loginError"></div>
<form id="loginForm" onsubmit="return doLogin(event)">
<div class="form-group">
<label class="form-label">{{ _('username') }}</label>
<input class="form-input" type="text" id="username" placeholder="admin" required autofocus>
</div>
<div class="form-group">
<label class="form-label">{{ _('password') }}</label>
<input class="form-input" type="password" id="password" placeholder="••••••••" required>
</div>
{% if captcha_settings.enabled %}
<div class="form-group">
<label class="form-label">{{ _('captcha') }}</label>
<div
style="display: flex; margin-bottom: var(--space-xs); border-radius: var(--radius-md); overflow: hidden; border: 1px solid var(--border-color);">
<img id="captchaImage" src="/api/auth/captcha" alt="Captcha"
style="width: 100%; height: 65px; object-fit: cover; object-position: center; cursor: pointer; background: #fff;"
onclick="refreshCaptcha()" title="{{ _('captcha_hint') }}">
</div>
<input class="form-input" type="text" id="captcha" placeholder="{{ _('captcha_placeholder') }}"
required>
</div>
{% endif %}
<button type="submit" class="btn btn-primary" id="loginBtn"
style="width:100%; margin-top:var(--space-md);">
<span id="loginBtnText">{{ _('login') }}</span>
<div class="spinner hidden" id="loginSpinner"></div>
</button>
</form>
</div>
</div>
</div>
<!-- ===== Language Modal ===== -->
<div class="modal-backdrop" id="langModal">
<div class="modal" style="max-width: 320px;">
<div class="modal-header">
<h2 class="modal-title">{{ _('select_lang') }}</h2>
<button class="modal-close" onclick="closeModal('langModal')">×</button>
</div>
<div style="display: flex; flex-direction: column; gap: var(--space-sm);">
{% set langs = [
('en', '🇺🇸', 'lang_en'),
('ru', '🇷🇺', 'lang_ru'),
('fr', '🇫🇷', 'lang_fr'),
('zh', '🇨🇳', 'lang_zh'),
('fa', '🇮🇷', 'lang_fa')
] %}
{% for l_code, l_flag, l_key in langs %}
<a href="/set_lang/{{ l_code }}" class="btn btn-secondary"
style="justify-content: flex-start; gap: var(--space-md); padding: var(--space-md);">
<span style="font-size: 1.4rem;">{{ l_flag }}</span>
<span style="font-weight: 500;">{{ _(l_key) }}</span>
{% if lang == l_code %}
<span class="lang-check"></span>
{% endif %}
</a>
{% endfor %}
</div>
</div>
</div>
<script>
window.I18N = {{ translations_json | safe }};
window._ = function (key) { return window.I18N[key] || key; };
/* Theme toggle */
(function () {
const saved = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
const updateUI = () => {
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
document.querySelectorAll('.theme-toggle').forEach(btn => {
if (btn.id === 'themeToggle') {
btn.textContent = isLight ? '☀️' : '🌙';
}
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', updateUI);
} else {
updateUI();
}
window.toggleTheme = function () {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
updateUI();
};
})();
/* ===== Modal Management ===== */
function openModal(id) {
const modal = document.getElementById(id);
if (modal) {
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
}
function closeModal(id) {
const modal = document.getElementById(id);
if (modal) {
modal.classList.remove('active');
document.body.style.overflow = '';
}
}
/* Close modal on backdrop click */
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-backdrop') && e.target.classList.contains('active')) {
e.target.classList.remove('active');
document.body.style.overflow = '';
}
});
/* Close modal on Escape */
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-backdrop.active').forEach(m => {
m.classList.remove('active');
});
document.body.style.overflow = '';
}
});
function refreshCaptcha() {
const img = document.getElementById('captchaImage');
if (img) {
img.src = '/api/auth/captcha?' + new Date().getTime();
}
}
async function doLogin(e) {
e.preventDefault();
const btn = document.getElementById('loginBtn');
const text = document.getElementById('loginBtnText');
const spinner = document.getElementById('loginSpinner');
const errEl = document.getElementById('loginError');
btn.disabled = true;
text.textContent = _('logging_in');
spinner.classList.remove('hidden');
errEl.classList.remove('visible');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
captcha: document.getElementById('captcha') ? document.getElementById('captcha').value : null,
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || _('login_error'));
}
window.location.href = '/';
} catch (err) {
errEl.textContent = err.message;
errEl.classList.add('visible');
refreshCaptcha();
if (document.getElementById('captcha')) {
document.getElementById('captcha').value = '';
}
} finally {
btn.disabled = false;
text.textContent = _('login');
spinner.classList.add('hidden');
}
}
</script>
</body>
</html>
+162
View File
@@ -0,0 +1,162 @@
{% extends "base.html" %}
{% block title_extra %} — {{ _('my_connections_title') }}{% endblock %}
{% block content %}
<section>
<h1 class="section-title">
<span class="icon">🔗</span>
{{ _('my_connections_title') }}
</h1>
{% if connections %}
<div class="clients-list" id="myConnectionsList">
{% for c in connections %}
<div class="client-item">
<div class="client-info">
<div class="client-avatar">🔗</div>
<div>
<div class="client-name">{{ c.name or 'VPN Connection' }}</div>
<div class="client-meta">
<span>🖥 {{ c.server_name }}</span>
<span class="badge badge-info" style="font-size:0.65rem;">
{{ '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))) }}
</span>
{% if c.created_at %}
<span>📅 {{ c.created_at[:10] }}</span>
{% endif %}
</div>
</div>
</div>
<div class="client-actions">
<button class="btn btn-primary btn-sm"
onclick="showMyConfig('{{ c.id }}', '{{ c.name or 'Connection' }}', '{{ c.protocol }}')">
{{ _('show_config') }}
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">🔗</div>
<div class="empty-title">{{ _('no_connections') }}</div>
<div class="empty-desc">{{ _('no_connections_user_desc') }}</div>
</div>
{% endif %}
</section>
<!-- ===== Config Modal ===== -->
<div class="modal-backdrop" id="configModal">
<div class="modal" style="max-width: 600px;">
<div class="modal-header">
<h2 class="modal-title" id="configModalTitle">{{ _('config') }}</h2>
<button class="modal-close" onclick="closeModal('configModal')">×</button>
</div>
<div class="config-tabs">
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('config_tab') }}</button>
<button class="config-tab" onclick="switchConfigTab('vpn')">{{ _('vpn_key_tab') }}</button>
<button class="config-tab" onclick="switchConfigTab('qr')">{{ _('qr_code_tab') }}</button>
</div>
<div class="config-panel active" id="panel-conf">
<div class="config-display">
<div class="config-text" id="configText"></div>
<div class="config-actions">
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentConfig)" style="flex:1">
{{ _('copy_config') }}
</button>
<button class="btn btn-primary btn-sm"
onclick="downloadFile(currentConfig, currentConfigName + '.conf')" style="flex:1">
{{ _('download_conf') }}
</button>
</div>
</div>
</div>
<div class="config-panel" id="panel-vpn">
<div class="vpn-link-box" id="vpnLinkText"></div>
<div class="config-actions" style="margin-top: var(--space-sm);">
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(currentVpnLink)" style="flex:1">
{{ _('copy_key') }}
</button>
</div>
<div class="form-hint" style="margin-top: var(--space-sm);">
{{ _('vpn_link_hint') }}
</div>
</div>
<div class="config-panel" id="panel-qr">
<div class="qr-container">
<div id="qrCode"></div>
<div class="qr-hint">{{ _('qr_hint') }}</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentConfig = '';
let currentVpnLink = '';
let currentConfigName = 'connection';
async function showMyConfig(connectionId, name, proto) {
try {
showToast(_('loading_connections'), 'info');
const result = await apiCall(`/api/my/connections/${connectionId}/config`, 'POST');
if (result.config) {
currentConfig = result.config;
currentVpnLink = result.vpn_link || '';
currentConfigName = name.replace(/\s+/g, '_');
document.getElementById('configModalTitle').textContent = `${_('config')} — ${name}`;
document.getElementById('configText').textContent = result.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
// Telemt (MTProxy) doesn't have a "VPN Link" (vpn://) format in Amnezia
const vpnTab = document.querySelectorAll('.config-tab')[1];
const vpnPanel = document.getElementById('panel-vpn');
if (proto === 'telemt') {
vpnTab.style.display = 'none';
} else {
vpnTab.style.display = '';
}
switchConfigTab('conf');
openModal('configModal');
generateQR(result.config);
}
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
}
}
function switchConfigTab(tab) {
document.querySelectorAll('.config-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.config-panel').forEach(p => p.classList.remove('active'));
const tabs = document.querySelectorAll('.config-tab');
const panels = { conf: 'panel-conf', vpn: 'panel-vpn', qr: 'panel-qr' };
const tabIdx = { conf: 0, vpn: 1, qr: 2 };
tabs[tabIdx[tab]].classList.add('active');
document.getElementById(panels[tab]).classList.add('active');
}
function generateQR(text) {
const container = document.getElementById('qrCode');
container.innerHTML = '';
try {
new QRCode(container, {
text: text, width: 280, height: 280,
colorDark: '#000000', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.L
});
} catch (e) {
container.innerHTML = `<div style="color:var(--text-muted);font-size:0.85rem;">${_('qr_error')}</div>`;
}
}
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+249
View File
@@ -0,0 +1,249 @@
{% extends "base.html" %}
{% block title_extra %} — {{ _('share_title') }}{% endblock %}
{% block content %}
<div class="card" style="max-width: 600px; margin: 2rem auto;">
<div class="card-header"
style="justify-content: center; text-align: center; flex-direction: column; gap: var(--space-xs);">
<h2 class="card-title">{{ _('vpn_access') }}</h2>
<p style="color: var(--text-muted); font-size: 0.9rem;">{{ _('user_label_share') }}: <strong>{{
share_user.username }}</strong>
</p>
</div>
{% if need_password %}
<div style="padding: var(--space-lg); text-align: center;">
<div class="logo-icon" style="font-size: 3rem; margin-bottom: var(--space-md);">🔐</div>
<p style="margin-bottom: var(--space-md);">{{ _('share_protected_desc') }}
</p>
<form id="authForm" onsubmit="authShare(event)"
style="display: flex; flex-direction: column; gap: var(--space-md); max-width: 300px; margin: 0 auto;">
<div class="form-group">
<input type="password" id="sharePassword" class="form-input" placeholder="{{ _('password') }}" required
autofocus>
</div>
<button type="submit" class="btn btn-primary" id="authBtn">
<span id="authBtnText">{{ _('login') }}</span>
<div class="spinner hidden" id="authSpinner"></div>
</button>
</form>
</div>
{% else %}
<div id="connectionsList" style="padding: var(--space-md);">
<div style="text-align: center; padding: 2rem;" id="loadingState">
<div class="spinner" style="width: 40px; height: 40px; margin: 0 auto;"></div>
<p style="margin-top: 1rem; color: var(--text-muted);">{{ _('loading_share_conns') }}</p>
</div>
<div id="connectionsGrid" class="hidden" style="display: grid; gap: var(--space-md);">
<!-- Connections will be here -->
</div>
<div id="emptyState" class="hidden" style="text-align: center; padding: 2rem;">
<p style="color: var(--text-muted);">{{ _('no_active_conns') }}</p>
</div>
</div>
{% endif %}
</div>
<!-- Modal for Config -->
<div class="modal-backdrop" id="configModal">
<div class="modal" style="max-width: 600px;">
<div class="modal-header">
<h2 class="modal-title" id="configModalTitle">{{ _('config') }}</h2>
<button class="modal-close" onclick="closeModal('configModal')">×</button>
</div>
<div class="config-tabs">
<button class="config-tab active" onclick="switchConfigTab('conf')">{{ _('config_tab') }}</button>
<button class="config-tab" onclick="switchConfigTab('vpn')">{{ _('vpn_key_tab') }}</button>
<button class="config-tab" onclick="switchConfigTab('qr')">{{ _('qr_code_tab') }}</button>
</div>
<div class="config-panel active" id="panel-conf">
<div class="config-display">
<textarea class="config-text" id="configText" readonly rows="12"
style="width:100%; border:none; background:transparent; color:inherit; font-family:monospace; resize:none; outline:none;"></textarea>
<div class="config-actions">
<button class="btn btn-secondary btn-sm" onclick="copyConfig()" style="flex:1">{{ _('copy_config')
}}</button>
<a id="downloadBtn" class="btn btn-primary btn-sm"
style="flex:1; text-decoration:none; display:flex; align-items:center; justify-content:center;">
{{ _('download_conf') }}
</a>
</div>
</div>
</div>
<div class="config-panel" id="panel-vpn">
<div class="vpn-link-box" id="vpnLinkText" style="min-height: 100px;"></div>
<div class="config-actions" style="margin-top:var(--space-sm);">
<button class="btn btn-secondary btn-sm" onclick="copyVpnLink()" style="flex:1">{{ _('copy_key')
}}</button>
</div>
</div>
<div class="config-panel" id="panel-qr">
<div class="qr-container">
<div id="qrcode"></div>
</div>
</div>
</div>
</div>
<script>
const TOKEN = "{{ token }}";
async function authShare(e) {
e.preventDefault();
const password = document.getElementById('sharePassword').value;
const btn = document.getElementById('authBtn');
const text = document.getElementById('authBtnText');
const spinner = document.getElementById('authSpinner');
btn.disabled = true;
text.classList.add('hidden');
spinner.classList.remove('hidden');
try {
const res = await fetch(`/api/share/${TOKEN}/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await res.json();
if (data.status === 'success') {
window.location.reload();
} else {
alert(data.error || _('login_error'));
}
} catch (err) {
alert(`${_('error')}: ` + err.message);
} finally {
btn.disabled = false;
text.classList.remove('hidden');
spinner.classList.add('hidden');
}
}
async function loadConnections() {
if (document.getElementById('loadingState') === null) return;
try {
const res = await fetch(`/api/share/${TOKEN}/connections`);
if (res.status === 401) return; // Wait for auth
const data = await res.json();
document.getElementById('loadingState').classList.add('hidden');
if (!data.connections || data.connections.length === 0) {
document.getElementById('emptyState').classList.remove('hidden');
return;
}
const grid = document.getElementById('connectionsGrid');
grid.classList.remove('hidden');
grid.innerHTML = data.connections.map(c => `
<div class="card" style="padding: var(--space-md); border: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--space-sm);">
<div style="text-align: initial;">
<div style="font-weight: 600; font-size: 1.1rem;">${c.name}</div>
<div style="font-size: 0.8rem; color: var(--text-muted);">${c.protocol.toUpperCase()} • ${c.server_name}</div>
</div>
<span class="badge badge-success">${_('active')}</span>
</div>
<button class="btn btn-secondary btn-sm w-full" onclick="showConfig('${c.id}', '${c.name}')">
${_('show_settings_btn')}
</button>
</div>
`).join('');
} catch (err) {
console.error(err);
}
}
function switchConfigTab(tab) {
document.querySelectorAll('.config-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.config-panel').forEach(p => p.classList.remove('active'));
const tabs = document.querySelectorAll('.config-tab');
const panels = { conf: 'panel-conf', vpn: 'panel-vpn', qr: 'panel-qr' };
const tabIdx = { conf: 0, vpn: 1, qr: 2 };
tabs[tabIdx[tab]].classList.add('active');
document.getElementById(panels[tab]).classList.add('active');
}
let currentConfig = '';
let currentVpnLink = '';
async function showConfig(connId, name) {
try {
const res = await fetch(`/api/share/${TOKEN}/config/${connId}`, { method: 'POST' });
const data = await res.json();
if (data.error) throw new Error(data.error);
currentConfig = data.config;
currentVpnLink = data.vpn_link || '';
document.getElementById('configText').value = data.config;
document.getElementById('vpnLinkText').textContent = currentVpnLink;
document.getElementById('configModalTitle').textContent = name;
const dl = document.getElementById('downloadBtn');
dl.onclick = () => downloadFile(data.config, `${name}.conf`);
const qrContainer = document.getElementById('qrcode');
qrContainer.innerHTML = '';
new QRCode(qrContainer, {
text: data.config,
width: 256,
height: 256,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.L
});
switchConfigTab('conf');
openModal('configModal');
} catch (err) {
alert(`${_('error')}: ` + err.message);
}
}
function copyConfig() {
copyToClipboard(currentConfig);
}
function copyVpnLink() {
copyToClipboard(currentVpnLink);
}
document.addEventListener('DOMContentLoaded', loadConnections);
</script>
<style>
.w-full {
width: 100%;
}
.spinner {
border: 3px solid rgba(255, 255, 255, 0.1);
border-top: 3px solid var(--accent-color);
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
{% endblock %}
+1084
View File
File diff suppressed because it is too large Load Diff
+423
View File
@@ -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 <token>` 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"
}
+390
View File
@@ -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 <token>` 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"
}
+390
View File
@@ -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 <token>` 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"
}
+423
View File
@@ -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 <token>`. Токены имеют права администратора и перестают работать, если их владелец отключён или понижен в роли.",
"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": "Обновить"
}
+390
View File
@@ -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 <token>` 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"
}