Add reverse proxy masking and Cloudflare panel SSL via DNS-01.
Introduce Caddy-based reverse proxy with decoy site and DPI masking, plus automatic Let's Encrypt certificate issuance for the panel through Cloudflare API without binding port 443. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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/**/*
|
||||||
|
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# PyInstaller folders
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.log
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# User data
|
||||||
|
data.json
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
+16
@@ -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"]
|
||||||
@@ -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>.
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
# Amnezia Web Panel
|
||||||
|
|
||||||
|
A modern, high-performance web interface for managing **AmneziaWG**, **Classic WireGuard**, **Xray (XTLS-Reality)**, **Telemt (Telegram MTProxy)**, **AmneziaDNS**, **AdGuard Home** and **SOCKS5** services on remote Ubuntu servers — from a single dashboard. Designed to provide a premium user experience with robust administrative capabilities.
|
||||||
|
|
||||||
|
> ### 🔄 Compatibility with Official Amnezia Client
|
||||||
|
>
|
||||||
|
> This panel is fully compatible with the official **Amnezia** applications!
|
||||||
|
>
|
||||||
|
> **How to connect an existing server:**
|
||||||
|
> 1. Add your pre-configured server by entering its **IP address**, **login** and **password**
|
||||||
|
> 2. Go to the "Added Servers" section
|
||||||
|
> 3. Wait for the automatic server verification
|
||||||
|
> 4. The panel will automatically detect:
|
||||||
|
> - ✅ Installed protocols
|
||||||
|
> - ✅ Existing users
|
||||||
|
> - ✅ Current configuration
|
||||||
|
>
|
||||||
|
> ⚡ **After verification, you can manage the server directly from the panel!**
|
||||||
|
|
||||||
|
## ⚠️ Legal Notice
|
||||||
|
|
||||||
|
> **This project is created solely for educational and research purposes.**
|
||||||
|
>
|
||||||
|
> **This project has never been intended for use in jurisdictions where the technologies employed are prohibited.** The author bears no responsibility for any unlawful use of this software.
|
||||||
|
|
||||||
|
**This project merely adds an abstraction layer for managing publicly available applications.** All applications belong to their respective owners. This project does not claim ownership over, nor does it modify, any third-party applications.
|
||||||
|
|
||||||
|
The use of traffic obfuscation tools may violate the laws of your country. Only use this software for lawful purposes, such as:
|
||||||
|
|
||||||
|
- **Penetration testing and security research**
|
||||||
|
- **CTF (Capture The Flag) competitions**
|
||||||
|
- **Academic and scientific research**
|
||||||
|
- **Testing and securing your own networks**
|
||||||
|
- **Improving defensive security measures**
|
||||||
|
- **Educational training in cybersecurity**
|
||||||
|
|
||||||
|
> **Nothing in this project constitutes an incitement to violate any applicable laws.**
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
### Additional Sections
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>👥 Users Management</b> (click to expand)</summary>
|
||||||
|
<br>
|
||||||
|
User management interface with permissions and access controls:
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>⚙️ System Settings</b> (click to expand)</summary>
|
||||||
|
<br>
|
||||||
|
Configuration panel for system parameters and preferences:
|
||||||
|
|
||||||
|

|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 🚀 Key Features
|
||||||
|
|
||||||
|
* **⚡ VPN Protocols**:
|
||||||
|
* **AmneziaWG (AWG / AWG 2.0 / AWG Legacy)**: Advanced WireGuard-based protocol with S3/S4 obfuscation to bypass deep packet inspection (DPI). Three coexisting variants — modern AWG 2.0 with full junk-packet masking, and a legacy variant for older clients.
|
||||||
|
* **Classic WireGuard**: Standard, high-performance WireGuard protocol for unmatched speed and broad device compatibility with traffic monitoring support.
|
||||||
|
* **Xray (XTLS-Reality)**: Stealthy protocol that masks VPN traffic as standard HTTPS browsing. Pinned to **Xray-core v26.x**; transparently reads both the **panel layout** (`meta.json` + `clientsTable.json`) and the **native Amnezia client layout** (`xray_*.key` files + `clientsTable`), so a node first installed via the official mobile/desktop app can be attached to the panel without re-installation.
|
||||||
|
* **Telemt (Telegram MTProxy)**: High-performance Telegram MTProxy with TLS emulation and comprehensive management (quotas, IP limits, real-time session tracking). Robust install path that auto-configures Docker's official apt/yum repository when needed.
|
||||||
|
* **🛠 Services**:
|
||||||
|
* **AmneziaDNS**: Internal DNS resolver on a private docker network (`amnezia-dns-net`, IP `172.29.172.254`) to prevent DNS leaks and blockings.
|
||||||
|
* **AdGuard Home** *(new)*: DNS-based ad blocker with a web admin UI. Two install modes: **Replace AmneziaDNS** (takes its IP, all VPN clients use AdGuard immediately) or **Side-by-side** (parallel deployment on `172.29.172.253`, web UI accessible only over the VPN by default). Optional opt-in checkboxes to expose the web UI / DoT / DoH on the host.
|
||||||
|
* **SOCKS5 Proxy** *(new)*: Single-account 3proxy-based SOCKS5 server modelled after the official Amnezia client. Auto-generated 16-character password on install, port and credentials editable later from the panel without re-install.
|
||||||
|
* **⚙️ Core Server Management**:
|
||||||
|
* **Add / Edit / Delete / Reorder** server entries — drag-and-drop reorder updates `server_id` references in saved connections automatically.
|
||||||
|
* **Live ping indicator** next to each server name — non-blocking TCP-connect probe to the SSH port, runs on the asyncio loop in parallel for all servers.
|
||||||
|
* **Clear server** wipes every Amnezia-related container, image and `/opt/amnezia` directory in a single sudo script — works for any current or future `amnezia-*` protocol.
|
||||||
|
* **Reboot** the server directly from the UI.
|
||||||
|
* Strictly concurrent protocol status polling — all 9 protocols/services checked in parallel for immediate feedback.
|
||||||
|
* **Asynchronous Processing**: Resilient, non-blocking background architecture prevents the UI panel from freezing, even if remote endpoints hang.
|
||||||
|
* **🌐 Internationalization (i18n)**:
|
||||||
|
* Full support for **English**, **Russian**, **French**, **Chinese**, and **Persian**.
|
||||||
|
* Native **RTL (Right-to-Left)** support for Persian language.
|
||||||
|
* **👥 Advanced User Management**:
|
||||||
|
* Role-based access (Admin, Support, Regular User).
|
||||||
|
* Traffic limits, status monitoring, and account expiration.
|
||||||
|
* One-click user enabling/disabling.
|
||||||
|
* **🎨 Premium UI/UX**:
|
||||||
|
* Stunning glassmorphism design.
|
||||||
|
* Dynamic **Dark/Light** mode transition.
|
||||||
|
* Fully responsive for mobile and desktop.
|
||||||
|
* **🤖 Telegram Bot Integration**:
|
||||||
|
* Notify users about new connections or limits.
|
||||||
|
* Integrated management via Telegram commands.
|
||||||
|
* **🔄 Built-in Update Checker**:
|
||||||
|
* View your current panel version directly in Settings.
|
||||||
|
* One-click check for fresh GitHub releases to stay up to date.
|
||||||
|
* **📤 Data Interoperability**:
|
||||||
|
* **Remnawave Sync**: Automatically import and sync users from Remnawave.
|
||||||
|
* **Simple Backup**: Effortless JSON-based export and restore of all panel data.
|
||||||
|
* **🔗 Public Sharing**:
|
||||||
|
* Generate password-protected links for users to download their configurations without panel access.
|
||||||
|
* **🌍 One-click Public Tunnels** *(new)*:
|
||||||
|
* Open the local panel to the internet from `/settings` using **Cloudflare Quick Tunnel** or **ngrok**.
|
||||||
|
* Shows the local server URL, installation state, running state, and issued public HTTPS URLs directly in the UI.
|
||||||
|
* Supports one-click install, enable, stop, and delete for panel-managed tunnel binaries.
|
||||||
|
* Persists tunnel PID/public URL state across panel restarts and can detect already running tunnel processes.
|
||||||
|
* Works on Windows, Linux, and Docker-friendly environments; `TUNNEL_BIN_DIR` and `TUNNEL_STATE_FILE` can override binary/state locations.
|
||||||
|
* **🔑 API Tokens for External Integrations** *(new)*:
|
||||||
|
* Issue bearer tokens from `/settings` for CI bots, monitoring, or any third-party service.
|
||||||
|
* Panel never stores the raw token — only its SHA-256 hash. The full value is shown **once** at creation; lose it and you must rotate.
|
||||||
|
* Tokens inherit the role of the admin who created them and are revoked automatically if that user is disabled or demoted.
|
||||||
|
* Send `Authorization: Bearer <token>` with any admin endpoint — every endpoint that accepts a session also accepts a token, no other changes.
|
||||||
|
|
||||||
|
## 💡 Need Additional Functionality?
|
||||||
|
|
||||||
|
If you require any custom features not currently available in the panel, **let us know – we'll implement them quickly!**
|
||||||
|
|
||||||
|
* **Database Support**: PostgreSQL, MySQL/MariaDB, SQLite, Oracle, and MS SQL Server
|
||||||
|
* **In-Panel File Editor**: Edit configuration files inside containers directly from the web interface
|
||||||
|
* **Backup & Restore nodes/protocols**: Comprehensive backup solutions for nodes and protocols
|
||||||
|
* **Protocol Migration**: Seamlessly move protocols between nodes
|
||||||
|
* **Xray Self-Steal Mode**: Advanced Xray configuration with self-steal functionality
|
||||||
|
* **And much more!**
|
||||||
|
|
||||||
|
**Or better yet, contribute!**
|
||||||
|
|
||||||
|
|
||||||
|
## 🏗 Prerequisites
|
||||||
|
|
||||||
|
* **Python 3.10+**
|
||||||
|
* Target servers: **Ubuntu 20.04/22.04/24.04** (Architecture: x86_64 or ARM64).
|
||||||
|
* SSH access to target servers (Password or Private Key).
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
1. **Clone the repository**:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/PRVTPRO/Amnezia-Web-Panel.git
|
||||||
|
cd Amnezia-Web-Panel
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Set up Virtual Environment**:
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Install Dependencies**:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
Launch the application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The panel will be accessible at `http://localhost:5000`.
|
||||||
|
|
||||||
|
## 📦 Installation Method 2
|
||||||
|
|
||||||
|
Download and run the executable file for your system.
|
||||||
|
```
|
||||||
|
Windows
|
||||||
|
Linux
|
||||||
|
Mac
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Docker Image
|
||||||
|
|
||||||
|
https://hub.docker.com/r/prvtpro/amnezia-panel
|
||||||
|
|
||||||
|
|
||||||
|
### Initial Login
|
||||||
|
* **Username**: `admin`
|
||||||
|
* **Password**: `admin`
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Secure your panel by changing the default password in the **Users** section immediately after first login.
|
||||||
|
|
||||||
|
## 🔧 Project Details
|
||||||
|
|
||||||
|
### API Documentation
|
||||||
|
|
||||||
|
The project includes self-documenting API endpoints, organised into clear tag groups:
|
||||||
|
|
||||||
|
* **Swagger UI**: `/docs`
|
||||||
|
* **ReDoc**: `/redoc` (pinned to a stable bundle, Google Fonts disabled — works on networks where they're blocked)
|
||||||
|
|
||||||
|
Routes are grouped in the docs as:
|
||||||
|
|
||||||
|
| Group | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| **System Templates** | HTML pages served to browsers (login, server detail, settings, /share). Not part of the JSON API. |
|
||||||
|
| **Authentication** | Login, captcha, session lifecycle. |
|
||||||
|
| **Servers** | Server inventory & host-level operations (add/edit/delete, ping, reorder, reboot, clear, stats). |
|
||||||
|
| **Protocols** | Install / uninstall / container / raw-config editing for every protocol & service on a server. |
|
||||||
|
| **Connections** | Per-protocol VPN client connections (CRUD, enable/disable, fetch config). |
|
||||||
|
| **Users** | Panel user accounts and the connections assigned to them. |
|
||||||
|
| **Self-service** | Endpoints called by a regular user for their own data (`/api/my/*`). |
|
||||||
|
| **Sharing** | Public, token-protected configuration sharing — no panel session required. |
|
||||||
|
| **Settings** | Panel-wide settings, Telegram bot, Remnawave sync, JSON backup/restore. |
|
||||||
|
| **API Tokens** | Create and revoke bearer tokens for external integrations. |
|
||||||
|
|
||||||
|
**Authentication for external integrations** — both session cookies and `Authorization: Bearer <token>` are accepted on every admin endpoint. Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN="awp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
|
||||||
|
# List panel users
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/users
|
||||||
|
|
||||||
|
# Add a server
|
||||||
|
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{"host":"1.2.3.4","username":"root","password":"...","name":"new-srv"}' \
|
||||||
|
http://your-panel:5000/api/servers/add
|
||||||
|
|
||||||
|
# Cheap reachability probe for monitoring
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/servers/0/ping
|
||||||
|
```
|
||||||
|
|
||||||
|
### Technology Stack
|
||||||
|
* **Backend**: FastAPI (Python), `asyncio` for concurrent SSH/probe work
|
||||||
|
* **Frontend**: Vanilla JS, Jinja2, Custom CSS (Glassmorphism, full set of CSS animations for promo blocks)
|
||||||
|
* **Database**: Local JSON storage (`data.json`) with an `asyncio.Lock` for thread-safe writes
|
||||||
|
* **SSH Engine**: Paramiko
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
web-panel/
|
||||||
|
├── app.py # FastAPI entry point + all routes
|
||||||
|
├── telegram_bot.py # Optional Telegram bot integration
|
||||||
|
├── managers/ # Protocol & service managers (one file per protocol)
|
||||||
|
│ ├── ssh_manager.py # SSH abstraction (Paramiko wrapper)
|
||||||
|
│ ├── awg_manager.py # AmneziaWG / AWG 2.0 / AWG Legacy
|
||||||
|
│ ├── wireguard_manager.py # Classic WireGuard
|
||||||
|
│ ├── xray_manager.py # Xray-core (VLESS-Reality)
|
||||||
|
│ ├── telemt_manager.py # Telegram MTProxy
|
||||||
|
│ ├── dns_manager.py # AmneziaDNS (Unbound)
|
||||||
|
│ ├── adguard_manager.py # AdGuard Home
|
||||||
|
│ └── socks5_manager.py # 3proxy-based SOCKS5
|
||||||
|
├── static/ # CSS / favicon / vendored JS
|
||||||
|
├── templates/ # Jinja2 templates
|
||||||
|
├── translations/ # en / ru / fr / zh / fa
|
||||||
|
└── data.json # Panel state (servers, users, tokens, settings)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛡 Security Recommendations
|
||||||
|
|
||||||
|
* **Reverse Proxy**: It is highly recommended to run the panel behind Nginx/Apache with an SSL certificate.
|
||||||
|
* **SSH Keys**: Use SSH keys rather than passwords for connecting to your VPN servers.
|
||||||
|
* **Secret Key**: Set a custom `SECRET_KEY` environment variable for secure session management.
|
||||||
|
* **API Tokens**: Treat each token like a password — store it in your integration's secret manager. Revoke it from `/settings` if it leaks or the integration is decommissioned. Rotate periodically; tokens inherit admin rights.
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit Pull Requests or open Issues for feature requests and bug reports.
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](../LICENSE) file for details.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
*Built with ❤️ for the Amnezia community.*
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
amnezia_panel:
|
||||||
|
image: prvtpro/amnezia-panel:1.4.4
|
||||||
|
container_name: amnezia_panel
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-5000}:5000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- amnezia_data:/app/data
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python3 -c \"import socket; s=socket.socket(); s.connect(('localhost', 5000)); s.close()\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
amnezia_data:
|
||||||
@@ -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`).
|
||||||
|
"""
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"""
|
||||||
|
AdGuard Home Manager — runs adguard/adguardhome in a Docker container,
|
||||||
|
joined to the same internal `amnezia-dns-net` network the rest of the panel
|
||||||
|
uses. Two install modes:
|
||||||
|
|
||||||
|
* 'replace' — removes the AmneziaDNS container (if present) and takes
|
||||||
|
its static IP (172.29.172.254) so VPN clients keep using
|
||||||
|
the same upstream.
|
||||||
|
* 'sidebyside' — runs alongside AmneziaDNS on a different static IP
|
||||||
|
(172.29.172.253). VPN users can hit it on demand
|
||||||
|
(e.g. via the web admin UI from inside the tunnel).
|
||||||
|
|
||||||
|
Initial setup of AdGuard itself runs through its built-in wizard on the web
|
||||||
|
UI port — we do not try to script it (the JSON setup API is unstable across
|
||||||
|
versions and trying to drive it programmatically tends to break on upgrade).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AdguardManager:
|
||||||
|
PROTOCOL = 'adguard'
|
||||||
|
CONTAINER_NAME = 'amnezia-adguard'
|
||||||
|
IMAGE_NAME = 'adguard/adguardhome:latest'
|
||||||
|
|
||||||
|
NETWORK_NAME = 'amnezia-dns-net'
|
||||||
|
NETWORK_SUBNET = '172.29.172.0/24'
|
||||||
|
REPLACE_IP = '172.29.172.254' # AmneziaDNS's slot
|
||||||
|
SIDEBYSIDE_IP = '172.29.172.253' # parallel to AmneziaDNS
|
||||||
|
|
||||||
|
HOST_DIR = '/opt/amnezia/adguard'
|
||||||
|
DEFAULT_DNS_PORT = 53
|
||||||
|
DEFAULT_WEB_PORT = 3000
|
||||||
|
DEFAULT_DOT_PORT = 853
|
||||||
|
DEFAULT_DOH_PORT = 443
|
||||||
|
|
||||||
|
def __init__(self, ssh):
|
||||||
|
self.ssh = ssh
|
||||||
|
|
||||||
|
# ===================== STATUS =====================
|
||||||
|
|
||||||
|
def check_docker_installed(self):
|
||||||
|
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
|
||||||
|
if code != 0:
|
||||||
|
return False
|
||||||
|
out2, _, _ = self.ssh.run_command(
|
||||||
|
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
|
||||||
|
)
|
||||||
|
return 'active' in out2 or 'running' in out2.lower()
|
||||||
|
|
||||||
|
def check_protocol_installed(self, protocol_type='adguard'):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
|
||||||
|
)
|
||||||
|
return self.CONTAINER_NAME in out.strip().split('\n')
|
||||||
|
|
||||||
|
def check_container_running(self):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
|
||||||
|
)
|
||||||
|
return 'Up' in out
|
||||||
|
|
||||||
|
def _container_ip(self):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker inspect -f '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}} {{{{end}}}}' {self.CONTAINER_NAME} 2>/dev/null"
|
||||||
|
)
|
||||||
|
ip = out.strip().split()[0] if out.strip() else ''
|
||||||
|
return ip
|
||||||
|
|
||||||
|
def _detect_mode(self):
|
||||||
|
"""Return 'replace' or 'sidebyside' based on the running container's IP.
|
||||||
|
Returns None if not detectable (container not running)."""
|
||||||
|
ip = self._container_ip()
|
||||||
|
if ip == self.REPLACE_IP:
|
||||||
|
return 'replace'
|
||||||
|
if ip == self.SIDEBYSIDE_IP:
|
||||||
|
return 'sidebyside'
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _exposed_web_port(self):
|
||||||
|
"""Reads back the host->container port mapping for the web UI port,
|
||||||
|
so the panel can show the correct admin URL after install."""
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker port {self.CONTAINER_NAME} 3000/tcp 2>/dev/null"
|
||||||
|
)
|
||||||
|
if not out.strip():
|
||||||
|
return None
|
||||||
|
# output like "0.0.0.0:3000" — take the last colon-separated chunk
|
||||||
|
last = out.strip().split('\n')[0].split(':')[-1].strip()
|
||||||
|
try:
|
||||||
|
return int(last)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_server_status(self, protocol_type='adguard'):
|
||||||
|
exists = self.check_protocol_installed()
|
||||||
|
running = self.check_container_running()
|
||||||
|
mode = self._detect_mode() if running else None
|
||||||
|
ip = self._container_ip() if running else ''
|
||||||
|
exposed_port = self._exposed_web_port() if running else None
|
||||||
|
# When the web UI is not bound to the host the user still needs an
|
||||||
|
# admin URL (reachable via VPN) — fall back to the default :3000 the
|
||||||
|
# container listens on inside the docker network.
|
||||||
|
return {
|
||||||
|
'container_exists': exists,
|
||||||
|
'container_running': running,
|
||||||
|
'mode': mode,
|
||||||
|
'internal_ip': ip,
|
||||||
|
'web_port': exposed_port or self.DEFAULT_WEB_PORT,
|
||||||
|
'web_exposed': exposed_port is not None,
|
||||||
|
'port': self.DEFAULT_DNS_PORT,
|
||||||
|
'protocol': protocol_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===================== INSTALL / REMOVE =====================
|
||||||
|
|
||||||
|
def _ensure_network(self):
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker network ls | grep -q {self.NETWORK_NAME} || "
|
||||||
|
f"docker network create --subnet {self.NETWORK_SUBNET} {self.NETWORK_NAME}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def install_protocol(
|
||||||
|
self,
|
||||||
|
protocol_type='adguard',
|
||||||
|
mode='sidebyside',
|
||||||
|
web_port=None,
|
||||||
|
expose_web=False,
|
||||||
|
dns_port=None,
|
||||||
|
dot_port=None,
|
||||||
|
doh_port=None,
|
||||||
|
expose_dns=False,
|
||||||
|
expose_dot=False,
|
||||||
|
expose_doh=False,
|
||||||
|
):
|
||||||
|
if not self.check_docker_installed():
|
||||||
|
return {'status': 'error', 'message': 'Docker not installed'}
|
||||||
|
if mode not in ('replace', 'sidebyside'):
|
||||||
|
return {'status': 'error', 'message': f"Invalid mode '{mode}'"}
|
||||||
|
|
||||||
|
web_port = int(web_port or self.DEFAULT_WEB_PORT)
|
||||||
|
dns_port = int(dns_port or self.DEFAULT_DNS_PORT)
|
||||||
|
dot_port = int(dot_port or self.DEFAULT_DOT_PORT)
|
||||||
|
doh_port = int(doh_port or self.DEFAULT_DOH_PORT)
|
||||||
|
|
||||||
|
# Persistent volumes — without these the AdGuard setup wizard would have
|
||||||
|
# to re-run on every container recreate.
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {self.HOST_DIR}/work {self.HOST_DIR}/conf")
|
||||||
|
|
||||||
|
self._ensure_network()
|
||||||
|
|
||||||
|
# Replace mode: detach + remove AmneziaDNS so we can claim its IP.
|
||||||
|
if mode == 'replace':
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker network disconnect {self.NETWORK_NAME} amnezia-dns 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
self.ssh.run_sudo_command("docker stop amnezia-dns 2>/dev/null || true")
|
||||||
|
self.ssh.run_sudo_command("docker rm -fv amnezia-dns 2>/dev/null || true")
|
||||||
|
target_ip = self.REPLACE_IP
|
||||||
|
else:
|
||||||
|
target_ip = self.SIDEBYSIDE_IP
|
||||||
|
|
||||||
|
if self.check_protocol_installed():
|
||||||
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} 2>/dev/null || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} 2>/dev/null || true")
|
||||||
|
|
||||||
|
self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}")
|
||||||
|
|
||||||
|
# Build port mapping. By default ports are reachable only inside
|
||||||
|
# `amnezia-dns-net` (so VPN clients hit them via the static IP).
|
||||||
|
# Optional `expose_*` flags add host port mappings for direct access.
|
||||||
|
ports = []
|
||||||
|
if expose_web:
|
||||||
|
ports.append(f"-p {web_port}:3000/tcp")
|
||||||
|
if expose_dns:
|
||||||
|
ports.append(f"-p {dns_port}:53/tcp")
|
||||||
|
ports.append(f"-p {dns_port}:53/udp")
|
||||||
|
if expose_dot:
|
||||||
|
ports.append(f"-p {dot_port}:853/tcp")
|
||||||
|
if expose_doh:
|
||||||
|
ports.append(f"-p {doh_port}:443/tcp")
|
||||||
|
ports_str = ' '.join(ports)
|
||||||
|
|
||||||
|
run_cmd = (
|
||||||
|
f"docker run -d --name {self.CONTAINER_NAME} --restart always "
|
||||||
|
f"--network {self.NETWORK_NAME} --ip {target_ip} "
|
||||||
|
f"-v {self.HOST_DIR}/work:/opt/adguardhome/work "
|
||||||
|
f"-v {self.HOST_DIR}/conf:/opt/adguardhome/conf "
|
||||||
|
f"{ports_str} "
|
||||||
|
f"{self.IMAGE_NAME}"
|
||||||
|
)
|
||||||
|
_, err, code = self.ssh.run_sudo_command(run_cmd)
|
||||||
|
if code != 0:
|
||||||
|
return {'status': 'error', 'message': f'Failed to start container: {err}'}
|
||||||
|
|
||||||
|
# Re-attach known VPN containers to the DNS network so they can reach
|
||||||
|
# AdGuard at target_ip (mirrors what dns_manager.py does on install).
|
||||||
|
for c in ('amnezia-awg', 'amnezia-awg2', 'amnezia-awg-legacy', 'amnezia-xray', 'amnezia-wireguard', 'telemt'):
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker ps --format '{{{{.Names}}}}' | grep -q '^{c}$' && "
|
||||||
|
f"docker network connect {self.NETWORK_NAME} {c} 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
|
||||||
|
url_host = self.ssh.host if expose_web else target_ip
|
||||||
|
admin_url = f"http://{url_host}:{web_port}"
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'protocol': 'adguard',
|
||||||
|
'mode': mode,
|
||||||
|
'internal_ip': target_ip,
|
||||||
|
'web_port': web_port,
|
||||||
|
'expose_web': bool(expose_web),
|
||||||
|
'admin_url': admin_url,
|
||||||
|
'message': 'AdGuard Home installed. Complete the setup wizard via the web UI.',
|
||||||
|
'log': [
|
||||||
|
f"AdGuard Home installed in '{mode}' mode",
|
||||||
|
f"Internal IP: {target_ip}",
|
||||||
|
f"Admin UI: {admin_url}" + ("" if expose_web else " (VPN-only — connect via VPN to reach it)"),
|
||||||
|
'Open the URL above to run the AdGuard setup wizard.',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def remove_container(self, protocol_type='adguard'):
|
||||||
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"rm -rf {self.HOST_DIR}")
|
||||||
|
return True
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,411 @@
|
|||||||
|
"""
|
||||||
|
Cloudflare DNS-01 ACME client for issuing the panel's own Let's Encrypt
|
||||||
|
certificate WITHOUT binding port 80/443.
|
||||||
|
|
||||||
|
Why not certbot: the panel ships as a single PyInstaller binary, so we implement
|
||||||
|
a minimal ACME v2 (RFC 8555) flow using `cryptography` + `httpx` only, both of
|
||||||
|
which are already dependencies. Validation is done via a Cloudflare DNS TXT
|
||||||
|
record (dns-01), which never touches the HTTP port — so the panel can keep
|
||||||
|
running on any port while the reverse proxy owns 443.
|
||||||
|
|
||||||
|
Public entry points:
|
||||||
|
issue_certificate(domain, cf_token, email, ...) -> {cert_pem, key_pem, ...}
|
||||||
|
get_cert_expiry(cert_pem) -> datetime | None
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Let's Encrypt production directory. Staging is available for testing but its
|
||||||
|
# certs are not browser-trusted, so we default to production.
|
||||||
|
ACME_DIRECTORY_PROD = "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
|
ACME_DIRECTORY_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||||
|
|
||||||
|
CF_API_BASE = "https://api.cloudflare.com/client/v4"
|
||||||
|
|
||||||
|
USER_AGENT = "amnezia-panel-acme/1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(data: bytes) -> str:
|
||||||
|
"""base64url without padding, per JWS spec."""
|
||||||
|
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
class CloudflareError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ACMEError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Cloudflare DNS API
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class CloudflareDNS:
|
||||||
|
def __init__(self, token: str, timeout: float = 30.0):
|
||||||
|
self.token = token.strip()
|
||||||
|
self._client = httpx.Client(
|
||||||
|
base_url=CF_API_BASE,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def _check(self, resp: httpx.Response) -> dict:
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except Exception:
|
||||||
|
raise CloudflareError(f"Cloudflare returned non-JSON ({resp.status_code}): {resp.text[:200]}")
|
||||||
|
if not body.get("success", False):
|
||||||
|
errors = body.get("errors", [])
|
||||||
|
msg = "; ".join(e.get("message", str(e)) for e in errors) or resp.text[:200]
|
||||||
|
raise CloudflareError(f"Cloudflare API error: {msg}")
|
||||||
|
return body
|
||||||
|
|
||||||
|
def verify_token(self) -> bool:
|
||||||
|
resp = self._client.get("/user/tokens/verify")
|
||||||
|
self._check(resp)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def find_zone_id(self, domain: str) -> str:
|
||||||
|
"""Resolve the zone that owns `domain`. Tries the domain and each parent
|
||||||
|
(sub.example.com -> example.com) so subdomains resolve to the apex zone."""
|
||||||
|
parts = domain.split(".")
|
||||||
|
candidates = [".".join(parts[i:]) for i in range(len(parts) - 1)]
|
||||||
|
for zone_name in candidates:
|
||||||
|
resp = self._client.get("/zones", params={"name": zone_name})
|
||||||
|
body = self._check(resp)
|
||||||
|
results = body.get("result", [])
|
||||||
|
if results:
|
||||||
|
return results[0]["id"]
|
||||||
|
raise CloudflareError(
|
||||||
|
f"No Cloudflare zone found for '{domain}'. Ensure the domain is added to this Cloudflare account and the token has Zone:Read."
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_txt_record(self, zone_id: str, name: str, content: str) -> str:
|
||||||
|
resp = self._client.post(
|
||||||
|
f"/zones/{zone_id}/dns_records",
|
||||||
|
json={"type": "TXT", "name": name, "content": content, "ttl": 60},
|
||||||
|
)
|
||||||
|
body = self._check(resp)
|
||||||
|
return body["result"]["id"]
|
||||||
|
|
||||||
|
def delete_record(self, zone_id: str, record_id: str):
|
||||||
|
try:
|
||||||
|
self._client.delete(f"/zones/{zone_id}/dns_records/{record_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to delete Cloudflare TXT record %s: %s", record_id, e)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Minimal ACME v2 client (RFC 8555) — dns-01 only
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class ACMEClient:
|
||||||
|
def __init__(self, directory_url: str = ACME_DIRECTORY_PROD, timeout: float = 60.0):
|
||||||
|
self.directory_url = directory_url
|
||||||
|
self._client = httpx.Client(
|
||||||
|
headers={"User-Agent": USER_AGENT, "Content-Type": "application/jose+json"},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
self._directory = None
|
||||||
|
self._nonce = None
|
||||||
|
self._account_key = None
|
||||||
|
self._kid = None
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
# ---- account key (RSA 2048) ----
|
||||||
|
|
||||||
|
def _gen_account_key(self):
|
||||||
|
self._account_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
|
||||||
|
def _jwk(self) -> dict:
|
||||||
|
pub = self._account_key.public_key().public_numbers()
|
||||||
|
e = pub.e.to_bytes((pub.e.bit_length() + 7) // 8, "big")
|
||||||
|
n = pub.n.to_bytes((pub.n.bit_length() + 7) // 8, "big")
|
||||||
|
return {"kty": "RSA", "e": _b64(e), "n": _b64(n)}
|
||||||
|
|
||||||
|
def _thumbprint(self) -> str:
|
||||||
|
jwk = self._jwk()
|
||||||
|
canonical = json.dumps({"e": jwk["e"], "kty": jwk["kty"], "n": jwk["n"]}, separators=(",", ":"), sort_keys=True)
|
||||||
|
return _b64(hashlib.sha256(canonical.encode()).digest())
|
||||||
|
|
||||||
|
# ---- low-level JWS transport ----
|
||||||
|
|
||||||
|
def _load_directory(self):
|
||||||
|
resp = self._client.get(self.directory_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
self._directory = resp.json()
|
||||||
|
|
||||||
|
def _get_nonce(self) -> str:
|
||||||
|
if self._nonce:
|
||||||
|
n, self._nonce = self._nonce, None
|
||||||
|
return n
|
||||||
|
resp = self._client.head(self._directory["newNonce"])
|
||||||
|
return resp.headers["Replay-Nonce"]
|
||||||
|
|
||||||
|
def _signed_request(self, url: str, payload, use_jwk: bool = False):
|
||||||
|
protected = {"alg": "RS256", "nonce": self._get_nonce(), "url": url}
|
||||||
|
if use_jwk:
|
||||||
|
protected["jwk"] = self._jwk()
|
||||||
|
else:
|
||||||
|
protected["kid"] = self._kid
|
||||||
|
|
||||||
|
protected_b64 = _b64(json.dumps(protected, separators=(",", ":")).encode())
|
||||||
|
if payload == "":
|
||||||
|
payload_b64 = "" # POST-as-GET
|
||||||
|
else:
|
||||||
|
payload_b64 = _b64(json.dumps(payload, separators=(",", ":")).encode())
|
||||||
|
|
||||||
|
signing_input = f"{protected_b64}.{payload_b64}".encode()
|
||||||
|
signature = self._account_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
|
||||||
|
|
||||||
|
jose = {"protected": protected_b64, "payload": payload_b64, "signature": _b64(signature)}
|
||||||
|
resp = self._client.post(url, content=json.dumps(jose))
|
||||||
|
if "Replay-Nonce" in resp.headers:
|
||||||
|
self._nonce = resp.headers["Replay-Nonce"]
|
||||||
|
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
try:
|
||||||
|
err = resp.json()
|
||||||
|
raise ACMEError(f"ACME error {resp.status_code}: {err.get('detail', resp.text)}")
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
raise ACMEError(f"ACME error {resp.status_code}: {resp.text[:300]}")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# ---- ACME flow ----
|
||||||
|
|
||||||
|
def register_account(self, email: str):
|
||||||
|
payload = {"termsOfServiceAgreed": True}
|
||||||
|
if email:
|
||||||
|
payload["contact"] = [f"mailto:{email}"]
|
||||||
|
resp = self._signed_request(self._directory["newAccount"], payload, use_jwk=True)
|
||||||
|
self._kid = resp.headers["Location"]
|
||||||
|
|
||||||
|
def new_order(self, domains: list) -> dict:
|
||||||
|
payload = {"identifiers": [{"type": "dns", "value": d} for d in domains]}
|
||||||
|
resp = self._signed_request(self._directory["newOrder"], payload)
|
||||||
|
order = resp.json()
|
||||||
|
order["_url"] = resp.headers["Location"]
|
||||||
|
return order
|
||||||
|
|
||||||
|
def get_authorization(self, auth_url: str) -> dict:
|
||||||
|
resp = self._signed_request(auth_url, "")
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def dns_challenge_value(self, token: str) -> str:
|
||||||
|
key_auth = f"{token}.{self._thumbprint()}"
|
||||||
|
return _b64(hashlib.sha256(key_auth.encode()).digest())
|
||||||
|
|
||||||
|
def trigger_challenge(self, challenge_url: str):
|
||||||
|
self._signed_request(challenge_url, {})
|
||||||
|
|
||||||
|
def poll_authorization(self, auth_url: str, attempts: int = 30, delay: float = 5.0) -> str:
|
||||||
|
for _ in range(attempts):
|
||||||
|
auth = self.get_authorization(auth_url)
|
||||||
|
status = auth.get("status")
|
||||||
|
if status == "valid":
|
||||||
|
return "valid"
|
||||||
|
if status == "invalid":
|
||||||
|
challenges = auth.get("challenges", [])
|
||||||
|
detail = ""
|
||||||
|
for c in challenges:
|
||||||
|
if c.get("error"):
|
||||||
|
detail = c["error"].get("detail", "")
|
||||||
|
raise ACMEError(f"DNS validation failed: {detail or 'authorization invalid'}")
|
||||||
|
time.sleep(delay)
|
||||||
|
raise ACMEError("Timed out waiting for DNS validation")
|
||||||
|
|
||||||
|
def finalize_order(self, finalize_url: str, csr_der: bytes) -> dict:
|
||||||
|
resp = self._signed_request(finalize_url, {"csr": _b64(csr_der)})
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def poll_order(self, order_url: str, attempts: int = 20, delay: float = 3.0) -> dict:
|
||||||
|
for _ in range(attempts):
|
||||||
|
resp = self._signed_request(order_url, "")
|
||||||
|
order = resp.json()
|
||||||
|
if order.get("status") == "valid":
|
||||||
|
return order
|
||||||
|
if order.get("status") == "invalid":
|
||||||
|
raise ACMEError("Order became invalid during finalization")
|
||||||
|
time.sleep(delay)
|
||||||
|
raise ACMEError("Timed out waiting for certificate issuance")
|
||||||
|
|
||||||
|
def download_certificate(self, cert_url: str) -> str:
|
||||||
|
resp = self._signed_request(cert_url, "")
|
||||||
|
return resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _build_csr(domains: list):
|
||||||
|
"""Generate an EC P-256 leaf key + CSR for the given domains."""
|
||||||
|
key = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
builder = x509.CertificateSigningRequestBuilder().subject_name(
|
||||||
|
x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, domains[0])])
|
||||||
|
)
|
||||||
|
san = x509.SubjectAlternativeName([x509.DNSName(d) for d in domains])
|
||||||
|
builder = builder.add_extension(san, critical=False)
|
||||||
|
csr = builder.sign(key, hashes.SHA256())
|
||||||
|
|
||||||
|
key_pem = key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
).decode()
|
||||||
|
return csr.public_bytes(serialization.Encoding.DER), key_pem
|
||||||
|
|
||||||
|
|
||||||
|
def issue_certificate(
|
||||||
|
domain: str,
|
||||||
|
cf_token: str,
|
||||||
|
email: str = "",
|
||||||
|
staging: bool = False,
|
||||||
|
dns_propagation_wait: int = 20,
|
||||||
|
progress=None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Issue a Let's Encrypt certificate for `domain` using Cloudflare dns-01.
|
||||||
|
|
||||||
|
Returns dict: {cert_pem, key_pem, domain, expires_at (iso), issued_at (iso)}
|
||||||
|
Raises CloudflareError / ACMEError on failure.
|
||||||
|
"""
|
||||||
|
def log(msg):
|
||||||
|
logger.info(msg)
|
||||||
|
if progress:
|
||||||
|
try:
|
||||||
|
progress(msg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
domain = domain.strip().lower().rstrip(".")
|
||||||
|
if not domain:
|
||||||
|
raise ACMEError("Domain is required")
|
||||||
|
|
||||||
|
directory = ACME_DIRECTORY_STAGING if staging else ACME_DIRECTORY_PROD
|
||||||
|
|
||||||
|
cf = CloudflareDNS(cf_token)
|
||||||
|
acme = ACMEClient(directory)
|
||||||
|
txt_record_id = None
|
||||||
|
zone_id = None
|
||||||
|
try:
|
||||||
|
log("Verifying Cloudflare API token...")
|
||||||
|
cf.verify_token()
|
||||||
|
zone_id = cf.find_zone_id(domain)
|
||||||
|
log(f"Cloudflare zone resolved for {domain}")
|
||||||
|
|
||||||
|
log("Setting up ACME account...")
|
||||||
|
acme._gen_account_key()
|
||||||
|
acme._load_directory()
|
||||||
|
acme.register_account(email)
|
||||||
|
|
||||||
|
log("Creating certificate order...")
|
||||||
|
order = acme.new_order([domain])
|
||||||
|
|
||||||
|
for auth_url in order["authorizations"]:
|
||||||
|
auth = acme.get_authorization(auth_url)
|
||||||
|
identifier = auth["identifier"]["value"]
|
||||||
|
|
||||||
|
dns_challenge = next(
|
||||||
|
(c for c in auth["challenges"] if c["type"] == "dns-01"), None
|
||||||
|
)
|
||||||
|
if not dns_challenge:
|
||||||
|
raise ACMEError("No dns-01 challenge offered by ACME server")
|
||||||
|
|
||||||
|
txt_value = acme.dns_challenge_value(dns_challenge["token"])
|
||||||
|
record_name = f"_acme-challenge.{identifier}"
|
||||||
|
|
||||||
|
log(f"Creating DNS TXT record {record_name} ...")
|
||||||
|
txt_record_id = cf.create_txt_record(zone_id, record_name, txt_value)
|
||||||
|
|
||||||
|
log(f"Waiting {dns_propagation_wait}s for DNS propagation...")
|
||||||
|
time.sleep(dns_propagation_wait)
|
||||||
|
|
||||||
|
log("Asking Let's Encrypt to validate...")
|
||||||
|
acme.trigger_challenge(dns_challenge["url"])
|
||||||
|
acme.poll_authorization(auth_url)
|
||||||
|
log("Domain validated ✓")
|
||||||
|
|
||||||
|
if txt_record_id:
|
||||||
|
cf.delete_record(zone_id, txt_record_id)
|
||||||
|
txt_record_id = None
|
||||||
|
|
||||||
|
log("Generating key and CSR...")
|
||||||
|
csr_der, key_pem = _build_csr([domain])
|
||||||
|
|
||||||
|
log("Finalizing order...")
|
||||||
|
acme.finalize_order(order["finalize"], csr_der)
|
||||||
|
final_order = acme.poll_order(order["_url"])
|
||||||
|
|
||||||
|
log("Downloading certificate...")
|
||||||
|
cert_pem = acme.download_certificate(final_order["certificate"])
|
||||||
|
|
||||||
|
expires_at = get_cert_expiry(cert_pem)
|
||||||
|
result = {
|
||||||
|
"cert_pem": cert_pem,
|
||||||
|
"key_pem": key_pem,
|
||||||
|
"domain": domain,
|
||||||
|
"issued_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||||
|
"staging": staging,
|
||||||
|
}
|
||||||
|
log("Certificate issued successfully ✓")
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
if txt_record_id and zone_id:
|
||||||
|
cf.delete_record(zone_id, txt_record_id)
|
||||||
|
cf.close()
|
||||||
|
acme.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_cert_expiry(cert_pem: str):
|
||||||
|
"""Return the notAfter datetime (UTC) of the first cert in the PEM chain."""
|
||||||
|
try:
|
||||||
|
cert = x509.load_pem_x509_certificate(cert_pem.encode())
|
||||||
|
try:
|
||||||
|
return cert.not_valid_after_utc
|
||||||
|
except AttributeError:
|
||||||
|
return cert.not_valid_after.replace(tzinfo=timezone.utc)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not parse certificate expiry: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def needs_renewal(expires_at_iso: str, days_before: int = 30) -> bool:
|
||||||
|
"""True if the cert expires within `days_before` days (or is unparseable)."""
|
||||||
|
if not expires_at_iso:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
expires = datetime.fromisoformat(expires_at_iso)
|
||||||
|
if expires.tzinfo is None:
|
||||||
|
expires = expires.replace(tzinfo=timezone.utc)
|
||||||
|
remaining = (expires - datetime.now(timezone.utc)).days
|
||||||
|
return remaining <= days_before
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
"""
|
||||||
|
Reverse Proxy Manager — Caddy 2 front for traffic masking.
|
||||||
|
|
||||||
|
Serves a polished decoy website on ports 80/443 and optionally reverse-proxies
|
||||||
|
a secret path to VPN backends (Xray, Telemt) on the internal Docker network.
|
||||||
|
Reduces DPI footprint: probes see a normal HTTPS site instead of a VPN endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BACKEND_UPSTREAMS = {
|
||||||
|
'none': None,
|
||||||
|
'xray': 'amnezia-xray',
|
||||||
|
'telemt': 'telemt',
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_VPN_PATH = '/cdn-cgi/challenge'
|
||||||
|
DEFAULT_BACKEND_PORT = 8443
|
||||||
|
|
||||||
|
|
||||||
|
class RevproxyManager:
|
||||||
|
PROTOCOL = 'revproxy'
|
||||||
|
CONTAINER_NAME = 'amnezia-revproxy'
|
||||||
|
REMOTE_DIR = '/opt/amnezia/revproxy'
|
||||||
|
SETTINGS_FILE = 'settings.json'
|
||||||
|
|
||||||
|
def __init__(self, ssh):
|
||||||
|
self.ssh = ssh
|
||||||
|
|
||||||
|
# ===================== STATUS =====================
|
||||||
|
|
||||||
|
def check_docker_installed(self):
|
||||||
|
out, _, code = self.ssh.run_command('docker --version 2>/dev/null')
|
||||||
|
return code == 0 and bool(out.strip())
|
||||||
|
|
||||||
|
def check_protocol_installed(self, protocol_type='revproxy'):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
|
||||||
|
)
|
||||||
|
return self.CONTAINER_NAME in out.strip().split('\n')
|
||||||
|
|
||||||
|
def check_container_running(self):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
|
||||||
|
)
|
||||||
|
return 'Up' in out
|
||||||
|
|
||||||
|
def get_server_status(self, protocol_type='revproxy'):
|
||||||
|
exists = self.check_protocol_installed()
|
||||||
|
running = self.check_container_running()
|
||||||
|
settings = self._read_settings() if exists else {}
|
||||||
|
site_url = self._build_site_url(settings)
|
||||||
|
return {
|
||||||
|
'container_exists': exists,
|
||||||
|
'container_running': running,
|
||||||
|
'port': 443,
|
||||||
|
'protocol': protocol_type,
|
||||||
|
'domain': settings.get('domain', ''),
|
||||||
|
'site_title': settings.get('site_title', 'CloudEdge'),
|
||||||
|
'backend': settings.get('backend', 'none'),
|
||||||
|
'backend_port': settings.get('backend_port', DEFAULT_BACKEND_PORT),
|
||||||
|
'vpn_path': settings.get('vpn_path', DEFAULT_VPN_PATH),
|
||||||
|
'tls_mode': settings.get('tls_mode', 'internal'),
|
||||||
|
'site_url': site_url,
|
||||||
|
'mask_enabled': settings.get('backend') in ('xray', 'telemt'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===================== INSTALL / UPDATE / REMOVE =====================
|
||||||
|
|
||||||
|
def install_protocol(
|
||||||
|
self,
|
||||||
|
protocol_type='revproxy',
|
||||||
|
domain=None,
|
||||||
|
site_title=None,
|
||||||
|
backend='none',
|
||||||
|
backend_port=None,
|
||||||
|
vpn_path=None,
|
||||||
|
tls_email=None,
|
||||||
|
enable_telemt_mask=False,
|
||||||
|
):
|
||||||
|
if not self.check_docker_installed():
|
||||||
|
return {'status': 'error', 'message': 'Docker not installed'}
|
||||||
|
|
||||||
|
backend = (backend or 'none').lower()
|
||||||
|
if backend not in BACKEND_UPSTREAMS:
|
||||||
|
backend = 'none'
|
||||||
|
|
||||||
|
backend_port = int(backend_port or DEFAULT_BACKEND_PORT)
|
||||||
|
vpn_path = self._normalize_path(vpn_path or DEFAULT_VPN_PATH)
|
||||||
|
site_title = (site_title or 'CloudEdge').strip() or 'CloudEdge'
|
||||||
|
domain = (domain or '').strip().lower()
|
||||||
|
tls_email = (tls_email or '').strip()
|
||||||
|
tls_mode = 'acme' if domain else 'internal'
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
'domain': domain,
|
||||||
|
'site_title': site_title,
|
||||||
|
'backend': backend,
|
||||||
|
'backend_port': backend_port,
|
||||||
|
'vpn_path': vpn_path,
|
||||||
|
'tls_email': tls_email,
|
||||||
|
'tls_mode': tls_mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
log = []
|
||||||
|
if self.check_protocol_installed():
|
||||||
|
log.append('Removing previous reverse proxy container...')
|
||||||
|
self.remove_container()
|
||||||
|
|
||||||
|
log.append('Ensuring Docker network amnezia-dns-net...')
|
||||||
|
self._ensure_network()
|
||||||
|
|
||||||
|
log.append('Ensuring docker compose plugin...')
|
||||||
|
self._ensure_docker_compose()
|
||||||
|
|
||||||
|
log.append('Uploading reverse proxy files...')
|
||||||
|
self._upload_bundle(settings)
|
||||||
|
|
||||||
|
log.append('Starting Caddy reverse proxy...')
|
||||||
|
out, err, code = self.ssh.run_sudo_command(
|
||||||
|
f"sh -c 'cd {self.REMOTE_DIR} && docker compose up -d'", timeout=180
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
out2, err2, code2 = self.ssh.run_sudo_command(
|
||||||
|
f"sh -c 'cd {self.REMOTE_DIR} && docker-compose up -d'", timeout=180
|
||||||
|
)
|
||||||
|
if code2 != 0:
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Failed to start reverse proxy: {err or err2 or out or out2}',
|
||||||
|
}
|
||||||
|
|
||||||
|
if backend == 'telemt' and enable_telemt_mask:
|
||||||
|
log.append('Enabling Telemt site masking (mask → Caddy)...')
|
||||||
|
self._configure_telemt_mask()
|
||||||
|
elif backend == 'xray':
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
'docker network connect amnezia-dns-net amnezia-xray 2>/dev/null || true'
|
||||||
|
)
|
||||||
|
|
||||||
|
site_url = self._build_site_url(settings)
|
||||||
|
log.append(f'Decoy site: {site_url}')
|
||||||
|
if backend != 'none':
|
||||||
|
log.append(f'VPN tunnel path: {vpn_path} → {BACKEND_UPSTREAMS[backend]}:{backend_port}')
|
||||||
|
log.append('Ensure the backend listens on the internal port, not host 443.')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'protocol': 'revproxy',
|
||||||
|
'port': 443,
|
||||||
|
'domain': domain,
|
||||||
|
'site_url': site_url,
|
||||||
|
'backend': backend,
|
||||||
|
'vpn_path': vpn_path,
|
||||||
|
'tls_mode': tls_mode,
|
||||||
|
'message': 'Reverse proxy installed',
|
||||||
|
'log': log,
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_settings(
|
||||||
|
self,
|
||||||
|
domain=None,
|
||||||
|
site_title=None,
|
||||||
|
backend=None,
|
||||||
|
backend_port=None,
|
||||||
|
vpn_path=None,
|
||||||
|
tls_email=None,
|
||||||
|
enable_telemt_mask=False,
|
||||||
|
):
|
||||||
|
if not self.check_protocol_installed():
|
||||||
|
return {'status': 'error', 'message': 'Reverse proxy not installed'}
|
||||||
|
|
||||||
|
current = self._read_settings()
|
||||||
|
settings = {
|
||||||
|
'domain': (domain if domain is not None else current.get('domain', '')).strip().lower(),
|
||||||
|
'site_title': (site_title if site_title is not None else current.get('site_title', 'CloudEdge')).strip(),
|
||||||
|
'backend': (backend if backend is not None else current.get('backend', 'none')).lower(),
|
||||||
|
'backend_port': int(backend_port if backend_port is not None else current.get('backend_port', DEFAULT_BACKEND_PORT)),
|
||||||
|
'vpn_path': self._normalize_path(vpn_path if vpn_path is not None else current.get('vpn_path', DEFAULT_VPN_PATH)),
|
||||||
|
'tls_email': (tls_email if tls_email is not None else current.get('tls_email', '')).strip(),
|
||||||
|
'tls_mode': 'acme' if (domain if domain is not None else current.get('domain')) else 'internal',
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings['backend'] not in BACKEND_UPSTREAMS:
|
||||||
|
settings['backend'] = 'none'
|
||||||
|
|
||||||
|
self._write_settings(settings)
|
||||||
|
self._write_caddyfile(settings)
|
||||||
|
self._write_site_html(settings)
|
||||||
|
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
|
||||||
|
|
||||||
|
if settings['backend'] == 'telemt' and enable_telemt_mask:
|
||||||
|
self._configure_telemt_mask()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
**settings,
|
||||||
|
'site_url': self._build_site_url(settings),
|
||||||
|
}
|
||||||
|
|
||||||
|
def save_server_config(self, protocol_type, config_content):
|
||||||
|
"""Save raw Caddyfile and hot-reload."""
|
||||||
|
path = f"{self.REMOTE_DIR}/Caddyfile"
|
||||||
|
self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), path)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {self.CONTAINER_NAME} caddy reload --config /etc/caddy/Caddyfile 2>/dev/null "
|
||||||
|
f"|| docker restart {self.CONTAINER_NAME}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def remove_container(self, protocol_type='revproxy'):
|
||||||
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"rm -rf {self.REMOTE_DIR}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_settings(self):
|
||||||
|
return self._read_settings()
|
||||||
|
|
||||||
|
def get_caddyfile(self):
|
||||||
|
out, _, code = self.ssh.run_sudo_command(f"cat {self.REMOTE_DIR}/Caddyfile 2>/dev/null")
|
||||||
|
if code != 0:
|
||||||
|
return ''
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ===================== INTERNALS =====================
|
||||||
|
|
||||||
|
def _ensure_network(self):
|
||||||
|
script = """
|
||||||
|
docker network inspect amnezia-dns-net >/dev/null 2>&1 || \
|
||||||
|
docker network create --subnet=172.29.172.0/24 amnezia-dns-net
|
||||||
|
"""
|
||||||
|
self.ssh.run_sudo_script(script)
|
||||||
|
|
||||||
|
def _ensure_docker_compose(self):
|
||||||
|
out, _, code = self.ssh.run_command('docker compose version 2>/dev/null')
|
||||||
|
if code == 0 and out.strip():
|
||||||
|
return
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
'apt-get update -y && apt-get install -y docker-compose-plugin 2>/dev/null || true',
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _upload_bundle(self, settings):
|
||||||
|
local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_revproxy')
|
||||||
|
remote = self.REMOTE_DIR
|
||||||
|
self.ssh.run_sudo_command(f'mkdir -p {remote}/site')
|
||||||
|
self.ssh.run_sudo_command(f'chmod -R 755 {remote}')
|
||||||
|
|
||||||
|
with open(os.path.join(local_dir, 'docker-compose.yml'), 'r', encoding='utf-8') as f:
|
||||||
|
self.ssh.upload_file_sudo(f.read(), f'{remote}/docker-compose.yml')
|
||||||
|
|
||||||
|
self._write_settings(settings)
|
||||||
|
self._write_caddyfile(settings)
|
||||||
|
self._write_site_html(settings)
|
||||||
|
|
||||||
|
for fname in ('index.html', 'style.css'):
|
||||||
|
local_path = os.path.join(local_dir, 'site', fname)
|
||||||
|
if os.path.exists(local_path) and fname != 'index.html':
|
||||||
|
with open(local_path, 'r', encoding='utf-8') as f:
|
||||||
|
self.ssh.upload_file_sudo(f.read(), f'{remote}/site/{fname}')
|
||||||
|
|
||||||
|
def _write_settings(self, settings):
|
||||||
|
content = json.dumps(settings, indent=2, ensure_ascii=False)
|
||||||
|
self.ssh.upload_file_sudo(content, f'{self.REMOTE_DIR}/{self.SETTINGS_FILE}')
|
||||||
|
|
||||||
|
def _read_settings(self):
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"cat {self.REMOTE_DIR}/{self.SETTINGS_FILE} 2>/dev/null"
|
||||||
|
)
|
||||||
|
if code != 0 or not out.strip():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(out)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _write_site_html(self, settings):
|
||||||
|
local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_revproxy', 'site')
|
||||||
|
with open(os.path.join(local_dir, 'index.html'), 'r', encoding='utf-8') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
title = settings.get('site_title', 'CloudEdge')
|
||||||
|
domain = settings.get('domain') or self.ssh.host
|
||||||
|
html = html.replace('{{SITE_TITLE}}', self._escape_html(title))
|
||||||
|
html = html.replace('{{SITE_DOMAIN}}', self._escape_html(domain))
|
||||||
|
self.ssh.upload_file_sudo(html, f'{self.REMOTE_DIR}/site/index.html')
|
||||||
|
|
||||||
|
style_path = os.path.join(local_dir, 'style.css')
|
||||||
|
if os.path.exists(style_path):
|
||||||
|
with open(style_path, 'r', encoding='utf-8') as f:
|
||||||
|
self.ssh.upload_file_sudo(f.read(), f'{self.REMOTE_DIR}/site/style.css')
|
||||||
|
|
||||||
|
def _write_caddyfile(self, settings):
|
||||||
|
caddyfile = self._build_caddyfile(settings)
|
||||||
|
self.ssh.upload_file_sudo(caddyfile, f'{self.REMOTE_DIR}/Caddyfile')
|
||||||
|
|
||||||
|
def _build_caddyfile(self, settings):
|
||||||
|
domain = settings.get('domain', '')
|
||||||
|
site_title = settings.get('site_title', 'CloudEdge')
|
||||||
|
backend = settings.get('backend', 'none')
|
||||||
|
backend_port = int(settings.get('backend_port', DEFAULT_BACKEND_PORT))
|
||||||
|
vpn_path = self._normalize_path(settings.get('vpn_path', DEFAULT_VPN_PATH))
|
||||||
|
tls_email = settings.get('tls_email', '')
|
||||||
|
|
||||||
|
site_address = domain if domain else ':443'
|
||||||
|
global_block = '{\n\tadmin off\n\tservers {\n\t\tprotocols h1 h2 h3\n\t}\n'
|
||||||
|
if tls_email and domain:
|
||||||
|
global_block += f'\temail {tls_email}\n'
|
||||||
|
global_block += '}\n\n'
|
||||||
|
|
||||||
|
tls_line = ''
|
||||||
|
if not domain:
|
||||||
|
tls_line = '\n\ttls internal'
|
||||||
|
|
||||||
|
backend_block = ''
|
||||||
|
if backend != 'none' and BACKEND_UPSTREAMS.get(backend):
|
||||||
|
upstream = f'{BACKEND_UPSTREAMS[backend]}:{backend_port}'
|
||||||
|
backend_block = f"""
|
||||||
|
\t@vpn path {vpn_path}*
|
||||||
|
\thandle @vpn {{
|
||||||
|
\t\treverse_proxy {upstream} {{
|
||||||
|
\t\t\theader_up Host {{host}}
|
||||||
|
\t\t\theader_up X-Real-IP {{remote_host}}
|
||||||
|
\t\t\theader_up X-Forwarded-For {{remote_host}}
|
||||||
|
\t\t\theader_up X-Forwarded-Proto {{scheme}}
|
||||||
|
\t\t\ttransport http {{
|
||||||
|
\t\t\t\tkeepalive 30s
|
||||||
|
\t\t\t\tkeepalive_idle_conns 100
|
||||||
|
\t\t\t}}
|
||||||
|
\t\t}}
|
||||||
|
\t}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"""{global_block}{site_address} {{{tls_line}
|
||||||
|
\troot * /srv/site
|
||||||
|
\tencode gzip zstd
|
||||||
|
|
||||||
|
\theader {{
|
||||||
|
\t\tStrict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
\t\tX-Content-Type-Options nosniff
|
||||||
|
\t\tX-Frame-Options SAMEORIGIN
|
||||||
|
\t\tReferrer-Policy strict-origin-when-cross-origin
|
||||||
|
\t\t-Server
|
||||||
|
\t}}
|
||||||
|
{backend_block}
|
||||||
|
\thandle {{
|
||||||
|
\t\ttry_files {{path}} {{path}}/ /index.html
|
||||||
|
\t\tfile_server
|
||||||
|
\t}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
:80 {{
|
||||||
|
\tredir https://{{host}}{{uri}} permanent
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _configure_telemt_mask(self):
|
||||||
|
"""Point Telemt mask at the Caddy container on the Docker network."""
|
||||||
|
config_path = '/opt/amnezia/telemt/config.toml'
|
||||||
|
out, _, code = self.ssh.run_sudo_command(f"test -f {config_path} && cat {config_path}")
|
||||||
|
if code != 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
config = out
|
||||||
|
config = re.sub(r'mask\s*=\s*(true|false)', 'mask = true', config)
|
||||||
|
if 'mask_host' in config:
|
||||||
|
config = re.sub(r'#?\s*mask_host\s*=\s*".*?"', 'mask_host = "amnezia-revproxy"', config)
|
||||||
|
else:
|
||||||
|
config = config.replace('[censorship]', '[censorship]\nmask_host = "amnezia-revproxy"')
|
||||||
|
|
||||||
|
if 'mask_port' in config:
|
||||||
|
config = re.sub(r'#?\s*mask_port\s*=\s*\d+', 'mask_port = 443', config)
|
||||||
|
else:
|
||||||
|
config = config.replace('mask_host = "amnezia-revproxy"', 'mask_host = "amnezia-revproxy"\nmask_port = 443')
|
||||||
|
|
||||||
|
self.ssh.upload_file_sudo(config.replace('\r\n', '\n'), config_path)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
'docker network connect amnezia-dns-net telemt 2>/dev/null || true'
|
||||||
|
)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
'docker kill -s HUP telemt 2>/dev/null || docker restart telemt 2>/dev/null || true'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_site_url(self, settings):
|
||||||
|
domain = settings.get('domain', '')
|
||||||
|
if domain:
|
||||||
|
return f'https://{domain}'
|
||||||
|
return f'https://{self.ssh.host}'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_path(path):
|
||||||
|
path = (path or DEFAULT_VPN_PATH).strip()
|
||||||
|
if not path.startswith('/'):
|
||||||
|
path = '/' + path
|
||||||
|
return path.rstrip('/') or '/'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _escape_html(text):
|
||||||
|
return (
|
||||||
|
str(text)
|
||||||
|
.replace('&', '&')
|
||||||
|
.replace('<', '<')
|
||||||
|
.replace('>', '>')
|
||||||
|
.replace('"', '"')
|
||||||
|
)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""
|
||||||
|
SOCKS5 Proxy Manager — runs 3proxy in a Docker container, modelled after the
|
||||||
|
official Amnezia client install (client/server_scripts/socks5_proxy/). Holds a
|
||||||
|
single user (port + username + password); credentials can be edited later from
|
||||||
|
the panel via update_credentials().
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import re
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_password(length=16):
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
class Socks5Manager:
|
||||||
|
PROTOCOL = 'socks5'
|
||||||
|
CONTAINER_NAME = 'amnezia-socks5proxy'
|
||||||
|
IMAGE_NAME = '3proxy/3proxy:0.9.5'
|
||||||
|
CONFIG_DIR = '/opt/amnezia/socks5proxy'
|
||||||
|
CONFIG_PATH = '/usr/local/3proxy/conf/3proxy.cfg'
|
||||||
|
|
||||||
|
DEFAULT_PORT = 38080
|
||||||
|
DEFAULT_USERNAME = 'proxy_user'
|
||||||
|
|
||||||
|
def __init__(self, ssh):
|
||||||
|
self.ssh = ssh
|
||||||
|
|
||||||
|
# ===================== STATUS =====================
|
||||||
|
|
||||||
|
def check_docker_installed(self):
|
||||||
|
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
|
||||||
|
if code != 0:
|
||||||
|
return False
|
||||||
|
out2, _, _ = self.ssh.run_command(
|
||||||
|
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
|
||||||
|
)
|
||||||
|
return 'active' in out2 or 'running' in out2.lower()
|
||||||
|
|
||||||
|
def check_protocol_installed(self, protocol_type='socks5'):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
|
||||||
|
)
|
||||||
|
return self.CONTAINER_NAME in out.strip().split('\n')
|
||||||
|
|
||||||
|
def check_container_running(self):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
|
||||||
|
)
|
||||||
|
return 'Up' in out
|
||||||
|
|
||||||
|
def get_server_status(self, protocol_type='socks5'):
|
||||||
|
exists = self.check_protocol_installed()
|
||||||
|
running = self.check_container_running()
|
||||||
|
creds = self.get_credentials() if exists else {}
|
||||||
|
return {
|
||||||
|
'container_exists': exists,
|
||||||
|
'container_running': running,
|
||||||
|
'port': creds.get('port'),
|
||||||
|
'username': creds.get('username'),
|
||||||
|
'protocol': protocol_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===================== CONFIG I/O =====================
|
||||||
|
|
||||||
|
def _build_config(self, username, password, port):
|
||||||
|
# Mirrors client/server_scripts/socks5_proxy/configure_container.sh.
|
||||||
|
# 'auth strong' enforces username/password on every connection;
|
||||||
|
# 'allow {user}' restricts the ACL to our single user only.
|
||||||
|
return (
|
||||||
|
"#!/bin/3proxy\n"
|
||||||
|
f"config {self.CONFIG_PATH}\n"
|
||||||
|
"timeouts 1 5 30 60 180 1800 15 60\n"
|
||||||
|
f"users {username}:CL:{password}\n"
|
||||||
|
"log /usr/local/3proxy/logs/3proxy.log\n"
|
||||||
|
"auth strong\n"
|
||||||
|
f"allow {username}\n"
|
||||||
|
f"socks -p{int(port)}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_config(self):
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {self.CONTAINER_NAME} cat {self.CONFIG_PATH} 2>/dev/null"
|
||||||
|
)
|
||||||
|
if code != 0 or not out.strip():
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"cat {self.CONFIG_DIR}/3proxy.cfg 2>/dev/null"
|
||||||
|
)
|
||||||
|
if code != 0 or not out.strip():
|
||||||
|
return ''
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _write_config(self, config_text):
|
||||||
|
# Write to host first (so we have a stable copy outside the container),
|
||||||
|
# then docker cp into the running container at the path 3proxy expects.
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {self.CONFIG_DIR}")
|
||||||
|
self.ssh.upload_file_sudo(config_text, f"{self.CONFIG_DIR}/3proxy.cfg")
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker cp {self.CONFIG_DIR}/3proxy.cfg {self.CONTAINER_NAME}:{self.CONFIG_PATH} 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_credentials(self, config_text):
|
||||||
|
creds = {'port': None, 'username': None, 'password': None}
|
||||||
|
if not config_text:
|
||||||
|
return creds
|
||||||
|
m_user = re.search(r'^\s*users\s+([^:\s]+):CL:(\S+)', config_text, re.MULTILINE)
|
||||||
|
if m_user:
|
||||||
|
creds['username'] = m_user.group(1)
|
||||||
|
creds['password'] = m_user.group(2)
|
||||||
|
m_port = re.search(r'^\s*socks\s+-p(\d+)', config_text, re.MULTILINE)
|
||||||
|
if m_port:
|
||||||
|
creds['port'] = int(m_port.group(1))
|
||||||
|
return creds
|
||||||
|
|
||||||
|
def get_credentials(self):
|
||||||
|
return self._parse_credentials(self._read_config())
|
||||||
|
|
||||||
|
# ===================== INSTALL / UPDATE / REMOVE =====================
|
||||||
|
|
||||||
|
def install_protocol(self, protocol_type='socks5', port=None, username=None, password=None):
|
||||||
|
if not self.check_docker_installed():
|
||||||
|
return {'status': 'error', 'message': 'Docker not installed'}
|
||||||
|
|
||||||
|
port = int(port or self.DEFAULT_PORT)
|
||||||
|
username = (username or self.DEFAULT_USERNAME).strip() or self.DEFAULT_USERNAME
|
||||||
|
password = (password or _generate_password()).strip() or _generate_password()
|
||||||
|
|
||||||
|
# Pull image (idempotent — fast no-op if cached)
|
||||||
|
self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}")
|
||||||
|
|
||||||
|
# Wipe any prior install, including the bind-mounted config dir, before
|
||||||
|
# writing a fresh config — leftover state would leak old credentials.
|
||||||
|
if self.check_protocol_installed():
|
||||||
|
self.remove_container()
|
||||||
|
|
||||||
|
config_text = self._build_config(username, password, port)
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {self.CONFIG_DIR}")
|
||||||
|
self.ssh.upload_file_sudo(config_text, f"{self.CONFIG_DIR}/3proxy.cfg")
|
||||||
|
|
||||||
|
# Bind-mount our config in place of the image's default. 3proxy reads
|
||||||
|
# /usr/local/3proxy/conf/3proxy.cfg by convention.
|
||||||
|
run_cmd = (
|
||||||
|
f"docker run -d --restart always "
|
||||||
|
f"--name {self.CONTAINER_NAME} "
|
||||||
|
f"-p {port}:{port}/tcp "
|
||||||
|
f"-v {self.CONFIG_DIR}/3proxy.cfg:{self.CONFIG_PATH}:ro "
|
||||||
|
f"{self.IMAGE_NAME} {self.CONFIG_PATH}"
|
||||||
|
)
|
||||||
|
_, err, code = self.ssh.run_sudo_command(run_cmd)
|
||||||
|
if code != 0:
|
||||||
|
return {'status': 'error', 'message': f'Failed to start container: {err}'}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'protocol': 'socks5',
|
||||||
|
'port': port,
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
'message': 'SOCKS5 proxy installed',
|
||||||
|
'log': [
|
||||||
|
f'SOCKS5 proxy listening on port {port}/TCP',
|
||||||
|
f'Username: {username}',
|
||||||
|
f'Password: {password}',
|
||||||
|
'Save these credentials — the password can also be viewed later via "Change settings".',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_credentials(self, port=None, username=None, password=None):
|
||||||
|
"""Apply new connection settings: regenerates the config file and
|
||||||
|
restarts the container so the new port mapping takes effect."""
|
||||||
|
if not self.check_protocol_installed():
|
||||||
|
return {'status': 'error', 'message': 'SOCKS5 not installed'}
|
||||||
|
|
||||||
|
current = self.get_credentials()
|
||||||
|
new_port = int(port if port is not None else (current.get('port') or self.DEFAULT_PORT))
|
||||||
|
new_user = (username or current.get('username') or self.DEFAULT_USERNAME).strip()
|
||||||
|
new_pass = (password or current.get('password') or _generate_password()).strip()
|
||||||
|
|
||||||
|
old_port = current.get('port')
|
||||||
|
|
||||||
|
# If the port changed we must recreate the container — `docker run -p`
|
||||||
|
# mappings are immutable on existing containers.
|
||||||
|
if old_port and new_port != old_port:
|
||||||
|
return self.install_protocol(
|
||||||
|
port=new_port, username=new_user, password=new_pass
|
||||||
|
)
|
||||||
|
|
||||||
|
config_text = self._build_config(new_user, new_pass, new_port)
|
||||||
|
self._write_config(config_text)
|
||||||
|
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'port': new_port,
|
||||||
|
'username': new_user,
|
||||||
|
'password': new_pass,
|
||||||
|
}
|
||||||
|
|
||||||
|
def remove_container(self, protocol_type='socks5'):
|
||||||
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"rm -rf {self.CONFIG_DIR}")
|
||||||
|
return True
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
from .ssh_manager import SSHManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class TelemtManager:
|
||||||
|
CONTAINER_NAME = "telemt"
|
||||||
|
API_URL = "http://127.0.0.1:9091"
|
||||||
|
|
||||||
|
def __init__(self, ssh_manager: SSHManager):
|
||||||
|
self.ssh = ssh_manager
|
||||||
|
|
||||||
|
def _api_request(self, method, path, data=None):
|
||||||
|
"""Execute a curl request inside the docker container."""
|
||||||
|
cmd = f"docker exec {self.CONTAINER_NAME} curl -s -X {method} {self.API_URL}{path}"
|
||||||
|
if data:
|
||||||
|
js_data = json.dumps(data).replace('"', '\\"')
|
||||||
|
cmd += f" -H 'Content-Type: application/json' -d \"{js_data}\""
|
||||||
|
|
||||||
|
out, err, code = self.ssh.run_sudo_command(cmd)
|
||||||
|
if code != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(out)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def check_docker_installed(self):
|
||||||
|
out, _, _ = self.ssh.run_command("docker --version 2>/dev/null")
|
||||||
|
return bool(out.strip())
|
||||||
|
|
||||||
|
def check_protocol_installed(self):
|
||||||
|
out, _, _ = self.ssh.run_command(f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'")
|
||||||
|
return out.strip() == self.CONTAINER_NAME
|
||||||
|
|
||||||
|
def get_server_status(self, protocol_type):
|
||||||
|
exists = self.check_protocol_installed()
|
||||||
|
out, _, _ = self.ssh.run_command(f"docker inspect -f '{{{{.State.Running}}}}' {self.CONTAINER_NAME} 2>/dev/null")
|
||||||
|
is_running = out.strip().lower() == 'true'
|
||||||
|
|
||||||
|
status = {
|
||||||
|
'container_exists': exists,
|
||||||
|
'container_running': is_running,
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_running:
|
||||||
|
# get external docker port mapping for 443
|
||||||
|
out, _, _ = self.ssh.run_command(f"docker port {self.CONTAINER_NAME} 443 2>/dev/null")
|
||||||
|
if out:
|
||||||
|
port = out.split(':')[-1].strip()
|
||||||
|
status['port'] = port
|
||||||
|
else:
|
||||||
|
status['port'] = None
|
||||||
|
|
||||||
|
config = self._get_server_config()
|
||||||
|
status['awg_params'] = self._parse_telemt_params(config)
|
||||||
|
|
||||||
|
# Count connections from API
|
||||||
|
clients = self.get_clients(protocol_type)
|
||||||
|
status['clients_count'] = len(clients)
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
def _ensure_docker_compose(self):
|
||||||
|
"""Make sure `docker compose` is available, installing the plugin if needed.
|
||||||
|
|
||||||
|
Why: `docker-buildx-plugin` and `docker-compose-plugin` only ship in Docker's
|
||||||
|
official apt/yum repo. When Docker was installed from distro packages
|
||||||
|
(e.g. `docker.io` on Ubuntu), that repo is not configured and a plain
|
||||||
|
`apt-get install docker-compose-plugin` fails. So we add the repo,
|
||||||
|
refresh package lists, then install.
|
||||||
|
"""
|
||||||
|
out, _, code = self.ssh.run_command("docker compose version 2>/dev/null")
|
||||||
|
if code == 0 and out.strip():
|
||||||
|
return
|
||||||
|
|
||||||
|
script = r"""
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -y || true
|
||||||
|
apt-get install -y ca-certificates curl gnupg || exit 1
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
. /etc/os-release
|
||||||
|
DOCKER_DISTRO="$ID"
|
||||||
|
case "$ID" in
|
||||||
|
linuxmint|pop|elementary|zorin) DOCKER_DISTRO="ubuntu" ;;
|
||||||
|
kali|parrot) DOCKER_DISTRO="debian" ;;
|
||||||
|
esac
|
||||||
|
if [ ! -s /etc/apt/keyrings/docker.asc ]; then
|
||||||
|
curl -fsSL "https://download.docker.com/linux/${DOCKER_DISTRO}/gpg" -o /etc/apt/keyrings/docker.asc || exit 1
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
fi
|
||||||
|
CODENAME="${UBUNTU_CODENAME:-$VERSION_CODENAME}"
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${DOCKER_DISTRO} ${CODENAME} stable" > /etc/apt/sources.list.d/docker.list
|
||||||
|
apt-get update -y || exit 1
|
||||||
|
apt-get install -y docker-buildx-plugin docker-compose-plugin || exit 1
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
dnf install -y dnf-plugins-core || exit 1
|
||||||
|
. /etc/os-release
|
||||||
|
dnf config-manager --add-repo "https://download.docker.com/linux/${ID}/docker-ce.repo" \
|
||||||
|
|| dnf config-manager --add-repo "https://download.docker.com/linux/centos/docker-ce.repo" \
|
||||||
|
|| exit 1
|
||||||
|
dnf makecache || true
|
||||||
|
dnf install -y docker-buildx-plugin docker-compose-plugin || exit 1
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
yum install -y yum-utils || exit 1
|
||||||
|
. /etc/os-release
|
||||||
|
yum-config-manager --add-repo "https://download.docker.com/linux/${ID}/docker-ce.repo" \
|
||||||
|
|| yum-config-manager --add-repo "https://download.docker.com/linux/centos/docker-ce.repo" \
|
||||||
|
|| exit 1
|
||||||
|
yum makecache || true
|
||||||
|
yum install -y docker-buildx-plugin docker-compose-plugin || exit 1
|
||||||
|
else
|
||||||
|
echo "Unsupported package manager" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker compose version
|
||||||
|
"""
|
||||||
|
out, err, code = self.ssh.run_sudo_script(script, timeout=300)
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(f"Failed to install docker compose plugin: {err or out}")
|
||||||
|
|
||||||
|
def install_protocol(self, protocol_type='telemt', port='443', tls_emulation=True, tls_domain="", max_connections=0):
|
||||||
|
results = []
|
||||||
|
if not self.check_docker_installed():
|
||||||
|
results.append("Installing Docker...")
|
||||||
|
self.ssh.run_sudo_command("curl -fsSL https://get.docker.com | sh", timeout=300)
|
||||||
|
|
||||||
|
if self.check_protocol_installed():
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -f {self.CONTAINER_NAME}")
|
||||||
|
|
||||||
|
results.append("Ensuring docker compose plugin...")
|
||||||
|
self._ensure_docker_compose()
|
||||||
|
|
||||||
|
results.append("Uploading Telemt files...")
|
||||||
|
local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_telemt')
|
||||||
|
remote_dir = "/opt/amnezia/telemt"
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {remote_dir}")
|
||||||
|
self.ssh.run_sudo_command(f"chmod 755 {remote_dir}")
|
||||||
|
|
||||||
|
# Read and patch config.toml
|
||||||
|
with open(os.path.join(local_dir, 'config.toml'), 'r', encoding='utf-8') as f:
|
||||||
|
config_content = f.read()
|
||||||
|
|
||||||
|
tls_emul_str = "true" if tls_emulation else "false"
|
||||||
|
config_content = re.sub(r'tls_emulation\s*=\s*(true|false|True|False)', f'tls_emulation = {tls_emul_str}', config_content)
|
||||||
|
|
||||||
|
if tls_emulation and tls_domain:
|
||||||
|
config_content = re.sub(r'tls_domain\s*=\s*".*?"', f'tls_domain = "{tls_domain}"', config_content)
|
||||||
|
|
||||||
|
if max_connections is not None and max_connections > 0:
|
||||||
|
config_content = re.sub(r'max_connections\s*=\s*\d+', f'max_connections = {max_connections}', config_content)
|
||||||
|
|
||||||
|
# Patch public_host and public_port for links
|
||||||
|
if "public_host =" in config_content or "# public_host =" in config_content:
|
||||||
|
config_content = re.sub(r'#?\s*public_host\s*=\s*".*?"', f'public_host = "{self.ssh.host}"', config_content)
|
||||||
|
else:
|
||||||
|
config_content = config_content.replace('[general.links]', f'[general.links]\npublic_host = "{self.ssh.host}"')
|
||||||
|
|
||||||
|
config_content = re.sub(r'public_port\s*=\s*\d+', f'public_port = {port}', config_content)
|
||||||
|
|
||||||
|
# Remove default hello user
|
||||||
|
config_content = re.sub(r'^hello\s*=\s*".*?"', '', config_content, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
self.ssh.upload_file_sudo(config_content, f"{remote_dir}/config.toml")
|
||||||
|
|
||||||
|
# Patch docker-compose.yml with proper port
|
||||||
|
with open(os.path.join(local_dir, 'docker-compose.yml'), 'r', encoding='utf-8') as f:
|
||||||
|
compose_content = f.read()
|
||||||
|
|
||||||
|
compose_content = re.sub(r'"443:443"', f'"{port}:443"', compose_content)
|
||||||
|
self.ssh.upload_file_sudo(compose_content, f"{remote_dir}/docker-compose.yml")
|
||||||
|
|
||||||
|
# Upload Dockerfile
|
||||||
|
with open(os.path.join(local_dir, 'Dockerfile'), 'r', encoding='utf-8') as f:
|
||||||
|
dockerfile = f.read()
|
||||||
|
self.ssh.upload_file_sudo(dockerfile, f"{remote_dir}/Dockerfile")
|
||||||
|
|
||||||
|
results.append("Starting Telemt container...")
|
||||||
|
out, err, code = self.ssh.run_sudo_command(f"sh -c 'cd {remote_dir} && docker compose up -d --build'", timeout=600)
|
||||||
|
if code != 0:
|
||||||
|
self.ssh.run_sudo_command(f"sh -c 'cd {remote_dir} && docker-compose up -d --build'", timeout=600)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"host": "",
|
||||||
|
"port": port,
|
||||||
|
"log": results
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_server_config(self):
|
||||||
|
out, _, code = self.ssh.run_sudo_command(f"cat /opt/amnezia/telemt/config.toml")
|
||||||
|
if code != 0: return ""
|
||||||
|
return out
|
||||||
|
|
||||||
|
def save_server_config(self, protocol_type, config_content):
|
||||||
|
self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), "/opt/amnezia/telemt/config.toml")
|
||||||
|
# Use SIGHUP (HUP) to reload MTProxy config without restarting the process/container.
|
||||||
|
# This keeps the traffic statistics (octets) in memory.
|
||||||
|
self.ssh.run_sudo_command(f"docker kill -s HUP {self.CONTAINER_NAME} || docker restart {self.CONTAINER_NAME}")
|
||||||
|
|
||||||
|
def _parse_telemt_params(self, config_text):
|
||||||
|
params = {}
|
||||||
|
m = re.search(r'tls_emulation\s*=\s*(true|false)', config_text, re.IGNORECASE)
|
||||||
|
if m: params['tls_emulation'] = m.group(1).lower() == 'true'
|
||||||
|
|
||||||
|
m = re.search(r'tls_domain\s*=\s*"([^"]+)"', config_text)
|
||||||
|
if m: params['tls_domain'] = m.group(1)
|
||||||
|
|
||||||
|
m = re.search(r'max_connections\s*=\s*(\d+)', config_text)
|
||||||
|
if m: params['max_connections'] = int(m.group(1))
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
def remove_container(self, protocol_type=None):
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -f {self.CONTAINER_NAME}")
|
||||||
|
self.ssh.run_sudo_command("rm -rf /opt/amnezia/telemt")
|
||||||
|
|
||||||
|
def get_clients(self, protocol_type):
|
||||||
|
api_data = {}
|
||||||
|
resp = self._api_request("GET", "/v1/users")
|
||||||
|
if resp and resp.get('ok'):
|
||||||
|
for u in resp.get('data', []):
|
||||||
|
api_data[u.get('username')] = u
|
||||||
|
|
||||||
|
config_text = self._get_server_config()
|
||||||
|
users = self._parse_users_from_config(config_text)
|
||||||
|
|
||||||
|
clients = []
|
||||||
|
needs_update = False
|
||||||
|
for username, secret in users.items():
|
||||||
|
user_stats = api_data.get(username.lstrip('#').strip(), {})
|
||||||
|
links = user_stats.get('links', {})
|
||||||
|
tg_link = ""
|
||||||
|
if links.get('tls'): tg_link = links['tls'][0]
|
||||||
|
elif links.get('secure'): tg_link = links['secure'][0]
|
||||||
|
elif links.get('classic'): tg_link = links['classic'][0]
|
||||||
|
|
||||||
|
enabled = not username.startswith('#')
|
||||||
|
clean_name = username.lstrip('#').strip()
|
||||||
|
|
||||||
|
total_octets = user_stats.get('total_octets', 0)
|
||||||
|
quota = user_stats.get('data_quota_bytes')
|
||||||
|
|
||||||
|
# AUTO-DISABLE IF QUOTA REACHED
|
||||||
|
if enabled and quota and total_octets >= quota:
|
||||||
|
logger.info(f"Auto-disabling client {clean_name} - quota reached: {total_octets} >= {quota}")
|
||||||
|
# We will trigger a toggle after we finish this loop to avoid re-reading config inside loop
|
||||||
|
enabled = False
|
||||||
|
needs_update = True
|
||||||
|
|
||||||
|
clients.append({
|
||||||
|
"clientId": clean_name,
|
||||||
|
"clientName": clean_name,
|
||||||
|
"enabled": enabled,
|
||||||
|
"creationDate": "",
|
||||||
|
"userData": {
|
||||||
|
"clientName": clean_name,
|
||||||
|
"token": secret,
|
||||||
|
"tg_link": tg_link,
|
||||||
|
"total_octets": total_octets,
|
||||||
|
"current_connections": user_stats.get('current_connections', 0),
|
||||||
|
"active_ips": user_stats.get('active_unique_ips', 0),
|
||||||
|
"quota": quota,
|
||||||
|
"expiry": user_stats.get('expiration_rfc3339')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if needs_update:
|
||||||
|
# Re-read and update config strictly at the end
|
||||||
|
for c in clients:
|
||||||
|
if not c['enabled']:
|
||||||
|
self.toggle_client(protocol_type, c['clientId'], False, restart=False)
|
||||||
|
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
|
||||||
|
|
||||||
|
return clients
|
||||||
|
|
||||||
|
def _parse_users_from_config(self, config_text):
|
||||||
|
users = {}
|
||||||
|
lines = config_text.split('\n')
|
||||||
|
in_section = False
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped == '[access.users]':
|
||||||
|
in_section = True
|
||||||
|
continue
|
||||||
|
if in_section and stripped.startswith('['):
|
||||||
|
break
|
||||||
|
if in_section and stripped:
|
||||||
|
commented = stripped.startswith('#')
|
||||||
|
content = stripped.lstrip('#').strip()
|
||||||
|
if '=' in content:
|
||||||
|
if content.lower().startswith('format:'): continue
|
||||||
|
name, secret = content.split('=', 1)
|
||||||
|
name = name.strip().strip('"').strip()
|
||||||
|
secret = secret.strip().strip('"').strip()
|
||||||
|
full_name = ("# " + name) if commented else name
|
||||||
|
users[full_name] = secret
|
||||||
|
return users
|
||||||
|
|
||||||
|
def add_client(self, protocol_type, name, host='', port='', **kwargs):
|
||||||
|
username = re.sub(r'[^a-zA-Z0-9_.-]', '', name.replace(' ', '_'))
|
||||||
|
if not username: username = "user_" + uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
config_text = self._get_server_config()
|
||||||
|
current_users = self._parse_users_from_config(config_text)
|
||||||
|
idx = 1
|
||||||
|
base_username = username
|
||||||
|
while any(u.lstrip('#').strip() == username for u in current_users):
|
||||||
|
username = f"{base_username}_{idx}"
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
secret = kwargs.get('secret') or secrets.token_hex(16)
|
||||||
|
|
||||||
|
# 1. Update config file for persistence (but don't restart yet)
|
||||||
|
config_text = self._insert_into_section(config_text, "access.users", f'{username} = "{secret}"')
|
||||||
|
|
||||||
|
api_payload = {
|
||||||
|
"username": username,
|
||||||
|
"secret": secret
|
||||||
|
}
|
||||||
|
|
||||||
|
if kwargs.get('telemt_quota'):
|
||||||
|
val = int(kwargs['telemt_quota'])
|
||||||
|
config_text = self._insert_into_section(config_text, "access.user_data_quota", f'{username} = {val}')
|
||||||
|
api_payload['data_quota_bytes'] = val
|
||||||
|
|
||||||
|
if kwargs.get('telemt_max_ips'):
|
||||||
|
val = int(kwargs['telemt_max_ips'])
|
||||||
|
config_text = self._insert_into_section(config_text, "access.user_max_unique_ips", f'{username} = {val}')
|
||||||
|
api_payload['max_unique_ips'] = val
|
||||||
|
|
||||||
|
if kwargs.get('telemt_expiry'):
|
||||||
|
val = kwargs['telemt_expiry']
|
||||||
|
config_text = self._insert_into_section(config_text, "access.user_expirations", f'{username} = "{val}"')
|
||||||
|
api_payload['expiration_rfc3339'] = val
|
||||||
|
|
||||||
|
if kwargs.get('user_ad_tag'):
|
||||||
|
val = kwargs['user_ad_tag']
|
||||||
|
config_text = self._insert_into_section(config_text, "access.user_ad_tags", f'{username} = "{val}"')
|
||||||
|
api_payload['user_ad_tag'] = val
|
||||||
|
|
||||||
|
if kwargs.get('max_tcp_conns'):
|
||||||
|
val = int(kwargs['max_tcp_conns'])
|
||||||
|
config_text = self._insert_into_section(config_text, "access.user_max_tcp_conns", f'{username} = {val}')
|
||||||
|
api_payload['max_tcp_conns'] = val
|
||||||
|
|
||||||
|
# Save config to host
|
||||||
|
self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), "/opt/amnezia/telemt/config.toml")
|
||||||
|
|
||||||
|
# 2. Call API for immediate effect
|
||||||
|
self._api_request("POST", "/v1/users", data=api_payload)
|
||||||
|
|
||||||
|
# Fetch the official link from API (it includes TLS emulation padding like 'ee...' if enabled)
|
||||||
|
link = self.get_client_config(protocol_type, username, host, port)
|
||||||
|
|
||||||
|
# Extreme fallback if API is slow or 404
|
||||||
|
if link == "Not found":
|
||||||
|
link = f"tg://proxy?server={host}&port={port}&secret={secret}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"client_id": username,
|
||||||
|
"config": link,
|
||||||
|
"vpn_link": link
|
||||||
|
}
|
||||||
|
|
||||||
|
def edit_client(self, protocol_type, client_id, new_params):
|
||||||
|
"""Update existing client parameters via API and in config."""
|
||||||
|
config_text = self._get_server_config()
|
||||||
|
api_payload = {}
|
||||||
|
|
||||||
|
if 'telemt_quota' in new_params:
|
||||||
|
val = int(new_params['telemt_quota']) if new_params['telemt_quota'] else None
|
||||||
|
config_text = self._update_line_in_section(config_text, "access.user_data_quota", client_id, val)
|
||||||
|
api_payload['data_quota_bytes'] = val
|
||||||
|
|
||||||
|
if 'telemt_max_ips' in new_params:
|
||||||
|
val = int(new_params['telemt_max_ips']) if new_params['telemt_max_ips'] else None
|
||||||
|
config_text = self._update_line_in_section(config_text, "access.user_max_unique_ips", client_id, val)
|
||||||
|
api_payload['max_unique_ips'] = val
|
||||||
|
|
||||||
|
if 'telemt_expiry' in new_params:
|
||||||
|
val = new_params['telemt_expiry']
|
||||||
|
quoted_val = f'"{val}"' if val else None
|
||||||
|
config_text = self._update_line_in_section(config_text, "access.user_expirations", client_id, quoted_val)
|
||||||
|
api_payload['expiration_rfc3339'] = val
|
||||||
|
|
||||||
|
if 'secret' in new_params:
|
||||||
|
val = new_params['secret']
|
||||||
|
quoted_val = f'"{val}"' if val else None
|
||||||
|
config_text = self._update_line_in_section(config_text, "access.users", client_id, quoted_val)
|
||||||
|
api_payload['secret'] = val
|
||||||
|
|
||||||
|
if 'user_ad_tag' in new_params:
|
||||||
|
val = new_params['user_ad_tag']
|
||||||
|
quoted_val = f'"{val}"' if val else None
|
||||||
|
config_text = self._update_line_in_section(config_text, "access.user_ad_tags", client_id, quoted_val)
|
||||||
|
api_payload['user_ad_tag'] = val
|
||||||
|
|
||||||
|
if 'max_tcp_conns' in new_params:
|
||||||
|
val = int(new_params['max_tcp_conns']) if new_params['max_tcp_conns'] else None
|
||||||
|
config_text = self._update_line_in_section(config_text, "access.user_max_tcp_conns", client_id, val)
|
||||||
|
api_payload['max_tcp_conns'] = val
|
||||||
|
|
||||||
|
# Save config to host
|
||||||
|
self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), "/opt/amnezia/telemt/config.toml")
|
||||||
|
|
||||||
|
# API call
|
||||||
|
self._api_request("PATCH", f"/v1/users/{client_id}", data=api_payload)
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
def _update_line_in_section(self, config_text, section_name, client_id, value):
|
||||||
|
lines = config_text.split('\n')
|
||||||
|
section_start = -1
|
||||||
|
section_end = -1
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.strip() == f"[{section_name}]":
|
||||||
|
section_start = i
|
||||||
|
elif section_start != -1 and line.strip().startswith('['):
|
||||||
|
section_end = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if section_end == -1: section_end = len(lines)
|
||||||
|
if section_start == -1:
|
||||||
|
if value is not None:
|
||||||
|
lines.append(f"[{section_name}]")
|
||||||
|
lines.append(f'{client_id} = {value}')
|
||||||
|
lines.append("")
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
found = False
|
||||||
|
for i in range(section_start + 1, section_end):
|
||||||
|
line = lines[i].strip().lstrip('#').strip()
|
||||||
|
if line.startswith(f"{client_id} ") or line.startswith(f"{client_id}="):
|
||||||
|
if value is None: lines.pop(i)
|
||||||
|
else: lines[i] = f'{client_id} = {value}'
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found and value is not None:
|
||||||
|
lines.insert(section_start + 1, f'{client_id} = {value}')
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
def _insert_into_section(self, config_text, section_name, line_to_insert):
|
||||||
|
lines = config_text.split('\n')
|
||||||
|
section_start = -1
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.strip() == f"[{section_name}]":
|
||||||
|
section_start = i
|
||||||
|
break
|
||||||
|
if section_start == -1:
|
||||||
|
lines.append(f"[{section_name}]")
|
||||||
|
lines.append(line_to_insert)
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
lines.insert(section_start + 1, line_to_insert)
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
def remove_client(self, protocol_type, client_id):
|
||||||
|
# 1. API
|
||||||
|
self._api_request("DELETE", f"/v1/users/{client_id}")
|
||||||
|
|
||||||
|
# 2. Config
|
||||||
|
config_text = self._get_server_config()
|
||||||
|
lines = config_text.split('\n')
|
||||||
|
new_lines = []
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip().lstrip('#').strip()
|
||||||
|
if stripped.startswith(f"{client_id} ") or stripped.startswith(f"{client_id}="):
|
||||||
|
continue
|
||||||
|
new_lines.append(line)
|
||||||
|
self.ssh.upload_file_sudo('\n'.join(new_lines).replace('\r\n', '\n'), "/opt/amnezia/telemt/config.toml")
|
||||||
|
|
||||||
|
def toggle_client(self, protocol_type, client_id, enable, restart=True):
|
||||||
|
# API doesn't have a direct "toggle", so we either set a huge quota or remove/re-add
|
||||||
|
# But for Telemt, commenting out in config is the persistent way.
|
||||||
|
# We'll use HUP after toggling in config.
|
||||||
|
|
||||||
|
config_text = self._get_server_config()
|
||||||
|
lines = config_text.split('\n')
|
||||||
|
new_lines = []
|
||||||
|
in_access_section = False
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith('[access.'): in_access_section = True
|
||||||
|
elif stripped.startswith('['): in_access_section = False
|
||||||
|
|
||||||
|
if in_access_section:
|
||||||
|
base_line = line.lstrip('#').strip()
|
||||||
|
if base_line.startswith(f"{client_id} ") or base_line.startswith(f"{client_id}="):
|
||||||
|
line = base_line if enable else f"# {base_line}"
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
self.ssh.upload_file_sudo('\n'.join(new_lines).replace('\r\n', '\n'), "/opt/amnezia/telemt/config.toml")
|
||||||
|
|
||||||
|
if enable:
|
||||||
|
# If enabling, we re-add via API since it might have been deleted from memory
|
||||||
|
secret = ""
|
||||||
|
users = self._parse_users_from_config('\n'.join(new_lines))
|
||||||
|
secret = users.get(client_id, "")
|
||||||
|
if secret:
|
||||||
|
self._api_request("POST", "/v1/users", data={"username": client_id, "secret": secret})
|
||||||
|
else:
|
||||||
|
# If disabling, we just delete from memory
|
||||||
|
self._api_request("DELETE", f"/v1/users/{client_id}")
|
||||||
|
|
||||||
|
if restart:
|
||||||
|
self.ssh.run_sudo_command(f"docker kill -s HUP {self.CONTAINER_NAME} || docker restart {self.CONTAINER_NAME}")
|
||||||
|
|
||||||
|
def get_client_config(self, protocol_type, client_id, host='', port=''):
|
||||||
|
resp = self._api_request("GET", f"/v1/users/{client_id}")
|
||||||
|
if resp and resp.get('ok'):
|
||||||
|
user = resp.get('data', {})
|
||||||
|
links = user.get('links', {})
|
||||||
|
if links.get('tls'): return links['tls'][0]
|
||||||
|
if links.get('secure'): return links['secure'][0]
|
||||||
|
if links.get('classic'): return links['classic'][0]
|
||||||
|
|
||||||
|
clients = self.get_clients(protocol_type)
|
||||||
|
c = next((c for c in clients if c['clientId'] == client_id), None)
|
||||||
|
if c:
|
||||||
|
secret = c.get('userData', {}).get('token', '')
|
||||||
|
if secret: return f"tg://proxy?server={host}&port={port}&secret={secret}"
|
||||||
|
return "Not found"
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,774 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
import base64
|
||||||
|
import shlex
|
||||||
|
from datetime import datetime
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class XrayManager:
|
||||||
|
"""Manages Xray (VLESS-Reality) protocol installation and client management."""
|
||||||
|
|
||||||
|
PROTOCOL = 'xray'
|
||||||
|
CONTAINER_NAME = 'amnezia-xray'
|
||||||
|
IMAGE_NAME = 'amneziavpn/amnezia-xray' # or we can build it
|
||||||
|
|
||||||
|
def __init__(self, ssh_manager):
|
||||||
|
self.ssh = ssh_manager
|
||||||
|
|
||||||
|
def _config_dir(self):
|
||||||
|
return '/opt/amnezia/xray'
|
||||||
|
|
||||||
|
def _config_path(self):
|
||||||
|
return f'{self._config_dir()}/server.json'
|
||||||
|
|
||||||
|
def _list_xray_files(self):
|
||||||
|
"""List filenames in the on-disk Xray config directory."""
|
||||||
|
out, _, code = self.ssh.run_sudo_command(f"ls -1 {self._config_dir()} 2>/dev/null")
|
||||||
|
if code != 0:
|
||||||
|
return []
|
||||||
|
return [f for f in out.strip().split('\n') if f]
|
||||||
|
|
||||||
|
def _detect_layout(self):
|
||||||
|
"""Pick which on-disk layout this installation uses.
|
||||||
|
|
||||||
|
'native' — official Amnezia client layout: xray_private.key,
|
||||||
|
xray_public.key, xray_short_id.key, xray_uuid.key plus a clientsTable
|
||||||
|
file without an extension.
|
||||||
|
'panel' — legacy web-panel layout: meta.json + clientsTable.json.
|
||||||
|
|
||||||
|
On a fresh node with no Xray files yet, defaults to 'native' so new
|
||||||
|
installs produce the same artifacts as the official client.
|
||||||
|
"""
|
||||||
|
if hasattr(self, '_cached_layout'):
|
||||||
|
return self._cached_layout
|
||||||
|
files = set(self._list_xray_files())
|
||||||
|
if {'xray_private.key', 'xray_public.key'} & files:
|
||||||
|
layout = 'native'
|
||||||
|
elif 'meta.json' in files:
|
||||||
|
layout = 'panel'
|
||||||
|
else:
|
||||||
|
layout = 'native'
|
||||||
|
self._cached_layout = layout
|
||||||
|
return layout
|
||||||
|
|
||||||
|
def _clients_table_filename(self):
|
||||||
|
return 'clientsTable' if self._detect_layout() == 'native' else 'clientsTable.json'
|
||||||
|
|
||||||
|
def _clients_table_path(self):
|
||||||
|
return f'{self._config_dir()}/{self._clients_table_filename()}'
|
||||||
|
|
||||||
|
def _read_remote_file(self, path):
|
||||||
|
"""Read a remote text file, preferring the running container's view."""
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {self.CONTAINER_NAME} cat {path} 2>/dev/null"
|
||||||
|
)
|
||||||
|
if code != 0 or not out:
|
||||||
|
out, _, code = self.ssh.run_sudo_command(f"cat {path} 2>/dev/null")
|
||||||
|
if code != 0 or not out.strip():
|
||||||
|
return None
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _derive_pubkey_from_priv(self, priv_key):
|
||||||
|
"""Derive the Reality public key from a private key via the xray binary.
|
||||||
|
Used as a fallback when xray_public.key is missing or unreadable.
|
||||||
|
"""
|
||||||
|
if not priv_key:
|
||||||
|
return ''
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {self.CONTAINER_NAME} /usr/bin/xray x25519 -i {priv_key}"
|
||||||
|
)
|
||||||
|
if code != 0 or not out.strip():
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker run --rm --entrypoint=\"\" {self.IMAGE_NAME} /usr/bin/xray x25519 -i {priv_key}"
|
||||||
|
)
|
||||||
|
if code != 0 or not out:
|
||||||
|
return ''
|
||||||
|
for line in out.split('\n'):
|
||||||
|
if 'Public' in line and ':' in line:
|
||||||
|
return line.split(':', 1)[1].strip()
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def _get_default_xray_uuid(self):
|
||||||
|
"""UUID of the install-time default client (xray_uuid.key) — meaningful only
|
||||||
|
for native-layout installs. Imports skip this UUID, mirroring the official
|
||||||
|
Amnezia client behaviour (see usersController.cpp::getXrayClients).
|
||||||
|
"""
|
||||||
|
if self._detect_layout() != 'native':
|
||||||
|
return ''
|
||||||
|
out = self._read_remote_file(f"{self._config_dir()}/xray_uuid.key")
|
||||||
|
return (out or '').strip()
|
||||||
|
|
||||||
|
# ===================== INSTALLATION =====================
|
||||||
|
|
||||||
|
def check_docker_installed(self):
|
||||||
|
out, err, code = self.ssh.run_command("docker --version 2>/dev/null")
|
||||||
|
if code != 0: return False
|
||||||
|
out2, _, code2 = self.ssh.run_command("systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null")
|
||||||
|
return 'active' in out2 or 'running' in out2.lower()
|
||||||
|
|
||||||
|
def check_container_running(self):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
|
||||||
|
)
|
||||||
|
return 'Up' in out
|
||||||
|
|
||||||
|
def check_protocol_installed(self):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
|
||||||
|
)
|
||||||
|
return self.CONTAINER_NAME in out.strip().split('\n')
|
||||||
|
|
||||||
|
def get_server_status(self, protocol):
|
||||||
|
exists = self.check_protocol_installed()
|
||||||
|
running = self.check_container_running()
|
||||||
|
clients = self.get_clients() if exists else []
|
||||||
|
meta = self._get_meta_json() if exists else {}
|
||||||
|
return {
|
||||||
|
'container_exists': exists,
|
||||||
|
'container_running': running,
|
||||||
|
'clients_count': len(clients),
|
||||||
|
'port': meta.get('port')
|
||||||
|
}
|
||||||
|
|
||||||
|
def install_protocol(self, port=443, site_name='yahoo.com'):
|
||||||
|
"""Full installation of Xray."""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if not self.check_docker_installed():
|
||||||
|
results.append("Installing Docker...")
|
||||||
|
# Using same install method as AWGManager or assume it's installed
|
||||||
|
pass
|
||||||
|
|
||||||
|
results.append("Removing old container if exists...")
|
||||||
|
if self.check_protocol_installed():
|
||||||
|
self.remove_container()
|
||||||
|
|
||||||
|
results.append("Building Docker image...")
|
||||||
|
dockerfile_folder = f"/opt/amnezia/{self.CONTAINER_NAME}"
|
||||||
|
dockerfile_content = """FROM alpine:3.15
|
||||||
|
RUN apk add --no-cache curl unzip bash openssl netcat-openbsd dumb-init rng-tools xz iptables ip6tables
|
||||||
|
RUN apk --update upgrade --no-cache
|
||||||
|
RUN mkdir -p /opt/amnezia/xray
|
||||||
|
RUN curl -L -H "Cache-Control: no-cache" -o /root/xray.zip "https://github.com/XTLS/Xray-core/releases/download/v26.3.27/Xray-linux-64.zip" && \\
|
||||||
|
unzip /root/xray.zip -d /usr/bin/ && \\
|
||||||
|
chmod a+x /usr/bin/xray && \\
|
||||||
|
rm /root/xray.zip
|
||||||
|
|
||||||
|
# Tune network
|
||||||
|
RUN echo "fs.file-max = 51200" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.core.rmem_max = 67108864" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.core.wmem_max = 67108864" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.core.netdev_max_backlog = 250000" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.core.somaxconn = 4096" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_syncookies = 1" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_tw_reuse = 1" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_tw_recycle = 0" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_fin_timeout = 30" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_keepalive_time = 1200" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.ip_local_port_range = 10000 65000" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_max_syn_backlog = 8192" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_max_tw_buckets = 5000" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_fastopen = 3" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_mem = 25600 51200 102400" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_rmem = 4096 87380 67108864" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_wmem = 4096 65536 67108864" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_mtu_probing = 1" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_congestion_control = hybla" >> /etc/sysctl.conf
|
||||||
|
|
||||||
|
RUN mkdir -p /etc/security && \\
|
||||||
|
echo "* soft nofile 51200" >> /etc/security/limits.conf && \\
|
||||||
|
echo "* hard nofile 51200" >> /etc/security/limits.conf
|
||||||
|
|
||||||
|
RUN echo '#!/bin/bash' > /opt/amnezia/start.sh && \\
|
||||||
|
echo 'sysctl -p /etc/sysctl.conf 2>/dev/null' >> /opt/amnezia/start.sh && \\
|
||||||
|
echo '/usr/bin/xray -config /opt/amnezia/xray/server.json' >> /opt/amnezia/start.sh && \\
|
||||||
|
chmod a+x /opt/amnezia/start.sh
|
||||||
|
|
||||||
|
ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
||||||
|
"""
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {dockerfile_folder}")
|
||||||
|
self.ssh.upload_file_sudo(dockerfile_content, f"{dockerfile_folder}/Dockerfile")
|
||||||
|
|
||||||
|
_, err, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker build --no-cache -t {self.IMAGE_NAME} {dockerfile_folder}", timeout=300
|
||||||
|
)
|
||||||
|
if code != 0: raise RuntimeError(f"Failed to build container: {err}")
|
||||||
|
|
||||||
|
results.append("Generating keys and config...")
|
||||||
|
# We generate a base config using a temp container or directly if host has openssl
|
||||||
|
|
||||||
|
# Xray keypair generation using a temporary run overriding the entrypoint
|
||||||
|
keypair_cmd = f"docker run --rm --entrypoint=\"\" {self.IMAGE_NAME} /usr/bin/xray x25519"
|
||||||
|
out_kp, err_kp, code_kp = self.ssh.run_sudo_command(keypair_cmd)
|
||||||
|
if code_kp != 0: raise RuntimeError(f"Failed to generate x25519 keys: {err_kp}")
|
||||||
|
|
||||||
|
priv_key = ""
|
||||||
|
pub_key = ""
|
||||||
|
for line in out_kp.split('\n'):
|
||||||
|
if "Private" in line: priv_key = line.split(":", 1)[1].strip()
|
||||||
|
if "Public" in line: pub_key = line.split(":", 1)[1].strip()
|
||||||
|
|
||||||
|
short_id_cmd = f"docker run --rm --entrypoint=\"\" {self.IMAGE_NAME} openssl rand -hex 8"
|
||||||
|
out_sid, _, _ = self.ssh.run_sudo_command(short_id_cmd)
|
||||||
|
short_id = out_sid.strip()
|
||||||
|
|
||||||
|
# Generate initial server.json with Stats and API enabled
|
||||||
|
server_json = {
|
||||||
|
"log": {"loglevel": "error"},
|
||||||
|
"stats": {},
|
||||||
|
"api": {
|
||||||
|
"services": ["StatsService", "LoggerService", "HandlerService"],
|
||||||
|
"tag": "api"
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"levels": {
|
||||||
|
"0": {"statsUserUplink": True, "statsUserDownlink": True}
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"statsInboundUplink": True, "statsInboundDownlink": True,
|
||||||
|
"statsOutboundUplink": True, "statsOutboundDownlink": True
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"port": int(port),
|
||||||
|
"protocol": "vless",
|
||||||
|
"tag": "proxy",
|
||||||
|
"settings": {
|
||||||
|
"clients": [],
|
||||||
|
"decryption": "none"
|
||||||
|
},
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "tcp",
|
||||||
|
"security": "reality",
|
||||||
|
"realitySettings": {
|
||||||
|
"dest": f"{site_name}:443",
|
||||||
|
"serverNames": [site_name],
|
||||||
|
"privateKey": priv_key,
|
||||||
|
"shortIds": [short_id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"port": 10085,
|
||||||
|
"protocol": "dokodemo-door",
|
||||||
|
"settings": {"address": "127.0.0.1"},
|
||||||
|
"tag": "api"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [{"protocol": "freedom"}],
|
||||||
|
"routing": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"inboundTag": ["api"],
|
||||||
|
"outboundTag": "api",
|
||||||
|
"type": "field"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ssh.run_sudo_command("mkdir -p /opt/amnezia/xray")
|
||||||
|
self.ssh.upload_file_sudo(json.dumps(server_json, indent=2), "/opt/amnezia/xray/server.json")
|
||||||
|
# Native layout — separate key files matching the official Amnezia client install.
|
||||||
|
# See client/server_scripts/xray/configure_container.sh for the canonical layout.
|
||||||
|
self.ssh.upload_file_sudo(priv_key + '\n', "/opt/amnezia/xray/xray_private.key")
|
||||||
|
self.ssh.upload_file_sudo(pub_key + '\n', "/opt/amnezia/xray/xray_public.key")
|
||||||
|
self.ssh.upload_file_sudo(short_id + '\n', "/opt/amnezia/xray/xray_short_id.key")
|
||||||
|
# xray_uuid.key marks the install-time "default" client whose ID gets skipped on
|
||||||
|
# auto-import. Panel installs do not reserve such a client, so we leave it empty.
|
||||||
|
self.ssh.upload_file_sudo('\n', "/opt/amnezia/xray/xray_uuid.key")
|
||||||
|
self.ssh.upload_file_sudo("[]", "/opt/amnezia/xray/clientsTable")
|
||||||
|
|
||||||
|
results.append("Starting container...")
|
||||||
|
run_cmd = f"""docker run -d \\
|
||||||
|
--restart always \\
|
||||||
|
--privileged \\
|
||||||
|
--cap-add=NET_ADMIN \\
|
||||||
|
-p {port}:{port}/tcp \\
|
||||||
|
-p {port}:{port}/udp \\
|
||||||
|
-v /opt/amnezia/xray:/opt/amnezia/xray \\
|
||||||
|
--name {self.CONTAINER_NAME} \\
|
||||||
|
{self.IMAGE_NAME}"""
|
||||||
|
|
||||||
|
_, err, code = self.ssh.run_sudo_command(run_cmd)
|
||||||
|
if code != 0: raise RuntimeError(f"Failed to run container: {err}")
|
||||||
|
|
||||||
|
# Try to connect to network if needed
|
||||||
|
self.ssh.run_sudo_command(f"docker network connect amnezia-dns-net {self.CONTAINER_NAME} || true")
|
||||||
|
|
||||||
|
results.append("Xray configured and running")
|
||||||
|
return {'status': 'success', 'protocol': 'xray', 'port': port, 'log': results}
|
||||||
|
|
||||||
|
def remove_container(self):
|
||||||
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME}")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME}")
|
||||||
|
self.ssh.run_sudo_command(f"docker rmi {self.IMAGE_NAME}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ===================== CLIENT MANAGEMENT =====================
|
||||||
|
|
||||||
|
def _get_server_json(self):
|
||||||
|
"""Read server.json — tries inside container first, falls back to host path."""
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {self.CONTAINER_NAME} cat {self._config_path()}"
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
out, _, code = self.ssh.run_sudo_command(f"cat {self._config_path()}")
|
||||||
|
if code != 0 or not out.strip():
|
||||||
|
return None
|
||||||
|
return json.loads(out)
|
||||||
|
|
||||||
|
def _save_server_json(self, data):
|
||||||
|
return self._write_server_json(data, restart=True)
|
||||||
|
|
||||||
|
def _write_server_json(self, data, restart=True):
|
||||||
|
"""Write server.json into container via docker cp AND sync to host path."""
|
||||||
|
tmp_file = "/tmp/_xray_server.json"
|
||||||
|
self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker cp {tmp_file} {self.CONTAINER_NAME}:{self._config_path()}"
|
||||||
|
)
|
||||||
|
# Also keep host copy in sync (handles both volume-mount and no-mount installs)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"cp {tmp_file} {self._config_path()} 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
if restart:
|
||||||
|
self.ssh.run_sudo_command(f"docker restart {self.CONTAINER_NAME}")
|
||||||
|
|
||||||
|
def _get_vless_inbound(self, config):
|
||||||
|
for inbound in config.get('inbounds', []):
|
||||||
|
if inbound.get('protocol') == 'vless':
|
||||||
|
return inbound
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_vless_inbound_tag(self, config):
|
||||||
|
inbound = self._get_vless_inbound(config)
|
||||||
|
return inbound.get('tag') if inbound else None
|
||||||
|
|
||||||
|
def _run_xray_api_json(self, subcommand, payload):
|
||||||
|
tmp_name = f"/tmp/_xray_api_{uuid.uuid4().hex}.json"
|
||||||
|
container_tmp = tmp_name
|
||||||
|
try:
|
||||||
|
self.ssh.upload_file_sudo(json.dumps(payload, indent=2), tmp_name)
|
||||||
|
_, err, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker cp {tmp_name} {self.CONTAINER_NAME}:{container_tmp}"
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
return False, err
|
||||||
|
out, err, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec -i {self.CONTAINER_NAME} /usr/bin/xray api {subcommand} "
|
||||||
|
f"-server=127.0.0.1:10085 {container_tmp}"
|
||||||
|
)
|
||||||
|
return code == 0, err or out
|
||||||
|
finally:
|
||||||
|
self.ssh.run_sudo_command(f"rm -f {tmp_name}")
|
||||||
|
self.ssh.run_sudo_command(f"docker exec -i {self.CONTAINER_NAME} rm -f {container_tmp} 2>/dev/null || true")
|
||||||
|
|
||||||
|
def _xray_api_add_user(self, config, client):
|
||||||
|
tag = self._get_vless_inbound_tag(config)
|
||||||
|
if not tag:
|
||||||
|
return False
|
||||||
|
payload = {
|
||||||
|
"inbounds": [{
|
||||||
|
"tag": tag,
|
||||||
|
"protocol": "vless",
|
||||||
|
"settings": {
|
||||||
|
"clients": [client],
|
||||||
|
"decryption": "none",
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
ok, message = self._run_xray_api_json('adu', payload)
|
||||||
|
if not ok:
|
||||||
|
logger.warning(f"Xray API add user failed: {message}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _xray_api_remove_user(self, config, client_id):
|
||||||
|
tag = self._get_vless_inbound_tag(config)
|
||||||
|
if not tag:
|
||||||
|
return False
|
||||||
|
cmd = (
|
||||||
|
f"docker exec -i {self.CONTAINER_NAME} /usr/bin/xray api rmu "
|
||||||
|
f"-server=127.0.0.1:10085 "
|
||||||
|
f"-tag={shlex.quote(tag)} {shlex.quote(client_id)}"
|
||||||
|
)
|
||||||
|
out, err, code = self.ssh.run_sudo_command(cmd)
|
||||||
|
if code != 0:
|
||||||
|
logger.warning(f"Xray API remove user failed: {err or out}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_meta_json(self):
|
||||||
|
"""Read protocol metadata. Supports both layouts.
|
||||||
|
|
||||||
|
Native layout pulls keys from xray_*.key files. Panel layout reads
|
||||||
|
meta.json. Either way, port and site_name come from server.json since
|
||||||
|
that is the authoritative runtime config — meta.json may go stale if
|
||||||
|
the user edits server.json directly via the panel.
|
||||||
|
"""
|
||||||
|
config = self._get_server_json() or {}
|
||||||
|
|
||||||
|
port = None
|
||||||
|
site_name = None
|
||||||
|
rs = {}
|
||||||
|
try:
|
||||||
|
ib = next(b for b in config.get('inbounds', []) if b.get('protocol') == 'vless')
|
||||||
|
port = ib.get('port')
|
||||||
|
rs = ib.get('streamSettings', {}).get('realitySettings', {}) or {}
|
||||||
|
names = rs.get('serverNames') or []
|
||||||
|
if names:
|
||||||
|
site_name = names[0]
|
||||||
|
except StopIteration:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self._detect_layout() == 'native':
|
||||||
|
priv = (self._read_remote_file(f"{self._config_dir()}/xray_private.key") or '').strip()
|
||||||
|
pub = (self._read_remote_file(f"{self._config_dir()}/xray_public.key") or '').strip()
|
||||||
|
sid = (self._read_remote_file(f"{self._config_dir()}/xray_short_id.key") or '').strip()
|
||||||
|
if not priv:
|
||||||
|
priv = rs.get('privateKey', '')
|
||||||
|
if not sid:
|
||||||
|
sids = rs.get('shortIds') or []
|
||||||
|
sid = sids[0] if sids else ''
|
||||||
|
if not pub:
|
||||||
|
pub = self._derive_pubkey_from_priv(priv)
|
||||||
|
return {
|
||||||
|
'private_key': priv,
|
||||||
|
'public_key': pub,
|
||||||
|
'short_id': sid,
|
||||||
|
'port': port,
|
||||||
|
'site_name': site_name or 'yahoo.com',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Panel (legacy) layout
|
||||||
|
out = self._read_remote_file(f"{self._config_dir()}/meta.json")
|
||||||
|
meta = {}
|
||||||
|
if out:
|
||||||
|
try:
|
||||||
|
meta = json.loads(out)
|
||||||
|
except Exception:
|
||||||
|
meta = {}
|
||||||
|
if port is not None:
|
||||||
|
meta['port'] = port
|
||||||
|
if site_name:
|
||||||
|
meta['site_name'] = site_name
|
||||||
|
if not meta.get('private_key'):
|
||||||
|
meta['private_key'] = rs.get('privateKey', '')
|
||||||
|
if not meta.get('short_id'):
|
||||||
|
sids = rs.get('shortIds') or []
|
||||||
|
if sids:
|
||||||
|
meta['short_id'] = sids[0]
|
||||||
|
if not meta.get('public_key') and meta.get('private_key'):
|
||||||
|
meta['public_key'] = self._derive_pubkey_from_priv(meta['private_key'])
|
||||||
|
return meta
|
||||||
|
|
||||||
|
def _get_clients_table(self):
|
||||||
|
"""Read clientsTable, trying both layout filenames."""
|
||||||
|
layout = self._detect_layout()
|
||||||
|
primary = 'clientsTable' if layout == 'native' else 'clientsTable.json'
|
||||||
|
fallback = 'clientsTable.json' if layout == 'native' else 'clientsTable'
|
||||||
|
for fname in (primary, fallback):
|
||||||
|
out = self._read_remote_file(f"{self._config_dir()}/{fname}")
|
||||||
|
if not out or not out.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return json.loads(out)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _save_clients_table(self, data):
|
||||||
|
"""Write clientsTable to the file matching the current layout, in both
|
||||||
|
the container and the host bind-mount.
|
||||||
|
"""
|
||||||
|
path = self._clients_table_path()
|
||||||
|
tmp_file = "/tmp/_xray_clients.json"
|
||||||
|
self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker cp {tmp_file} {self.CONTAINER_NAME}:{path}"
|
||||||
|
)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"cp {tmp_file} {path} 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _upgrade_config_for_stats(self, config, restart=True):
|
||||||
|
"""Injects API and Stats blocks into older Xray configs transparently."""
|
||||||
|
dirty = False
|
||||||
|
if 'stats' not in config:
|
||||||
|
config['stats'] = {}
|
||||||
|
dirty = True
|
||||||
|
if 'api' not in config:
|
||||||
|
config['api'] = {"services": ["StatsService", "LoggerService", "HandlerService"], "tag": "api"}
|
||||||
|
dirty = True
|
||||||
|
else:
|
||||||
|
services = config['api'].setdefault('services', [])
|
||||||
|
if 'HandlerService' not in services:
|
||||||
|
services.append('HandlerService')
|
||||||
|
dirty = True
|
||||||
|
if 'policy' not in config:
|
||||||
|
config['policy'] = {
|
||||||
|
"levels": {"0": {"statsUserUplink": True, "statsUserDownlink": True}},
|
||||||
|
"system": {"statsInboundUplink": True, "statsInboundDownlink": True, "statsOutboundUplink": True, "statsOutboundDownlink": True}
|
||||||
|
}
|
||||||
|
dirty = True
|
||||||
|
if 'routing' not in config:
|
||||||
|
config['routing'] = {"rules": [{"inboundTag": ["api"], "outboundTag": "api", "type": "field"}]}
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
has_api_inbound = any(ib.get('tag') == 'api' for ib in config.get('inbounds', []))
|
||||||
|
if not has_api_inbound:
|
||||||
|
config.setdefault('inbounds', []).append({
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"port": 10085,
|
||||||
|
"protocol": "dokodemo-door",
|
||||||
|
"settings": {"address": "127.0.0.1"},
|
||||||
|
"tag": "api"
|
||||||
|
})
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
for ib in config.get('inbounds', []):
|
||||||
|
if ib.get('protocol') == 'vless':
|
||||||
|
if not ib.get('tag'):
|
||||||
|
ib['tag'] = 'proxy'
|
||||||
|
dirty = True
|
||||||
|
for c in ib.get('settings', {}).get('clients', []):
|
||||||
|
if 'email' not in c:
|
||||||
|
c['email'] = c['id']
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
if dirty:
|
||||||
|
self._write_server_json(config, restart=restart)
|
||||||
|
return dirty
|
||||||
|
|
||||||
|
def _query_xray_stats(self):
|
||||||
|
"""Query Xray API for traffic stats using xray api command."""
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec -i {self.CONTAINER_NAME} /usr/bin/xray api statsquery -server=127.0.0.1:10085"
|
||||||
|
)
|
||||||
|
if code != 0 or not out.strip():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats_raw = json.loads(out)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
# Output format: {"stat": [{"name": "user>>>uid>>>traffic>>>downlink", "value": "123"}, ...]}
|
||||||
|
for item in stats_raw.get('stat', []):
|
||||||
|
name_parts = item.get('name', '').split('>>>')
|
||||||
|
if len(name_parts) == 4 and name_parts[0] == 'user':
|
||||||
|
uid = name_parts[1]
|
||||||
|
t_type = name_parts[3] # uplink or downlink
|
||||||
|
val = int(item.get('value', 0))
|
||||||
|
|
||||||
|
if uid not in results:
|
||||||
|
results[uid] = {'rx': 0, 'tx': 0}
|
||||||
|
|
||||||
|
if t_type == 'downlink':
|
||||||
|
results[uid]['rx'] = val
|
||||||
|
elif t_type == 'uplink':
|
||||||
|
results[uid]['tx'] = val
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _format_bytes(self, size):
|
||||||
|
# Format bytes to string like AWG (e.g., 1.50 MiB)
|
||||||
|
power = 2**10
|
||||||
|
n = 0
|
||||||
|
powers = {0: 'B', 1: 'KiB', 2: 'MiB', 3: 'GiB', 4: 'TiB'}
|
||||||
|
while size > power:
|
||||||
|
size /= power
|
||||||
|
n += 1
|
||||||
|
v = round(size, 2)
|
||||||
|
if v == int(v):
|
||||||
|
v = int(v)
|
||||||
|
return f"{v} {powers.get(n, 'B')}"
|
||||||
|
|
||||||
|
def get_clients(self, protocol=None):
|
||||||
|
config = self._get_server_json()
|
||||||
|
if not config:
|
||||||
|
return []
|
||||||
|
|
||||||
|
self._upgrade_config_for_stats(config, restart=False)
|
||||||
|
|
||||||
|
# Collect all client IDs currently registered in the Xray server config
|
||||||
|
xray_clients = []
|
||||||
|
for ib in config.get('inbounds', []):
|
||||||
|
if ib.get('protocol') == 'vless':
|
||||||
|
xray_clients.extend(ib.get('settings', {}).get('clients', []))
|
||||||
|
|
||||||
|
clients_table = self._get_clients_table()
|
||||||
|
table_ids = {c['clientId'] for c in clients_table}
|
||||||
|
|
||||||
|
# Auto-import clients present in server.json but missing from clientsTable
|
||||||
|
# (e.g. added via the native Amnezia phone/desktop app). Skip the install-time
|
||||||
|
# default UUID for native-layout installs — the official client treats it as
|
||||||
|
# the device of the user who installed the server, not a manageable client.
|
||||||
|
default_uuid = self._get_default_xray_uuid()
|
||||||
|
updated = False
|
||||||
|
for xc in xray_clients:
|
||||||
|
uid = xc.get('id')
|
||||||
|
if not uid or uid in table_ids or uid == default_uuid:
|
||||||
|
continue
|
||||||
|
clients_table.append({
|
||||||
|
'clientId': uid,
|
||||||
|
'userData': {
|
||||||
|
'clientName': f'Imported_{uid[:8]}',
|
||||||
|
'creationDate': datetime.now().isoformat(),
|
||||||
|
'enabled': True
|
||||||
|
}
|
||||||
|
})
|
||||||
|
table_ids.add(uid)
|
||||||
|
updated = True
|
||||||
|
logger.info(f"Auto-imported Xray client {uid[:8]} from server.json")
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
self._save_clients_table(clients_table)
|
||||||
|
|
||||||
|
stats = self._query_xray_stats()
|
||||||
|
|
||||||
|
for c in clients_table:
|
||||||
|
uid = c.get('clientId', '')
|
||||||
|
if uid in stats:
|
||||||
|
user_data = c.setdefault('userData', {})
|
||||||
|
rx = stats[uid]['rx']
|
||||||
|
tx = stats[uid]['tx']
|
||||||
|
if rx > 0 or tx > 0:
|
||||||
|
user_data['dataReceived'] = self._format_bytes(rx)
|
||||||
|
user_data['dataSent'] = self._format_bytes(tx)
|
||||||
|
user_data['dataReceivedBytes'] = rx
|
||||||
|
user_data['dataSentBytes'] = tx
|
||||||
|
|
||||||
|
return clients_table
|
||||||
|
|
||||||
|
def get_client_config(self, protocol, client_id, server_host, port):
|
||||||
|
clients = self._get_clients_table()
|
||||||
|
client = next((c for c in clients if c['clientId'] == client_id), None)
|
||||||
|
if not client: return None
|
||||||
|
|
||||||
|
meta = self._get_meta_json()
|
||||||
|
if not meta: return None
|
||||||
|
|
||||||
|
config = self._get_server_json()
|
||||||
|
sni = meta.get('site_name', 'yahoo.com')
|
||||||
|
if config:
|
||||||
|
try:
|
||||||
|
sni = config['inbounds'][0]['streamSettings']['realitySettings']['serverNames'][0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Format URL
|
||||||
|
# vless://{id}@{host}:{port}?type=tcp&security=reality&pbk={public_key}&sni={sni}&fp=chrome&sid={short_id}&spx=%2F&flow=xtls-rprx-vision#{name}
|
||||||
|
|
||||||
|
name = client.get('userData', {}).get('clientName', 'vpn')
|
||||||
|
encoded_name = urllib.parse.quote(name)
|
||||||
|
|
||||||
|
url = (
|
||||||
|
f"vless://{client_id}@{server_host}:{meta.get('port', port)}"
|
||||||
|
f"?type=tcp&security=reality&pbk={meta['public_key']}"
|
||||||
|
f"&sni={sni}&fp=chrome&sid={meta['short_id']}"
|
||||||
|
f"&spx=%2F&flow=xtls-rprx-vision#{encoded_name}"
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
|
||||||
|
def add_client(self, protocol, client_name, server_host, port):
|
||||||
|
client_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
config = self._get_server_json()
|
||||||
|
if not config: raise RuntimeError("Xray server config not found.")
|
||||||
|
|
||||||
|
self._upgrade_config_for_stats(config, restart=False)
|
||||||
|
|
||||||
|
inbound = self._get_vless_inbound(config)
|
||||||
|
if not inbound:
|
||||||
|
raise RuntimeError("Xray VLESS inbound not found.")
|
||||||
|
|
||||||
|
# Ensure clients structure exists
|
||||||
|
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
|
||||||
|
client = {
|
||||||
|
"id": client_id,
|
||||||
|
"flow": "xtls-rprx-vision",
|
||||||
|
"email": client_id
|
||||||
|
}
|
||||||
|
if not self._xray_api_add_user(config, client):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Xray runtime API is not available for hot user updates. "
|
||||||
|
"The server config was upgraded, but the container must be restarted once to enable HandlerService. "
|
||||||
|
"Restart the Xray container manually and try again."
|
||||||
|
)
|
||||||
|
clients_node.append(client)
|
||||||
|
self._write_server_json(config, restart=False)
|
||||||
|
|
||||||
|
# Update table
|
||||||
|
clients_table = self._get_clients_table()
|
||||||
|
clients_table.append({
|
||||||
|
'clientId': client_id,
|
||||||
|
'userData': {
|
||||||
|
'clientName': client_name,
|
||||||
|
'creationDate': datetime.now().isoformat(),
|
||||||
|
'enabled': True
|
||||||
|
}
|
||||||
|
})
|
||||||
|
self._save_clients_table(clients_table)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'client_id': client_id,
|
||||||
|
'config': self.get_client_config(protocol, client_id, server_host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
def toggle_client(self, protocol, client_id, enable):
|
||||||
|
config = self._get_server_json()
|
||||||
|
self._upgrade_config_for_stats(config, restart=False)
|
||||||
|
inbound = self._get_vless_inbound(config)
|
||||||
|
if not inbound:
|
||||||
|
raise RuntimeError("Xray VLESS inbound not found.")
|
||||||
|
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
|
||||||
|
|
||||||
|
# If toggling on and not present, we can re-add it from clientsTable
|
||||||
|
if enable:
|
||||||
|
if not any(c['id'] == client_id for c in clients_node):
|
||||||
|
client = {
|
||||||
|
"id": client_id,
|
||||||
|
"flow": "xtls-rprx-vision",
|
||||||
|
"email": client_id
|
||||||
|
}
|
||||||
|
if not self._xray_api_add_user(config, client):
|
||||||
|
raise RuntimeError("Xray runtime API failed to enable the client without restarting the container.")
|
||||||
|
clients_node.append(client)
|
||||||
|
else:
|
||||||
|
if not self._xray_api_remove_user(config, client_id):
|
||||||
|
raise RuntimeError("Xray runtime API failed to disable the client without restarting the container.")
|
||||||
|
inbound['settings']['clients'] = [c for c in clients_node if c['id'] != client_id]
|
||||||
|
|
||||||
|
self._write_server_json(config, restart=False)
|
||||||
|
|
||||||
|
clients_table = self._get_clients_table()
|
||||||
|
for c in clients_table:
|
||||||
|
if c['clientId'] == client_id:
|
||||||
|
c.setdefault('userData', {})['enabled'] = enable
|
||||||
|
self._save_clients_table(clients_table)
|
||||||
|
|
||||||
|
def remove_client(self, protocol, client_id):
|
||||||
|
config = self._get_server_json()
|
||||||
|
self._upgrade_config_for_stats(config, restart=False)
|
||||||
|
inbound = self._get_vless_inbound(config)
|
||||||
|
if not inbound:
|
||||||
|
raise RuntimeError("Xray VLESS inbound not found.")
|
||||||
|
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
|
||||||
|
if not self._xray_api_remove_user(config, client_id):
|
||||||
|
raise RuntimeError("Xray runtime API failed to remove the client without restarting the container.")
|
||||||
|
inbound['settings']['clients'] = [c for c in clients_node if c['id'] != client_id]
|
||||||
|
self._write_server_json(config, restart=False)
|
||||||
|
|
||||||
|
clients_table = self._get_clients_table()
|
||||||
|
clients_table = [c for c in clients_table if c['clientId'] != client_id]
|
||||||
|
self._save_clients_table(clients_table)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
revproxy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: amnezia-revproxy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- ./site:/srv/site:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
networks:
|
||||||
|
- amnezia-dns-net
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
cap_add:
|
||||||
|
- NET_BIND_SERVICE
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
ulimits:
|
||||||
|
nofile:
|
||||||
|
soft: 65536
|
||||||
|
hard: 65536
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
amnezia-dns-net:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="Cloud infrastructure and managed services for modern teams.">
|
||||||
|
<meta name="robots" content="index, follow">
|
||||||
|
<title>{{SITE_TITLE}}</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☁</text></svg>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<div class="container header-inner">
|
||||||
|
<div class="logo">{{SITE_TITLE}}</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="#services">Services</a>
|
||||||
|
<a href="#about">About</a>
|
||||||
|
<a href="#contact">Contact</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<div class="container">
|
||||||
|
<p class="hero-badge">Trusted by teams worldwide</p>
|
||||||
|
<h1>Reliable cloud infrastructure<br>for your business</h1>
|
||||||
|
<p class="hero-lead">We deliver secure hosting, CDN acceleration, and 24/7 monitoring — so you can focus on building products.</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="btn btn-primary" href="#contact">Get started</a>
|
||||||
|
<a class="btn btn-secondary" href="#services">Learn more</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="services" class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h2>Our services</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-icon">🚀</div>
|
||||||
|
<h3>Managed hosting</h3>
|
||||||
|
<p>High-availability clusters with automatic failover and daily backups.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-icon">🔒</div>
|
||||||
|
<h3>Security</h3>
|
||||||
|
<p>TLS everywhere, WAF protection, and compliance-ready infrastructure.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<div class="card-icon">📊</div>
|
||||||
|
<h3>Analytics</h3>
|
||||||
|
<p>Real-time metrics, uptime dashboards, and actionable alerts.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="about" class="section section-muted">
|
||||||
|
<div class="container about">
|
||||||
|
<div>
|
||||||
|
<h2>Built for uptime</h2>
|
||||||
|
<p>Since 2018 we have operated edge nodes across multiple regions. Our platform is designed for low latency, horizontal scaling, and predictable billing.</p>
|
||||||
|
<ul class="checklist">
|
||||||
|
<li>99.9% SLA on production workloads</li>
|
||||||
|
<li>ISO 27001 aligned practices</li>
|
||||||
|
<li>Human support, not ticket bots</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div><strong>40+</strong><span>Regions</span></div>
|
||||||
|
<div><strong>2M+</strong><span>Requests/day</span></div>
|
||||||
|
<div><strong>12ms</strong><span>Avg latency</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="contact" class="section">
|
||||||
|
<div class="container contact">
|
||||||
|
<h2>Contact us</h2>
|
||||||
|
<p>hello@{{SITE_DOMAIN}}</p>
|
||||||
|
<p class="muted">Business hours: Mon–Fri, 9:00–18:00 UTC</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<p>© 2026 {{SITE_TITLE}}. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0b1220;
|
||||||
|
--surface: #111827;
|
||||||
|
--text: #e5e7eb;
|
||||||
|
--muted: #9ca3af;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #2563eb;
|
||||||
|
--border: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container { max-width: 1100px; margin: 0 auto; padding: 0 24px; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: rgba(11, 18, 32, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo { font-weight: 700; font-size: 1.1rem; letter-spacing: -0.02em; }
|
||||||
|
|
||||||
|
.nav { display: flex; gap: 24px; }
|
||||||
|
.nav a { color: var(--muted); text-decoration: none; font-size: 0.95rem; transition: color 0.2s; }
|
||||||
|
.nav a:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 96px 0 80px;
|
||||||
|
background: radial-gradient(ellipse 80% 60% at 50% -20%, rgba(59,130,246,0.25), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(59,130,246,0.15);
|
||||||
|
color: #93c5fd;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: clamp(2rem, 5vw, 3.25rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-lead {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 560px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary { background: var(--accent); color: #fff; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); transform: translateY(-1px); }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: rgba(255,255,255,0.1); }
|
||||||
|
|
||||||
|
.section { padding: 72px 0; }
|
||||||
|
.section-muted { background: var(--surface); }
|
||||||
|
|
||||||
|
.section h2 { font-size: 1.75rem; margin-bottom: 32px; letter-spacing: -0.02em; }
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 28px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon { font-size: 2rem; margin-bottom: 12px; }
|
||||||
|
.card h3 { margin-bottom: 8px; font-size: 1.1rem; }
|
||||||
|
.card p { color: var(--muted); font-size: 0.95rem; }
|
||||||
|
|
||||||
|
.about {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 48px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about p { color: var(--muted); margin-bottom: 20px; }
|
||||||
|
|
||||||
|
.checklist { list-style: none; }
|
||||||
|
.checklist li {
|
||||||
|
padding: 8px 0 8px 28px;
|
||||||
|
position: relative;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.checklist li::before {
|
||||||
|
content: "✓";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: #34d399;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stats strong { display: block; font-size: 1.75rem; color: var(--text); }
|
||||||
|
.stats span { font-size: 0.85rem; color: var(--muted); }
|
||||||
|
|
||||||
|
.contact { text-align: center; }
|
||||||
|
.contact p { font-size: 1.1rem; margin-bottom: 8px; }
|
||||||
|
.muted { color: var(--muted); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 32px 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav { display: none; }
|
||||||
|
.about { grid-template-columns: 1fr; }
|
||||||
|
.stats { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.12.1
|
||||||
|
bcrypt==5.0.0
|
||||||
|
blinker==1.9.0
|
||||||
|
certifi==2026.2.25
|
||||||
|
cffi==2.0.0
|
||||||
|
click==8.3.1
|
||||||
|
colorama==0.4.6
|
||||||
|
cryptography==44.0.0
|
||||||
|
fastapi==0.115.12
|
||||||
|
Flask==3.1.0
|
||||||
|
h11==0.16.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httptools==0.7.1
|
||||||
|
httpx==0.25.2
|
||||||
|
idna==3.11
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
multicolorcaptcha==1.2.0
|
||||||
|
paramiko==3.5.1
|
||||||
|
pillow==12.1.1
|
||||||
|
pycparser==3.0
|
||||||
|
pydantic==2.12.5
|
||||||
|
pydantic_core==2.41.5
|
||||||
|
PyNaCl==1.6.2
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
python-multipart==0.0.20
|
||||||
|
python-telegram-bot==20.7
|
||||||
|
PyYAML==6.0.3
|
||||||
|
sniffio==1.3.1
|
||||||
|
starlette==0.46.2
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
uvicorn==0.34.0
|
||||||
|
watchfiles==1.1.1
|
||||||
|
websockets==16.0
|
||||||
|
Werkzeug==3.1.6
|
||||||
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 456 KiB |
File diff suppressed because it is too large
Load Diff
@@ -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 |
Vendored
+1
File diff suppressed because one or more lines are too long
+404
@@ -0,0 +1,404 @@
|
|||||||
|
"""
|
||||||
|
Telegram bot for Amnezia Web Panel.
|
||||||
|
Uses raw Telegram Bot API via httpx — no library version conflicts.
|
||||||
|
Runs as a background asyncio task alongside the FastAPI app.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
# Global state
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
_bot_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
|
||||||
|
def is_running() -> bool:
|
||||||
|
return _bot_task is not None and not _bot_task.done()
|
||||||
|
|
||||||
|
|
||||||
|
def launch_bot(token: str, load_data_fn: Callable, generate_vpn_link_fn: Callable):
|
||||||
|
global _bot_task
|
||||||
|
_bot_task = asyncio.create_task(
|
||||||
|
_run_bot(token, load_data_fn, generate_vpn_link_fn),
|
||||||
|
name="telegram_bot",
|
||||||
|
)
|
||||||
|
return _bot_task
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_bot():
|
||||||
|
global _bot_task
|
||||||
|
if _bot_task and not _bot_task.done():
|
||||||
|
_bot_task.cancel()
|
||||||
|
try:
|
||||||
|
await _bot_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
_bot_task = None
|
||||||
|
logger.info("Telegram bot stopped.")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
# Low-level Telegram API helpers
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
class TelegramAPI:
|
||||||
|
def __init__(self, token: str, client: httpx.AsyncClient):
|
||||||
|
self.base = f"https://api.telegram.org/bot{token}"
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
async def call(self, method: str, **params) -> dict:
|
||||||
|
r = await self.client.post(f"{self.base}/{method}", json=params, timeout=30)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
async def get_updates(self, offset: int = 0, timeout: int = 25) -> list:
|
||||||
|
r = await self.client.post(
|
||||||
|
f"{self.base}/getUpdates",
|
||||||
|
json={"offset": offset, "timeout": timeout, "allowed_updates": ["message", "callback_query"]},
|
||||||
|
timeout=timeout + 10,
|
||||||
|
)
|
||||||
|
data = r.json()
|
||||||
|
if data.get("ok"):
|
||||||
|
return data["result"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def send_message(self, chat_id, text: str, reply_markup=None, parse_mode="HTML") -> dict:
|
||||||
|
import json
|
||||||
|
params = {"chat_id": chat_id, "text": text, "parse_mode": parse_mode}
|
||||||
|
if reply_markup:
|
||||||
|
params["reply_markup"] = json.dumps(reply_markup)
|
||||||
|
return (await self.call("sendMessage", **params))
|
||||||
|
|
||||||
|
async def edit_message(self, chat_id, message_id, text: str, reply_markup=None, parse_mode="HTML"):
|
||||||
|
import json
|
||||||
|
params = {"chat_id": chat_id, "message_id": message_id, "text": text, "parse_mode": parse_mode}
|
||||||
|
if reply_markup:
|
||||||
|
params["reply_markup"] = json.dumps(reply_markup)
|
||||||
|
await self.call("editMessageText", **params)
|
||||||
|
|
||||||
|
async def answer_callback(self, callback_query_id: str, text: str = ""):
|
||||||
|
await self.call("answerCallbackQuery", callback_query_id=callback_query_id, text=text)
|
||||||
|
|
||||||
|
async def send_document(self, chat_id, filename: str, content: bytes, caption: str = ""):
|
||||||
|
files = {"document": (filename, content, "text/plain")}
|
||||||
|
data = {"chat_id": str(chat_id), "caption": caption}
|
||||||
|
r = await self.client.post(f"{self.base}/sendDocument", data=data, files=files, timeout=30)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
# Helpers
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
def _find_user(load_data_fn: Callable, tg_id: str):
|
||||||
|
data = load_data_fn()
|
||||||
|
tg_id_clean = str(tg_id).lstrip("@")
|
||||||
|
for u in data.get("users", []):
|
||||||
|
stored = str(u.get("telegramId", "") or "").lstrip("@")
|
||||||
|
if stored and stored == tg_id_clean:
|
||||||
|
return u
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_connections_keyboard(conns: list, data: dict) -> dict:
|
||||||
|
"""Build inline keyboard where each button = one connection."""
|
||||||
|
rows = []
|
||||||
|
servers = data.get("servers", [])
|
||||||
|
for c in conns:
|
||||||
|
sid = c.get("server_id", 0)
|
||||||
|
server_name = "Unknown"
|
||||||
|
if sid < len(servers):
|
||||||
|
srv = servers[sid]
|
||||||
|
server_name = srv.get("name") or srv.get("host", "Unknown")[:20]
|
||||||
|
proto = c.get("protocol", "").upper()
|
||||||
|
name = c.get("name", "Connection")
|
||||||
|
label = f"🔐 {name} · {proto} · {server_name}"
|
||||||
|
# callback_data must be ≤ 64 bytes — use short prefix
|
||||||
|
rows.append([{"text": label, "callback_data": f"cfg:{c['id']}"}])
|
||||||
|
rows.append([{"text": "🔄 Обновить список", "callback_data": "refresh"}])
|
||||||
|
return {"inline_keyboard": rows}
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
# /start handler — shows connections list immediately
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
async def _handle_start(api: TelegramAPI, msg: dict, load_data_fn: Callable):
|
||||||
|
chat_id = msg["chat"]["id"]
|
||||||
|
tg_id = str(msg["from"]["id"])
|
||||||
|
first_name = msg["from"].get("first_name", "")
|
||||||
|
|
||||||
|
panel_user = _find_user(load_data_fn, tg_id)
|
||||||
|
|
||||||
|
if not panel_user:
|
||||||
|
await api.send_message(
|
||||||
|
chat_id,
|
||||||
|
f"👋 Hi, <b>{first_name}</b>!\n\n"
|
||||||
|
"Your Telegram account is not linked to any panel user.\n"
|
||||||
|
"Please contact your administrator — they need to add your Telegram ID to your profile.\n\n"
|
||||||
|
f"Your Telegram ID: <code>{tg_id}</code>",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
data = load_data_fn()
|
||||||
|
conns = [c for c in data.get("user_connections", []) if c["user_id"] == panel_user["id"]]
|
||||||
|
|
||||||
|
if not conns:
|
||||||
|
await api.send_message(
|
||||||
|
chat_id,
|
||||||
|
f"👋 Hi, <b>{first_name}</b>!\n\n"
|
||||||
|
f"You are registered as <b>{panel_user['username']}</b>.\n\n"
|
||||||
|
"You have no connections yet. Please contact your administrator.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
kb = _build_connections_keyboard(conns, data)
|
||||||
|
await api.send_message(
|
||||||
|
chat_id,
|
||||||
|
f"👋 Hi, <b>{first_name}</b>!\n\n"
|
||||||
|
f"You are registered as <b>{panel_user['username']}</b>.\n\n"
|
||||||
|
f"<b>Your connections</b> ({len(conns)}) — tap to get config:",
|
||||||
|
reply_markup=kb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
# Refresh — edit existing message with updated list
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
async def _handle_refresh(
|
||||||
|
api: TelegramAPI, chat_id: int, message_id: int, callback_id: str, tg_id: str, load_data_fn: Callable
|
||||||
|
):
|
||||||
|
await api.answer_callback(callback_id, "Updated!")
|
||||||
|
panel_user = _find_user(load_data_fn, tg_id)
|
||||||
|
if not panel_user:
|
||||||
|
await api.edit_message(chat_id, message_id, "❌ Access denied.")
|
||||||
|
return
|
||||||
|
data = load_data_fn()
|
||||||
|
conns = [c for c in data.get("user_connections", []) if c["user_id"] == panel_user["id"]]
|
||||||
|
if not conns:
|
||||||
|
await api.edit_message(chat_id, message_id, "You have no connections.")
|
||||||
|
return
|
||||||
|
kb = _build_connections_keyboard(conns, data)
|
||||||
|
await api.edit_message(
|
||||||
|
chat_id, message_id,
|
||||||
|
f"<b>Your connections</b> ({len(conns)}) — tap to get config:",
|
||||||
|
reply_markup=kb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
# Get config — send multiple messages with different formats
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
async def _handle_get_config(
|
||||||
|
api: TelegramAPI,
|
||||||
|
chat_id: int,
|
||||||
|
message_id: int,
|
||||||
|
callback_id: str,
|
||||||
|
conn_id: str,
|
||||||
|
tg_id: str,
|
||||||
|
load_data_fn: Callable,
|
||||||
|
generate_vpn_link_fn: Callable,
|
||||||
|
):
|
||||||
|
await api.answer_callback(callback_id, "Fetching config...")
|
||||||
|
|
||||||
|
panel_user = _find_user(load_data_fn, tg_id)
|
||||||
|
if not panel_user:
|
||||||
|
await api.send_message(chat_id, "❌ Access denied.")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = load_data_fn()
|
||||||
|
conn = next(
|
||||||
|
(c for c in data.get("user_connections", [])
|
||||||
|
if c["id"] == conn_id and c["user_id"] == panel_user["id"]),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not conn:
|
||||||
|
await api.send_message(chat_id, "❌ Connection not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
sid = conn["server_id"]
|
||||||
|
servers = data.get("servers", [])
|
||||||
|
if sid >= len(servers):
|
||||||
|
await api.send_message(chat_id, "❌ Server not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
server = servers[sid]
|
||||||
|
proto = conn.get("protocol", "awg")
|
||||||
|
conn_name = conn.get("name", "Connection")
|
||||||
|
|
||||||
|
# Send "Loading..." as new message
|
||||||
|
loading_result = await api.send_message(chat_id, f"⏳ Fetching config for <b>{conn_name}</b>...")
|
||||||
|
loading_msg_id = loading_result.get("result", {}).get("message_id")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
from managers.ssh_manager import SSHManager
|
||||||
|
from managers.awg_manager import AWGManager
|
||||||
|
from managers.xray_manager import XrayManager
|
||||||
|
|
||||||
|
ssh = SSHManager(
|
||||||
|
server["host"],
|
||||||
|
server.get("ssh_port", 22),
|
||||||
|
server["username"],
|
||||||
|
server.get("password", ""),
|
||||||
|
server.get("private_key", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
proto_info = server.get("protocols", {}).get(proto, {})
|
||||||
|
port = proto_info.get("port", "55424")
|
||||||
|
|
||||||
|
def _get_cfg():
|
||||||
|
ssh.connect()
|
||||||
|
from managers.wireguard_manager import WireGuardManager
|
||||||
|
from managers.telemt_manager import TelemtManager
|
||||||
|
|
||||||
|
if proto == "xray":
|
||||||
|
mgr = XrayManager(ssh)
|
||||||
|
cfg = mgr.get_client_config(proto, conn["client_id"], server["host"], port)
|
||||||
|
elif proto == "wireguard":
|
||||||
|
mgr = WireGuardManager(ssh)
|
||||||
|
cfg = mgr.get_client_config(conn["client_id"], server["host"])
|
||||||
|
elif proto == "telemt":
|
||||||
|
mgr = TelemtManager(ssh)
|
||||||
|
cfg = mgr.get_client_config(proto, conn["client_id"], server["host"], port)
|
||||||
|
else:
|
||||||
|
# awg, awg2, awg_legacy
|
||||||
|
mgr = AWGManager(ssh)
|
||||||
|
cfg = mgr.get_client_config(proto, conn["client_id"], server["host"], port)
|
||||||
|
ssh.disconnect()
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
config = await asyncio.to_thread(_get_cfg)
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
if loading_msg_id:
|
||||||
|
await api.edit_message(chat_id, loading_msg_id, "❌ Failed to retrieve configuration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
vpn_link = generate_vpn_link_fn(config) if config else ""
|
||||||
|
|
||||||
|
# Delete loading message
|
||||||
|
if loading_msg_id:
|
||||||
|
await api.call("deleteMessage", chat_id=chat_id, message_id=loading_msg_id)
|
||||||
|
|
||||||
|
# ------- 1. Header -------
|
||||||
|
server_name = server.get("name") or server.get("host", "Unknown")
|
||||||
|
await api.send_message(
|
||||||
|
chat_id,
|
||||||
|
f"✅ <b>{conn_name}</b>\n"
|
||||||
|
f"🌐 Server: <b>{server_name}</b>\n"
|
||||||
|
f"🔌 Protocol: <b>{proto.upper()}</b>",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------- 2. Send config (format depends on protocol) -------
|
||||||
|
# Protocols that return a link/URI rather than an INI-style config file
|
||||||
|
is_link_proto = proto in ("xray", "telemt")
|
||||||
|
|
||||||
|
if is_link_proto:
|
||||||
|
# Show as a tappable link — no .conf file needed
|
||||||
|
await api.send_message(
|
||||||
|
chat_id,
|
||||||
|
f"🔗 <b>Connection link</b> (tap to copy):\n<code>{config}</code>",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# AWG / WireGuard — INI config text
|
||||||
|
MAX_LEN = 4000
|
||||||
|
if len(config) <= MAX_LEN:
|
||||||
|
await api.send_message(chat_id, f"<b>📄 Configuration:</b>\n<pre>{config}</pre>")
|
||||||
|
else:
|
||||||
|
chunks = [config[i:i+MAX_LEN] for i in range(0, len(config), MAX_LEN)]
|
||||||
|
for i, chunk in enumerate(chunks, 1):
|
||||||
|
await api.send_message(chat_id, f"<b>📄 Configuration (part {i}/{len(chunks)}):</b>\n<pre>{chunk}</pre>")
|
||||||
|
|
||||||
|
# VPN deep-link (vpn:// base64 URI for the Amnezia app)
|
||||||
|
vpn_link = generate_vpn_link_fn(config)
|
||||||
|
if vpn_link:
|
||||||
|
await api.send_message(
|
||||||
|
chat_id,
|
||||||
|
f"🔗 <b>VPN Link</b> (tap to copy):\n<code>{vpn_link}</code>",
|
||||||
|
)
|
||||||
|
|
||||||
|
# .conf file attachment
|
||||||
|
filename = f"{conn_name.replace(' ', '_')}.conf"
|
||||||
|
await api.send_document(
|
||||||
|
chat_id,
|
||||||
|
filename=filename,
|
||||||
|
content=config.encode("utf-8"),
|
||||||
|
caption=f"📁 Config file: {conn_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Bot: error getting config")
|
||||||
|
if loading_msg_id:
|
||||||
|
await api.edit_message(chat_id, loading_msg_id, f"❌ Error: {e}")
|
||||||
|
else:
|
||||||
|
await api.send_message(chat_id, f"❌ Error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
# Main polling loop
|
||||||
|
# ----------------------------------------------------------------------- #
|
||||||
|
async def _run_bot(token: str, load_data_fn: Callable, generate_vpn_link_fn: Callable):
|
||||||
|
offset = 0
|
||||||
|
logger.info("Telegram bot started (raw httpx polling).")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
api = TelegramAPI(token, client)
|
||||||
|
|
||||||
|
me = await api.call("getMe")
|
||||||
|
if not me.get("ok"):
|
||||||
|
logger.error(f"Telegram bot: invalid token or API error: {me}")
|
||||||
|
return
|
||||||
|
logger.info(f"Telegram bot logged in as @{me['result']['username']}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
updates = await api.get_updates(offset=offset, timeout=25)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Telegram bot polling cancelled.")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Telegram bot polling error: {e}")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for update in updates:
|
||||||
|
offset = update["update_id"] + 1
|
||||||
|
try:
|
||||||
|
await _dispatch(api, update, load_data_fn, generate_vpn_link_fn)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Telegram bot: error handling update {update['update_id']}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch(api: TelegramAPI, update: dict, load_data_fn: Callable, generate_vpn_link_fn: Callable):
|
||||||
|
# --- Text messages ---
|
||||||
|
if "message" in update:
|
||||||
|
msg = update["message"]
|
||||||
|
text = msg.get("text", "")
|
||||||
|
if text.startswith("/start"):
|
||||||
|
await _handle_start(api, msg, load_data_fn)
|
||||||
|
elif text.startswith("/connections"):
|
||||||
|
# Alias for /start
|
||||||
|
await _handle_start(api, msg, load_data_fn)
|
||||||
|
|
||||||
|
# --- Inline button callbacks ---
|
||||||
|
elif "callback_query" in update:
|
||||||
|
cq = update["callback_query"]
|
||||||
|
callback_id = cq["id"]
|
||||||
|
data_str = cq.get("data", "")
|
||||||
|
chat_id = cq["message"]["chat"]["id"]
|
||||||
|
message_id = cq["message"]["message_id"]
|
||||||
|
tg_id = str(cq["from"]["id"])
|
||||||
|
|
||||||
|
if data_str == "refresh":
|
||||||
|
await _handle_refresh(api, chat_id, message_id, callback_id, tg_id, load_data_fn)
|
||||||
|
elif data_str.startswith("cfg:"):
|
||||||
|
conn_id = data_str[4:]
|
||||||
|
await _handle_get_config(
|
||||||
|
api, chat_id, message_id, callback_id,
|
||||||
|
conn_id, tg_id, load_data_fn, generate_vpn_link_fn
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
@@ -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 = 'Подключить';
|
||||||
|
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 %}
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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 %}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
|||||||
|
{
|
||||||
|
"nav_servers": "Servers",
|
||||||
|
"nav_users": "Users",
|
||||||
|
"nav_settings": "Settings",
|
||||||
|
"nav_connections": "Connections",
|
||||||
|
"nav_logout": "Logout",
|
||||||
|
"theme_dark": "Dark",
|
||||||
|
"theme_light": "Light",
|
||||||
|
"back_to_servers": "← Back to servers",
|
||||||
|
"server_check": "Check",
|
||||||
|
"server_checking": "Checking...",
|
||||||
|
"protocols": "Protocols",
|
||||||
|
"install": "Install",
|
||||||
|
"uninstall": "Uninstall",
|
||||||
|
"run": "Running",
|
||||||
|
"stop": "Stopped",
|
||||||
|
"not_installed": "Not installed",
|
||||||
|
"installed": "Installed",
|
||||||
|
"add_server": "Add Server",
|
||||||
|
"no_servers": "No servers added",
|
||||||
|
"name": "Name",
|
||||||
|
"host": "Host",
|
||||||
|
"port": "Port",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Actions",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"copy": "Copy",
|
||||||
|
"download": "Download",
|
||||||
|
"config": "Configuration",
|
||||||
|
"qr_code": "QR code",
|
||||||
|
"vpn_key": "VPN key",
|
||||||
|
"clients": "Clients",
|
||||||
|
"traffic": "Traffic",
|
||||||
|
"limit": "Limit",
|
||||||
|
"manage": "Manage",
|
||||||
|
"no_protocols": "No protocols",
|
||||||
|
"add_server_desc": "Add your first VPN server to get started. You'll need SSH credentials to connect.",
|
||||||
|
"ssh_host": "Host / IP",
|
||||||
|
"ssh_port": "SSH Port",
|
||||||
|
"ssh_user": "User",
|
||||||
|
"ssh_password": "Password",
|
||||||
|
"ssh_key": "SSH Key",
|
||||||
|
"ssh_key_placeholder": "Private SSH Key",
|
||||||
|
"ssh_key_hint": "Paste the content of your id_rsa or id_ed25519 file",
|
||||||
|
"connect": "Connect",
|
||||||
|
"connecting": "Connecting...",
|
||||||
|
"server_added": "Server added successfully",
|
||||||
|
"server_deleted": "Server deleted",
|
||||||
|
"delete_confirm": "Are you sure you want to delete this server?",
|
||||||
|
"success": "Success",
|
||||||
|
"error": "Error",
|
||||||
|
"info": "Info",
|
||||||
|
"login_title": "Login to Panel",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"captcha": "Captcha",
|
||||||
|
"captcha_placeholder": "Enter characters from image",
|
||||||
|
"captcha_hint": "Click to refresh image",
|
||||||
|
"login": "Login",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"login_error": "Login failed",
|
||||||
|
"check_done": "Check complete",
|
||||||
|
"check_error": "Connection error",
|
||||||
|
"ssh_ok": "SSH Connection",
|
||||||
|
"docker_ok": "Docker",
|
||||||
|
"docker_not_installed": "Docker not installed",
|
||||||
|
"awg_desc": "A newer version of the protocol based on awg-go. Supports advanced obfuscation with S3, S4 parameters.",
|
||||||
|
"awg_legacy_desc": "Original AWG version based on WireGuard kernel. Compatible with older client versions.",
|
||||||
|
"xray_desc": "Modern protocol that masks traffic as regular web traffic (XTLS-Reality). Resistant to deep packet analysis.",
|
||||||
|
"wireguard_desc": "Standard and fastest VPN protocol. Supported natively on all modern OS, but easily detected by DPI.",
|
||||||
|
"not_checked": "Not checked",
|
||||||
|
"connections": "Connections",
|
||||||
|
"add": "Add",
|
||||||
|
"loading_connections": "Loading connections...",
|
||||||
|
"no_connections": "No connections",
|
||||||
|
"no_connections_desc": "Add your first connection to generate a VPN configuration",
|
||||||
|
"install_protocol": "Install protocol",
|
||||||
|
"port_default_hint": "Default port: 55424. Make sure it's not busy",
|
||||||
|
"port_xray_hint": "Default port: 443 (recommended for Xray). Make sure it's not taken by another web server.",
|
||||||
|
"reinstall": "Reinstall",
|
||||||
|
"uninstall_confirm": "Uninstall {}? All connections and configurations will be lost.",
|
||||||
|
"stop_container_confirm": "Stop container {}?",
|
||||||
|
"start_container_confirm": "Start container {}?",
|
||||||
|
"stopping": "Stopping",
|
||||||
|
"starting": "Starting",
|
||||||
|
"stopped": "stopped",
|
||||||
|
"started": "started",
|
||||||
|
"server_config": "Server configuration",
|
||||||
|
"add_connection": "Add connection",
|
||||||
|
"connection_name": "Connection name",
|
||||||
|
"connection_name_placeholder": "Example: iPhone, Laptop",
|
||||||
|
"assign_to_user": "Assign to user (optional)",
|
||||||
|
"no_assign": "— No assignment —",
|
||||||
|
"create": "Create",
|
||||||
|
"creating": "Creating...",
|
||||||
|
"connection_created": "Connection \"{}\" created",
|
||||||
|
"delete_connection_confirm": "Delete this connection? The configuration will stop working.",
|
||||||
|
"connection_deleted": "Connection deleted",
|
||||||
|
"config_tab": "📄 Config",
|
||||||
|
"vpn_key_tab": "🔑 VPN-key",
|
||||||
|
"qr_code_tab": "📱 QR-code",
|
||||||
|
"copy_config": "📋 Copy",
|
||||||
|
"download_conf": "⬇ Download .conf",
|
||||||
|
"copy_key": "📋 Copy key",
|
||||||
|
"vpn_link_hint": "Paste this link into the AmneziaVPN app for automatic configuration",
|
||||||
|
"qr_hint": "Scan the QR code in the AmneziaVPN app",
|
||||||
|
"no_data": "No data",
|
||||||
|
"start_btn": "▶ Start",
|
||||||
|
"stop_btn": "⏹ Stop",
|
||||||
|
"config_btn": "⚙️ Config",
|
||||||
|
"done": "Done",
|
||||||
|
"installing": "Installing...",
|
||||||
|
"start_install": "Starting installation...",
|
||||||
|
"check_docker": "Checking Docker...",
|
||||||
|
"prepare_host": "Preparing host...",
|
||||||
|
"build_container": "Building container...",
|
||||||
|
"install_success": "Protocol installed successfully!",
|
||||||
|
"install_error": "Installation error: ",
|
||||||
|
"qr_error": "Failed to generate QR code",
|
||||||
|
"users_title": "Users",
|
||||||
|
"search_placeholder": "Search by name, email or Telegram...",
|
||||||
|
"add_user": "Add User",
|
||||||
|
"prev_page": "← Back",
|
||||||
|
"next_page": "Next →",
|
||||||
|
"page_info": "Page {} of {}",
|
||||||
|
"loading_users": "Loading users...",
|
||||||
|
"nothing_found": "Nothing found",
|
||||||
|
"search_empty_desc": "Try changing your query or create a new user",
|
||||||
|
"username_label": "Username",
|
||||||
|
"password_label": "Password",
|
||||||
|
"role_label": "Role",
|
||||||
|
"role_user_desc": "User — view only own connections",
|
||||||
|
"role_support_desc": "Support — server management",
|
||||||
|
"role_admin_desc": "Admin — full access",
|
||||||
|
"tg_id_label": "Telegram ID (opt.)",
|
||||||
|
"email_label": "Email (opt.)",
|
||||||
|
"traffic_limit_label": "Traffic limit (GB) (opt., 0 = ∞)",
|
||||||
|
"expiration_date_label": "Expiration date",
|
||||||
|
"description_label": "Description (opt.)",
|
||||||
|
"auto_conn_title": "Automatic connection creation (optional)",
|
||||||
|
"server_label": "Server",
|
||||||
|
"no_create_conn": "— Do not create connection —",
|
||||||
|
"protocol_label": "Protocol",
|
||||||
|
"edit_user_title": "Edit user: {}",
|
||||||
|
"edit_user_tg": "Telegram ID (optional)",
|
||||||
|
"edit_user_email": "Email (optional)",
|
||||||
|
"edit_user_limit": "Traffic limit (GB) (0 = ∞)",
|
||||||
|
"new_password_hint": "New password (leave empty if no change needed)",
|
||||||
|
"op_type_label": "Operation type",
|
||||||
|
"create_new_conn": "Create new connection",
|
||||||
|
"link_existing_conn": "Link existing",
|
||||||
|
"select_existing_conn": "Select existing connection",
|
||||||
|
"existing_conn_hint": "Only connections not linked to users will be shown",
|
||||||
|
"conn_name_panel": "Connection name (in panel)",
|
||||||
|
"user_conns_title": "User connections",
|
||||||
|
"no_free_conns": "No free connections",
|
||||||
|
"linking": "Linking...",
|
||||||
|
"conn_linked": "Connection linked",
|
||||||
|
"enabling": "Enabling",
|
||||||
|
"disabling": "Disabling",
|
||||||
|
"user_enabled": "User enabled",
|
||||||
|
"user_disabled": "User disabled",
|
||||||
|
"delete_user_confirm": "Delete user? All their connections will be deleted.",
|
||||||
|
"share_access": "Share access",
|
||||||
|
"enable_public_access": "Enable public access",
|
||||||
|
"share_password_label": "Password (optional)",
|
||||||
|
"share_password_hint": "If set, user must enter it",
|
||||||
|
"public_link_label": "Public link",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"out_of": "Of",
|
||||||
|
"settings_title": "Panel Settings",
|
||||||
|
"appearance": "🎨 Appearance",
|
||||||
|
"title_label": "Title",
|
||||||
|
"logo_label": "Logo (Emoji or symbol)",
|
||||||
|
"subtitle_label": "Subtitle",
|
||||||
|
"captcha_title": "🔐 Captcha",
|
||||||
|
"enable_captcha": "Enable captcha (multicolorcaptcha)",
|
||||||
|
"telegram_bot_title": "🤖 Telegram Bot",
|
||||||
|
"bot_token_label": "Bot Token (from @BotFather)",
|
||||||
|
"bot_status": "Status",
|
||||||
|
"bot_running": "✅ Running",
|
||||||
|
"bot_stopped": "⏹ Stopped",
|
||||||
|
"bot_stop_btn": "⏹ Stop",
|
||||||
|
"bot_start_btn": "▶️ Start",
|
||||||
|
"bot_hint": "After changing the token, save the settings — the bot will restart automatically. Users must have a Telegram ID in their profile.",
|
||||||
|
"api_docs_title": "🔌 API Documentation",
|
||||||
|
"api_docs_hint": "Use these interfaces to explore the panel's API capabilities",
|
||||||
|
"tunnels_title": "Tunnels",
|
||||||
|
"local_server": "Local Server",
|
||||||
|
"tunnel_install_enable": "Install & Enable",
|
||||||
|
"tunnel_enable": "Enable Tunnel",
|
||||||
|
"tunnel_running": "Tunnel running",
|
||||||
|
"tunnel_stop": "Stop",
|
||||||
|
"tunnel_stopping": "Stopping...",
|
||||||
|
"tunnel_stopped": "Tunnel stopped",
|
||||||
|
"tunnel_delete": "Delete",
|
||||||
|
"tunnel_deleted": "Tunnel deleted",
|
||||||
|
"tunnel_delete_confirm": "Stop and delete this panel-managed tunnel binary?",
|
||||||
|
"tunnel_starting": "Starting tunnel...",
|
||||||
|
"tunnel_started": "Tunnel started",
|
||||||
|
"tunnel_waiting_url": "Tunnel started. Waiting for public URL...",
|
||||||
|
"tunnel_no_public_url": "Public URL will appear here after the tunnel starts.",
|
||||||
|
"tunnels_hint": "Quick tunnels expose this local panel to the internet. Keep admin credentials strong and share public URLs only with trusted users.",
|
||||||
|
"ngrok_authtoken_placeholder": "ngrok authtoken (optional for some accounts)",
|
||||||
|
"import_users_title": "📤 Import Users",
|
||||||
|
"import_source_label": "Import / Sync Source",
|
||||||
|
"remnawave_url_label": "Remnawave URL",
|
||||||
|
"api_key_label": "API Key",
|
||||||
|
"enable_sync": "Enable synchronization",
|
||||||
|
"sync_hint": "Users will be created, disabled, and deleted automatically based on Remnawave data",
|
||||||
|
"sync_now_btn": "🔄 Sync now",
|
||||||
|
"delete_sync_btn": "🗑 Delete synchronization",
|
||||||
|
"auto_create_conns": "Create connections automatically",
|
||||||
|
"sync_server_label": "Server for new connections",
|
||||||
|
"save_changes": "💾 Save changes",
|
||||||
|
"sync_success": "Sync completed: {} users",
|
||||||
|
"sync_deleted": "Deleted {} synced users",
|
||||||
|
"delete_sync_confirm": "Are you sure you want to delete ALL synced users and their connections? This action is irreversible.",
|
||||||
|
"settings_saved": "Settings saved successfully",
|
||||||
|
"bot_started": "Bot started",
|
||||||
|
"bot_stopped_msg": "Bot stopped",
|
||||||
|
"sync_running": "Syncing...",
|
||||||
|
"deleting": "Deleting...",
|
||||||
|
"my_connections_title": "My Connections",
|
||||||
|
"show_config": "📄 Show config",
|
||||||
|
"no_connections_user_desc": "Contact administrator to create a VPN connection",
|
||||||
|
"share_title": "Connection Access",
|
||||||
|
"vpn_access": "VPN Access",
|
||||||
|
"user_label_share": "User",
|
||||||
|
"share_protected_desc": "This access is password protected. Please enter it to continue.",
|
||||||
|
"auth_success": "Auth success",
|
||||||
|
"loading_share_conns": "Loading connections...",
|
||||||
|
"no_active_conns": "You have no active connections yet.",
|
||||||
|
"show_settings_btn": "🔑 Show settings",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"active": "Active",
|
||||||
|
"site_description": "Amnezia Web Panel — management of VPN servers and protocols AmneziaWG",
|
||||||
|
"toggle_theme": "Change theme",
|
||||||
|
"copied_to_clipboard": "Copied to clipboard",
|
||||||
|
"share_not_found": "404 Not Found",
|
||||||
|
"share_not_found_desc": "Either the link is incorrect or access has been disabled.",
|
||||||
|
"invalid_captcha": "Invalid captcha",
|
||||||
|
"account_disabled": "Account disabled",
|
||||||
|
"invalid_login": "Invalid login or password",
|
||||||
|
"user_exists": "User with this name already exists",
|
||||||
|
"cannot_delete_self": "You cannot delete yourself",
|
||||||
|
"wrong_share_password": "Wrong password",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"select_lang": "Select Language",
|
||||||
|
"lang_ru": "Русский",
|
||||||
|
"lang_en": "English",
|
||||||
|
"lang_fr": "Français",
|
||||||
|
"lang_zh": "中文 (Chinese)",
|
||||||
|
"lang_fa": "فارسی (Persian)",
|
||||||
|
"backup_title": "Simple Backup",
|
||||||
|
"download_backup": "Download data.json",
|
||||||
|
"restore_backup": "Restore from file",
|
||||||
|
"restore_confirm": "Are you sure? Current data will be overwritten and the application will restart.",
|
||||||
|
"restore_success": "Restore successful! Restarting...",
|
||||||
|
"invalid_backup_file": "Invalid backup file or structure",
|
||||||
|
"config_unavailable": "Configuration unavailable",
|
||||||
|
"config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user's device and cannot be recovered by the server.",
|
||||||
|
"client_public_key": "Client public key:",
|
||||||
|
"telemt_desc": "MTProxy-based Telegram proxy with advanced obfuscation and TLS emulation support.",
|
||||||
|
"tls_emulation": "TLS Emulation",
|
||||||
|
"tls_domain": "Masking Domain",
|
||||||
|
"max_connections_limit": "Max Connections",
|
||||||
|
"max_connections_hint": "0 for unlimited. Limits total concurrent users.",
|
||||||
|
"traffic_reset_strategy_label": "Traffic reset strategy",
|
||||||
|
"traffic_reset_never": "Never reset",
|
||||||
|
"traffic_reset_daily": "Daily reset",
|
||||||
|
"traffic_reset_weekly": "Weekly reset",
|
||||||
|
"traffic_reset_monthly": "Monthly reset",
|
||||||
|
"traffic_reset_hint": "How often should user traffic be reset",
|
||||||
|
"traffic_used_total": "Total spent",
|
||||||
|
"traffic_reset_info": "Next reset: {}",
|
||||||
|
"services": "Services",
|
||||||
|
"dns_desc": "Personal DNS server to block ads and protect the privacy of your queries.",
|
||||||
|
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
|
||||||
|
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
|
||||||
|
"socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.",
|
||||||
|
"socks5_username": "Username",
|
||||||
|
"socks5_password": "Password",
|
||||||
|
"socks5_password_placeholder": "Leave empty to auto-generate",
|
||||||
|
"socks5_password_hint": "If left empty, a random 16-character password will be generated.",
|
||||||
|
"socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.",
|
||||||
|
"socks5_change_settings": "Change settings",
|
||||||
|
"socks5_settings_title": "SOCKS5 — Connection settings",
|
||||||
|
"socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.",
|
||||||
|
"socks5_settings_saved": "SOCKS5 settings saved",
|
||||||
|
"adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.",
|
||||||
|
"adguard_mode": "Installation mode",
|
||||||
|
"adguard_mode_sidebyside": "Side-by-side",
|
||||||
|
"adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)",
|
||||||
|
"adguard_mode_replace": "Replace AmneziaDNS",
|
||||||
|
"adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately",
|
||||||
|
"adguard_dns_port": "DNS port (53)",
|
||||||
|
"adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.",
|
||||||
|
"adguard_web_port": "Web UI port",
|
||||||
|
"adguard_dot_port": "DoT port",
|
||||||
|
"adguard_doh_port": "DoH port",
|
||||||
|
"adguard_expose_web": "Expose to internet",
|
||||||
|
"adguard_expose": "Expose to host",
|
||||||
|
"adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.",
|
||||||
|
"adguard_internal_ip": "Internal IP",
|
||||||
|
"adguard_web_ui": "Admin URL",
|
||||||
|
"adguard_open_ui": "Open Web UI",
|
||||||
|
"edit_server_title": "Edit server",
|
||||||
|
"edit_keep_credential": "Leave empty to keep the current value",
|
||||||
|
"server_saved": "Server settings saved",
|
||||||
|
"servers_reordered": "Servers reordered",
|
||||||
|
"ping_checking": "Checking connectivity...",
|
||||||
|
"offline": "offline",
|
||||||
|
"api_tokens_title": "API tokens",
|
||||||
|
"api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer <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.",
|
||||||
|
"revproxy_desc": "Caddy reverse proxy with a decoy website on ports 80/443. Masks VPN traffic behind a normal HTTPS site to reduce DPI detection.",
|
||||||
|
"revproxy_domain": "Domain name",
|
||||||
|
"revproxy_domain_hint": "Optional. Point DNS A-record to this server for Let's Encrypt TLS. Leave empty for a self-signed certificate.",
|
||||||
|
"revproxy_site_title": "Site title",
|
||||||
|
"revproxy_tls_email": "ACME email",
|
||||||
|
"revproxy_tls_email_hint": "Required for automatic Let's Encrypt certificates when a domain is set.",
|
||||||
|
"revproxy_backend": "VPN backend",
|
||||||
|
"revproxy_backend_none": "Decoy site only",
|
||||||
|
"revproxy_backend_port": "Backend port",
|
||||||
|
"revproxy_vpn_path": "Secret tunnel path",
|
||||||
|
"revproxy_vpn_path_hint": "Hidden path proxied to the VPN backend (e.g. /cdn-cgi/challenge).",
|
||||||
|
"revproxy_telemt_mask": "Enable Telemt site masking via Caddy",
|
||||||
|
"revproxy_install_hint": "Port 443 is used by the reverse proxy. Reinstall Xray/Telemt on an internal port (e.g. 8443) before enabling backend routing.",
|
||||||
|
"revproxy_port_hint": "Reverse proxy binds host ports 80 and 443 (HTTPS + HTTP/3).",
|
||||||
|
"revproxy_site": "Decoy site",
|
||||||
|
"revproxy_open_site": "Open site",
|
||||||
|
"revproxy_change_settings": "Settings",
|
||||||
|
"revproxy_settings_title": "Reverse Proxy Settings",
|
||||||
|
"management": "Management",
|
||||||
|
"server_management": "Server Management",
|
||||||
|
"server_management_desc": "Perform system-level actions on your machine.",
|
||||||
|
"check_server_services": "Check the server services",
|
||||||
|
"reboot_server": "Reboot server",
|
||||||
|
"clear_server": "Clear server",
|
||||||
|
"remove_server": "Remove server from panel",
|
||||||
|
"ssl_title": "SSL / HTTPS Settings",
|
||||||
|
"enable_https": "Enable HTTPS",
|
||||||
|
"panel_port_label": "Panel Port",
|
||||||
|
"domain_label": "Domain name",
|
||||||
|
"cert_path_label": "SSL Certificate path (.pem)",
|
||||||
|
"key_path_label": "Private Key path (.pem)",
|
||||||
|
"or_paste_text": "OR paste certificate text",
|
||||||
|
"cert_text_label": "Certificate content (CRT)",
|
||||||
|
"key_text_label": "Private Key content (KEY)",
|
||||||
|
"ssl_hint": "After enabling, the panel will be accessible via https:// on the specified domain. Make sure the file paths or text data are correct.",
|
||||||
|
"ssl_provider_label": "Certificate source",
|
||||||
|
"ssl_provider_manual": "Manual (paths / paste)",
|
||||||
|
"ssl_provider_cloudflare": "Cloudflare (auto Let's Encrypt)",
|
||||||
|
"cf_token_label": "Cloudflare API Token",
|
||||||
|
"cf_token_hint": "Create a token with Zone:DNS:Edit and Zone:Zone:Read permissions for your domain's zone. Used for DNS-01 validation only — port 80/443 stays free.",
|
||||||
|
"acme_email_label": "Email (Let's Encrypt notifications)",
|
||||||
|
"acme_staging_label": "Use staging (test certificates)",
|
||||||
|
"cf_issue_btn": "Issue / Renew certificate",
|
||||||
|
"cf_issue_hint": "Issues a real Let's Encrypt certificate via Cloudflare DNS without binding port 443. Restart the panel afterwards to apply.",
|
||||||
|
"cf_issuing": "Issuing...",
|
||||||
|
"cf_issued_ok": "Certificate issued successfully",
|
||||||
|
"cf_restart_required": "Restart the panel to apply the new certificate",
|
||||||
|
"cf_need_domain": "Enter a domain first",
|
||||||
|
"cf_cert_active": "Active",
|
||||||
|
"cf_cert_none": "No certificate yet",
|
||||||
|
"cf_days_left": "days left",
|
||||||
|
"about_title": "About & Updates",
|
||||||
|
"current_version": "Current version",
|
||||||
|
"check_updates": "Check for updates",
|
||||||
|
"checking_updates": "Checking...",
|
||||||
|
"update_available": "New version available",
|
||||||
|
"download_update": "Download update",
|
||||||
|
"up_to_date": "You have the latest version"
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
{
|
||||||
|
"nav_servers": "سرورها",
|
||||||
|
"nav_users": "کاربران",
|
||||||
|
"nav_settings": "تنظیمات",
|
||||||
|
"nav_connections": "اتصالها",
|
||||||
|
"nav_logout": "خروج",
|
||||||
|
"theme_dark": "تاریک",
|
||||||
|
"theme_light": "روشن",
|
||||||
|
"back_to_servers": "← بازگشت به سرورها",
|
||||||
|
"server_check": "بررسی",
|
||||||
|
"server_checking": "در حال بررسی...",
|
||||||
|
"protocols": "پروتکلها",
|
||||||
|
"install": "نصب",
|
||||||
|
"uninstall": "حذف نصب",
|
||||||
|
"run": "در حال اجرا",
|
||||||
|
"stop": "متوقف شده",
|
||||||
|
"not_installed": "نصب نشده",
|
||||||
|
"installed": "نصب شده",
|
||||||
|
"add_server": "افزودن سرور",
|
||||||
|
"no_servers": "هیچ سروری اضافه نشده است",
|
||||||
|
"name": "نام",
|
||||||
|
"host": "میزبان",
|
||||||
|
"port": "پورت",
|
||||||
|
"status": "وضعیت",
|
||||||
|
"actions": "عملکردها",
|
||||||
|
"edit": "ویرایش",
|
||||||
|
"delete": "حذف",
|
||||||
|
"save": "ذخیره",
|
||||||
|
"cancel": "لغو",
|
||||||
|
"loading": "در حال بارگذاری...",
|
||||||
|
"copy": "کپی",
|
||||||
|
"download": "دانلود",
|
||||||
|
"config": "پیکربندی",
|
||||||
|
"qr_code": "کد QR",
|
||||||
|
"vpn_key": "کلید VPN",
|
||||||
|
"clients": "کلاینتها",
|
||||||
|
"traffic": "ترافیک",
|
||||||
|
"limit": "محدودیت",
|
||||||
|
"manage": "مدیریت",
|
||||||
|
"no_protocols": "بدون پروتکل",
|
||||||
|
"add_server_desc": "اولین سرور VPN خود را اضافه کنید. برای اتصال به اطلاعات SSH نیاز دارید.",
|
||||||
|
"ssh_host": "میزبان / IP",
|
||||||
|
"ssh_port": "پورت SSH",
|
||||||
|
"ssh_user": "نام کاربری",
|
||||||
|
"ssh_password": "رمز عبور",
|
||||||
|
"ssh_key": "کلید SSH",
|
||||||
|
"ssh_key_placeholder": "کلید خصوصی SSH",
|
||||||
|
"ssh_key_hint": "محتوای فایل id_rsa یا id_ed25519 خود را اینجا قرار دهید",
|
||||||
|
"connect": "اتصال",
|
||||||
|
"connecting": "در حال اتصال...",
|
||||||
|
"server_added": "سرور با موفقیت اضافه شد",
|
||||||
|
"server_deleted": "سرور حذف شد",
|
||||||
|
"delete_confirm": "آیا از حذف این سرور مطمئن هستید؟",
|
||||||
|
"success": "موفقیت",
|
||||||
|
"error": "خطا",
|
||||||
|
"info": "اطلاعات",
|
||||||
|
"login_title": "ورود به پنل",
|
||||||
|
"username": "نام کاربری",
|
||||||
|
"password": "رمز عبور",
|
||||||
|
"captcha": "کد امنیتی",
|
||||||
|
"captcha_placeholder": "کاراکترهای تصویر را وارد کنید",
|
||||||
|
"captcha_hint": "برای نوسازی تصویر کلیک کنید",
|
||||||
|
"login": "ورود",
|
||||||
|
"logging_in": "در حال ورود...",
|
||||||
|
"login_error": "ورود ناموفق بود",
|
||||||
|
"check_done": "بررسی کامل شد",
|
||||||
|
"check_error": "خطای اتصال",
|
||||||
|
"ssh_ok": "اتصال SSH برقرار است",
|
||||||
|
"docker_ok": "داکر نصب است",
|
||||||
|
"docker_not_installed": "داکر نصب نیست",
|
||||||
|
"awg_desc": "نسخه جدید پروتکل بر پایه awg-go. پشتیبانی از مبهمسازی پیشرفته (S3, S4).",
|
||||||
|
"awg_legacy_desc": "نسخه اصلی AWG. سازگار با نسخههای قدیمی کلاینت.",
|
||||||
|
"xray_desc": "تغییر ظاهر ترافیک به ترافیک معمولی وب (XTLS-Reality). مقاوم در برابر فیلترینگ شدید.",
|
||||||
|
"not_checked": "بررسی نشده",
|
||||||
|
"connections": "اتصالها",
|
||||||
|
"add": "افزودن",
|
||||||
|
"loading_connections": "در حال بارگذاری اتصالها...",
|
||||||
|
"no_connections": "بدون اتصال",
|
||||||
|
"no_connections_desc": "اولین اتصال خود را برای ایجاد پیکربندی VPN اضافه کنید",
|
||||||
|
"install_protocol": "نصب پروتکل",
|
||||||
|
"port_default_hint": "پورت پیشفرض: 55424. مطمئن شوید این پورت آزاد است.",
|
||||||
|
"port_xray_hint": "پورت پیشنهادی: 443. مطمئن شوید توسط وبسرور دیگری اشغال نشده باشد.",
|
||||||
|
"reinstall": "نصب مجدد",
|
||||||
|
"uninstall_confirm": "حذف نصب {}؟ تمام اتصالها و پیکربندیها از بین خواهند رفت.",
|
||||||
|
"stop_container_confirm": "توقف کانتینر {}؟",
|
||||||
|
"start_container_confirm": "شروع کانتینر {}؟",
|
||||||
|
"stopping": "در حال توقف",
|
||||||
|
"starting": "در حال شروع",
|
||||||
|
"stopped": "متوقف شد",
|
||||||
|
"started": "شروع شد",
|
||||||
|
"server_config": "پیکربندی سرور",
|
||||||
|
"add_connection": "افزودن اتصال",
|
||||||
|
"connection_name": "نام اتصال",
|
||||||
|
"connection_name_placeholder": "مثال: iPhone, Laptop",
|
||||||
|
"assign_to_user": "تخصیص به کاربر (اختیاری)",
|
||||||
|
"no_assign": "— بدون تخصیص —",
|
||||||
|
"create": "ایجاد",
|
||||||
|
"creating": "در حال ایجاد...",
|
||||||
|
"connection_created": "اتصال \"{}\" ایجاد شد",
|
||||||
|
"delete_connection_confirm": "این اتصال حذف شود؟ پیکربندی دیگر کار نخواهد کرد.",
|
||||||
|
"connection_deleted": "اتصال حذف شد",
|
||||||
|
"config_tab": "📄 فایل",
|
||||||
|
"vpn_key_tab": "🔑 کلید-VPN",
|
||||||
|
"qr_code_tab": "📱 کد-QR",
|
||||||
|
"copy_config": "📋 کپی",
|
||||||
|
"download_conf": "⬇ دانلود .conf",
|
||||||
|
"copy_key": "📋 کپی کلید",
|
||||||
|
"vpn_link_hint": "این لینک را برای تنظیم خودکار در برنامه AmneziaVPN وارد کنید",
|
||||||
|
"qr_hint": "کد QR را در برنامه AmneziaVPN اسکن کنید",
|
||||||
|
"no_data": "بدون داده",
|
||||||
|
"start_btn": "▶ شروع",
|
||||||
|
"stop_btn": "⏹ توقف",
|
||||||
|
"config_btn": "⚙️ تنظیم",
|
||||||
|
"done": "انجام شد",
|
||||||
|
"installing": "در حال نصب...",
|
||||||
|
"start_install": "شروع نصب...",
|
||||||
|
"check_docker": "بررسی داکر...",
|
||||||
|
"prepare_host": "آمادهسازی میزبان...",
|
||||||
|
"build_container": "ساخت کانتینر...",
|
||||||
|
"install_success": "پروتکل با موفقیت نصب شد!",
|
||||||
|
"install_error": "خطا در نصب: ",
|
||||||
|
"qr_error": "ایجاد کد QR ناموفق بود",
|
||||||
|
"users_title": "کاربران",
|
||||||
|
"search_placeholder": "جستجو با نام، ایمیل یا تلگرام...",
|
||||||
|
"add_user": "افزودن کاربر",
|
||||||
|
"prev_page": "← قبلی",
|
||||||
|
"next_page": "بعدی →",
|
||||||
|
"page_info": "صفحه {} از {}",
|
||||||
|
"loading_users": "در حال بارگذاری کاربران...",
|
||||||
|
"nothing_found": "چیزی یافت نشد",
|
||||||
|
"search_empty_desc": "جستجوی دیگری را امتحان کنید یا کاربر جدید بسازید",
|
||||||
|
"username_label": "نام کاربری",
|
||||||
|
"password_label": "رمز عبور",
|
||||||
|
"role_label": "نقش",
|
||||||
|
"role_user_desc": "User — فقط مشاهده اتصالهای خود",
|
||||||
|
"role_support_desc": "Support — مدیریت سرورها",
|
||||||
|
"role_admin_desc": "Admin — دسترسی کامل",
|
||||||
|
"tg_id_label": "آیدی تلگرام (اختیاری)",
|
||||||
|
"email_label": "ایمیل (اختیاری)",
|
||||||
|
"traffic_limit_label": "محدودیت ترافیک (گیگابایت) (0 = نامحدود)",
|
||||||
|
"description_label": "توضیحات (اختیاری)",
|
||||||
|
"auto_conn_title": "ایجاد خودکار اتصال (اختیاری)",
|
||||||
|
"server_label": "سرور",
|
||||||
|
"no_create_conn": "— عدم ایجاد اتصال —",
|
||||||
|
"protocol_label": "پروتکل",
|
||||||
|
"edit_user_title": "ویرایش کاربر: {}",
|
||||||
|
"edit_user_tg": "آیدی تلگرام (اختیاری)",
|
||||||
|
"edit_user_email": "ایمیل (اختیاری)",
|
||||||
|
"edit_user_limit": "محدودیت ترافیک (گیگابایت) (0 = نامحدود)",
|
||||||
|
"new_password_hint": "رمز عبور جدید (اگر تغییری نمیخواهید خالی بگذارید)",
|
||||||
|
"op_type_label": "نوع عملیات",
|
||||||
|
"create_new_conn": "ایجاد اتصال جدید",
|
||||||
|
"link_existing_conn": "اتصال به موجود",
|
||||||
|
"select_existing_conn": "انتخاب اتصال موجود",
|
||||||
|
"existing_conn_hint": "فقط اتصالهای بدون کاربر نمایش داده میشوند",
|
||||||
|
"conn_name_panel": "نام اتصال (در پنل)",
|
||||||
|
"user_conns_title": "اتصالهای کاربر",
|
||||||
|
"no_free_conns": "اتصال آزاد موجود نیست",
|
||||||
|
"linking": "در حال اتصال...",
|
||||||
|
"conn_linked": "اتصال برقرار شد",
|
||||||
|
"enabling": "در حال فعالسازی",
|
||||||
|
"disabling": "در حال غیرفعالسازی",
|
||||||
|
"user_enabled": "کاربر فعال شد",
|
||||||
|
"user_disabled": "کاربر غیرفعال شد",
|
||||||
|
"delete_user_confirm": "کاربر حذف شود؟ تمام اتصالهای او حذف خواهند شد.",
|
||||||
|
"share_access": "اشتراکگذاری دسترسی",
|
||||||
|
"enable_public_access": "فعالسازی دسترسی عمومی",
|
||||||
|
"share_password_label": "رمز عبور (اختیاری)",
|
||||||
|
"share_password_hint": "در صورت تنظیم، کاربر باید آن را وارد کند",
|
||||||
|
"public_link_label": "لینک عمومی",
|
||||||
|
"disabled": "غیرفعال",
|
||||||
|
"out_of": "از",
|
||||||
|
"settings_title": "تنظیمات پنل",
|
||||||
|
"appearance": "🎨 ظاهر",
|
||||||
|
"title_label": "عنوان (Title)",
|
||||||
|
"logo_label": "لوگو (ایموجی یا نماد)",
|
||||||
|
"subtitle_label": "زیرعنوان (Subtitle)",
|
||||||
|
"captcha_title": "🔐 کد امنیتی",
|
||||||
|
"enable_captcha": "فعالسازی کد امنیتی",
|
||||||
|
"telegram_bot_title": "🤖 ربات تلگرام",
|
||||||
|
"bot_token_label": "توکن ربات (از @BotFather)",
|
||||||
|
"bot_status": "وضعیت",
|
||||||
|
"bot_running": "✅ در حال اجرا",
|
||||||
|
"bot_stopped": "⏹ متوقف شده",
|
||||||
|
"bot_stop_btn": "⏹ توقف",
|
||||||
|
"bot_start_btn": "▶️ شروع",
|
||||||
|
"bot_hint": "پس از تغییر توکن تنظیمات را ذخیره کنید تا ربات بازراهاندازی شود.",
|
||||||
|
"api_docs_title": "🔌 مستندات API",
|
||||||
|
"api_docs_hint": "از این رابطها برای بررسی قابلیتهای API استفاده کنید",
|
||||||
|
"tunnels_title": "Tunnels",
|
||||||
|
"local_server": "سرور محلی",
|
||||||
|
"tunnel_install_enable": "نصب و فعالسازی",
|
||||||
|
"tunnel_enable": "فعالسازی تونل",
|
||||||
|
"tunnel_running": "تونل در حال اجرا است",
|
||||||
|
"tunnel_stop": "توقف",
|
||||||
|
"tunnel_stopping": "در حال توقف...",
|
||||||
|
"tunnel_stopped": "تونل متوقف شد",
|
||||||
|
"tunnel_delete": "حذف",
|
||||||
|
"tunnel_deleted": "تونل حذف شد",
|
||||||
|
"tunnel_delete_confirm": "این فایل اجرایی تونل مدیریتشده توسط پنل متوقف و حذف شود؟",
|
||||||
|
"tunnel_starting": "در حال شروع تونل...",
|
||||||
|
"tunnel_started": "تونل شروع شد",
|
||||||
|
"tunnel_waiting_url": "تونل شروع شد. در انتظار آدرس عمومی...",
|
||||||
|
"tunnel_no_public_url": "آدرس عمومی پس از شروع تونل اینجا نمایش داده میشود.",
|
||||||
|
"tunnels_hint": "تونلهای سریع این پنل محلی را در اینترنت منتشر میکنند. رمز مدیر را قوی نگه دارید و آدرسهای عمومی را فقط با کاربران مورد اعتماد به اشتراک بگذارید.",
|
||||||
|
"ngrok_authtoken_placeholder": "ngrok authtoken (برای برخی حسابها اختیاری است)",
|
||||||
|
"import_users_title": "📤 وارد کردن کاربران",
|
||||||
|
"import_source_label": "منبع وارد کردن / همگامسازی",
|
||||||
|
"remnawave_url_label": "آدرس Remnawave",
|
||||||
|
"api_key_label": "کلید API",
|
||||||
|
"enable_sync": "فعالسازی همگامسازی",
|
||||||
|
"sync_hint": "کاربران طبق دادههای Remnawave بهطور خودکار مدیریت میشوند",
|
||||||
|
"sync_now_btn": "🔄 همگامسازی اکنون",
|
||||||
|
"delete_sync_btn": "🗑 حذف همگامسازی",
|
||||||
|
"auto_create_conns": "ایجاد خودکار اتصالها",
|
||||||
|
"sync_server_label": "سرور برای اتصالهای جدید",
|
||||||
|
"save_changes": "💾 ذخیره تغییرات",
|
||||||
|
"sync_success": "همگامسازی انجام شد: {} کاربر",
|
||||||
|
"sync_deleted": "{} کاربر همگامسازی شده حذف شدند",
|
||||||
|
"delete_sync_confirm": "آیا از حذف تمام کاربران همگامسازی شده مطمئن هستید؟",
|
||||||
|
"settings_saved": "تنظیمات با موفقیت ذخیره شد",
|
||||||
|
"bot_started": "ربات شروع به کار کرد",
|
||||||
|
"bot_stopped_msg": "ربات متوقف شد",
|
||||||
|
"sync_running": "در حال همگامسازی...",
|
||||||
|
"deleting": "در حال حذف...",
|
||||||
|
"my_connections_title": "اتصالهای من",
|
||||||
|
"show_config": "📄 نمایش پیکربندی",
|
||||||
|
"no_connections_user_desc": "برای ایجاد اتصال VPN با مدیر تماس بگیرید",
|
||||||
|
"share_title": "دسترسی به اتصال",
|
||||||
|
"vpn_access": "دسترسی VPN",
|
||||||
|
"user_label_share": "کاربر",
|
||||||
|
"share_protected_desc": "این دسترسی با رمز عبور محافظت شده است.",
|
||||||
|
"auth_success": "ورود موفقیتآمیز",
|
||||||
|
"loading_share_conns": "در حال بارگذاری...",
|
||||||
|
"no_active_conns": "هنوز هیچ اتصال فعالی ندارید.",
|
||||||
|
"show_settings_btn": "🔑 نمایش تنظیمات",
|
||||||
|
"copied": "کپی شد!",
|
||||||
|
"active": "فعال",
|
||||||
|
"site_description": "پنل وب آمنزیا — مدیریت سرورها و پروتکلهای AmneziaWG",
|
||||||
|
"toggle_theme": "تغییر تم",
|
||||||
|
"copied_to_clipboard": "در حافظه کپی شد",
|
||||||
|
"share_not_found": "404 یافت نشد",
|
||||||
|
"share_not_found_desc": "لینک اشتباه است یا دسترسی غیرفعال شده است.",
|
||||||
|
"invalid_captcha": "کد امنیتی اشتباه است",
|
||||||
|
"account_disabled": "حساب کاربری غیرفعال است",
|
||||||
|
"invalid_login": "نام کاربری یا رمز عبور اشتباه است",
|
||||||
|
"user_exists": "کاربری با این نام قبلاً ثبت شده است",
|
||||||
|
"cannot_delete_self": "شما نمیتوانید خودتان را حذف کنید",
|
||||||
|
"wrong_share_password": "رمز عبور اشتباه است",
|
||||||
|
"saving": "در حال ذخیره...",
|
||||||
|
"select_lang": "انتخاب زبان",
|
||||||
|
"lang_ru": "Русский",
|
||||||
|
"lang_en": "English",
|
||||||
|
"lang_fr": "Français",
|
||||||
|
"lang_zh": "中文 (Chinese)",
|
||||||
|
"lang_fa": "فارسی (Persian)",
|
||||||
|
"backup_title": "پشتیبانگیری ساده",
|
||||||
|
"download_backup": "دانلود data.json",
|
||||||
|
"restore_backup": "بازیابی از فایل",
|
||||||
|
"restore_confirm": "آیا مطمئن هستید؟ دادههای فعلی بازنویسی میشوند و برنامه دوباره راهاندازی خواهد شد.",
|
||||||
|
"restore_success": "بازیابی موفقیتآمیز بود! در حال راهاندازی مجدد...",
|
||||||
|
"invalid_backup_file": "فایل پشتیبان یا ساختار نامعتبر است",
|
||||||
|
"config_unavailable": "پیکربندی در دسترس نیست",
|
||||||
|
"config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره میشود و توسط سرور قابل بازیابی نیست.",
|
||||||
|
"client_public_key": "کلید عمومی کلاینت:",
|
||||||
|
"management": "مدیریت",
|
||||||
|
"server_management": "مدیریت سرور",
|
||||||
|
"server_management_desc": "انجام اقدامات در سطح سیستم روی دستگاه شما.",
|
||||||
|
"check_server_services": "بررسی سرویسهای سرور",
|
||||||
|
"reboot_server": "راهاندازی مجدد سرور",
|
||||||
|
"clear_server": "پاکسازی سرور",
|
||||||
|
"remove_server": "حذف سرور از پنل",
|
||||||
|
"telemt_desc": "پروکسی تلگرام بر پایهی MTProxy با پوششهای پیشرفته.",
|
||||||
|
"dns_desc": "سرور DNS شخصی برای مسدود کردن تبلیغات و حفظ حریم خصوصی.",
|
||||||
|
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
|
||||||
|
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
|
||||||
|
"socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.",
|
||||||
|
"socks5_username": "Username",
|
||||||
|
"socks5_password": "Password",
|
||||||
|
"socks5_password_placeholder": "Leave empty to auto-generate",
|
||||||
|
"socks5_password_hint": "If left empty, a random 16-character password will be generated.",
|
||||||
|
"socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.",
|
||||||
|
"socks5_change_settings": "Change settings",
|
||||||
|
"socks5_settings_title": "SOCKS5 — Connection settings",
|
||||||
|
"socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.",
|
||||||
|
"socks5_settings_saved": "SOCKS5 settings saved",
|
||||||
|
"adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.",
|
||||||
|
"adguard_mode": "Installation mode",
|
||||||
|
"adguard_mode_sidebyside": "Side-by-side",
|
||||||
|
"adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)",
|
||||||
|
"adguard_mode_replace": "Replace AmneziaDNS",
|
||||||
|
"adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately",
|
||||||
|
"adguard_dns_port": "DNS port (53)",
|
||||||
|
"adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.",
|
||||||
|
"adguard_web_port": "Web UI port",
|
||||||
|
"adguard_dot_port": "DoT port",
|
||||||
|
"adguard_doh_port": "DoH port",
|
||||||
|
"adguard_expose_web": "Expose to internet",
|
||||||
|
"adguard_expose": "Expose to host",
|
||||||
|
"adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.",
|
||||||
|
"adguard_internal_ip": "Internal IP",
|
||||||
|
"adguard_web_ui": "Admin URL",
|
||||||
|
"adguard_open_ui": "Open Web UI",
|
||||||
|
"edit_server_title": "Edit server",
|
||||||
|
"edit_keep_credential": "Leave empty to keep the current value",
|
||||||
|
"server_saved": "Server settings saved",
|
||||||
|
"servers_reordered": "Servers reordered",
|
||||||
|
"ping_checking": "Checking connectivity...",
|
||||||
|
"offline": "offline",
|
||||||
|
"api_tokens_title": "API tokens",
|
||||||
|
"api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer <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.",
|
||||||
|
"revproxy_desc": "Caddy reverse proxy with a decoy website on ports 80/443. Masks VPN traffic behind a normal HTTPS site to reduce DPI detection.",
|
||||||
|
"revproxy_domain": "Domain name",
|
||||||
|
"revproxy_domain_hint": "Optional. Point DNS A-record to this server for Let's Encrypt TLS. Leave empty for a self-signed certificate.",
|
||||||
|
"revproxy_site_title": "Site title",
|
||||||
|
"revproxy_tls_email": "ACME email",
|
||||||
|
"revproxy_tls_email_hint": "Required for automatic Let's Encrypt certificates when a domain is set.",
|
||||||
|
"revproxy_backend": "VPN backend",
|
||||||
|
"revproxy_backend_none": "Decoy site only",
|
||||||
|
"revproxy_backend_port": "Backend port",
|
||||||
|
"revproxy_vpn_path": "Secret tunnel path",
|
||||||
|
"revproxy_vpn_path_hint": "Hidden path proxied to the VPN backend (e.g. /cdn-cgi/challenge).",
|
||||||
|
"revproxy_telemt_mask": "Enable Telemt site masking via Caddy",
|
||||||
|
"revproxy_install_hint": "Port 443 is used by the reverse proxy. Reinstall Xray/Telemt on an internal port (e.g. 8443) before enabling backend routing.",
|
||||||
|
"revproxy_port_hint": "Reverse proxy binds host ports 80 and 443 (HTTPS + HTTP/3).",
|
||||||
|
"revproxy_site": "Decoy site",
|
||||||
|
"revproxy_open_site": "Open site",
|
||||||
|
"revproxy_change_settings": "Settings",
|
||||||
|
"revproxy_settings_title": "Reverse Proxy Settings",
|
||||||
|
"ssl_provider_label": "Certificate source",
|
||||||
|
"ssl_provider_manual": "Manual (paths / paste)",
|
||||||
|
"ssl_provider_cloudflare": "Cloudflare (auto Let's Encrypt)",
|
||||||
|
"cf_token_label": "Cloudflare API Token",
|
||||||
|
"cf_token_hint": "Create a token with Zone:DNS:Edit and Zone:Zone:Read permissions. Used for DNS-01 validation only — port 80/443 stays free.",
|
||||||
|
"acme_email_label": "Email (Let's Encrypt notifications)",
|
||||||
|
"acme_staging_label": "Use staging (test certificates)",
|
||||||
|
"cf_issue_btn": "Issue / Renew certificate",
|
||||||
|
"cf_issue_hint": "Issues a real Let's Encrypt certificate via Cloudflare DNS without binding port 443. Restart the panel afterwards to apply.",
|
||||||
|
"cf_issuing": "Issuing...",
|
||||||
|
"cf_issued_ok": "Certificate issued successfully",
|
||||||
|
"cf_restart_required": "Restart the panel to apply the new certificate",
|
||||||
|
"cf_need_domain": "Enter a domain first",
|
||||||
|
"cf_cert_active": "Active",
|
||||||
|
"cf_cert_none": "No certificate yet",
|
||||||
|
"cf_days_left": "days left"
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
{
|
||||||
|
"nav_servers": "Serveurs",
|
||||||
|
"nav_users": "Utilisateurs",
|
||||||
|
"nav_settings": "Paramètres",
|
||||||
|
"nav_connections": "Connexions",
|
||||||
|
"nav_logout": "Déconnexion",
|
||||||
|
"theme_dark": "Sombre",
|
||||||
|
"theme_light": "Clair",
|
||||||
|
"back_to_servers": "← Retour aux serveurs",
|
||||||
|
"server_check": "Vérifier",
|
||||||
|
"server_checking": "Vérification...",
|
||||||
|
"protocols": "Protocoles",
|
||||||
|
"install": "Installer",
|
||||||
|
"uninstall": "Désinstaller",
|
||||||
|
"run": "En cours",
|
||||||
|
"stop": "Arrêté",
|
||||||
|
"not_installed": "Pas installé",
|
||||||
|
"installed": "Installé",
|
||||||
|
"add_server": "Ajouter un serveur",
|
||||||
|
"no_servers": "Aucun serveur ajouté",
|
||||||
|
"name": "Nom",
|
||||||
|
"host": "Hôte",
|
||||||
|
"port": "Port",
|
||||||
|
"status": "Statut",
|
||||||
|
"actions": "Actions",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"copy": "Copier",
|
||||||
|
"download": "Télécharger",
|
||||||
|
"config": "Configuration",
|
||||||
|
"qr_code": "Code QR",
|
||||||
|
"vpn_key": "Clé VPN",
|
||||||
|
"clients": "Clients",
|
||||||
|
"traffic": "Trafic",
|
||||||
|
"limit": "Limite",
|
||||||
|
"manage": "Gérer",
|
||||||
|
"no_protocols": "Aucun protocole",
|
||||||
|
"add_server_desc": "Ajoutez votre premier serveur VPN pour commencer. Identifiants SSH requis.",
|
||||||
|
"ssh_host": "Hôte / IP",
|
||||||
|
"ssh_port": "Port SSH",
|
||||||
|
"ssh_user": "Utilisateur",
|
||||||
|
"ssh_password": "Mot de passe",
|
||||||
|
"ssh_key": "Clé SSH",
|
||||||
|
"ssh_key_placeholder": "Clé SSH privée",
|
||||||
|
"ssh_key_hint": "Collez le contenu de votre fichier id_rsa ou id_ed25519",
|
||||||
|
"connect": "Connecter",
|
||||||
|
"connecting": "Connexion...",
|
||||||
|
"server_added": "Serveur ajouté avec succès",
|
||||||
|
"server_deleted": "Serveur supprimé",
|
||||||
|
"delete_confirm": "Voulez-vous vraiment supprimer ce serveur ?",
|
||||||
|
"success": "Succès",
|
||||||
|
"error": "Erreur",
|
||||||
|
"info": "Info",
|
||||||
|
"login_title": "Connexion au Panneau",
|
||||||
|
"username": "Nom d'utilisateur",
|
||||||
|
"password": "Mot de passe",
|
||||||
|
"captcha": "Captcha",
|
||||||
|
"captcha_placeholder": "Entrez les caractères",
|
||||||
|
"captcha_hint": "Cliquez pour rafraîchir",
|
||||||
|
"login": "Connexion",
|
||||||
|
"logging_in": "Connexion...",
|
||||||
|
"login_error": "Échec de la connexion",
|
||||||
|
"check_done": "Vérification terminée",
|
||||||
|
"check_error": "Erreur de connexion",
|
||||||
|
"ssh_ok": "Connexion SSH",
|
||||||
|
"docker_ok": "Docker",
|
||||||
|
"docker_not_installed": "Docker non installé",
|
||||||
|
"awg_desc": "Version moderne basée sur awg-go. Obfuscation avancée (S3, S4).",
|
||||||
|
"awg_legacy_desc": "Version AWG originale. Compatible avec les anciens clients.",
|
||||||
|
"xray_desc": "Masque le trafic en trafic web normal (XTLS-Reality). Résiste au DPI.",
|
||||||
|
"not_checked": "Non vérifié",
|
||||||
|
"connections": "Connexions",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"loading_connections": "Chargement des connexions...",
|
||||||
|
"no_connections": "Aucune connexion",
|
||||||
|
"no_connections_desc": "Ajoutez votre première connexion pour générer un fichier VPN",
|
||||||
|
"install_protocol": "Installer le protocole",
|
||||||
|
"port_default_hint": "Port par défaut : 55424. Assurez-vous qu'il est libre.",
|
||||||
|
"port_xray_hint": "Port recommandé : 443. Assurez-vous qu'il n'est pas utilisé par un serveur web.",
|
||||||
|
"reinstall": "Réinstaller",
|
||||||
|
"uninstall_confirm": "Désinstaller {} ? Toutes les données seront perdues.",
|
||||||
|
"stop_container_confirm": "Arrêter le conteneur {} ?",
|
||||||
|
"start_container_confirm": "Démarrer le conteneur {} ?",
|
||||||
|
"stopping": "Arrêt",
|
||||||
|
"starting": "Démarrage",
|
||||||
|
"stopped": "arrêté",
|
||||||
|
"started": "démarré",
|
||||||
|
"server_config": "Configuration du serveur",
|
||||||
|
"add_connection": "Ajouter une connexion",
|
||||||
|
"connection_name": "Nom de la connexion",
|
||||||
|
"connection_name_placeholder": "Ex: iPhone, Laptop",
|
||||||
|
"assign_to_user": "Assigner à l'utilisateur (optionnel)",
|
||||||
|
"no_assign": "— Aucune assignation —",
|
||||||
|
"create": "Créer",
|
||||||
|
"creating": "Création...",
|
||||||
|
"connection_created": "Connexion \"{}\" créée",
|
||||||
|
"delete_connection_confirm": "Supprimer cette connexion ? Elle cessera de fonctionner.",
|
||||||
|
"connection_deleted": "Connexion supprimée",
|
||||||
|
"config_tab": "📄 Config",
|
||||||
|
"vpn_key_tab": "🔑 Clé-VPN",
|
||||||
|
"qr_code_tab": "📱 Code-QR",
|
||||||
|
"copy_config": "📋 Copier",
|
||||||
|
"download_conf": "⬇ Télécharger .conf",
|
||||||
|
"copy_key": "📋 Copier la clé",
|
||||||
|
"vpn_link_hint": "Collez ce lien dans AmneziaVPN pour une config automatique",
|
||||||
|
"qr_hint": "Scannez le code QR dans l'application AmneziaVPN",
|
||||||
|
"no_data": "Aucune donnée",
|
||||||
|
"start_btn": "▶ Start",
|
||||||
|
"stop_btn": "⏹ Stop",
|
||||||
|
"config_btn": "⚙️ Config",
|
||||||
|
"done": "Terminé",
|
||||||
|
"installing": "Installation...",
|
||||||
|
"start_install": "Début de l'installation...",
|
||||||
|
"check_docker": "Vérification de Docker...",
|
||||||
|
"prepare_host": "Préparation de l'hôte...",
|
||||||
|
"build_container": "Construction du conteneur...",
|
||||||
|
"install_success": "Protocole installé avec succès !",
|
||||||
|
"install_error": "Erreur d'installation : ",
|
||||||
|
"qr_error": "Échec de génération du QR code",
|
||||||
|
"users_title": "Utilisateurs",
|
||||||
|
"search_placeholder": "Recherche par nom, email ou Telegram...",
|
||||||
|
"add_user": "Ajouter Utilisateur",
|
||||||
|
"prev_page": "← Retour",
|
||||||
|
"next_page": "Suivant →",
|
||||||
|
"page_info": "Page {} sur {}",
|
||||||
|
"loading_users": "Chargement des utilisateurs...",
|
||||||
|
"nothing_found": "Rien trouvé",
|
||||||
|
"search_empty_desc": "Essayez une autre requête ou créez un utilisateur",
|
||||||
|
"username_label": "Nom d'utilisateur",
|
||||||
|
"password_label": "Mot de passe",
|
||||||
|
"role_label": "Rôle",
|
||||||
|
"role_user_desc": "User — voit ses propres connexions",
|
||||||
|
"role_support_desc": "Support — gestion des serveurs",
|
||||||
|
"role_admin_desc": "Admin — accès complet",
|
||||||
|
"tg_id_label": "ID Telegram (opt.)",
|
||||||
|
"email_label": "Email (opt.)",
|
||||||
|
"traffic_limit_label": "Limite trafic (Go) (0 = illimité)",
|
||||||
|
"description_label": "Description (opt.)",
|
||||||
|
"auto_conn_title": "Création auto de connexion (optionnel)",
|
||||||
|
"server_label": "Serveur",
|
||||||
|
"no_create_conn": "— Ne pas créer —",
|
||||||
|
"protocol_label": "Protocole",
|
||||||
|
"edit_user_title": "Modifier utilisateur : {}",
|
||||||
|
"edit_user_tg": "ID Telegram (optionnel)",
|
||||||
|
"edit_user_email": "Email (optionnel)",
|
||||||
|
"edit_user_limit": "Limite trafic (Go) (0 = illimité)",
|
||||||
|
"new_password_hint": "Nouveau mot de passe (laisser vide si inchangé)",
|
||||||
|
"op_type_label": "Type d'opération",
|
||||||
|
"create_new_conn": "Créer nouvelle connexion",
|
||||||
|
"link_existing_conn": "Lier existante",
|
||||||
|
"select_existing_conn": "Sélectionner connexion",
|
||||||
|
"existing_conn_hint": "Seules les connexions libres sont affichées",
|
||||||
|
"conn_name_panel": "Nom (dans le panneau)",
|
||||||
|
"user_conns_title": "Connexions utilisateur",
|
||||||
|
"no_free_conns": "Aucune connexion libre",
|
||||||
|
"linking": "Liaison...",
|
||||||
|
"conn_linked": "Connexion liée",
|
||||||
|
"enabling": "Activation",
|
||||||
|
"disabling": "Désactivation",
|
||||||
|
"user_enabled": "Utilisateur activé",
|
||||||
|
"user_disabled": "Utilisateur désactivé",
|
||||||
|
"delete_user_confirm": "Supprimer l'utilisateur ? Ses connexions seront supprimées.",
|
||||||
|
"share_access": "Partager l'accès",
|
||||||
|
"enable_public_access": "Activer l'accès public",
|
||||||
|
"share_password_label": "Mot de passe (optionnel)",
|
||||||
|
"share_password_hint": "Si défini, requis pour l'accès",
|
||||||
|
"public_link_label": "Lien public",
|
||||||
|
"disabled": "Désactivé",
|
||||||
|
"out_of": "sur",
|
||||||
|
"settings_title": "Paramètres du Panneau",
|
||||||
|
"appearance": "🎨 Apparence",
|
||||||
|
"title_label": "Titre",
|
||||||
|
"logo_label": "Logo (Emoji ou symbole)",
|
||||||
|
"subtitle_label": "Sous-titre",
|
||||||
|
"captcha_title": "🔐 Captcha",
|
||||||
|
"enable_captcha": "Activer captcha",
|
||||||
|
"telegram_bot_title": "🤖 Bot Telegram",
|
||||||
|
"bot_token_label": "Bot Token (@BotFather)",
|
||||||
|
"bot_status": "Statut",
|
||||||
|
"bot_running": "✅ En cours",
|
||||||
|
"bot_stopped": "⏹ Arrêté",
|
||||||
|
"bot_stop_btn": "⏹ Arrêter",
|
||||||
|
"bot_start_btn": "▶️ Démarrer",
|
||||||
|
"bot_hint": "Sauvegardez après modification du token. Le bot redémarrera.",
|
||||||
|
"api_docs_title": "🔌 Documentation API",
|
||||||
|
"api_docs_hint": "Utilisez ces interfaces pour explorer l'API",
|
||||||
|
"tunnels_title": "Tunnels",
|
||||||
|
"local_server": "Serveur local",
|
||||||
|
"tunnel_install_enable": "Installer et activer",
|
||||||
|
"tunnel_enable": "Activer le tunnel",
|
||||||
|
"tunnel_running": "Tunnel en cours",
|
||||||
|
"tunnel_stop": "Arrêter",
|
||||||
|
"tunnel_stopping": "Arrêt...",
|
||||||
|
"tunnel_stopped": "Tunnel arrêté",
|
||||||
|
"tunnel_delete": "Supprimer",
|
||||||
|
"tunnel_deleted": "Tunnel supprimé",
|
||||||
|
"tunnel_delete_confirm": "Arrêter et supprimer ce binaire de tunnel géré par le panneau ?",
|
||||||
|
"tunnel_starting": "Démarrage du tunnel...",
|
||||||
|
"tunnel_started": "Tunnel démarré",
|
||||||
|
"tunnel_waiting_url": "Tunnel démarré. En attente de l'URL publique...",
|
||||||
|
"tunnel_no_public_url": "L'URL publique apparaîtra ici après le démarrage du tunnel.",
|
||||||
|
"tunnels_hint": "Les tunnels rapides exposent ce panneau local sur Internet. Gardez des identifiants administrateur robustes et partagez les URL publiques uniquement avec des utilisateurs de confiance.",
|
||||||
|
"ngrok_authtoken_placeholder": "ngrok authtoken (optionnel pour certains comptes)",
|
||||||
|
"import_users_title": "📤 Import Utilisateurs",
|
||||||
|
"import_source_label": "Source Import / Sync",
|
||||||
|
"remnawave_url_label": "URL Remnawave",
|
||||||
|
"api_key_label": "Clé API",
|
||||||
|
"enable_sync": "Activer synchronisation",
|
||||||
|
"sync_hint": "Synchro automatique avec Remnawave",
|
||||||
|
"sync_now_btn": "🔄 Sync maintenant",
|
||||||
|
"delete_sync_btn": "🗑 Supprimer synchro",
|
||||||
|
"auto_create_conns": "Créer connexions auto",
|
||||||
|
"sync_server_label": "Serveur pour nouvelles connexions",
|
||||||
|
"save_changes": "💾 Enregistrer changements",
|
||||||
|
"sync_success": "Sync terminée : {} utilisateurs",
|
||||||
|
"sync_deleted": "Supprimé {} utilisateurs sync",
|
||||||
|
"delete_sync_confirm": "Supprimer TOUS les utilisateurs synchronisés ? Irréversible.",
|
||||||
|
"settings_saved": "Paramètres enregistrés",
|
||||||
|
"bot_started": "Bot démarré",
|
||||||
|
"bot_stopped_msg": "Bot arrêté",
|
||||||
|
"sync_running": "Synchronisation...",
|
||||||
|
"deleting": "Suppression...",
|
||||||
|
"my_connections_title": "Mes Connexions",
|
||||||
|
"show_config": "📄 Voir config",
|
||||||
|
"no_connections_user_desc": "Contactez l'admin pour une connexion",
|
||||||
|
"share_title": "Accès Connexion",
|
||||||
|
"vpn_access": "Accès VPN",
|
||||||
|
"user_label_share": "Utilisateur",
|
||||||
|
"share_protected_desc": "Accès protégé par mot de passe.",
|
||||||
|
"auth_success": "Succès Auth",
|
||||||
|
"loading_share_conns": "Chargement...",
|
||||||
|
"no_active_conns": "Aucune connexion active.",
|
||||||
|
"show_settings_btn": "🔑 Voir paramètres",
|
||||||
|
"copied": "Copié !",
|
||||||
|
"active": "Actif",
|
||||||
|
"site_description": "Panneau Web Amnezia — gestion serveurs VPN et AmneziaWG",
|
||||||
|
"toggle_theme": "Changer thème",
|
||||||
|
"copied_to_clipboard": "Copié dans le presse-papier",
|
||||||
|
"share_not_found": "404 Introuvable",
|
||||||
|
"share_not_found_desc": "Lien invalide ou accès désactivé.",
|
||||||
|
"invalid_captcha": "Captcha invalide",
|
||||||
|
"account_disabled": "Compte désactivé",
|
||||||
|
"invalid_login": "Login ou password incorrect",
|
||||||
|
"user_exists": "L'utilisateur existe déjà",
|
||||||
|
"cannot_delete_self": "Vous ne pouvez pas vous supprimer",
|
||||||
|
"wrong_share_password": "Mauvais mot de passe",
|
||||||
|
"saving": "Enregistrement...",
|
||||||
|
"select_lang": "Choisir la langue",
|
||||||
|
"lang_ru": "Русский",
|
||||||
|
"lang_en": "English",
|
||||||
|
"lang_fr": "Français",
|
||||||
|
"lang_zh": "中文 (Chinese)",
|
||||||
|
"lang_fa": "فارسی (Persian)",
|
||||||
|
"backup_title": "Sauvegarde Simple",
|
||||||
|
"download_backup": "Télécharger data.json",
|
||||||
|
"restore_backup": "Restaurer depuis un fichier",
|
||||||
|
"restore_confirm": "Êtes-vous sûr ? Les données actuelles seront écrasées et l'application redémarrera.",
|
||||||
|
"restore_success": "Restauration réussie ! Redémarrage...",
|
||||||
|
"invalid_backup_file": "Fichier de sauvegarde ou structure invalide",
|
||||||
|
"config_unavailable": "Configuration indisponible",
|
||||||
|
"config_unavailable_desc": "Ce client a été créé via l'application native Amnezia.\\nLa clé privée est stockée uniquement sur l'appareil de l'utilisateur et ne peut pas être récupérée par le serveur.",
|
||||||
|
"client_public_key": "Clé publique du client :",
|
||||||
|
"management": "Gestion",
|
||||||
|
"server_management": "Gestion du serveur",
|
||||||
|
"server_management_desc": "Effectuer des actions au niveau du système sur votre machine.",
|
||||||
|
"check_server_services": "Vérifier les services du serveur",
|
||||||
|
"reboot_server": "Redémarrer le serveur",
|
||||||
|
"clear_server": "Réinitialiser le serveur",
|
||||||
|
"remove_server": "Retirer le serveur du panneau",
|
||||||
|
"telemt_desc": "Proxy Telegram basé sur MTProxy avec obfuscation avancée.",
|
||||||
|
"dns_desc": "Serveur DNS personnel pour bloquer les publicités et protéger la vie privée.",
|
||||||
|
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
|
||||||
|
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
|
||||||
|
"socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.",
|
||||||
|
"socks5_username": "Username",
|
||||||
|
"socks5_password": "Password",
|
||||||
|
"socks5_password_placeholder": "Leave empty to auto-generate",
|
||||||
|
"socks5_password_hint": "If left empty, a random 16-character password will be generated.",
|
||||||
|
"socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.",
|
||||||
|
"socks5_change_settings": "Change settings",
|
||||||
|
"socks5_settings_title": "SOCKS5 — Connection settings",
|
||||||
|
"socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.",
|
||||||
|
"socks5_settings_saved": "SOCKS5 settings saved",
|
||||||
|
"adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.",
|
||||||
|
"adguard_mode": "Installation mode",
|
||||||
|
"adguard_mode_sidebyside": "Side-by-side",
|
||||||
|
"adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)",
|
||||||
|
"adguard_mode_replace": "Replace AmneziaDNS",
|
||||||
|
"adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately",
|
||||||
|
"adguard_dns_port": "DNS port (53)",
|
||||||
|
"adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.",
|
||||||
|
"adguard_web_port": "Web UI port",
|
||||||
|
"adguard_dot_port": "DoT port",
|
||||||
|
"adguard_doh_port": "DoH port",
|
||||||
|
"adguard_expose_web": "Expose to internet",
|
||||||
|
"adguard_expose": "Expose to host",
|
||||||
|
"adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.",
|
||||||
|
"adguard_internal_ip": "Internal IP",
|
||||||
|
"adguard_web_ui": "Admin URL",
|
||||||
|
"adguard_open_ui": "Open Web UI",
|
||||||
|
"edit_server_title": "Edit server",
|
||||||
|
"edit_keep_credential": "Leave empty to keep the current value",
|
||||||
|
"server_saved": "Server settings saved",
|
||||||
|
"servers_reordered": "Servers reordered",
|
||||||
|
"ping_checking": "Checking connectivity...",
|
||||||
|
"offline": "offline",
|
||||||
|
"api_tokens_title": "API tokens",
|
||||||
|
"api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer <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.",
|
||||||
|
"revproxy_desc": "Caddy reverse proxy with a decoy website on ports 80/443. Masks VPN traffic behind a normal HTTPS site to reduce DPI detection.",
|
||||||
|
"revproxy_domain": "Domain name",
|
||||||
|
"revproxy_domain_hint": "Optional. Point DNS A-record to this server for Let's Encrypt TLS. Leave empty for a self-signed certificate.",
|
||||||
|
"revproxy_site_title": "Site title",
|
||||||
|
"revproxy_tls_email": "ACME email",
|
||||||
|
"revproxy_tls_email_hint": "Required for automatic Let's Encrypt certificates when a domain is set.",
|
||||||
|
"revproxy_backend": "VPN backend",
|
||||||
|
"revproxy_backend_none": "Decoy site only",
|
||||||
|
"revproxy_backend_port": "Backend port",
|
||||||
|
"revproxy_vpn_path": "Secret tunnel path",
|
||||||
|
"revproxy_vpn_path_hint": "Hidden path proxied to the VPN backend (e.g. /cdn-cgi/challenge).",
|
||||||
|
"revproxy_telemt_mask": "Enable Telemt site masking via Caddy",
|
||||||
|
"revproxy_install_hint": "Port 443 is used by the reverse proxy. Reinstall Xray/Telemt on an internal port (e.g. 8443) before enabling backend routing.",
|
||||||
|
"revproxy_port_hint": "Reverse proxy binds host ports 80 and 443 (HTTPS + HTTP/3).",
|
||||||
|
"revproxy_site": "Decoy site",
|
||||||
|
"revproxy_open_site": "Open site",
|
||||||
|
"revproxy_change_settings": "Settings",
|
||||||
|
"revproxy_settings_title": "Reverse Proxy Settings",
|
||||||
|
"ssl_provider_label": "Certificate source",
|
||||||
|
"ssl_provider_manual": "Manual (paths / paste)",
|
||||||
|
"ssl_provider_cloudflare": "Cloudflare (auto Let's Encrypt)",
|
||||||
|
"cf_token_label": "Cloudflare API Token",
|
||||||
|
"cf_token_hint": "Create a token with Zone:DNS:Edit and Zone:Zone:Read permissions. Used for DNS-01 validation only — port 80/443 stays free.",
|
||||||
|
"acme_email_label": "Email (Let's Encrypt notifications)",
|
||||||
|
"acme_staging_label": "Use staging (test certificates)",
|
||||||
|
"cf_issue_btn": "Issue / Renew certificate",
|
||||||
|
"cf_issue_hint": "Issues a real Let's Encrypt certificate via Cloudflare DNS without binding port 443. Restart the panel afterwards to apply.",
|
||||||
|
"cf_issuing": "Issuing...",
|
||||||
|
"cf_issued_ok": "Certificate issued successfully",
|
||||||
|
"cf_restart_required": "Restart the panel to apply the new certificate",
|
||||||
|
"cf_need_domain": "Enter a domain first",
|
||||||
|
"cf_cert_active": "Active",
|
||||||
|
"cf_cert_none": "No certificate yet",
|
||||||
|
"cf_days_left": "days left"
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
{
|
||||||
|
"nav_servers": "Серверы",
|
||||||
|
"nav_users": "Пользователи",
|
||||||
|
"nav_settings": "Настройки",
|
||||||
|
"nav_connections": "Подключения",
|
||||||
|
"nav_logout": "Выход",
|
||||||
|
"theme_dark": "Темная",
|
||||||
|
"theme_light": "Светлая",
|
||||||
|
"back_to_servers": "← Назад к серверам",
|
||||||
|
"server_check": "Проверить",
|
||||||
|
"server_checking": "Проверка...",
|
||||||
|
"protocols": "Протоколы",
|
||||||
|
"install": "Установить",
|
||||||
|
"uninstall": "Удалить",
|
||||||
|
"run": "Работает",
|
||||||
|
"stop": "Остановлен",
|
||||||
|
"not_installed": "Не установлен",
|
||||||
|
"installed": "Установлен",
|
||||||
|
"add_server": "Добавить сервер",
|
||||||
|
"no_servers": "Серверы не добавлены",
|
||||||
|
"name": "Название",
|
||||||
|
"host": "Адрес",
|
||||||
|
"port": "Порт",
|
||||||
|
"status": "Статус",
|
||||||
|
"actions": "Действия",
|
||||||
|
"edit": "Редактировать",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"copy": "Копировать",
|
||||||
|
"download": "Скачать",
|
||||||
|
"config": "Конфигурация",
|
||||||
|
"qr_code": "QR-код",
|
||||||
|
"vpn_key": "VPN-ключ",
|
||||||
|
"clients": "Клиенты",
|
||||||
|
"traffic": "Трафик",
|
||||||
|
"limit": "Limit",
|
||||||
|
"manage": "Управление",
|
||||||
|
"no_protocols": "Нет протоколов",
|
||||||
|
"add_server_desc": "Добавьте свой первый VPN сервер для начала работы. Вам понадобятся SSH-данные для подключения.",
|
||||||
|
"ssh_host": "Хост / IP",
|
||||||
|
"ssh_port": "SSH Порт",
|
||||||
|
"ssh_user": "Пользователь",
|
||||||
|
"ssh_password": "Пароль",
|
||||||
|
"ssh_key": "SSH Ключ",
|
||||||
|
"ssh_key_placeholder": "Приватный SSH ключ",
|
||||||
|
"ssh_key_hint": "Вставьте содержимое вашего файла id_rsa или id_ed25519",
|
||||||
|
"connect": "Подключить",
|
||||||
|
"connecting": "Подключение...",
|
||||||
|
"server_added": "Сервер успешно добавлен",
|
||||||
|
"server_deleted": "Сервер удалён",
|
||||||
|
"delete_confirm": "Вы уверены, что хотите удалить этот сервер?",
|
||||||
|
"success": "Успешно",
|
||||||
|
"error": "Ошибка",
|
||||||
|
"info": "Информация",
|
||||||
|
"login_title": "Вход в панель",
|
||||||
|
"username": "Логин",
|
||||||
|
"password": "Пароль",
|
||||||
|
"captcha": "Капча",
|
||||||
|
"captcha_placeholder": "Введите символы с картинки",
|
||||||
|
"captcha_hint": "Нажмите для обновления картинки",
|
||||||
|
"login": "Войти",
|
||||||
|
"logging_in": "Вход...",
|
||||||
|
"login_error": "Ошибка входа",
|
||||||
|
"check_done": "Проверка завершена",
|
||||||
|
"check_error": "Ошибка подключения",
|
||||||
|
"ssh_ok": "SSH подключение",
|
||||||
|
"docker_ok": "Docker",
|
||||||
|
"docker_not_installed": "Docker не установлен",
|
||||||
|
"awg_desc": "Новая версия протокола на основе awg-go. Поддерживает расширенную обфускацию с параметрами S3, S4.",
|
||||||
|
"awg_legacy_desc": "Оригинальная версия AWG на базе ядра WireGuard. Совместима с клиентами старых версий.",
|
||||||
|
"xray_desc": "Современный протокол с маскировкой под обычный веб-трафик (XTLS-Reality). Устойчив к глубокому анализу пакетов.",
|
||||||
|
"wireguard_desc": "Стандартный и самый быстрый VPN-протокол. Встроен во все современные ОС, но легко блокируется DPI.",
|
||||||
|
"not_checked": "Не проверено",
|
||||||
|
"connections": "Подключения",
|
||||||
|
"add": "Добавить",
|
||||||
|
"loading_connections": "Загрузка подключений...",
|
||||||
|
"no_connections": "Нет подключений",
|
||||||
|
"no_connections_desc": "Добавьте первое подключение для генерации VPN конфигурации",
|
||||||
|
"install_protocol": "Установить протокол",
|
||||||
|
"port_default_hint": "Порт по умолчанию: 55424. Убедитесь, что он не занят",
|
||||||
|
"port_xray_hint": "Порт по умолчанию: 443 (рекомендуется для Xray). Убедитесь, что он не занят другим веб-сервером.",
|
||||||
|
"reinstall": "Переустановить",
|
||||||
|
"uninstall_confirm": "Удалить {}? Все подключения и конфигурации будут потеряны.",
|
||||||
|
"stop_container_confirm": "Остановить контейнер {}?",
|
||||||
|
"start_container_confirm": "Запустить контейнер {}?",
|
||||||
|
"stopping": "Остановка",
|
||||||
|
"starting": "Запуск",
|
||||||
|
"stopped": "остановлен",
|
||||||
|
"started": "запущен",
|
||||||
|
"server_config": "Конфигурация сервера",
|
||||||
|
"add_connection": "Добавить подключение",
|
||||||
|
"connection_name": "Имя подключения",
|
||||||
|
"connection_name_placeholder": "Например: iPhone, Laptop",
|
||||||
|
"assign_to_user": "Назначить пользователю (опционально)",
|
||||||
|
"no_assign": "— Без привязки —",
|
||||||
|
"create": "Создать",
|
||||||
|
"creating": "Создание...",
|
||||||
|
"connection_created": "Подключение \"{}\" создано",
|
||||||
|
"delete_connection_confirm": "Удалить это подключение? Конфигурация перестанет работать.",
|
||||||
|
"connection_deleted": "Подключение удалено",
|
||||||
|
"config_tab": "📄 Конфиг",
|
||||||
|
"vpn_key_tab": "🔑 VPN-ключ",
|
||||||
|
"qr_code_tab": "📱 QR-код",
|
||||||
|
"copy_config": "📋 Копировать",
|
||||||
|
"download_conf": "⬇ Скачать .conf",
|
||||||
|
"copy_key": "📋 Копировать ключ",
|
||||||
|
"vpn_link_hint": "Вставьте эту ссылку в приложение AmneziaVPN для автоматической настройки",
|
||||||
|
"qr_hint": "Отсканируйте QR-код в приложении AmneziaVPN",
|
||||||
|
"no_data": "Нет данных",
|
||||||
|
"start_btn": "▶ Старт",
|
||||||
|
"stop_btn": "⏹ Стоп",
|
||||||
|
"config_btn": "⚙️ Конфиг",
|
||||||
|
"done": "Готово",
|
||||||
|
"installing": "Установка...",
|
||||||
|
"start_install": "Начинаем установку...",
|
||||||
|
"check_docker": "Проверка Docker...",
|
||||||
|
"prepare_host": "Подготовка хоста...",
|
||||||
|
"build_container": "Сборка контейнера...",
|
||||||
|
"install_success": "Протокол установлен успешно!",
|
||||||
|
"install_error": "Ошибка установки: ",
|
||||||
|
"qr_error": "Не удалось сгенерировать QR-код",
|
||||||
|
"users_title": "Пользователи",
|
||||||
|
"search_placeholder": "Поиск по имени, email или Telegram...",
|
||||||
|
"add_user": "Добавить пользователя",
|
||||||
|
"prev_page": "← Назад",
|
||||||
|
"next_page": "Вперёд →",
|
||||||
|
"page_info": "Страница {} из {}",
|
||||||
|
"loading_users": "Загрузка пользователей...",
|
||||||
|
"nothing_found": "Ничего не найдено",
|
||||||
|
"search_empty_desc": "Попробуйте изменить запрос или создайте нового пользователя",
|
||||||
|
"username_label": "Имя пользователя",
|
||||||
|
"password_label": "Пароль",
|
||||||
|
"role_label": "Роль",
|
||||||
|
"role_user_desc": "User — только просмотр своих подключений",
|
||||||
|
"role_support_desc": "Support — управление серверами",
|
||||||
|
"role_admin_desc": "Admin — полный доступ",
|
||||||
|
"tg_id_label": "Telegram ID (опц.)",
|
||||||
|
"email_label": "Email (опц.)",
|
||||||
|
"traffic_limit_label": "Лимит трафика (ГБ) (опц., 0 = ∞)",
|
||||||
|
"expiration_date_label": "Срок действия (до)",
|
||||||
|
"description_label": "Описание (опц.)",
|
||||||
|
"auto_conn_title": "Автоматическое создание подключения (опционально)",
|
||||||
|
"server_label": "Сервер",
|
||||||
|
"no_create_conn": "— Не создавать подключение —",
|
||||||
|
"protocol_label": "Протокол",
|
||||||
|
"edit_user_title": "Редактировать пользователя: {}",
|
||||||
|
"edit_user_tg": "Telegram ID (опционально)",
|
||||||
|
"edit_user_email": "Email (опционально)",
|
||||||
|
"edit_user_limit": "Лимит трафика (ГБ) (0 = ∞)",
|
||||||
|
"new_password_hint": "Новый пароль (оставьте пустым, если не нужно менять)",
|
||||||
|
"op_type_label": "Тип операции",
|
||||||
|
"create_new_conn": "Создать новое подключение",
|
||||||
|
"link_existing_conn": "Привязать существующее",
|
||||||
|
"select_existing_conn": "Выберите существующее подключение",
|
||||||
|
"existing_conn_hint": "Будут показаны только не привязанные к пользователям подключения",
|
||||||
|
"conn_name_panel": "Имя подключения (в панели)",
|
||||||
|
"user_conns_title": "Подключения пользователя",
|
||||||
|
"no_free_conns": "Нет свободных подключений",
|
||||||
|
"linking": "Привязка...",
|
||||||
|
"conn_linked": "Подключение привязано",
|
||||||
|
"enabling": "Включение",
|
||||||
|
"disabling": "Отключение",
|
||||||
|
"user_enabled": "Пользователь включён",
|
||||||
|
"user_disabled": "Пользователь отключён",
|
||||||
|
"delete_user_confirm": "Удалить пользователя? Все его подключения будут удалены.",
|
||||||
|
"share_access": "Поделиться доступом",
|
||||||
|
"enable_public_access": "Включить публичный доступ",
|
||||||
|
"share_password_label": "Пароль (опционально)",
|
||||||
|
"share_password_hint": "Если установлен, пользователь должен будет его ввести",
|
||||||
|
"public_link_label": "Публичная ссылка",
|
||||||
|
"disabled": "Отключён",
|
||||||
|
"out_of": "Из",
|
||||||
|
"settings_title": "Настройки панели",
|
||||||
|
"appearance": "🎨 Внешний вид",
|
||||||
|
"title_label": "Заголовок (Title)",
|
||||||
|
"logo_label": "Логотип (Emoji или символ)",
|
||||||
|
"subtitle_label": "Подзаголовок (Subtitle)",
|
||||||
|
"captcha_title": "🔐 Капча",
|
||||||
|
"enable_captcha": "Включить капчу (multicolorcaptcha)",
|
||||||
|
"telegram_bot_title": "🤖 Telegram Bot",
|
||||||
|
"bot_token_label": "Bot Token (от @BotFather)",
|
||||||
|
"bot_status": "Статус",
|
||||||
|
"bot_running": "✅ Запущен",
|
||||||
|
"bot_stopped": "⏹ Остановлен",
|
||||||
|
"bot_stop_btn": "⏹ Остановить",
|
||||||
|
"bot_start_btn": "▶️ Запустить",
|
||||||
|
"bot_hint": "После изменения токена сохраните настройки — бот перезапустится автоматически. Пользователи должны иметь Telegram ID в профиле.",
|
||||||
|
"api_docs_title": "🔌 API Документация",
|
||||||
|
"api_docs_hint": "Используйте эти интерфейсы для изучения возможностей API панели",
|
||||||
|
"tunnels_title": "Tunnels",
|
||||||
|
"local_server": "Local Server",
|
||||||
|
"tunnel_install_enable": "Install & Enable",
|
||||||
|
"tunnel_enable": "Enable Tunnel",
|
||||||
|
"tunnel_running": "Туннель запущен",
|
||||||
|
"tunnel_stop": "Остановить",
|
||||||
|
"tunnel_stopping": "Остановка...",
|
||||||
|
"tunnel_stopped": "Туннель остановлен",
|
||||||
|
"tunnel_delete": "Удалить",
|
||||||
|
"tunnel_deleted": "Туннель удалён",
|
||||||
|
"tunnel_delete_confirm": "Остановить и удалить бинарный файл туннеля, установленный панелью?",
|
||||||
|
"tunnel_starting": "Запуск туннеля...",
|
||||||
|
"tunnel_started": "Туннель запущен",
|
||||||
|
"tunnel_waiting_url": "Туннель запущен. Ждем публичный адрес...",
|
||||||
|
"tunnel_no_public_url": "Публичный адрес появится здесь после запуска туннеля.",
|
||||||
|
"tunnels_hint": "Быстрые туннели публикуют локальную панель в интернете. Используйте надежный пароль администратора и передавайте публичные адреса только доверенным пользователям.",
|
||||||
|
"ngrok_authtoken_placeholder": "ngrok authtoken (для некоторых аккаунтов необязательно)",
|
||||||
|
"import_users_title": "📤 Импорт пользователей",
|
||||||
|
"import_source_label": "Источник импорта / Синхронизации",
|
||||||
|
"remnawave_url_label": "Remnawave URL",
|
||||||
|
"api_key_label": "API Key",
|
||||||
|
"enable_sync": "Включить синхронизацию",
|
||||||
|
"sync_hint": "Пользователи будут создаваться, отключаться и удаляться автоматически на основе данных из Remnawave",
|
||||||
|
"sync_now_btn": "🔄 Синхронизировать сейчас",
|
||||||
|
"delete_sync_btn": "🗑 Удалить синхронизацию",
|
||||||
|
"auto_create_conns": "Создавать подключения автоматически",
|
||||||
|
"sync_server_label": "Сервер для новых подключений",
|
||||||
|
"save_changes": "💾 Сохранить изменения",
|
||||||
|
"sync_success": "Синхронизация завершена: {} пользователей",
|
||||||
|
"sync_deleted": "Удалено {} синхронизированных пользователей",
|
||||||
|
"delete_sync_confirm": "Вы уверены, что хотите удалить ВСЕХ синхронизированных пользователей и их подключения? Это действие необратимо.",
|
||||||
|
"settings_saved": "Настройки успешно сохранены",
|
||||||
|
"bot_started": "Бот запущен",
|
||||||
|
"bot_stopped_msg": "Бот остановлен",
|
||||||
|
"sync_running": "Синхронизация...",
|
||||||
|
"deleting": "Удаление...",
|
||||||
|
"my_connections_title": "Мои подключения",
|
||||||
|
"show_config": "📄 Показать конфиг",
|
||||||
|
"no_connections_user_desc": "Обратитесь к администратору для создания VPN подключения",
|
||||||
|
"share_title": "Доступ к подключениям",
|
||||||
|
"vpn_access": "VPN Доступ",
|
||||||
|
"user_label_share": "Пользователь",
|
||||||
|
"share_protected_desc": "Этот доступ защищен паролем. Пожалуйста, введите его для продолжения.",
|
||||||
|
"auth_success": "Вход выполнен",
|
||||||
|
"loading_share_conns": "Загрузка подключений...",
|
||||||
|
"no_active_conns": "У вас пока нет активных подключений.",
|
||||||
|
"show_settings_btn": "🔑 Показать настройки",
|
||||||
|
"copied": "Скопировано!",
|
||||||
|
"active": "Активен",
|
||||||
|
"site_description": "Amnezia Web Panel — управление VPN серверами и протоколами AmneziaWG",
|
||||||
|
"toggle_theme": "Сменить тему",
|
||||||
|
"copied_to_clipboard": "Скопировано в буфер обмена",
|
||||||
|
"share_not_found": "404 Не Найдено",
|
||||||
|
"share_not_found_desc": "Либо ссылка неверна, либо доступ был отключен.",
|
||||||
|
"invalid_captcha": "Неверная капча",
|
||||||
|
"account_disabled": "Аккаунт отключён",
|
||||||
|
"invalid_login": "Неверный логин или пароль",
|
||||||
|
"user_exists": "Пользователь с таким именем уже существует",
|
||||||
|
"cannot_delete_self": "Нельзя удалить самого себя",
|
||||||
|
"wrong_share_password": "Неверный пароль",
|
||||||
|
"saving": "Сохранение...",
|
||||||
|
"select_lang": "Выберите язык",
|
||||||
|
"lang_ru": "Русский",
|
||||||
|
"lang_en": "English",
|
||||||
|
"lang_fr": "Français",
|
||||||
|
"lang_zh": "中文 (Chinese)",
|
||||||
|
"lang_fa": "فارسی (Persian)",
|
||||||
|
"backup_title": "Резервное копирование",
|
||||||
|
"download_backup": "Скачать data.json",
|
||||||
|
"restore_backup": "Восстановить из файла",
|
||||||
|
"restore_confirm": "Вы уверены? Текущие данные будут перезаписаны, и приложение перезагрузится.",
|
||||||
|
"restore_success": "Восстановление успешно! Перезагрузка...",
|
||||||
|
"invalid_backup_file": "Неверный файл резервной копии или структура",
|
||||||
|
"config_unavailable": "Конфигурация недоступна",
|
||||||
|
"config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.",
|
||||||
|
"client_public_key": "Публичный ключ клиента:",
|
||||||
|
"telemt_desc": "Прокси для Telegram на базе MTProxy с продвинутой обфускацией и эмуляцией TLS.",
|
||||||
|
"tls_emulation": "TLS-эмуляция",
|
||||||
|
"tls_domain": "Домен маскировки",
|
||||||
|
"max_connections_limit": "Макс. соединений",
|
||||||
|
"max_connections_hint": "0 — без ограничений. Ограничивает одновременных пользователей.",
|
||||||
|
"traffic_reset_strategy_label": "Стратегия сброса трафика",
|
||||||
|
"traffic_reset_never": "Никогда не сбрасывать",
|
||||||
|
"traffic_reset_daily": "Сброс ежедневно",
|
||||||
|
"traffic_reset_weekly": "Сброс еженедельно",
|
||||||
|
"traffic_reset_monthly": "Сброс ежемесячно",
|
||||||
|
"traffic_reset_hint": "Как часто следует сбрасывать трафик пользователя",
|
||||||
|
"traffic_used_total": "Всего потрачено",
|
||||||
|
"traffic_reset_info": "Следующий сброс: {}",
|
||||||
|
"services": "Сервисы",
|
||||||
|
"dns_desc": "Персональный DNS-сервер для блокировки рекламы и защиты приватности ваших запросов.",
|
||||||
|
"dns_internal_hint": "Сервис будет запущен во внутренней сети (172.29.172.254) без привязки к портам хоста.",
|
||||||
|
"telemt_port_hint": "Порт для Telegram-прокси (обычно 443).",
|
||||||
|
"socks5_desc": "SOCKS5-прокси (3proxy) с одной учётной записью и аутентификацией по логину/паролю.",
|
||||||
|
"socks5_username": "Логин",
|
||||||
|
"socks5_password": "Пароль",
|
||||||
|
"socks5_password_placeholder": "Оставьте пустым для автогенерации",
|
||||||
|
"socks5_password_hint": "Если поле пустое, будет сгенерирован случайный пароль на 16 символов.",
|
||||||
|
"socks5_port_hint": "TCP-порт, на котором будет слушать прокси. В официальном клиенте Amnezia по умолчанию 38080.",
|
||||||
|
"socks5_change_settings": "Изменить настройки",
|
||||||
|
"socks5_settings_title": "SOCKS5 — Настройки подключения",
|
||||||
|
"socks5_port_change_hint": "Смена порта пересоздаёт контейнер — текущие соединения оборвутся.",
|
||||||
|
"socks5_settings_saved": "Настройки SOCKS5 сохранены",
|
||||||
|
"adguard_desc": "AdGuard Home — DNS-блокировщик рекламы с веб-интерфейсом администратора.",
|
||||||
|
"adguard_mode": "Режим установки",
|
||||||
|
"adguard_mode_sidebyside": "Рядом с AmneziaDNS",
|
||||||
|
"adguard_mode_sidebyside_hint": "поднимается параллельно AmneziaDNS на отдельном внутреннем IP (172.29.172.253)",
|
||||||
|
"adguard_mode_replace": "Заменить AmneziaDNS",
|
||||||
|
"adguard_mode_replace_hint": "удаляет контейнер amnezia-dns и забирает его IP — VPN-клиенты сразу пойдут в AdGuard",
|
||||||
|
"adguard_dns_port": "DNS-порт (53)",
|
||||||
|
"adguard_dns_port_hint": "Внутренний DNS-порт, на который ходят VPN-клиенты через docker-сеть.",
|
||||||
|
"adguard_web_port": "Порт Web UI",
|
||||||
|
"adguard_dot_port": "Порт DoT",
|
||||||
|
"adguard_doh_port": "Порт DoH",
|
||||||
|
"adguard_expose_web": "Опубликовать наружу",
|
||||||
|
"adguard_expose": "Опубликовать наружу",
|
||||||
|
"adguard_install_hint": "Первоначальная настройка AdGuard проходит через мастер на веб-интерфейсе после установки.",
|
||||||
|
"adguard_internal_ip": "Внутренний IP",
|
||||||
|
"adguard_web_ui": "Адрес панели",
|
||||||
|
"adguard_open_ui": "Открыть Web UI",
|
||||||
|
"edit_server_title": "Изменить сервер",
|
||||||
|
"edit_keep_credential": "Оставьте пустым, чтобы сохранить текущее значение",
|
||||||
|
"server_saved": "Настройки сервера сохранены",
|
||||||
|
"servers_reordered": "Порядок серверов изменён",
|
||||||
|
"ping_checking": "Проверяем соединение...",
|
||||||
|
"offline": "недоступен",
|
||||||
|
"api_tokens_title": "API-токены",
|
||||||
|
"api_tokens_hint": "Bearer-токены для внешних интеграций. Передавайте токен в заголовке `Authorization: Bearer <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.",
|
||||||
|
"revproxy_desc": "Reverse proxy на Caddy с декой-сайтом на портах 80/443. Маскирует VPN-трафик под обычный HTTPS-сайт для снижения детекции DPI.",
|
||||||
|
"revproxy_domain": "Домен",
|
||||||
|
"revproxy_domain_hint": "Необязательно. Направьте A-запись DNS на этот сервер для Let's Encrypt. Пусто — самоподписанный сертификат.",
|
||||||
|
"revproxy_site_title": "Название сайта",
|
||||||
|
"revproxy_tls_email": "Email для ACME",
|
||||||
|
"revproxy_tls_email_hint": "Нужен для автоматического получения сертификата Let's Encrypt при указании домена.",
|
||||||
|
"revproxy_backend": "VPN-бэкенд",
|
||||||
|
"revproxy_backend_none": "Только декой-сайт",
|
||||||
|
"revproxy_backend_port": "Порт бэкенда",
|
||||||
|
"revproxy_vpn_path": "Секретный путь туннеля",
|
||||||
|
"revproxy_vpn_path_hint": "Скрытый путь, проксируемый на VPN-бэкенд (напр. /cdn-cgi/challenge).",
|
||||||
|
"revproxy_telemt_mask": "Включить маскировку Telemt через Caddy",
|
||||||
|
"revproxy_install_hint": "Порт 443 занимает reverse proxy. Переустановите Xray/Telemt на внутренний порт (напр. 8443) перед маршрутизацией бэкенда.",
|
||||||
|
"revproxy_port_hint": "Reverse proxy слушает порты 80 и 443 (HTTPS + HTTP/3).",
|
||||||
|
"revproxy_site": "Декой-сайт",
|
||||||
|
"revproxy_open_site": "Открыть сайт",
|
||||||
|
"revproxy_change_settings": "Настройки",
|
||||||
|
"revproxy_settings_title": "Настройки Reverse Proxy",
|
||||||
|
"management": "Управление",
|
||||||
|
"server_management": "Управление сервером",
|
||||||
|
"server_management_desc": "Выполнение системных действий на вашей машине.",
|
||||||
|
"check_server_services": "Проверить сервисы сервера",
|
||||||
|
"reboot_server": "Перезагрузить сервер",
|
||||||
|
"clear_server": "Очистить сервер",
|
||||||
|
"remove_server": "Удалить сервер из панели",
|
||||||
|
"ssl_title": "SSL / HTTPS Настройки",
|
||||||
|
"enable_https": "Включить HTTPS",
|
||||||
|
"panel_port_label": "Порт панели",
|
||||||
|
"domain_label": "Доменное имя",
|
||||||
|
"cert_path_label": "Путь к SSL сертификату (.pem)",
|
||||||
|
"key_path_label": "Путь к приватному ключу (.pem)",
|
||||||
|
"or_paste_text": "ИЛИ вставьте текст сертификатов",
|
||||||
|
"cert_text_label": "Содержимое сертификата (CRT)",
|
||||||
|
"key_text_label": "Содержимое ключа (KEY)",
|
||||||
|
"ssl_hint": "После включения панель будет доступна по https:// на указанном домене. Убедитесь, что пути к файлам или текстовые данные верны.",
|
||||||
|
"ssl_provider_label": "Источник сертификата",
|
||||||
|
"ssl_provider_manual": "Вручную (пути / вставка)",
|
||||||
|
"ssl_provider_cloudflare": "Cloudflare (авто Let's Encrypt)",
|
||||||
|
"cf_token_label": "API-токен Cloudflare",
|
||||||
|
"cf_token_hint": "Создайте токен с правами Zone:DNS:Edit и Zone:Zone:Read для зоны вашего домена. Используется только для DNS-01 проверки — порты 80/443 остаются свободными.",
|
||||||
|
"acme_email_label": "Email (уведомления Let's Encrypt)",
|
||||||
|
"acme_staging_label": "Тестовый режим (staging-сертификаты)",
|
||||||
|
"cf_issue_btn": "Выпустить / Обновить сертификат",
|
||||||
|
"cf_issue_hint": "Выпускает настоящий сертификат Let's Encrypt через DNS Cloudflare без занятия порта 443. После выпуска перезапустите панель.",
|
||||||
|
"cf_issuing": "Выпускаем...",
|
||||||
|
"cf_issued_ok": "Сертификат успешно выпущен",
|
||||||
|
"cf_restart_required": "Перезапустите панель, чтобы применить новый сертификат",
|
||||||
|
"cf_need_domain": "Сначала укажите домен",
|
||||||
|
"cf_cert_active": "Активен",
|
||||||
|
"cf_cert_none": "Сертификат ещё не выпущен",
|
||||||
|
"cf_days_left": "дн. осталось",
|
||||||
|
"about_title": "О программе и обновления",
|
||||||
|
"current_version": "Текущая версия",
|
||||||
|
"check_updates": "Проверить обновления",
|
||||||
|
"checking_updates": "Проверка...",
|
||||||
|
"update_available": "Доступна новая версия",
|
||||||
|
"download_update": "Скачать обновление",
|
||||||
|
"up_to_date": "У вас установлена последняя версия"
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
{
|
||||||
|
"nav_servers": "服务器",
|
||||||
|
"nav_users": "用户列表",
|
||||||
|
"nav_settings": "设置",
|
||||||
|
"nav_connections": "连接管理",
|
||||||
|
"nav_logout": "登出",
|
||||||
|
"theme_dark": "深色模式",
|
||||||
|
"theme_light": "浅色模式",
|
||||||
|
"back_to_servers": "← 返回服务器列表",
|
||||||
|
"server_check": "检查状态",
|
||||||
|
"server_checking": "正在检查...",
|
||||||
|
"protocols": "协议",
|
||||||
|
"install": "安装",
|
||||||
|
"uninstall": "卸载",
|
||||||
|
"run": "运行中",
|
||||||
|
"stop": "已停止",
|
||||||
|
"not_installed": "未安装",
|
||||||
|
"installed": "已安装",
|
||||||
|
"add_server": "添加服务器",
|
||||||
|
"no_servers": "尚未添加服务器",
|
||||||
|
"name": "名称",
|
||||||
|
"host": "地址",
|
||||||
|
"port": "端口",
|
||||||
|
"status": "状态",
|
||||||
|
"actions": "操作",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"save": "保存",
|
||||||
|
"cancel": "取消",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"copy": "复制",
|
||||||
|
"download": "下载",
|
||||||
|
"config": "配置",
|
||||||
|
"qr_code": "二维码",
|
||||||
|
"vpn_key": "VPN 密钥",
|
||||||
|
"clients": "客户端",
|
||||||
|
"traffic": "流量",
|
||||||
|
"limit": "限制",
|
||||||
|
"manage": "管理",
|
||||||
|
"no_protocols": "无协议",
|
||||||
|
"add_server_desc": "添加您的第一台 VPN 服务器。需要 SSH 凭据进行连接。",
|
||||||
|
"ssh_host": "主机 / IP",
|
||||||
|
"ssh_port": "SSH 端口",
|
||||||
|
"ssh_user": "用户名",
|
||||||
|
"ssh_password": "密码",
|
||||||
|
"ssh_key": "SSH 密钥",
|
||||||
|
"ssh_key_placeholder": "私钥 (Private Key)",
|
||||||
|
"ssh_key_hint": "粘贴 id_rsa 或 id_ed25519 文件的内容",
|
||||||
|
"connect": "连接",
|
||||||
|
"connecting": "正在连接...",
|
||||||
|
"server_added": "服务器添加成功",
|
||||||
|
"server_deleted": "服务器已删除",
|
||||||
|
"delete_confirm": "确定要删除此服务器吗?",
|
||||||
|
"success": "成功",
|
||||||
|
"error": "错误",
|
||||||
|
"info": "信息",
|
||||||
|
"login_title": "登录管理面板",
|
||||||
|
"username": "用户名",
|
||||||
|
"password": "密码",
|
||||||
|
"captcha": "验证码",
|
||||||
|
"captcha_placeholder": "输入图中字符",
|
||||||
|
"captcha_hint": "点击刷新验证码",
|
||||||
|
"login": "登录",
|
||||||
|
"logging_in": "登录中...",
|
||||||
|
"login_error": "登录失败",
|
||||||
|
"check_done": "检查完成",
|
||||||
|
"check_error": "连接错误",
|
||||||
|
"ssh_ok": "SSH 连接正常",
|
||||||
|
"docker_ok": "Docker 正常",
|
||||||
|
"docker_not_installed": "Docker 未安装",
|
||||||
|
"awg_desc": "基于 awg-go 的新版协议。支持 S3, S4 高级混淆。",
|
||||||
|
"awg_legacy_desc": "原始 AWG 版本。兼容旧版客户端。",
|
||||||
|
"xray_desc": "将流量伪装成普通网页流量 (XTLS-Reality),抗封锁能力强。",
|
||||||
|
"not_checked": "未检查",
|
||||||
|
"connections": "连接",
|
||||||
|
"add": "添加",
|
||||||
|
"loading_connections": "正在加载连接...",
|
||||||
|
"no_connections": "无连接",
|
||||||
|
"no_connections_desc": "添加首个连接以生成 VPN 配置文件",
|
||||||
|
"install_protocol": "安装协议",
|
||||||
|
"port_default_hint": "默认端口: 55424。请确保端口未被占用。",
|
||||||
|
"port_xray_hint": "推荐端口: 443。请确保未被其他 Web 服务器使用。",
|
||||||
|
"reinstall": "重新安装",
|
||||||
|
"uninstall_confirm": "确定卸载 {}?所有连接和配置都将丢失。",
|
||||||
|
"stop_container_confirm": "停止容器 {}?",
|
||||||
|
"start_container_confirm": "启动容器 {}?",
|
||||||
|
"stopping": "停止中",
|
||||||
|
"starting": "启动中",
|
||||||
|
"stopped": "已停止",
|
||||||
|
"started": "已启动",
|
||||||
|
"server_config": "服务端配置",
|
||||||
|
"add_connection": "添加连接",
|
||||||
|
"connection_name": "连接名称",
|
||||||
|
"connection_name_placeholder": "例如: iPhone, 笔记本电脑",
|
||||||
|
"assign_to_user": "分配给用户 (可选)",
|
||||||
|
"no_assign": "— 不分配 —",
|
||||||
|
"create": "创建",
|
||||||
|
"creating": "创建中...",
|
||||||
|
"connection_created": "连接 \"{}\" 已创建",
|
||||||
|
"delete_connection_confirm": "确定删除此连接?配置将失效。",
|
||||||
|
"connection_deleted": "连接已删除",
|
||||||
|
"config_tab": "📄 配置文件",
|
||||||
|
"vpn_key_tab": "🔑 VPN 密钥",
|
||||||
|
"qr_code_tab": "📱 二维码",
|
||||||
|
"copy_config": "📋 复制配置",
|
||||||
|
"download_conf": "⬇ 下载 .conf 文件",
|
||||||
|
"copy_key": "📋 复制密钥",
|
||||||
|
"vpn_link_hint": "将此链接粘贴到 AmneziaVPN 应用中即可自动配置",
|
||||||
|
"qr_hint": "使用 AmneziaVPN 应用扫描二维码",
|
||||||
|
"no_data": "无数据",
|
||||||
|
"start_btn": "▶ 启动",
|
||||||
|
"stop_btn": "⏹ 停止",
|
||||||
|
"config_btn": "⚙️ 配置",
|
||||||
|
"done": "完成",
|
||||||
|
"installing": "安装中...",
|
||||||
|
"start_install": "开始安装...",
|
||||||
|
"check_docker": "检查 Docker...",
|
||||||
|
"prepare_host": "准备环境...",
|
||||||
|
"build_container": "构建容器...",
|
||||||
|
"install_success": "协议安装成功!",
|
||||||
|
"install_error": "安装失败: ",
|
||||||
|
"qr_error": "无法生成二维码",
|
||||||
|
"users_title": "用户管理",
|
||||||
|
"search_placeholder": "搜索用户名、邮箱或 Telegram ID...",
|
||||||
|
"add_user": "添加用户",
|
||||||
|
"prev_page": "← 上一页",
|
||||||
|
"next_page": "下一页 →",
|
||||||
|
"page_info": "第 {} 页,共 {} 页",
|
||||||
|
"loading_users": "正在加载用户...",
|
||||||
|
"nothing_found": "未找到匹配项",
|
||||||
|
"search_empty_desc": "请尝试更改查询条件或创建新用户",
|
||||||
|
"username_label": "用户名",
|
||||||
|
"password_label": "密码",
|
||||||
|
"role_label": "角色",
|
||||||
|
"role_user_desc": "User — 仅查看自己的连接",
|
||||||
|
"role_support_desc": "Support — 服务器管理权限",
|
||||||
|
"role_admin_desc": "Admin — 全部权限",
|
||||||
|
"tg_id_label": "Telegram ID (可选)",
|
||||||
|
"email_label": "邮箱 (可选)",
|
||||||
|
"traffic_limit_label": "流量限制 (GB) (0 = 不限制)",
|
||||||
|
"description_label": "备注 (可选)",
|
||||||
|
"auto_conn_title": "自动创建连接 (可选)",
|
||||||
|
"server_label": "服务器",
|
||||||
|
"no_create_conn": "— 不自动创建 —",
|
||||||
|
"protocol_label": "协议",
|
||||||
|
"edit_user_title": "编辑用户: {}",
|
||||||
|
"edit_user_tg": "Telegram ID (可选)",
|
||||||
|
"edit_user_email": "邮箱 (可选)",
|
||||||
|
"edit_user_limit": "流量限制 (GB) (0 = 不限制)",
|
||||||
|
"new_password_hint": "新密码 (如不修改请留空)",
|
||||||
|
"op_type_label": "操作类型",
|
||||||
|
"create_new_conn": "创建新连接",
|
||||||
|
"link_existing_conn": "关联现有连接",
|
||||||
|
"select_existing_conn": "选择现有连接",
|
||||||
|
"existing_conn_hint": "仅显示尚未关联用户的连接",
|
||||||
|
"conn_name_panel": "连接名称 (面板显示)",
|
||||||
|
"user_conns_title": "用户连接列表",
|
||||||
|
"no_free_conns": "无可用空闲连接",
|
||||||
|
"linking": "关联中...",
|
||||||
|
"conn_linked": "连接关联成功",
|
||||||
|
"enabling": "启用中",
|
||||||
|
"disabling": "禁用中",
|
||||||
|
"user_enabled": "用户已启用",
|
||||||
|
"user_disabled": "用户已禁用",
|
||||||
|
"delete_user_confirm": "确定删除该用户?其所有连接都将被删除。",
|
||||||
|
"share_access": "分享访问权限",
|
||||||
|
"enable_public_access": "启用公开访问",
|
||||||
|
"share_password_label": "密码 (可选)",
|
||||||
|
"share_password_hint": "如设置,访问者需输入密码",
|
||||||
|
"public_link_label": "公开链接",
|
||||||
|
"disabled": "已禁用",
|
||||||
|
"out_of": "/",
|
||||||
|
"settings_title": "面板设置",
|
||||||
|
"appearance": "🎨 外观设置",
|
||||||
|
"title_label": "页面标题 (Title)",
|
||||||
|
"logo_label": "图标 (Emoji 或 字符)",
|
||||||
|
"subtitle_label": "副标题 (Subtitle)",
|
||||||
|
"captcha_title": "🔐 验证码",
|
||||||
|
"enable_captcha": "启用登录验证码",
|
||||||
|
"telegram_bot_title": "🤖 Telegram 机器人",
|
||||||
|
"bot_token_label": "Bot Token (来自 @BotFather)",
|
||||||
|
"bot_status": "状态",
|
||||||
|
"bot_running": "✅ 运行中",
|
||||||
|
"bot_stopped": "⏹ 已停止",
|
||||||
|
"bot_stop_btn": "⏹ 停止",
|
||||||
|
"bot_start_btn": "▶️ 启动",
|
||||||
|
"bot_hint": "修改 Token 后请保存,机器人将自动重启。用户需在个人资料中填写 Telegram ID。",
|
||||||
|
"api_docs_title": "🔌 API 文档",
|
||||||
|
"api_docs_hint": "使用这些接口了解面板功能",
|
||||||
|
"tunnels_title": "Tunnels",
|
||||||
|
"local_server": "本地服务器",
|
||||||
|
"tunnel_install_enable": "安装并启用",
|
||||||
|
"tunnel_enable": "启用隧道",
|
||||||
|
"tunnel_running": "隧道运行中",
|
||||||
|
"tunnel_stop": "停止",
|
||||||
|
"tunnel_stopping": "正在停止...",
|
||||||
|
"tunnel_stopped": "隧道已停止",
|
||||||
|
"tunnel_delete": "删除",
|
||||||
|
"tunnel_deleted": "隧道已删除",
|
||||||
|
"tunnel_delete_confirm": "停止并删除由面板管理的隧道二进制文件?",
|
||||||
|
"tunnel_starting": "正在启动隧道...",
|
||||||
|
"tunnel_started": "隧道已启动",
|
||||||
|
"tunnel_waiting_url": "隧道已启动,正在等待公网地址...",
|
||||||
|
"tunnel_no_public_url": "隧道启动后公网地址会显示在这里。",
|
||||||
|
"tunnels_hint": "快速隧道会将本地面板暴露到互联网。请使用强管理员密码,并仅与可信用户共享公网地址。",
|
||||||
|
"ngrok_authtoken_placeholder": "ngrok authtoken(某些账号可选)",
|
||||||
|
"import_users_title": "📤 导入用户",
|
||||||
|
"import_source_label": "导入 / 同步源",
|
||||||
|
"remnawave_url_label": "Remnawave 地址",
|
||||||
|
"api_key_label": "API 密钥",
|
||||||
|
"enable_sync": "开启同步",
|
||||||
|
"sync_hint": "将根据 Remnawave 数据自动创建、禁用和删除用户",
|
||||||
|
"sync_now_btn": "🔄 立即同步",
|
||||||
|
"delete_sync_btn": "🗑 删除同步数据",
|
||||||
|
"auto_create_conns": "自动创建连接",
|
||||||
|
"sync_server_label": "新连接的服务器",
|
||||||
|
"save_changes": "💾 保存修改",
|
||||||
|
"sync_success": "同步完成: {} 个用户已更新",
|
||||||
|
"sync_deleted": "已删除 {} 个同步用户",
|
||||||
|
"delete_sync_confirm": "确定要删除所有同步用户及其连接吗?此操作不可逆。",
|
||||||
|
"settings_saved": "设置已保存",
|
||||||
|
"bot_started": "机器人已启动",
|
||||||
|
"bot_stopped_msg": "机器人已停止",
|
||||||
|
"sync_running": "同步中...",
|
||||||
|
"deleting": "删除中...",
|
||||||
|
"my_connections_title": "我的连接",
|
||||||
|
"show_config": "📄 查看配置",
|
||||||
|
"no_connections_user_desc": "请联系管理员创建 VPN 连接",
|
||||||
|
"share_title": "访问连接",
|
||||||
|
"vpn_access": "VPN 访问",
|
||||||
|
"user_label_share": "用户",
|
||||||
|
"share_protected_desc": "此访问受密码保护。请输入密码以继续。",
|
||||||
|
"auth_success": "身份验证通过",
|
||||||
|
"loading_share_conns": "正在加载连接...",
|
||||||
|
"no_active_conns": "您目前没有活跃连接。",
|
||||||
|
"show_settings_btn": "🔑 显示设置",
|
||||||
|
"copied": "已复制!",
|
||||||
|
"active": "活跃",
|
||||||
|
"site_description": "Amnezia Web Panel — VPN 服务器及 AmneziaWG 协议管理工具",
|
||||||
|
"toggle_theme": "切换主题",
|
||||||
|
"copied_to_clipboard": "已复制到剪贴板",
|
||||||
|
"share_not_found": "404 页面未找到",
|
||||||
|
"share_not_found_desc": "链接无效或访问已被禁用。",
|
||||||
|
"invalid_captcha": "验证码错误",
|
||||||
|
"account_disabled": "账户已被禁用",
|
||||||
|
"invalid_login": "用户名或密码错误",
|
||||||
|
"user_exists": "该用户名已存在",
|
||||||
|
"cannot_delete_self": "您无法删除自己的账户",
|
||||||
|
"wrong_share_password": "密码错误",
|
||||||
|
"saving": "正在保存...",
|
||||||
|
"select_lang": "选择语言",
|
||||||
|
"lang_ru": "Русский",
|
||||||
|
"lang_en": "English",
|
||||||
|
"lang_fr": "Français",
|
||||||
|
"lang_zh": "中文 (Chinese)",
|
||||||
|
"lang_fa": "فارسی (Persian)",
|
||||||
|
"backup_title": "简易备份",
|
||||||
|
"download_backup": "下载 data.json",
|
||||||
|
"restore_backup": "从文件恢复",
|
||||||
|
"restore_confirm": "您确定吗?当前数据将被覆盖,应用程序将重新启动。",
|
||||||
|
"restore_success": "恢复成功!正在重启...",
|
||||||
|
"invalid_backup_file": "备份文件无效或结构错误",
|
||||||
|
"config_unavailable": "配置文件不可用",
|
||||||
|
"config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。",
|
||||||
|
"client_public_key": "客户端公钥:",
|
||||||
|
"management": "管理",
|
||||||
|
"server_management": "服务器管理",
|
||||||
|
"server_management_desc": "在您的机器上执行系统级别的操作。",
|
||||||
|
"check_server_services": "检查服务器服务",
|
||||||
|
"reboot_server": "重启服务器",
|
||||||
|
"clear_server": "清除服务器",
|
||||||
|
"remove_server": "从面板移除服务器",
|
||||||
|
"telemt_desc": "基于 MTProxy 的 Telegram 代理,支持高级混淆。",
|
||||||
|
"dns_desc": "防止广告和保护隐私的个人 DNS 服务器。",
|
||||||
|
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
|
||||||
|
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
|
||||||
|
"socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.",
|
||||||
|
"socks5_username": "Username",
|
||||||
|
"socks5_password": "Password",
|
||||||
|
"socks5_password_placeholder": "Leave empty to auto-generate",
|
||||||
|
"socks5_password_hint": "If left empty, a random 16-character password will be generated.",
|
||||||
|
"socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.",
|
||||||
|
"socks5_change_settings": "Change settings",
|
||||||
|
"socks5_settings_title": "SOCKS5 — Connection settings",
|
||||||
|
"socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.",
|
||||||
|
"socks5_settings_saved": "SOCKS5 settings saved",
|
||||||
|
"adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.",
|
||||||
|
"adguard_mode": "Installation mode",
|
||||||
|
"adguard_mode_sidebyside": "Side-by-side",
|
||||||
|
"adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)",
|
||||||
|
"adguard_mode_replace": "Replace AmneziaDNS",
|
||||||
|
"adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately",
|
||||||
|
"adguard_dns_port": "DNS port (53)",
|
||||||
|
"adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.",
|
||||||
|
"adguard_web_port": "Web UI port",
|
||||||
|
"adguard_dot_port": "DoT port",
|
||||||
|
"adguard_doh_port": "DoH port",
|
||||||
|
"adguard_expose_web": "Expose to internet",
|
||||||
|
"adguard_expose": "Expose to host",
|
||||||
|
"adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.",
|
||||||
|
"adguard_internal_ip": "Internal IP",
|
||||||
|
"adguard_web_ui": "Admin URL",
|
||||||
|
"adguard_open_ui": "Open Web UI",
|
||||||
|
"edit_server_title": "Edit server",
|
||||||
|
"edit_keep_credential": "Leave empty to keep the current value",
|
||||||
|
"server_saved": "Server settings saved",
|
||||||
|
"servers_reordered": "Servers reordered",
|
||||||
|
"ping_checking": "Checking connectivity...",
|
||||||
|
"offline": "offline",
|
||||||
|
"api_tokens_title": "API tokens",
|
||||||
|
"api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer <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.",
|
||||||
|
"revproxy_desc": "Caddy reverse proxy with a decoy website on ports 80/443. Masks VPN traffic behind a normal HTTPS site to reduce DPI detection.",
|
||||||
|
"revproxy_domain": "Domain name",
|
||||||
|
"revproxy_domain_hint": "Optional. Point DNS A-record to this server for Let's Encrypt TLS. Leave empty for a self-signed certificate.",
|
||||||
|
"revproxy_site_title": "Site title",
|
||||||
|
"revproxy_tls_email": "ACME email",
|
||||||
|
"revproxy_tls_email_hint": "Required for automatic Let's Encrypt certificates when a domain is set.",
|
||||||
|
"revproxy_backend": "VPN backend",
|
||||||
|
"revproxy_backend_none": "Decoy site only",
|
||||||
|
"revproxy_backend_port": "Backend port",
|
||||||
|
"revproxy_vpn_path": "Secret tunnel path",
|
||||||
|
"revproxy_vpn_path_hint": "Hidden path proxied to the VPN backend (e.g. /cdn-cgi/challenge).",
|
||||||
|
"revproxy_telemt_mask": "Enable Telemt site masking via Caddy",
|
||||||
|
"revproxy_install_hint": "Port 443 is used by the reverse proxy. Reinstall Xray/Telemt on an internal port (e.g. 8443) before enabling backend routing.",
|
||||||
|
"revproxy_port_hint": "Reverse proxy binds host ports 80 and 443 (HTTPS + HTTP/3).",
|
||||||
|
"revproxy_site": "Decoy site",
|
||||||
|
"revproxy_open_site": "Open site",
|
||||||
|
"revproxy_change_settings": "Settings",
|
||||||
|
"revproxy_settings_title": "Reverse Proxy Settings",
|
||||||
|
"ssl_provider_label": "Certificate source",
|
||||||
|
"ssl_provider_manual": "Manual (paths / paste)",
|
||||||
|
"ssl_provider_cloudflare": "Cloudflare (auto Let's Encrypt)",
|
||||||
|
"cf_token_label": "Cloudflare API Token",
|
||||||
|
"cf_token_hint": "Create a token with Zone:DNS:Edit and Zone:Zone:Read permissions. Used for DNS-01 validation only — port 80/443 stays free.",
|
||||||
|
"acme_email_label": "Email (Let's Encrypt notifications)",
|
||||||
|
"acme_staging_label": "Use staging (test certificates)",
|
||||||
|
"cf_issue_btn": "Issue / Renew certificate",
|
||||||
|
"cf_issue_hint": "Issues a real Let's Encrypt certificate via Cloudflare DNS without binding port 443. Restart the panel afterwards to apply.",
|
||||||
|
"cf_issuing": "Issuing...",
|
||||||
|
"cf_issued_ok": "Certificate issued successfully",
|
||||||
|
"cf_restart_required": "Restart the panel to apply the new certificate",
|
||||||
|
"cf_need_domain": "Enter a domain first",
|
||||||
|
"cf_cert_active": "Active",
|
||||||
|
"cf_cert_none": "No certificate yet",
|
||||||
|
"cf_days_left": "days left"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
Reference in New Issue
Block a user