Add Amnezia Web Panel source with PostgreSQL 17 storage.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
# Panel
|
||||||
|
APP_PORT=5000
|
||||||
|
SECRET_KEY=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# PostgreSQL 17
|
||||||
|
POSTGRES_USER=amnezia
|
||||||
|
POSTGRES_PASSWORD=amnezia
|
||||||
|
POSTGRES_DB=amnezia_panel
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
# Used by the panel process (local run or Docker)
|
||||||
|
# Docker Compose sets this automatically to point at the db service.
|
||||||
|
DATABASE_URL=postgresql://amnezia:amnezia@localhost:5432/amnezia_panel
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
name: Build Binaries
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pyinstaller
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Build Linux
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
pyinstaller --name Amnezia-Web-Panel-Linux \
|
||||||
|
--add-data "static:static" \
|
||||||
|
--add-data "templates:templates" \
|
||||||
|
--add-data "translations:translations" \
|
||||||
|
--hidden-import uvicorn \
|
||||||
|
--hidden-import fastapi \
|
||||||
|
--clean \
|
||||||
|
-y \
|
||||||
|
-F app.py
|
||||||
|
|
||||||
|
- name: Build Windows
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
run: |
|
||||||
|
pyinstaller --name Amnezia-Web-Panel-Windows `
|
||||||
|
--add-data "static;static" `
|
||||||
|
--add-data "templates;templates" `
|
||||||
|
--add-data "translations;translations" `
|
||||||
|
--hidden-import uvicorn `
|
||||||
|
--hidden-import fastapi `
|
||||||
|
--clean `
|
||||||
|
-y `
|
||||||
|
-F app.py
|
||||||
|
|
||||||
|
- name: Build macOS
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
run: |
|
||||||
|
pyinstaller --name Amnezia-Web-Panel-macOS \
|
||||||
|
--add-data "static:static" \
|
||||||
|
--add-data "templates:templates" \
|
||||||
|
--add-data "translations:translations" \
|
||||||
|
--hidden-import uvicorn \
|
||||||
|
--hidden-import fastapi \
|
||||||
|
--clean \
|
||||||
|
-y \
|
||||||
|
-F app.py
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: binaries-${{ runner.os }}
|
||||||
|
path: dist/*
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: build
|
||||||
|
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Display structure
|
||||||
|
run: ls -R artifacts
|
||||||
|
|
||||||
|
- name: Publish Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
generate_release_notes: true
|
||||||
|
files: |
|
||||||
|
artifacts/**/*
|
||||||
|
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# PyInstaller folders
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.log
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# User data
|
||||||
|
data.json
|
||||||
|
data.json.migrated.*.bak
|
||||||
|
tunnels_state.json
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
+16
@@ -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,38 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Optional image variant with Cloudflare WARP installed inside the container.
|
||||||
|
# Use with:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.warp.yml up -d --build
|
||||||
|
|
||||||
|
FROM python:3.14-slim-bookworm
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG CLOUDFLARE_WARP_REPO_CODENAME=bookworm
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
gnupg \
|
||||||
|
iproute2 \
|
||||||
|
iputils-ping \
|
||||||
|
procps \
|
||||||
|
&& mkdir -p /usr/share/keyrings \
|
||||||
|
&& curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg \
|
||||||
|
| gpg --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg \
|
||||||
|
&& echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ ${CLOUDFLARE_WARP_REPO_CODENAME} main" \
|
||||||
|
> /etc/apt/sources.list.d/cloudflare-client.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends cloudflare-warp \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt requirements.txt
|
||||||
|
RUN pip3 install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
COPY docker-entrypoint-warp.sh /usr/local/bin/docker-entrypoint-warp.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint-warp.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["docker-entrypoint-warp.sh"]
|
||||||
|
CMD ["python3", "app.py"]
|
||||||
@@ -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,29 @@
|
|||||||
|
"""Database package for Amnezia Web Panel (PostgreSQL 17)."""
|
||||||
|
|
||||||
|
from .connection import close_pool, get_database_url, init_schema
|
||||||
|
from .store import (
|
||||||
|
clear_tunnel_state,
|
||||||
|
ensure_db_ready,
|
||||||
|
export_data_dict,
|
||||||
|
import_from_json_file,
|
||||||
|
load_data,
|
||||||
|
load_tunnel_state,
|
||||||
|
save_data,
|
||||||
|
save_tunnel_state,
|
||||||
|
update_tunnel_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'clear_tunnel_state',
|
||||||
|
'close_pool',
|
||||||
|
'ensure_db_ready',
|
||||||
|
'export_data_dict',
|
||||||
|
'get_database_url',
|
||||||
|
'import_from_json_file',
|
||||||
|
'init_schema',
|
||||||
|
'load_data',
|
||||||
|
'load_tunnel_state',
|
||||||
|
'save_data',
|
||||||
|
'save_tunnel_state',
|
||||||
|
'update_tunnel_state',
|
||||||
|
]
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""PostgreSQL connection pool for Amnezia Web Panel."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_DATABASE_URL = 'postgresql://amnezia:amnezia@localhost:5432/amnezia_panel'
|
||||||
|
|
||||||
|
_pool = None
|
||||||
|
_pool_lock = threading.Lock()
|
||||||
|
_schema_ready = False
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_url() -> str:
|
||||||
|
return os.environ.get('DATABASE_URL', DEFAULT_DATABASE_URL).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_pool():
|
||||||
|
global _pool
|
||||||
|
if _pool is not None:
|
||||||
|
return _pool
|
||||||
|
with _pool_lock:
|
||||||
|
if _pool is not None:
|
||||||
|
return _pool
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
|
url = get_database_url()
|
||||||
|
logger.info('Connecting to PostgreSQL…')
|
||||||
|
_pool = ConnectionPool(
|
||||||
|
conninfo=url,
|
||||||
|
min_size=1,
|
||||||
|
max_size=10,
|
||||||
|
kwargs={
|
||||||
|
'autocommit': False,
|
||||||
|
'row_factory': dict_row,
|
||||||
|
},
|
||||||
|
open=True,
|
||||||
|
)
|
||||||
|
return _pool
|
||||||
|
|
||||||
|
|
||||||
|
def close_pool():
|
||||||
|
global _pool, _schema_ready
|
||||||
|
with _pool_lock:
|
||||||
|
if _pool is not None:
|
||||||
|
_pool.close()
|
||||||
|
_pool = None
|
||||||
|
_schema_ready = False
|
||||||
|
|
||||||
|
|
||||||
|
def init_schema():
|
||||||
|
"""Create tables if they do not exist."""
|
||||||
|
global _schema_ready
|
||||||
|
if _schema_ready:
|
||||||
|
return
|
||||||
|
schema_path = Path(__file__).with_name('schema.sql')
|
||||||
|
sql = schema_path.read_text(encoding='utf-8')
|
||||||
|
pool = get_pool()
|
||||||
|
with pool.connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# psycopg3 executes one statement per execute()
|
||||||
|
for raw in sql.split(';'):
|
||||||
|
lines = [
|
||||||
|
ln for ln in raw.splitlines()
|
||||||
|
if ln.strip() and not ln.strip().startswith('--')
|
||||||
|
]
|
||||||
|
stmt = '\n'.join(lines).strip()
|
||||||
|
if stmt:
|
||||||
|
cur.execute(stmt)
|
||||||
|
conn.commit()
|
||||||
|
_schema_ready = True
|
||||||
|
logger.info('PostgreSQL schema ready')
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""One-shot CLI: import legacy data.json into PostgreSQL.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m db.migrate_json [path/to/data.json]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||||
|
logger = logging.getLogger('migrate_json')
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
argv = list(argv if argv is not None else sys.argv[1:])
|
||||||
|
force = '--force' in argv
|
||||||
|
paths = [a for a in argv if not a.startswith('-')]
|
||||||
|
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
app_path = os.path.dirname(sys.executable)
|
||||||
|
else:
|
||||||
|
app_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
data_file = paths[0] if paths else os.path.join(app_path, 'data.json')
|
||||||
|
|
||||||
|
from db.store import import_from_json_file, is_database_empty, load_data
|
||||||
|
|
||||||
|
if not os.path.exists(data_file):
|
||||||
|
logger.error('File not found: %s', data_file)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not is_database_empty():
|
||||||
|
logger.warning('Database is not empty — import will overwrite panel tables.')
|
||||||
|
if not force:
|
||||||
|
logger.error('Re-run with --force to overwrite existing data.')
|
||||||
|
return 2
|
||||||
|
|
||||||
|
import_from_json_file(data_file, backup=True)
|
||||||
|
data = load_data()
|
||||||
|
logger.info(
|
||||||
|
'Done. servers=%s users=%s connections=%s tokens=%s',
|
||||||
|
len(data['servers']),
|
||||||
|
len(data['users']),
|
||||||
|
len(data['user_connections']),
|
||||||
|
len(data['api_tokens']),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- Amnezia Web Panel — PostgreSQL 17 schema
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
position INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
host TEXT NOT NULL DEFAULT '',
|
||||||
|
ssh_port INTEGER NOT NULL DEFAULT 22,
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
|
password TEXT,
|
||||||
|
private_key TEXT,
|
||||||
|
server_info JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
protocols JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
telegram_id TEXT,
|
||||||
|
email TEXT,
|
||||||
|
description TEXT,
|
||||||
|
traffic_limit BIGINT NOT NULL DEFAULT 0,
|
||||||
|
traffic_used BIGINT NOT NULL DEFAULT 0,
|
||||||
|
traffic_total BIGINT NOT NULL DEFAULT 0,
|
||||||
|
traffic_reset_strategy TEXT NOT NULL DEFAULT 'never',
|
||||||
|
last_reset_at TIMESTAMPTZ,
|
||||||
|
expiration_date TIMESTAMPTZ,
|
||||||
|
remnawave_uuid TEXT,
|
||||||
|
share_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
share_token TEXT,
|
||||||
|
share_password_hash TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_connections (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
server_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
protocol TEXT NOT NULL DEFAULT '',
|
||||||
|
client_id TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
last_bytes BIGINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_connections_user_id ON user_connections(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_connections_server_id ON user_connections(server_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
token_prefix TEXT NOT NULL DEFAULT '',
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ,
|
||||||
|
last_used_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||||
|
data JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tunnel_state (
|
||||||
|
provider TEXT PRIMARY KEY,
|
||||||
|
data JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO settings (id, data) VALUES (1, '{}'::jsonb)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
+413
@@ -0,0 +1,413 @@
|
|||||||
|
"""Panel data store backed by PostgreSQL 17.
|
||||||
|
|
||||||
|
Preserves the same dict shape that used to live in data.json so existing
|
||||||
|
FastAPI handlers keep working without a full rewrite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from .connection import get_pool, init_schema
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
'appearance': {
|
||||||
|
'title': 'Amnezia',
|
||||||
|
'logo': '❤️',
|
||||||
|
'subtitle': 'Web Panel',
|
||||||
|
},
|
||||||
|
'sync': {
|
||||||
|
'remnawave_url': '',
|
||||||
|
'remnawave_api_key': '',
|
||||||
|
'remnawave_sync': False,
|
||||||
|
'remnawave_sync_users': False,
|
||||||
|
'remnawave_create_conns': False,
|
||||||
|
'remnawave_server_id': 0,
|
||||||
|
'remnawave_protocol': 'awg',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(value: Any) -> Optional[datetime]:
|
||||||
|
if value is None or value == '':
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.endswith('Z'):
|
||||||
|
text = text[:-1] + '+00:00'
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ts_iso(value: Any) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat()
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_uuid(value: Any) -> UUID:
|
||||||
|
return UUID(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_settings(raw: Optional[dict]) -> dict:
|
||||||
|
settings = json.loads(json.dumps(DEFAULT_SETTINGS))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return settings
|
||||||
|
for section, defaults in DEFAULT_SETTINGS.items():
|
||||||
|
incoming = raw.get(section)
|
||||||
|
if isinstance(incoming, dict) and isinstance(defaults, dict):
|
||||||
|
merged = dict(defaults)
|
||||||
|
merged.update(incoming)
|
||||||
|
settings[section] = merged
|
||||||
|
elif section in raw:
|
||||||
|
settings[section] = raw[section]
|
||||||
|
for key, value in raw.items():
|
||||||
|
if key not in settings:
|
||||||
|
settings[key] = value
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_server(row) -> dict:
|
||||||
|
return {
|
||||||
|
'name': row['name'] or '',
|
||||||
|
'host': row['host'] or '',
|
||||||
|
'ssh_port': int(row['ssh_port'] or 22),
|
||||||
|
'username': row['username'] or '',
|
||||||
|
'password': row['password'],
|
||||||
|
'private_key': row['private_key'],
|
||||||
|
'server_info': dict(row['server_info'] or {}),
|
||||||
|
'protocols': dict(row['protocols'] or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_user(row) -> dict:
|
||||||
|
return {
|
||||||
|
'id': str(row['id']),
|
||||||
|
'username': row['username'],
|
||||||
|
'password_hash': row['password_hash'] or '',
|
||||||
|
'role': row['role'] or 'user',
|
||||||
|
'enabled': bool(row['enabled']),
|
||||||
|
'created_at': _ts_iso(row['created_at']),
|
||||||
|
'telegramId': row['telegram_id'],
|
||||||
|
'email': row['email'],
|
||||||
|
'description': row['description'],
|
||||||
|
'traffic_limit': int(row['traffic_limit'] or 0),
|
||||||
|
'traffic_used': int(row['traffic_used'] or 0),
|
||||||
|
'traffic_total': int(row['traffic_total'] or 0),
|
||||||
|
'traffic_reset_strategy': row['traffic_reset_strategy'] or 'never',
|
||||||
|
'last_reset_at': _ts_iso(row['last_reset_at']),
|
||||||
|
'expiration_date': _ts_iso(row['expiration_date']),
|
||||||
|
'remnawave_uuid': row['remnawave_uuid'],
|
||||||
|
'share_enabled': bool(row['share_enabled']),
|
||||||
|
'share_token': row['share_token'],
|
||||||
|
'share_password_hash': row['share_password_hash'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_connection(row) -> dict:
|
||||||
|
return {
|
||||||
|
'id': str(row['id']),
|
||||||
|
'user_id': str(row['user_id']),
|
||||||
|
'server_id': int(row['server_id'] or 0),
|
||||||
|
'protocol': row['protocol'] or '',
|
||||||
|
'client_id': row['client_id'] or '',
|
||||||
|
'name': row['name'] or '',
|
||||||
|
'created_at': _ts_iso(row['created_at']),
|
||||||
|
'last_bytes': int(row['last_bytes'] or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_token(row) -> dict:
|
||||||
|
return {
|
||||||
|
'id': str(row['id']),
|
||||||
|
'name': row['name'] or '',
|
||||||
|
'token_hash': row['token_hash'] or '',
|
||||||
|
'token_prefix': row['token_prefix'] or '',
|
||||||
|
'user_id': str(row['user_id']),
|
||||||
|
'created_at': _ts_iso(row['created_at']),
|
||||||
|
'last_used_at': _ts_iso(row['last_used_at']),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_data() -> dict:
|
||||||
|
init_schema()
|
||||||
|
pool = get_pool()
|
||||||
|
with pool.connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
'SELECT position, name, host, ssh_port, username, password, '
|
||||||
|
'private_key, server_info, protocols '
|
||||||
|
'FROM servers ORDER BY position ASC'
|
||||||
|
)
|
||||||
|
servers = [_row_to_server(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
'SELECT id, username, password_hash, role, enabled, created_at, '
|
||||||
|
'telegram_id, email, description, traffic_limit, traffic_used, '
|
||||||
|
'traffic_total, traffic_reset_strategy, last_reset_at, '
|
||||||
|
'expiration_date, remnawave_uuid, share_enabled, share_token, '
|
||||||
|
'share_password_hash FROM users ORDER BY created_at NULLS LAST, username'
|
||||||
|
)
|
||||||
|
users = [_row_to_user(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
'SELECT id, user_id, server_id, protocol, client_id, name, '
|
||||||
|
'created_at, last_bytes FROM user_connections '
|
||||||
|
'ORDER BY created_at NULLS LAST, id'
|
||||||
|
)
|
||||||
|
user_connections = [_row_to_connection(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
'SELECT id, name, token_hash, token_prefix, user_id, '
|
||||||
|
'created_at, last_used_at FROM api_tokens '
|
||||||
|
'ORDER BY created_at NULLS LAST, id'
|
||||||
|
)
|
||||||
|
api_tokens = [_row_to_token(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute('SELECT data FROM settings WHERE id = 1')
|
||||||
|
settings_row = cur.fetchone()
|
||||||
|
settings = _merge_settings(settings_row['data'] if settings_row else None)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'servers': servers,
|
||||||
|
'users': users,
|
||||||
|
'user_connections': user_connections,
|
||||||
|
'api_tokens': api_tokens,
|
||||||
|
'settings': settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_data(data: dict) -> None:
|
||||||
|
"""Replace panel state in a single transaction (same semantics as rewriting data.json)."""
|
||||||
|
init_schema()
|
||||||
|
servers = data.get('servers') or []
|
||||||
|
users = data.get('users') or []
|
||||||
|
connections = data.get('user_connections') or []
|
||||||
|
tokens = data.get('api_tokens') or []
|
||||||
|
settings = _merge_settings(data.get('settings'))
|
||||||
|
|
||||||
|
# Keep only connections whose user still exists
|
||||||
|
user_ids = {str(u.get('id')) for u in users if u.get('id')}
|
||||||
|
connections = [c for c in connections if str(c.get('user_id')) in user_ids]
|
||||||
|
tokens = [t for t in tokens if str(t.get('user_id')) in user_ids]
|
||||||
|
|
||||||
|
pool = get_pool()
|
||||||
|
with pool.connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Order matters for FKs: children first on delete, parents first on insert
|
||||||
|
cur.execute('DELETE FROM api_tokens')
|
||||||
|
cur.execute('DELETE FROM user_connections')
|
||||||
|
cur.execute('DELETE FROM users')
|
||||||
|
cur.execute('DELETE FROM servers')
|
||||||
|
|
||||||
|
for pos, server in enumerate(servers):
|
||||||
|
cur.execute(
|
||||||
|
'INSERT INTO servers (position, name, host, ssh_port, username, '
|
||||||
|
'password, private_key, server_info, protocols) '
|
||||||
|
'VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)',
|
||||||
|
(
|
||||||
|
pos,
|
||||||
|
server.get('name') or '',
|
||||||
|
server.get('host') or '',
|
||||||
|
int(server.get('ssh_port') or 22),
|
||||||
|
server.get('username') or '',
|
||||||
|
server.get('password'),
|
||||||
|
server.get('private_key'),
|
||||||
|
Jsonb(server.get('server_info') or {}),
|
||||||
|
Jsonb(server.get('protocols') or {}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
cur.execute(
|
||||||
|
'INSERT INTO users ('
|
||||||
|
'id, username, password_hash, role, enabled, created_at, '
|
||||||
|
'telegram_id, email, description, traffic_limit, traffic_used, '
|
||||||
|
'traffic_total, traffic_reset_strategy, last_reset_at, '
|
||||||
|
'expiration_date, remnawave_uuid, share_enabled, share_token, '
|
||||||
|
'share_password_hash'
|
||||||
|
') VALUES ('
|
||||||
|
'%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s'
|
||||||
|
')',
|
||||||
|
(
|
||||||
|
_as_uuid(user['id']),
|
||||||
|
user.get('username') or '',
|
||||||
|
user.get('password_hash') or '',
|
||||||
|
user.get('role') or 'user',
|
||||||
|
bool(user.get('enabled', True)),
|
||||||
|
_parse_ts(user.get('created_at')),
|
||||||
|
str(user['telegramId']) if user.get('telegramId') is not None else None,
|
||||||
|
user.get('email'),
|
||||||
|
user.get('description'),
|
||||||
|
int(user.get('traffic_limit') or 0),
|
||||||
|
int(user.get('traffic_used') or 0),
|
||||||
|
int(user.get('traffic_total') or 0),
|
||||||
|
user.get('traffic_reset_strategy') or 'never',
|
||||||
|
_parse_ts(user.get('last_reset_at')),
|
||||||
|
_parse_ts(user.get('expiration_date')),
|
||||||
|
user.get('remnawave_uuid'),
|
||||||
|
bool(user.get('share_enabled', False)),
|
||||||
|
user.get('share_token'),
|
||||||
|
user.get('share_password_hash'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for conn_row in connections:
|
||||||
|
cur.execute(
|
||||||
|
'INSERT INTO user_connections ('
|
||||||
|
'id, user_id, server_id, protocol, client_id, name, created_at, last_bytes'
|
||||||
|
') VALUES (%s, %s, %s, %s, %s, %s, %s, %s)',
|
||||||
|
(
|
||||||
|
_as_uuid(conn_row['id']),
|
||||||
|
_as_uuid(conn_row['user_id']),
|
||||||
|
int(conn_row.get('server_id') or 0),
|
||||||
|
conn_row.get('protocol') or '',
|
||||||
|
conn_row.get('client_id') or '',
|
||||||
|
conn_row.get('name') or '',
|
||||||
|
_parse_ts(conn_row.get('created_at')),
|
||||||
|
int(conn_row.get('last_bytes') or 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for token in tokens:
|
||||||
|
cur.execute(
|
||||||
|
'INSERT INTO api_tokens ('
|
||||||
|
'id, name, token_hash, token_prefix, user_id, created_at, last_used_at'
|
||||||
|
') VALUES (%s, %s, %s, %s, %s, %s, %s)',
|
||||||
|
(
|
||||||
|
_as_uuid(token['id']),
|
||||||
|
token.get('name') or '',
|
||||||
|
token.get('token_hash') or '',
|
||||||
|
token.get('token_prefix') or '',
|
||||||
|
_as_uuid(token['user_id']),
|
||||||
|
_parse_ts(token.get('created_at')),
|
||||||
|
_parse_ts(token.get('last_used_at')),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
'INSERT INTO settings (id, data) VALUES (1, %s) '
|
||||||
|
'ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data',
|
||||||
|
(Jsonb(settings),),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def export_data_dict() -> dict:
|
||||||
|
"""Export current DB state as the legacy data.json document."""
|
||||||
|
return load_data()
|
||||||
|
|
||||||
|
|
||||||
|
def is_database_empty() -> bool:
|
||||||
|
init_schema()
|
||||||
|
pool = get_pool()
|
||||||
|
with pool.connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute('SELECT COUNT(*) AS n FROM users')
|
||||||
|
users = cur.fetchone()['n']
|
||||||
|
cur.execute('SELECT COUNT(*) AS n FROM servers')
|
||||||
|
servers = cur.fetchone()['n']
|
||||||
|
return users == 0 and servers == 0
|
||||||
|
|
||||||
|
|
||||||
|
def import_from_json_file(path: str | os.PathLike, *, backup: bool = True) -> bool:
|
||||||
|
"""Import legacy data.json into Postgres. Returns True if import ran."""
|
||||||
|
path = Path(path)
|
||||||
|
if not path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
with path.open('r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(f'Invalid data.json: expected object, got {type(data).__name__}')
|
||||||
|
|
||||||
|
data.setdefault('servers', [])
|
||||||
|
data.setdefault('users', [])
|
||||||
|
data.setdefault('user_connections', [])
|
||||||
|
data.setdefault('api_tokens', [])
|
||||||
|
data.setdefault('settings', {})
|
||||||
|
|
||||||
|
save_data(data)
|
||||||
|
|
||||||
|
if backup:
|
||||||
|
stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||||
|
backup_path = path.with_name(f'data.json.migrated.{stamp}.bak')
|
||||||
|
try:
|
||||||
|
shutil.copy2(path, backup_path)
|
||||||
|
logger.info('Backed up legacy data.json to %s', backup_path)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning('Could not backup data.json: %s', e)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Imported data.json → PostgreSQL (%s servers, %s users, %s connections)',
|
||||||
|
len(data.get('servers', [])),
|
||||||
|
len(data.get('users', [])),
|
||||||
|
len(data.get('user_connections', [])),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def load_tunnel_state() -> dict:
|
||||||
|
init_schema()
|
||||||
|
pool = get_pool()
|
||||||
|
with pool.connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute('SELECT provider, data FROM tunnel_state')
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return {row['provider']: dict(row['data'] or {}) for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def save_tunnel_state(state: dict) -> None:
|
||||||
|
init_schema()
|
||||||
|
pool = get_pool()
|
||||||
|
with pool.connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute('DELETE FROM tunnel_state')
|
||||||
|
for provider, payload in (state or {}).items():
|
||||||
|
cur.execute(
|
||||||
|
'INSERT INTO tunnel_state (provider, data) VALUES (%s, %s)',
|
||||||
|
(str(provider), Jsonb(payload or {})),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_tunnel_state(provider: str, **updates) -> None:
|
||||||
|
state = load_tunnel_state()
|
||||||
|
provider_state = state.get(provider, {})
|
||||||
|
provider_state.update(updates)
|
||||||
|
state[provider] = provider_state
|
||||||
|
save_tunnel_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_tunnel_state(provider: str) -> None:
|
||||||
|
state = load_tunnel_state()
|
||||||
|
if provider in state:
|
||||||
|
state.pop(provider)
|
||||||
|
save_tunnel_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_db_ready(legacy_data_file: Optional[str] = None) -> None:
|
||||||
|
"""Init schema and one-shot import from data.json when DB is empty."""
|
||||||
|
init_schema()
|
||||||
|
if legacy_data_file and is_database_empty() and os.path.exists(legacy_data_file):
|
||||||
|
logger.info('Empty database — importing legacy %s', legacy_data_file)
|
||||||
|
import_from_json_file(legacy_data_file)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Optional Docker Compose override for running Cloudflare WARP inside the panel container.
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.warp.yml up -d --build
|
||||||
|
#
|
||||||
|
# Why the extra privileges:
|
||||||
|
# WARP needs a TUN device and NET_ADMIN to create/manage its tunnel interface.
|
||||||
|
|
||||||
|
services:
|
||||||
|
amnezia_panel:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.warp
|
||||||
|
image: amnezia-panel:warp-local
|
||||||
|
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- SYS_MODULE
|
||||||
|
devices:
|
||||||
|
- /dev/net/tun:/dev/net/tun
|
||||||
|
sysctls:
|
||||||
|
net.ipv4.conf.all.src_valid_mark: "1"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- amnezia_data:/app/data
|
||||||
|
- cloudflare_warp:/var/lib/cloudflare-warp
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
cloudflare_warp:
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17
|
||||||
|
container_name: amnezia_panel_db
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-amnezia}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-amnezia}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-amnezia_panel}
|
||||||
|
volumes:
|
||||||
|
- amnezia_pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-amnezia} -d ${POSTGRES_DB:-amnezia_panel}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
amnezia_panel:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: amnezia-panel:local
|
||||||
|
container_name: amnezia_panel
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-5000}:5000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-amnezia}:${POSTGRES_PASSWORD:-amnezia}@db:5432/${POSTGRES_DB:-amnezia_panel}
|
||||||
|
SECRET_KEY: ${SECRET_KEY:-}
|
||||||
|
APP_PORT: ${APP_PORT:-5000}
|
||||||
|
volumes:
|
||||||
|
- amnezia_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python3 -c \"import socket; s=socket.socket(); s.connect(('localhost', 5000)); s.close()\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
amnezia_data:
|
||||||
|
amnezia_pgdata:
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Start Cloudflare WARP daemon inside the container when present.
|
||||||
|
# The panel controls it through warp-cli from the Settings page.
|
||||||
|
if command -v warp-svc >/dev/null 2>&1; then
|
||||||
|
mkdir -p /var/lib/cloudflare-warp
|
||||||
|
warp-svc >/tmp/warp-svc.log 2>&1 &
|
||||||
|
echo "$!" >/tmp/warp-svc.pid
|
||||||
|
|
||||||
|
# Best-effort short wait: app can still start even if WARP service needs more time.
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 10 ]; do
|
||||||
|
if warp-cli --accept-tos status >/tmp/warp-cli-status.log 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -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,264 @@
|
|||||||
|
"""
|
||||||
|
AdGuard Home Manager — runs adguard/adguardhome in a Docker container,
|
||||||
|
joined to the same internal `amnezia-dns-net` network the rest of the panel
|
||||||
|
uses. Two install modes:
|
||||||
|
|
||||||
|
* 'replace' — removes the AmneziaDNS container (if present) and takes
|
||||||
|
its static IP (172.29.172.254) so VPN clients keep using
|
||||||
|
the same upstream.
|
||||||
|
* 'sidebyside' — runs alongside AmneziaDNS on a different static IP
|
||||||
|
(172.29.172.253). VPN users can hit it on demand
|
||||||
|
(e.g. via the web admin UI from inside the tunnel).
|
||||||
|
|
||||||
|
Initial setup of AdGuard itself runs through its built-in wizard on the web
|
||||||
|
UI port — we do not try to script it (the JSON setup API is unstable across
|
||||||
|
versions and trying to drive it programmatically tends to break on upgrade).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AdguardManager:
|
||||||
|
PROTOCOL = 'adguard'
|
||||||
|
CONTAINER_NAME = 'amnezia-adguard'
|
||||||
|
IMAGE_NAME = 'adguard/adguardhome:latest'
|
||||||
|
|
||||||
|
NETWORK_NAME = 'amnezia-dns-net'
|
||||||
|
NETWORK_SUBNET = '172.29.172.0/24'
|
||||||
|
REPLACE_IP = '172.29.172.254' # AmneziaDNS's slot
|
||||||
|
SIDEBYSIDE_IP = '172.29.172.253' # parallel to AmneziaDNS
|
||||||
|
|
||||||
|
HOST_DIR = '/opt/amnezia/adguard'
|
||||||
|
DEFAULT_DNS_PORT = 53
|
||||||
|
DEFAULT_WEB_PORT = 3000
|
||||||
|
DEFAULT_DOT_PORT = 853
|
||||||
|
DEFAULT_DOH_PORT = 443
|
||||||
|
|
||||||
|
def __init__(self, ssh):
|
||||||
|
self.ssh = ssh
|
||||||
|
|
||||||
|
# ===================== STATUS =====================
|
||||||
|
|
||||||
|
def check_docker_installed(self):
|
||||||
|
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
|
||||||
|
if code != 0:
|
||||||
|
return False
|
||||||
|
out2, _, _ = self.ssh.run_command(
|
||||||
|
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
|
||||||
|
)
|
||||||
|
return 'active' in out2 or 'running' in out2.lower()
|
||||||
|
|
||||||
|
def check_protocol_installed(self, protocol_type='adguard'):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
|
||||||
|
)
|
||||||
|
return self.CONTAINER_NAME in out.strip().split('\n')
|
||||||
|
|
||||||
|
def check_container_running(self):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
|
||||||
|
)
|
||||||
|
return 'Up' in out
|
||||||
|
|
||||||
|
def _container_ip(self):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker inspect -f '{{{{range .NetworkSettings.Networks}}}}{{{{.IPAddress}}}} {{{{end}}}}' {self.CONTAINER_NAME} 2>/dev/null"
|
||||||
|
)
|
||||||
|
ip = out.strip().split()[0] if out.strip() else ''
|
||||||
|
return ip
|
||||||
|
|
||||||
|
def _detect_mode(self):
|
||||||
|
"""Return 'replace' or 'sidebyside' based on the running container's IP.
|
||||||
|
Returns None if not detectable (container not running)."""
|
||||||
|
ip = self._container_ip()
|
||||||
|
if ip == self.REPLACE_IP:
|
||||||
|
return 'replace'
|
||||||
|
if ip == self.SIDEBYSIDE_IP:
|
||||||
|
return 'sidebyside'
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _container_web_port(self):
|
||||||
|
"""Read the AdGuard HTTP address configured inside the container.
|
||||||
|
During the first-run wizard AdGuard writes web.listen_addresses to
|
||||||
|
AdGuardHome.yaml. If the wizard has not been completed yet, the
|
||||||
|
container is started with --web-addr 0.0.0.0:<port> and this method can
|
||||||
|
read that port from docker inspect."""
|
||||||
|
grep_cmd = "grep -E '^[[:space:]]*-?[[:space:]]*[0-9.]+:[0-9]+$' /opt/adguardhome/conf/AdGuardHome.yaml 2>/dev/null | head -n1"
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f'docker exec {self.CONTAINER_NAME} sh -c "{grep_cmd}"'
|
||||||
|
)
|
||||||
|
line = out.strip().split('\n')[0].strip().lstrip('-').strip() if out.strip() else ''
|
||||||
|
if ':' in line:
|
||||||
|
try:
|
||||||
|
return int(line.rsplit(':', 1)[1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker inspect -f '{{{{json .Config.Cmd}}}}' {self.CONTAINER_NAME} 2>/dev/null"
|
||||||
|
)
|
||||||
|
marker = '--web-addr'
|
||||||
|
if marker in out:
|
||||||
|
parts = out.replace('[', ' ').replace(']', ' ').replace(',', ' ').replace('\"', ' ').split()
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if part == marker and i + 1 < len(parts) and ':' in parts[i + 1]:
|
||||||
|
try:
|
||||||
|
return int(parts[i + 1].rsplit(':', 1)[1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _exposed_web_port(self, container_port=None):
|
||||||
|
"""Reads back the host->container port mapping for the web UI port,
|
||||||
|
so the panel can show the correct admin URL after install."""
|
||||||
|
container_port = int(container_port or self.DEFAULT_WEB_PORT)
|
||||||
|
for port in (container_port, self.DEFAULT_WEB_PORT):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker port {self.CONTAINER_NAME} {port}/tcp 2>/dev/null"
|
||||||
|
)
|
||||||
|
if not out.strip():
|
||||||
|
continue
|
||||||
|
# output like "0.0.0.0:3000" — take the last colon-separated chunk
|
||||||
|
last = out.strip().split('\n')[0].split(':')[-1].strip()
|
||||||
|
try:
|
||||||
|
return int(last)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_server_status(self, protocol_type='adguard'):
|
||||||
|
exists = self.check_protocol_installed()
|
||||||
|
running = self.check_container_running()
|
||||||
|
mode = self._detect_mode() if running else None
|
||||||
|
ip = self._container_ip() if running else ''
|
||||||
|
container_web_port = self._container_web_port() if running else None
|
||||||
|
exposed_port = self._exposed_web_port(container_web_port) if running else None
|
||||||
|
# When the web UI is not bound to the host the user still needs an
|
||||||
|
# admin URL (reachable via VPN) — use the actual container web port
|
||||||
|
# when known, otherwise fall back to AdGuard's default :3000.
|
||||||
|
return {
|
||||||
|
'container_exists': exists,
|
||||||
|
'container_running': running,
|
||||||
|
'mode': mode,
|
||||||
|
'internal_ip': ip,
|
||||||
|
'web_port': exposed_port or container_web_port or self.DEFAULT_WEB_PORT,
|
||||||
|
'web_exposed': exposed_port is not None,
|
||||||
|
'port': self.DEFAULT_DNS_PORT,
|
||||||
|
'protocol': protocol_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===================== INSTALL / REMOVE =====================
|
||||||
|
|
||||||
|
def _ensure_network(self):
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker network ls | grep -q {self.NETWORK_NAME} || "
|
||||||
|
f"docker network create --subnet {self.NETWORK_SUBNET} {self.NETWORK_NAME}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def install_protocol(
|
||||||
|
self,
|
||||||
|
protocol_type='adguard',
|
||||||
|
mode='sidebyside',
|
||||||
|
web_port=None,
|
||||||
|
expose_web=False,
|
||||||
|
dns_port=None,
|
||||||
|
dot_port=None,
|
||||||
|
doh_port=None,
|
||||||
|
expose_dns=False,
|
||||||
|
expose_dot=False,
|
||||||
|
expose_doh=False,
|
||||||
|
):
|
||||||
|
if not self.check_docker_installed():
|
||||||
|
return {'status': 'error', 'message': 'Docker not installed'}
|
||||||
|
if mode not in ('replace', 'sidebyside'):
|
||||||
|
return {'status': 'error', 'message': f"Invalid mode '{mode}'"}
|
||||||
|
|
||||||
|
web_port = int(web_port or self.DEFAULT_WEB_PORT)
|
||||||
|
dns_port = int(dns_port or self.DEFAULT_DNS_PORT)
|
||||||
|
dot_port = int(dot_port or self.DEFAULT_DOT_PORT)
|
||||||
|
doh_port = int(doh_port or self.DEFAULT_DOH_PORT)
|
||||||
|
|
||||||
|
# Persistent volumes — without these the AdGuard setup wizard would have
|
||||||
|
# to re-run on every container recreate.
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {self.HOST_DIR}/work {self.HOST_DIR}/conf")
|
||||||
|
|
||||||
|
self._ensure_network()
|
||||||
|
|
||||||
|
# Replace mode: detach + remove AmneziaDNS so we can claim its IP.
|
||||||
|
if mode == 'replace':
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker network disconnect {self.NETWORK_NAME} amnezia-dns 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
self.ssh.run_sudo_command("docker stop amnezia-dns 2>/dev/null || true")
|
||||||
|
self.ssh.run_sudo_command("docker rm -fv amnezia-dns 2>/dev/null || true")
|
||||||
|
target_ip = self.REPLACE_IP
|
||||||
|
else:
|
||||||
|
target_ip = self.SIDEBYSIDE_IP
|
||||||
|
|
||||||
|
if self.check_protocol_installed():
|
||||||
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} 2>/dev/null || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} 2>/dev/null || true")
|
||||||
|
|
||||||
|
self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}")
|
||||||
|
|
||||||
|
# Build port mapping. By default ports are reachable only inside
|
||||||
|
# `amnezia-dns-net` (so VPN clients hit them via the static IP).
|
||||||
|
# Optional `expose_*` flags add host port mappings for direct access.
|
||||||
|
ports = []
|
||||||
|
if expose_web:
|
||||||
|
ports.append(f"-p {web_port}:{web_port}/tcp")
|
||||||
|
if expose_dns:
|
||||||
|
ports.append(f"-p {dns_port}:53/tcp")
|
||||||
|
ports.append(f"-p {dns_port}:53/udp")
|
||||||
|
if expose_dot:
|
||||||
|
ports.append(f"-p {dot_port}:853/tcp")
|
||||||
|
if expose_doh:
|
||||||
|
ports.append(f"-p {doh_port}:443/tcp")
|
||||||
|
ports_str = ' '.join(ports)
|
||||||
|
|
||||||
|
run_cmd = (
|
||||||
|
f"docker run -d --name {self.CONTAINER_NAME} --restart always "
|
||||||
|
f"--network {self.NETWORK_NAME} --ip {target_ip} "
|
||||||
|
f"-v {self.HOST_DIR}/work:/opt/adguardhome/work "
|
||||||
|
f"-v {self.HOST_DIR}/conf:/opt/adguardhome/conf "
|
||||||
|
f"{ports_str} "
|
||||||
|
f"{self.IMAGE_NAME} --web-addr 0.0.0.0:{web_port}"
|
||||||
|
)
|
||||||
|
_, err, code = self.ssh.run_sudo_command(run_cmd)
|
||||||
|
if code != 0:
|
||||||
|
return {'status': 'error', 'message': f'Failed to start container: {err}'}
|
||||||
|
|
||||||
|
# Re-attach known VPN containers to the DNS network so they can reach
|
||||||
|
# AdGuard at target_ip (mirrors what dns_manager.py does on install).
|
||||||
|
for c in ('amnezia-awg', 'amnezia-awg2', 'amnezia-awg-legacy', 'amnezia-xray', 'amnezia-wireguard', 'telemt'):
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker ps --format '{{{{.Names}}}}' | grep -q '^{c}$' && "
|
||||||
|
f"docker network connect {self.NETWORK_NAME} {c} 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
|
||||||
|
url_host = self.ssh.host if expose_web else target_ip
|
||||||
|
admin_url = f"http://{url_host}:{web_port}"
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'protocol': 'adguard',
|
||||||
|
'mode': mode,
|
||||||
|
'internal_ip': target_ip,
|
||||||
|
'web_port': web_port,
|
||||||
|
'expose_web': bool(expose_web),
|
||||||
|
'admin_url': admin_url,
|
||||||
|
'message': 'AdGuard Home installed. Complete the setup wizard via the web UI.',
|
||||||
|
'log': [
|
||||||
|
f"AdGuard Home installed in '{mode}' mode",
|
||||||
|
f"Internal IP: {target_ip}",
|
||||||
|
f"Admin UI: {admin_url}" + ("" if expose_web else " (VPN-only — connect via VPN to reach it)"),
|
||||||
|
'Open the URL above to run the AdGuard setup wizard.',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def remove_container(self, protocol_type='adguard'):
|
||||||
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"rm -rf {self.HOST_DIR}")
|
||||||
|
return True
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
|
||||||
|
class BackupManager:
|
||||||
|
"""Create/list downloadable protocol backups on the remote server.
|
||||||
|
|
||||||
|
Backups are intentionally allowlisted per protocol and include only files
|
||||||
|
needed to recreate protocol state: host config directories, matching
|
||||||
|
container config directories when present, and docker inspect metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BACKUP_ROOT = '/opt/amnezia/backups'
|
||||||
|
|
||||||
|
def __init__(self, ssh_manager):
|
||||||
|
self.ssh = ssh_manager
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def proto_base(protocol):
|
||||||
|
return str(protocol or '').split('__', 1)[0]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def proto_instance(protocol):
|
||||||
|
match = re.search(r'__(\d+)$', str(protocol or ''))
|
||||||
|
return int(match.group(1)) if match else 1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def safe_protocol(protocol):
|
||||||
|
safe = re.sub(r'[^a-zA-Z0-9_.-]+', '_', str(protocol or '').replace('__', '-'))
|
||||||
|
return safe.strip('._-') or 'protocol'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def safe_filename(filename):
|
||||||
|
name = str(filename or '')
|
||||||
|
if not re.fullmatch(r'[A-Za-z0-9_.-]+\.tar\.gz', name):
|
||||||
|
return None
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _paths_for(self, protocol, container_name):
|
||||||
|
base = self.proto_base(protocol)
|
||||||
|
idx = self.proto_instance(protocol)
|
||||||
|
paths = {
|
||||||
|
'host': [],
|
||||||
|
'container': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def inst_path(path, suffix_fmt='-{idx}'):
|
||||||
|
return path if idx <= 1 else f"{path}{suffix_fmt.format(idx=idx)}"
|
||||||
|
|
||||||
|
if base in ('awg', 'awg2', 'awg_legacy'):
|
||||||
|
paths['host'] = ['/opt/amnezia/awg', f'/opt/amnezia/{container_name}']
|
||||||
|
paths['container'] = ['/opt/amnezia/awg', '/opt/amnezia/start.sh']
|
||||||
|
elif base == 'wireguard':
|
||||||
|
paths['host'] = ['/opt/amnezia/wireguard', f'/opt/amnezia/{container_name}']
|
||||||
|
paths['container'] = ['/opt/amnezia/wireguard', '/opt/amnezia/start.sh']
|
||||||
|
elif base == 'xray':
|
||||||
|
config_dir = inst_path('/opt/amnezia/xray')
|
||||||
|
paths['host'] = [config_dir, f'/opt/amnezia/{container_name}']
|
||||||
|
paths['container'] = [config_dir]
|
||||||
|
elif base == 'telemt':
|
||||||
|
remote_dir = inst_path('/opt/amnezia/telemt')
|
||||||
|
paths['host'] = [remote_dir]
|
||||||
|
paths['container'] = [remote_dir]
|
||||||
|
elif base == 'dns':
|
||||||
|
paths['host'] = ['/opt/amnezia/dns']
|
||||||
|
paths['container'] = ['/opt/amnezia/dns']
|
||||||
|
elif base == 'adguard':
|
||||||
|
paths['host'] = ['/opt/amnezia/adguard']
|
||||||
|
paths['container'] = ['/opt/adguardhome/conf', '/opt/adguardhome/work']
|
||||||
|
elif base == 'socks5':
|
||||||
|
config_dir = inst_path('/opt/amnezia/socks5proxy')
|
||||||
|
paths['host'] = [config_dir]
|
||||||
|
paths['container'] = ['/etc/3proxy']
|
||||||
|
elif base == 'nginx':
|
||||||
|
paths['host'] = ['/opt/amnezia/nginx']
|
||||||
|
paths['container'] = ['/etc/nginx/conf.d', '/usr/share/nginx/html']
|
||||||
|
else:
|
||||||
|
paths['host'] = [f'/opt/amnezia/{base}']
|
||||||
|
paths['container'] = [f'/opt/amnezia/{base}']
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
def list_backups(self, protocol):
|
||||||
|
safe_proto = self.safe_protocol(protocol)
|
||||||
|
backup_dir = f'{self.BACKUP_ROOT}/{safe_proto}'
|
||||||
|
cmd = (
|
||||||
|
f"mkdir -p {shlex.quote(backup_dir)} && "
|
||||||
|
f"find {shlex.quote(backup_dir)} -maxdepth 1 -type f -name '*.tar.gz' "
|
||||||
|
"-printf '%f|%s|%T@\\n' 2>/dev/null | sort -t '|' -k3,3nr"
|
||||||
|
)
|
||||||
|
out, err, code = self.ssh.run_sudo_command(cmd)
|
||||||
|
if code != 0:
|
||||||
|
return {'status': 'error', 'message': err or out or 'Failed to list backups'}
|
||||||
|
backups = []
|
||||||
|
for line in (out or '').splitlines():
|
||||||
|
parts = line.split('|')
|
||||||
|
if len(parts) != 3:
|
||||||
|
continue
|
||||||
|
name, size, mtime = parts
|
||||||
|
backups.append({
|
||||||
|
'name': name,
|
||||||
|
'size': int(float(size or 0)),
|
||||||
|
'mtime': float(mtime or 0),
|
||||||
|
})
|
||||||
|
return {'status': 'success', 'protocol': protocol, 'backups': backups}
|
||||||
|
|
||||||
|
def create_backup(self, protocol, container_name):
|
||||||
|
safe_proto = self.safe_protocol(protocol)
|
||||||
|
backup_dir = f'{self.BACKUP_ROOT}/{safe_proto}'
|
||||||
|
paths = self._paths_for(protocol, container_name)
|
||||||
|
host_paths = ' '.join(shlex.quote(p) for p in paths['host'])
|
||||||
|
container_paths = ' '.join(shlex.quote(p) for p in paths['container'])
|
||||||
|
protocol_q = shlex.quote(str(protocol))
|
||||||
|
safe_proto_q = shlex.quote(safe_proto)
|
||||||
|
container_q = shlex.quote(str(container_name or ''))
|
||||||
|
backup_dir_q = shlex.quote(backup_dir)
|
||||||
|
|
||||||
|
script = f"""
|
||||||
|
set -eu
|
||||||
|
umask 077
|
||||||
|
protocol={protocol_q}
|
||||||
|
safe_proto={safe_proto_q}
|
||||||
|
container={container_q}
|
||||||
|
backup_dir={backup_dir_q}
|
||||||
|
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
|
work_dir=$(mktemp -d /tmp/amnezia-backup-${{safe_proto}}.XXXXXX)
|
||||||
|
cleanup() {{ rm -rf "$work_dir"; }}
|
||||||
|
trap cleanup EXIT
|
||||||
|
mkdir -p "$backup_dir" "$work_dir/host" "$work_dir/container" "$work_dir/docker"
|
||||||
|
cat > "$work_dir/backup-info.json" <<EOF
|
||||||
|
{{
|
||||||
|
"protocol": "$protocol",
|
||||||
|
"container": "$container",
|
||||||
|
"created_at_utc": "$timestamp",
|
||||||
|
"backup_format": "amnezia-web-panel-protocol-v1"
|
||||||
|
}}
|
||||||
|
EOF
|
||||||
|
copy_host_path() {{
|
||||||
|
src="$1"
|
||||||
|
if [ -e "$src" ]; then
|
||||||
|
mkdir -p "$work_dir/host$(dirname "$src")"
|
||||||
|
cp -a "$src" "$work_dir/host$src"
|
||||||
|
fi
|
||||||
|
}}
|
||||||
|
copy_container_path() {{
|
||||||
|
src="$1"
|
||||||
|
if [ -n "$container" ] && docker inspect "$container" >/dev/null 2>&1; then
|
||||||
|
mkdir -p "$work_dir/container$(dirname "$src")"
|
||||||
|
docker cp "$container:$src" "$work_dir/container$src" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
}}
|
||||||
|
for p in {host_paths}; do copy_host_path "$p"; done
|
||||||
|
for p in {container_paths}; do copy_container_path "$p"; done
|
||||||
|
if [ -n "$container" ] && docker inspect "$container" >/dev/null 2>&1; then
|
||||||
|
docker inspect "$container" > "$work_dir/docker/inspect.json" 2>/dev/null || true
|
||||||
|
docker logs --tail 300 "$container" > "$work_dir/docker/logs-tail.txt" 2>&1 || true
|
||||||
|
fi
|
||||||
|
archive="$backup_dir/${{safe_proto}}-${{timestamp}}.tar.gz"
|
||||||
|
tar -C "$work_dir" -czf "$archive" .
|
||||||
|
chmod 0644 "$archive"
|
||||||
|
printf '%s\n' "$archive"
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
out, err, code = self.ssh.run_sudo_command(script)
|
||||||
|
if code != 0:
|
||||||
|
return {'status': 'error', 'message': err or out or 'Failed to create backup'}
|
||||||
|
path = (out or '').strip().splitlines()[-1] if (out or '').strip() else ''
|
||||||
|
name = path.rsplit('/', 1)[-1] if path else ''
|
||||||
|
return {'status': 'success', 'protocol': protocol, 'backup': {'name': name, 'path': path}}
|
||||||
@@ -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,383 @@
|
|||||||
|
"""
|
||||||
|
NGINX Web Server Manager.
|
||||||
|
|
||||||
|
Runs an NGINX container with a single editable site (index.html) and a
|
||||||
|
Let's Encrypt certificate issued through the webroot HTTP-01 challenge.
|
||||||
|
The panel owns:
|
||||||
|
- nginx.conf: editable through the Config button
|
||||||
|
- index.html: editable through the Site button
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _q(value):
|
||||||
|
return shlex.quote(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
class NginxManager:
|
||||||
|
PROTOCOL = 'nginx'
|
||||||
|
CONTAINER_NAME = 'amnezia-nginx'
|
||||||
|
IMAGE_NAME = 'nginx:alpine'
|
||||||
|
CERTBOT_IMAGE = 'certbot/certbot:latest'
|
||||||
|
CERTBOT_CONTAINER_NAME = 'amnezia-nginx-certbot'
|
||||||
|
BASE_DIR = '/opt/amnezia/nginx'
|
||||||
|
CONF_DIR = f'{BASE_DIR}/conf'
|
||||||
|
HTML_DIR = f'{BASE_DIR}/html'
|
||||||
|
CERTBOT_WWW_DIR = f'{BASE_DIR}/certbot/www'
|
||||||
|
LETSENCRYPT_DIR = f'{BASE_DIR}/letsencrypt'
|
||||||
|
META_PATH = f'{BASE_DIR}/metadata.json'
|
||||||
|
NGINX_CONF_PATH = f'{CONF_DIR}/default.conf'
|
||||||
|
INDEX_PATH = f'{HTML_DIR}/index.html'
|
||||||
|
DEFAULT_PORT = 443
|
||||||
|
|
||||||
|
def __init__(self, ssh, protocol='nginx'):
|
||||||
|
self.ssh = ssh
|
||||||
|
self.protocol = protocol or self.PROTOCOL
|
||||||
|
|
||||||
|
# ===================== STATUS =====================
|
||||||
|
|
||||||
|
def check_docker_installed(self):
|
||||||
|
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
|
||||||
|
if code != 0:
|
||||||
|
return False
|
||||||
|
out2, _, _ = self.ssh.run_command(
|
||||||
|
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
|
||||||
|
)
|
||||||
|
return 'active' in out2 or 'running' in out2.lower()
|
||||||
|
|
||||||
|
def check_protocol_installed(self, protocol_type='nginx'):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
|
||||||
|
)
|
||||||
|
return self.CONTAINER_NAME in out.strip().split('\n')
|
||||||
|
|
||||||
|
def check_container_running(self, protocol_type='nginx'):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
|
||||||
|
)
|
||||||
|
return 'Up' in out
|
||||||
|
|
||||||
|
def check_certbot_running(self):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps --filter name=^{self.CERTBOT_CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
|
||||||
|
)
|
||||||
|
return 'Up' in out
|
||||||
|
|
||||||
|
def get_server_status(self, protocol_type='nginx'):
|
||||||
|
exists = self.check_protocol_installed(protocol_type)
|
||||||
|
running = self.check_container_running(protocol_type)
|
||||||
|
meta = self._read_metadata() if exists else {}
|
||||||
|
config = self._get_server_config(protocol_type) if exists else ''
|
||||||
|
domain = meta.get('domain') or self._parse_domain(config)
|
||||||
|
port = int(meta.get('port') or self.DEFAULT_PORT)
|
||||||
|
return {
|
||||||
|
'container_exists': exists,
|
||||||
|
'container_running': running,
|
||||||
|
'port': port,
|
||||||
|
'domain': domain,
|
||||||
|
'email': meta.get('email'),
|
||||||
|
'site_url': self._site_url(domain, port) if domain else None,
|
||||||
|
'protocol': protocol_type,
|
||||||
|
'base_protocol': self.PROTOCOL,
|
||||||
|
'instance': 1,
|
||||||
|
'container_name': self.CONTAINER_NAME,
|
||||||
|
'certbot_container_name': self.CERTBOT_CONTAINER_NAME,
|
||||||
|
'certbot_running': self.check_certbot_running() if exists else False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===================== CONFIG BUILDERS =====================
|
||||||
|
|
||||||
|
def _validate_domain(self, domain):
|
||||||
|
domain = (domain or '').strip().lower()
|
||||||
|
if not domain or len(domain) > 253:
|
||||||
|
raise ValueError('Domain is required')
|
||||||
|
if not re.match(r'^(?!-)[a-z0-9.-]+(?<!-)$', domain) or '.' not in domain:
|
||||||
|
raise ValueError('Invalid domain name')
|
||||||
|
return domain
|
||||||
|
|
||||||
|
def _validate_email(self, email):
|
||||||
|
email = (email or '').strip()
|
||||||
|
if not email or '@' not in email:
|
||||||
|
raise ValueError('Valid Let\'s Encrypt email is required')
|
||||||
|
return email
|
||||||
|
|
||||||
|
def _site_url(self, domain, port):
|
||||||
|
if not domain:
|
||||||
|
return None
|
||||||
|
port = int(port or self.DEFAULT_PORT)
|
||||||
|
return f'https://{domain}' if port == 443 else f'https://{domain}:{port}'
|
||||||
|
|
||||||
|
def _build_initial_config(self, domain):
|
||||||
|
return f"""server {{
|
||||||
|
listen 80;
|
||||||
|
server_name {domain};
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {{
|
||||||
|
root /var/www/certbot;
|
||||||
|
}}
|
||||||
|
|
||||||
|
location / {{
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _build_ssl_config(self, domain, port):
|
||||||
|
redirect_target = 'https://$host$request_uri' if int(port) == 443 else f'https://$host:{int(port)}$request_uri'
|
||||||
|
return f"""server {{
|
||||||
|
listen 80;
|
||||||
|
server_name {domain};
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {{
|
||||||
|
root /var/www/certbot;
|
||||||
|
}}
|
||||||
|
|
||||||
|
location / {{
|
||||||
|
return 301 {redirect_target};
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
server {{
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name {domain};
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/{domain}/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/{domain}/privkey.pem;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {{
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _default_index(self, domain):
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<html lang=\"en\">
|
||||||
|
<head>
|
||||||
|
<meta charset=\"utf-8\">
|
||||||
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
|
||||||
|
<title>{domain}</title>
|
||||||
|
<style>
|
||||||
|
body {{ margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: system-ui, -apple-system, Segoe UI, sans-serif; background: #0f172a; color: #e2e8f0; }}
|
||||||
|
main {{ text-align: center; padding: 32px; }}
|
||||||
|
h1 {{ font-size: clamp(2rem, 6vw, 4rem); margin: 0 0 12px; }}
|
||||||
|
p {{ color: #94a3b8; font-size: 1.1rem; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>NGINX is running</h1>
|
||||||
|
<p>{domain}</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _parse_domain(self, config_text):
|
||||||
|
m = re.search(r'^\s*server_name\s+([^;\s]+)', config_text or '', re.MULTILINE)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
# ===================== FILE I/O =====================
|
||||||
|
|
||||||
|
def _read_file(self, path):
|
||||||
|
out, _, code = self.ssh.run_sudo_command(f"cat {_q(path)} 2>/dev/null")
|
||||||
|
return out if code == 0 else ''
|
||||||
|
|
||||||
|
def _write_file(self, path, content):
|
||||||
|
parent = path.rsplit('/', 1)[0]
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {_q(parent)}")
|
||||||
|
self.ssh.upload_file_sudo(content, path)
|
||||||
|
|
||||||
|
def _read_metadata(self):
|
||||||
|
raw = self._read_file(self.META_PATH)
|
||||||
|
if not raw.strip():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _write_metadata(self, meta):
|
||||||
|
self._write_file(self.META_PATH, json.dumps(meta, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
def _get_server_config(self, protocol_type='nginx'):
|
||||||
|
config = self._read_file(self.NGINX_CONF_PATH)
|
||||||
|
if config:
|
||||||
|
return config
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {self.CONTAINER_NAME} cat /etc/nginx/conf.d/default.conf 2>/dev/null"
|
||||||
|
)
|
||||||
|
return out if code == 0 else ''
|
||||||
|
|
||||||
|
def save_server_config(self, protocol_type='nginx', config_text=''):
|
||||||
|
old_config = self._get_server_config(protocol_type)
|
||||||
|
self._write_file(self.NGINX_CONF_PATH, config_text or '')
|
||||||
|
if self.check_container_running(protocol_type):
|
||||||
|
_, err, code = self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -t", timeout=30)
|
||||||
|
if code != 0:
|
||||||
|
self._write_file(self.NGINX_CONF_PATH, old_config)
|
||||||
|
self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -s reload 2>/dev/null || true")
|
||||||
|
raise RuntimeError(f'Invalid NGINX config: {err}')
|
||||||
|
self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -s reload")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_site_index(self, protocol_type='nginx'):
|
||||||
|
return self._read_file(self.INDEX_PATH)
|
||||||
|
|
||||||
|
def save_site_index(self, protocol_type='nginx', html=''):
|
||||||
|
self._write_file(self.INDEX_PATH, html or '')
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ===================== INSTALL / REMOVE =====================
|
||||||
|
|
||||||
|
def _run_nginx_container(self, port):
|
||||||
|
run_cmd = (
|
||||||
|
f"docker run -d --restart always "
|
||||||
|
f"--name {self.CONTAINER_NAME} "
|
||||||
|
f"-p 80:80/tcp "
|
||||||
|
f"-p {int(port)}:443/tcp "
|
||||||
|
f"-v {self.CONF_DIR}:/etc/nginx/conf.d:ro "
|
||||||
|
f"-v {self.HTML_DIR}:/usr/share/nginx/html:ro "
|
||||||
|
f"-v {self.CERTBOT_WWW_DIR}:/var/www/certbot:ro "
|
||||||
|
f"-v {self.LETSENCRYPT_DIR}:/etc/letsencrypt:ro "
|
||||||
|
f"{self.IMAGE_NAME}"
|
||||||
|
)
|
||||||
|
return self.ssh.run_sudo_command(run_cmd, timeout=60)
|
||||||
|
|
||||||
|
def _run_certbot_renewer(self, domain, email):
|
||||||
|
loop_script = f"""
|
||||||
|
set -eu
|
||||||
|
if [ ! -f /etc/letsencrypt/live/{domain}/fullchain.pem ]; then
|
||||||
|
certbot certonly --webroot --webroot-path /var/www/certbot --email {_q(email)} --agree-tos --no-eff-email -d {_q(domain)}
|
||||||
|
fi
|
||||||
|
kill -HUP 1 2>/dev/null || true
|
||||||
|
while :; do
|
||||||
|
certbot renew --webroot --webroot-path /var/www/certbot --quiet --deploy-hook 'kill -HUP 1 2>/dev/null || true'
|
||||||
|
sleep 12h & wait $!
|
||||||
|
done
|
||||||
|
"""
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CERTBOT_CONTAINER_NAME} || true")
|
||||||
|
run_cmd = (
|
||||||
|
f"docker run -d --restart always "
|
||||||
|
f"--name {self.CERTBOT_CONTAINER_NAME} "
|
||||||
|
f"--pid=container:{self.CONTAINER_NAME} "
|
||||||
|
f"-v {self.CERTBOT_WWW_DIR}:/var/www/certbot "
|
||||||
|
f"-v {self.LETSENCRYPT_DIR}:/etc/letsencrypt "
|
||||||
|
f"--entrypoint /bin/sh "
|
||||||
|
f"{self.CERTBOT_IMAGE} -c {_q(loop_script)}"
|
||||||
|
)
|
||||||
|
return self.ssh.run_sudo_command(run_cmd, timeout=60)
|
||||||
|
|
||||||
|
def _wait_for_certificate(self, domain):
|
||||||
|
wait_script = f"""
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if [ -s {self.LETSENCRYPT_DIR}/live/{domain}/fullchain.pem ] && [ -s {self.LETSENCRYPT_DIR}/live/{domain}/privkey.pem ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if ! docker ps --filter name=^{self.CERTBOT_CONTAINER_NAME}$ --format '{{{{.Names}}}}' | grep -qx {self.CERTBOT_CONTAINER_NAME}; then
|
||||||
|
docker logs --tail 120 {self.CERTBOT_CONTAINER_NAME} 2>&1 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
docker logs --tail 120 {self.CERTBOT_CONTAINER_NAME} 2>&1 || true
|
||||||
|
exit 1
|
||||||
|
"""
|
||||||
|
return self.ssh.run_sudo_command(f"sh -c {_q(wait_script)}", timeout=330)
|
||||||
|
|
||||||
|
def install_protocol(self, protocol_type='nginx', port=None, email=None, domain=None):
|
||||||
|
if not self.check_docker_installed():
|
||||||
|
return {'status': 'error', 'message': 'Docker not installed'}
|
||||||
|
|
||||||
|
port = int(port or self.DEFAULT_PORT)
|
||||||
|
if port == 80:
|
||||||
|
return {'status': 'error', 'message': 'Port 80 is reserved for Let\'s Encrypt validation'}
|
||||||
|
domain = self._validate_domain(domain)
|
||||||
|
email = self._validate_email(email)
|
||||||
|
|
||||||
|
self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}", timeout=180)
|
||||||
|
self.ssh.run_sudo_command(f"docker pull {self.CERTBOT_IMAGE}", timeout=180)
|
||||||
|
|
||||||
|
if self.check_protocol_installed(protocol_type):
|
||||||
|
self.remove_container(protocol_type)
|
||||||
|
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"mkdir -p {self.CONF_DIR} {self.HTML_DIR} {self.CERTBOT_WWW_DIR} {self.LETSENCRYPT_DIR}"
|
||||||
|
)
|
||||||
|
self._write_file(self.NGINX_CONF_PATH, self._build_initial_config(domain))
|
||||||
|
self._write_file(self.INDEX_PATH, self._default_index(domain))
|
||||||
|
self._write_metadata({'domain': domain, 'email': email, 'port': port})
|
||||||
|
|
||||||
|
_, err, code = self._run_nginx_container(port)
|
||||||
|
if code != 0:
|
||||||
|
return {'status': 'error', 'message': f'Failed to start NGINX container: {err}'}
|
||||||
|
|
||||||
|
_, err, code = self._run_certbot_renewer(domain, email)
|
||||||
|
if code != 0:
|
||||||
|
self.ssh.run_sudo_command(f"docker logs --tail 100 {self.CONTAINER_NAME} 2>/dev/null || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
|
||||||
|
return {'status': 'error', 'message': f'Failed to start certbot renewer container: {err}'}
|
||||||
|
|
||||||
|
out, err, code = self._wait_for_certificate(domain)
|
||||||
|
if code != 0:
|
||||||
|
self.ssh.run_sudo_command(f"docker logs --tail 120 {self.CERTBOT_CONTAINER_NAME} 2>/dev/null || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CERTBOT_CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': f"Let's Encrypt certificate issue failed. Make sure {domain} points to this server and port 80 is reachable. {err or out}",
|
||||||
|
}
|
||||||
|
|
||||||
|
self._write_file(self.NGINX_CONF_PATH, self._build_ssl_config(domain, port))
|
||||||
|
_, err, code = self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -t", timeout=30)
|
||||||
|
if code != 0:
|
||||||
|
return {'status': 'error', 'message': f'Generated NGINX config is invalid: {err}'}
|
||||||
|
self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -s reload")
|
||||||
|
|
||||||
|
url = self._site_url(domain, port)
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'protocol': protocol_type,
|
||||||
|
'base_protocol': self.PROTOCOL,
|
||||||
|
'instance': 1,
|
||||||
|
'container_name': self.CONTAINER_NAME,
|
||||||
|
'certbot_container_name': self.CERTBOT_CONTAINER_NAME,
|
||||||
|
'certbot_running': self.check_certbot_running(),
|
||||||
|
'port': port,
|
||||||
|
'domain': domain,
|
||||||
|
'email': email,
|
||||||
|
'site_url': url,
|
||||||
|
'message': 'NGINX web server installed',
|
||||||
|
'log': [
|
||||||
|
f'NGINX HTTPS port: {port}/TCP',
|
||||||
|
'Port 80/TCP is used for Let\'s Encrypt HTTP-01 validation',
|
||||||
|
f'Certificate issued for {domain}',
|
||||||
|
f'Auto-renewal container: {self.CERTBOT_CONTAINER_NAME}',
|
||||||
|
f'Site URL: {url}',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def remove_container(self, protocol_type='nginx'):
|
||||||
|
self.ssh.run_sudo_command(f"docker stop {self.CERTBOT_CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CERTBOT_CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
|
||||||
|
self.ssh.run_sudo_command(f"rm -rf {self.BASE_DIR}")
|
||||||
|
return True
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
SOCKS5 Proxy Manager — runs 3proxy in a Docker container, modelled after the
|
||||||
|
official Amnezia client install (client/server_scripts/socks5_proxy/). Holds a
|
||||||
|
single user (port + username + password); credentials can be edited later from
|
||||||
|
the panel via update_credentials().
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import re
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_password(length=16):
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
class Socks5Manager:
|
||||||
|
PROTOCOL = 'socks5'
|
||||||
|
CONTAINER_NAME = 'amnezia-socks5proxy'
|
||||||
|
IMAGE_NAME = '3proxy/3proxy:0.9.5'
|
||||||
|
CONFIG_DIR = '/opt/amnezia/socks5proxy'
|
||||||
|
CONFIG_PATH = '/etc/3proxy/3proxy.cfg'
|
||||||
|
|
||||||
|
DEFAULT_PORT = 38080
|
||||||
|
DEFAULT_USERNAME = 'proxy_user'
|
||||||
|
|
||||||
|
def __init__(self, ssh, protocol='socks5'):
|
||||||
|
self.ssh = ssh
|
||||||
|
self.protocol = protocol or self.PROTOCOL
|
||||||
|
self.instance = self._instance_index(self.protocol)
|
||||||
|
self.container_name = self._container_name(self.protocol)
|
||||||
|
self.config_dir = self._config_dir(self.protocol)
|
||||||
|
|
||||||
|
def _instance_index(self, protocol=None):
|
||||||
|
parts = str(protocol or self.protocol or '').split('__', 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
try:
|
||||||
|
return max(1, int(parts[1]))
|
||||||
|
except ValueError:
|
||||||
|
return 1
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def _container_name(self, protocol=None):
|
||||||
|
idx = self._instance_index(protocol or self.protocol)
|
||||||
|
return self.CONTAINER_NAME if idx <= 1 else f'{self.CONTAINER_NAME}-{idx}'
|
||||||
|
|
||||||
|
def _config_dir(self, protocol=None):
|
||||||
|
idx = self._instance_index(protocol or self.protocol)
|
||||||
|
return self.CONFIG_DIR if idx <= 1 else f'{self.CONFIG_DIR}-{idx}'
|
||||||
|
|
||||||
|
# ===================== STATUS =====================
|
||||||
|
|
||||||
|
def check_docker_installed(self):
|
||||||
|
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
|
||||||
|
if code != 0:
|
||||||
|
return False
|
||||||
|
out2, _, _ = self.ssh.run_command(
|
||||||
|
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
|
||||||
|
)
|
||||||
|
return 'active' in out2 or 'running' in out2.lower()
|
||||||
|
|
||||||
|
def check_protocol_installed(self, protocol_type='socks5'):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps -a --filter name=^{self._container_name(protocol_type)}$ --format '{{{{.Names}}}}'"
|
||||||
|
)
|
||||||
|
return self._container_name(protocol_type) in out.strip().split('\n')
|
||||||
|
|
||||||
|
def check_container_running(self, protocol_type='socks5'):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps --filter name=^{self._container_name(protocol_type)}$ --format '{{{{.Status}}}}'"
|
||||||
|
)
|
||||||
|
return 'Up' in out
|
||||||
|
|
||||||
|
def get_server_status(self, protocol_type='socks5'):
|
||||||
|
exists = self.check_protocol_installed(protocol_type)
|
||||||
|
running = self.check_container_running(protocol_type)
|
||||||
|
creds = self.get_credentials() if exists else {}
|
||||||
|
return {
|
||||||
|
'container_exists': exists,
|
||||||
|
'container_running': running,
|
||||||
|
'port': creds.get('port'),
|
||||||
|
'username': creds.get('username'),
|
||||||
|
'protocol': protocol_type,
|
||||||
|
'base_protocol': self.PROTOCOL,
|
||||||
|
'instance': self._instance_index(protocol_type),
|
||||||
|
'container_name': self._container_name(protocol_type),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===================== CONFIG I/O =====================
|
||||||
|
|
||||||
|
def _build_config(self, username, password, port):
|
||||||
|
# Mirrors client/server_scripts/socks5_proxy/configure_container.sh.
|
||||||
|
# 'auth strong' enforces username/password on every connection;
|
||||||
|
# 'allow {user}' restricts the ACL to our single user only.
|
||||||
|
return (
|
||||||
|
"#!/bin/3proxy\n"
|
||||||
|
f"config {self.CONFIG_PATH}\n"
|
||||||
|
"timeouts 1 5 30 60 180 1800 15 60\n"
|
||||||
|
f"users {username}:CL:{password}\n"
|
||||||
|
"log\n"
|
||||||
|
"auth strong\n"
|
||||||
|
f"allow {username}\n"
|
||||||
|
f"socks -p{int(port)}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_config(self):
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {self.container_name} cat {self.CONFIG_PATH} 2>/dev/null"
|
||||||
|
)
|
||||||
|
if code != 0 or not out.strip():
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"cat {self.config_dir}/3proxy.cfg 2>/dev/null"
|
||||||
|
)
|
||||||
|
if code != 0 or not out.strip():
|
||||||
|
return ''
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _write_config(self, config_text):
|
||||||
|
# Write to host first (so we have a stable copy outside the container),
|
||||||
|
# then docker cp into the running container at the path 3proxy expects.
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {self.config_dir}")
|
||||||
|
self.ssh.upload_file_sudo(config_text, f"{self.config_dir}/3proxy.cfg")
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker cp {self.config_dir}/3proxy.cfg {self.container_name}:{self.CONFIG_PATH} 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_credentials(self, config_text):
|
||||||
|
creds = {'port': None, 'username': None, 'password': None}
|
||||||
|
if not config_text:
|
||||||
|
return creds
|
||||||
|
m_user = re.search(r'^\s*users\s+([^:\s]+):CL:(\S+)', config_text, re.MULTILINE)
|
||||||
|
if m_user:
|
||||||
|
creds['username'] = m_user.group(1)
|
||||||
|
creds['password'] = m_user.group(2)
|
||||||
|
m_port = re.search(r'^\s*socks\s+-p(\d+)', config_text, re.MULTILINE)
|
||||||
|
if m_port:
|
||||||
|
creds['port'] = int(m_port.group(1))
|
||||||
|
return creds
|
||||||
|
|
||||||
|
def get_credentials(self):
|
||||||
|
return self._parse_credentials(self._read_config())
|
||||||
|
|
||||||
|
# ===================== INSTALL / UPDATE / REMOVE =====================
|
||||||
|
|
||||||
|
def install_protocol(self, protocol_type='socks5', port=None, username=None, password=None):
|
||||||
|
if not self.check_docker_installed():
|
||||||
|
return {'status': 'error', 'message': 'Docker not installed'}
|
||||||
|
|
||||||
|
port = int(port or self.DEFAULT_PORT)
|
||||||
|
username = (username or self.DEFAULT_USERNAME).strip() or self.DEFAULT_USERNAME
|
||||||
|
password = (password or _generate_password()).strip() or _generate_password()
|
||||||
|
|
||||||
|
# Pull image (idempotent — fast no-op if cached)
|
||||||
|
self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}")
|
||||||
|
|
||||||
|
# Wipe any prior install, including the bind-mounted config dir, before
|
||||||
|
# writing a fresh config — leftover state would leak old credentials.
|
||||||
|
install_protocol = protocol_type or self.protocol
|
||||||
|
container_name = self._container_name(install_protocol)
|
||||||
|
config_dir = self._config_dir(install_protocol)
|
||||||
|
|
||||||
|
if self.check_protocol_installed(install_protocol):
|
||||||
|
self.remove_container(install_protocol)
|
||||||
|
|
||||||
|
config_text = self._build_config(username, password, port)
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {config_dir}")
|
||||||
|
self.ssh.upload_file_sudo(config_text, f"{config_dir}/3proxy.cfg")
|
||||||
|
|
||||||
|
# The 3proxy image reads /etc/3proxy/3proxy.cfg by default.
|
||||||
|
# Do not pass the config path as the container command: Docker would try
|
||||||
|
# to execute the config file and fail with "permission denied".
|
||||||
|
run_cmd = (
|
||||||
|
f"docker run -d --restart always "
|
||||||
|
f"--name {container_name} "
|
||||||
|
f"-p {port}:{port}/tcp "
|
||||||
|
f"-v {config_dir}:/etc/3proxy:ro "
|
||||||
|
f"{self.IMAGE_NAME}"
|
||||||
|
)
|
||||||
|
_, err, code = self.ssh.run_sudo_command(run_cmd)
|
||||||
|
if code != 0:
|
||||||
|
return {'status': 'error', 'message': f'Failed to start container: {err}'}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'protocol': install_protocol,
|
||||||
|
'base_protocol': self.PROTOCOL,
|
||||||
|
'instance': self._instance_index(install_protocol),
|
||||||
|
'container_name': container_name,
|
||||||
|
'port': port,
|
||||||
|
'username': username,
|
||||||
|
'password': password,
|
||||||
|
'message': 'SOCKS5 proxy installed',
|
||||||
|
'log': [
|
||||||
|
f'SOCKS5 proxy listening on port {port}/TCP',
|
||||||
|
f'Username: {username}',
|
||||||
|
f'Password: {password}',
|
||||||
|
'Save these credentials — the password can also be viewed later via "Change settings".',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_credentials(self, port=None, username=None, password=None):
|
||||||
|
"""Apply new connection settings: regenerates the config file and
|
||||||
|
restarts the container so the new port mapping takes effect."""
|
||||||
|
if not self.check_protocol_installed(self.protocol):
|
||||||
|
return {'status': 'error', 'message': 'SOCKS5 not installed'}
|
||||||
|
|
||||||
|
current = self.get_credentials()
|
||||||
|
new_port = int(port if port is not None else (current.get('port') or self.DEFAULT_PORT))
|
||||||
|
new_user = (username or current.get('username') or self.DEFAULT_USERNAME).strip()
|
||||||
|
new_pass = (password or current.get('password') or _generate_password()).strip()
|
||||||
|
|
||||||
|
old_port = current.get('port')
|
||||||
|
|
||||||
|
# If the port changed we must recreate the container — `docker run -p`
|
||||||
|
# mappings are immutable on existing containers.
|
||||||
|
if old_port and new_port != old_port:
|
||||||
|
return self.install_protocol(
|
||||||
|
protocol_type=self.protocol, port=new_port, username=new_user, password=new_pass
|
||||||
|
)
|
||||||
|
|
||||||
|
config_text = self._build_config(new_user, new_pass, new_port)
|
||||||
|
self._write_config(config_text)
|
||||||
|
self.ssh.run_sudo_command(f"docker restart {self.container_name}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'port': new_port,
|
||||||
|
'username': new_user,
|
||||||
|
'password': new_pass,
|
||||||
|
}
|
||||||
|
|
||||||
|
def remove_container(self, protocol_type='socks5'):
|
||||||
|
self.ssh.run_sudo_command(f"docker stop {self._container_name(protocol_type)} || true")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self._container_name(protocol_type)} || true")
|
||||||
|
self.ssh.run_sudo_command(f"rm -rf {self._config_dir(protocol_type)}")
|
||||||
|
return True
|
||||||
@@ -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,571 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
from .ssh_manager import SSHManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class TelemtManager:
|
||||||
|
CONTAINER_NAME = "telemt"
|
||||||
|
API_URL = "http://127.0.0.1:9091"
|
||||||
|
|
||||||
|
def __init__(self, ssh_manager: SSHManager, protocol='telemt'):
|
||||||
|
self.ssh = ssh_manager
|
||||||
|
self.protocol = protocol or 'telemt'
|
||||||
|
self.instance = self._instance_index(self.protocol)
|
||||||
|
self.container_name = self._container_name(self.protocol)
|
||||||
|
self.remote_dir = self._remote_dir(self.protocol)
|
||||||
|
|
||||||
|
def _instance_index(self, protocol):
|
||||||
|
parts = str(protocol or '').split('__', 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
try:
|
||||||
|
return max(1, int(parts[1]))
|
||||||
|
except ValueError:
|
||||||
|
return 1
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def _container_name(self, protocol=None):
|
||||||
|
idx = self._instance_index(protocol or self.protocol)
|
||||||
|
base_name = self.CONTAINER_NAME
|
||||||
|
return base_name if idx <= 1 else f'{base_name}-{idx}'
|
||||||
|
|
||||||
|
def _remote_dir(self, protocol=None):
|
||||||
|
idx = self._instance_index(protocol or self.protocol)
|
||||||
|
return '/opt/amnezia/telemt' if idx <= 1 else f'/opt/amnezia/telemt-{idx}'
|
||||||
|
|
||||||
|
def _config_path(self):
|
||||||
|
return f'{self.remote_dir}/config.toml'
|
||||||
|
|
||||||
|
def _api_host_ports(self):
|
||||||
|
# Host API mappings are only for diagnostics; panel talks via docker exec.
|
||||||
|
# Keep first instance backward-compatible, avoid 9090/9091 collisions later.
|
||||||
|
if self.instance <= 1:
|
||||||
|
return 9090, 9091
|
||||||
|
base = 9090 + (self.instance * 10)
|
||||||
|
return base, base + 1
|
||||||
|
|
||||||
|
def _api_request(self, method, path, data=None):
|
||||||
|
"""Execute a curl request inside the docker container."""
|
||||||
|
cmd = f"docker exec {self.container_name} curl -s -X {method} {self.API_URL}{path}"
|
||||||
|
if data:
|
||||||
|
js_data = json.dumps(data).replace('"', '\\"')
|
||||||
|
cmd += f" -H 'Content-Type: application/json' -d \"{js_data}\""
|
||||||
|
|
||||||
|
out, err, code = self.ssh.run_sudo_command(cmd)
|
||||||
|
if code != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(out)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def check_docker_installed(self):
|
||||||
|
out, _, _ = self.ssh.run_command("docker --version 2>/dev/null")
|
||||||
|
return bool(out.strip())
|
||||||
|
|
||||||
|
def check_protocol_installed(self):
|
||||||
|
out, _, _ = self.ssh.run_command(f"docker ps -a --filter name=^{self.container_name}$ --format '{{{{.Names}}}}'")
|
||||||
|
return out.strip() == self.container_name
|
||||||
|
|
||||||
|
def get_server_status(self, protocol_type):
|
||||||
|
exists = self.check_protocol_installed()
|
||||||
|
out, _, _ = self.ssh.run_command(f"docker inspect -f '{{{{.State.Running}}}}' {self.container_name} 2>/dev/null")
|
||||||
|
is_running = out.strip().lower() == 'true'
|
||||||
|
|
||||||
|
status = {
|
||||||
|
'container_exists': exists,
|
||||||
|
'container_running': is_running,
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_running:
|
||||||
|
# get external docker port mapping for 443
|
||||||
|
out, _, _ = self.ssh.run_command(f"docker port {self.container_name} 443 2>/dev/null")
|
||||||
|
if out:
|
||||||
|
port = out.split(':')[-1].strip()
|
||||||
|
status['port'] = port
|
||||||
|
else:
|
||||||
|
status['port'] = None
|
||||||
|
|
||||||
|
config = self._get_server_config()
|
||||||
|
status['awg_params'] = self._parse_telemt_params(config)
|
||||||
|
|
||||||
|
# Count connections from API
|
||||||
|
clients = self.get_clients(protocol_type)
|
||||||
|
status['clients_count'] = len(clients)
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
def _ensure_docker_compose(self):
|
||||||
|
"""Make sure `docker compose` is available, installing the plugin if needed.
|
||||||
|
|
||||||
|
Why: `docker-buildx-plugin` and `docker-compose-plugin` only ship in Docker's
|
||||||
|
official apt/yum repo. When Docker was installed from distro packages
|
||||||
|
(e.g. `docker.io` on Ubuntu), that repo is not configured and a plain
|
||||||
|
`apt-get install docker-compose-plugin` fails. So we add the repo,
|
||||||
|
refresh package lists, then install.
|
||||||
|
"""
|
||||||
|
out, _, code = self.ssh.run_command("docker compose version 2>/dev/null")
|
||||||
|
if code == 0 and out.strip():
|
||||||
|
return
|
||||||
|
|
||||||
|
script = r"""
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -y || true
|
||||||
|
apt-get install -y ca-certificates curl gnupg || exit 1
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
. /etc/os-release
|
||||||
|
DOCKER_DISTRO="$ID"
|
||||||
|
case "$ID" in
|
||||||
|
linuxmint|pop|elementary|zorin) DOCKER_DISTRO="ubuntu" ;;
|
||||||
|
kali|parrot) DOCKER_DISTRO="debian" ;;
|
||||||
|
esac
|
||||||
|
if [ ! -s /etc/apt/keyrings/docker.asc ]; then
|
||||||
|
curl -fsSL "https://download.docker.com/linux/${DOCKER_DISTRO}/gpg" -o /etc/apt/keyrings/docker.asc || exit 1
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
|
fi
|
||||||
|
CODENAME="${UBUNTU_CODENAME:-$VERSION_CODENAME}"
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${DOCKER_DISTRO} ${CODENAME} stable" > /etc/apt/sources.list.d/docker.list
|
||||||
|
apt-get update -y || exit 1
|
||||||
|
apt-get install -y docker-buildx-plugin docker-compose-plugin || exit 1
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
dnf install -y dnf-plugins-core || exit 1
|
||||||
|
. /etc/os-release
|
||||||
|
dnf config-manager --add-repo "https://download.docker.com/linux/${ID}/docker-ce.repo" \
|
||||||
|
|| dnf config-manager --add-repo "https://download.docker.com/linux/centos/docker-ce.repo" \
|
||||||
|
|| exit 1
|
||||||
|
dnf makecache || true
|
||||||
|
dnf install -y docker-buildx-plugin docker-compose-plugin || exit 1
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
yum install -y yum-utils || exit 1
|
||||||
|
. /etc/os-release
|
||||||
|
yum-config-manager --add-repo "https://download.docker.com/linux/${ID}/docker-ce.repo" \
|
||||||
|
|| yum-config-manager --add-repo "https://download.docker.com/linux/centos/docker-ce.repo" \
|
||||||
|
|| exit 1
|
||||||
|
yum makecache || true
|
||||||
|
yum install -y docker-buildx-plugin docker-compose-plugin || exit 1
|
||||||
|
else
|
||||||
|
echo "Unsupported package manager" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker compose version
|
||||||
|
"""
|
||||||
|
out, err, code = self.ssh.run_sudo_script(script, timeout=300)
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(f"Failed to install docker compose plugin: {err or out}")
|
||||||
|
|
||||||
|
def install_protocol(self, protocol_type='telemt', port='443', tls_emulation=True, tls_domain="", max_connections=0):
|
||||||
|
results = []
|
||||||
|
if not self.check_docker_installed():
|
||||||
|
results.append("Installing Docker...")
|
||||||
|
self.ssh.run_sudo_command("curl -fsSL https://get.docker.com | sh", timeout=300)
|
||||||
|
|
||||||
|
if self.check_protocol_installed():
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -f {self.container_name}")
|
||||||
|
|
||||||
|
results.append("Ensuring docker compose plugin...")
|
||||||
|
self._ensure_docker_compose()
|
||||||
|
|
||||||
|
results.append("Uploading Telemt files...")
|
||||||
|
local_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'protocol_telemt')
|
||||||
|
remote_dir = self.remote_dir
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {remote_dir}")
|
||||||
|
self.ssh.run_sudo_command(f"chmod 755 {remote_dir}")
|
||||||
|
|
||||||
|
# Read and patch config.toml
|
||||||
|
with open(os.path.join(local_dir, 'config.toml'), 'r', encoding='utf-8') as f:
|
||||||
|
config_content = f.read()
|
||||||
|
|
||||||
|
tls_emul_str = "true" if tls_emulation else "false"
|
||||||
|
config_content = re.sub(r'tls_emulation\s*=\s*(true|false|True|False)', f'tls_emulation = {tls_emul_str}', config_content)
|
||||||
|
|
||||||
|
if tls_emulation and tls_domain:
|
||||||
|
config_content = re.sub(r'tls_domain\s*=\s*".*?"', f'tls_domain = "{tls_domain}"', config_content)
|
||||||
|
|
||||||
|
if max_connections is not None and max_connections > 0:
|
||||||
|
config_content = re.sub(r'max_connections\s*=\s*\d+', f'max_connections = {max_connections}', config_content)
|
||||||
|
|
||||||
|
# Patch public_host and public_port for links
|
||||||
|
if "public_host =" in config_content or "# public_host =" in config_content:
|
||||||
|
config_content = re.sub(r'#?\s*public_host\s*=\s*".*?"', f'public_host = "{self.ssh.host}"', config_content)
|
||||||
|
else:
|
||||||
|
config_content = config_content.replace('[general.links]', f'[general.links]\npublic_host = "{self.ssh.host}"')
|
||||||
|
|
||||||
|
config_content = re.sub(r'public_port\s*=\s*\d+', f'public_port = {port}', config_content)
|
||||||
|
|
||||||
|
# Remove default hello user
|
||||||
|
config_content = re.sub(r'^hello\s*=\s*".*?"', '', config_content, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
self.ssh.upload_file_sudo(config_content, f"{remote_dir}/config.toml")
|
||||||
|
|
||||||
|
# Patch docker-compose.yml with proper port
|
||||||
|
with open(os.path.join(local_dir, 'docker-compose.yml'), 'r', encoding='utf-8') as f:
|
||||||
|
compose_content = f.read()
|
||||||
|
|
||||||
|
compose_content = re.sub(r'"443:443"', f'"{port}:443"', compose_content)
|
||||||
|
compose_content = re.sub(r'container_name:\s*telemt', f'container_name: {self.container_name}', compose_content)
|
||||||
|
if self.instance > 1:
|
||||||
|
api_port_9090, api_port_9091 = self._api_host_ports()
|
||||||
|
compose_content = re.sub(r'"127\.0\.0\.1:9090:9090"', f'"127.0.0.1:{api_port_9090}:9090"', compose_content)
|
||||||
|
compose_content = re.sub(r'"127\.0\.0\.1:9091:9091"', f'"127.0.0.1:{api_port_9091}:9091"', compose_content)
|
||||||
|
self.ssh.upload_file_sudo(compose_content, f"{remote_dir}/docker-compose.yml")
|
||||||
|
|
||||||
|
# Upload Dockerfile
|
||||||
|
with open(os.path.join(local_dir, 'Dockerfile'), 'r', encoding='utf-8') as f:
|
||||||
|
dockerfile = f.read()
|
||||||
|
self.ssh.upload_file_sudo(dockerfile, f"{remote_dir}/Dockerfile")
|
||||||
|
|
||||||
|
results.append("Starting Telemt container...")
|
||||||
|
out, err, code = self.ssh.run_sudo_command(f"sh -c 'cd {remote_dir} && docker compose up -d --build'", timeout=600)
|
||||||
|
if code != 0:
|
||||||
|
self.ssh.run_sudo_command(f"sh -c 'cd {remote_dir} && docker-compose up -d --build'", timeout=600)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"protocol": self.protocol,
|
||||||
|
"host": "",
|
||||||
|
"port": port,
|
||||||
|
"log": results
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_server_config(self):
|
||||||
|
out, _, code = self.ssh.run_sudo_command(f"cat {self._config_path()}")
|
||||||
|
if code != 0: return ""
|
||||||
|
return out
|
||||||
|
|
||||||
|
def save_server_config(self, protocol_type, config_content):
|
||||||
|
self.ssh.upload_file_sudo(config_content.replace('\r\n', '\n'), f"{self._config_path()}")
|
||||||
|
# Use SIGHUP (HUP) to reload MTProxy config without restarting the process/container.
|
||||||
|
# This keeps the traffic statistics (octets) in memory.
|
||||||
|
self.ssh.run_sudo_command(f"docker kill -s HUP {self.container_name} || docker restart {self.container_name}")
|
||||||
|
|
||||||
|
def _parse_telemt_params(self, config_text):
|
||||||
|
params = {}
|
||||||
|
m = re.search(r'tls_emulation\s*=\s*(true|false)', config_text, re.IGNORECASE)
|
||||||
|
if m: params['tls_emulation'] = m.group(1).lower() == 'true'
|
||||||
|
|
||||||
|
m = re.search(r'tls_domain\s*=\s*"([^"]+)"', config_text)
|
||||||
|
if m: params['tls_domain'] = m.group(1)
|
||||||
|
|
||||||
|
m = re.search(r'max_connections\s*=\s*(\d+)', config_text)
|
||||||
|
if m: params['max_connections'] = int(m.group(1))
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
def remove_container(self, protocol_type=None):
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -f {self.container_name}")
|
||||||
|
self.ssh.run_sudo_command(f"rm -rf {self.remote_dir}")
|
||||||
|
|
||||||
|
def get_clients(self, protocol_type):
|
||||||
|
api_data = {}
|
||||||
|
resp = self._api_request("GET", "/v1/users")
|
||||||
|
if resp and resp.get('ok'):
|
||||||
|
for u in resp.get('data', []):
|
||||||
|
api_data[u.get('username')] = u
|
||||||
|
|
||||||
|
config_text = self._get_server_config()
|
||||||
|
users = self._parse_users_from_config(config_text)
|
||||||
|
|
||||||
|
clients = []
|
||||||
|
needs_update = False
|
||||||
|
for username, secret in users.items():
|
||||||
|
user_stats = api_data.get(username.lstrip('#').strip(), {})
|
||||||
|
links = user_stats.get('links', {})
|
||||||
|
tg_link = ""
|
||||||
|
if links.get('tls'): tg_link = links['tls'][0]
|
||||||
|
elif links.get('secure'): tg_link = links['secure'][0]
|
||||||
|
elif links.get('classic'): tg_link = links['classic'][0]
|
||||||
|
|
||||||
|
enabled = not username.startswith('#')
|
||||||
|
clean_name = username.lstrip('#').strip()
|
||||||
|
|
||||||
|
total_octets = user_stats.get('total_octets', 0)
|
||||||
|
quota = user_stats.get('data_quota_bytes')
|
||||||
|
|
||||||
|
# AUTO-DISABLE IF QUOTA REACHED
|
||||||
|
if enabled and quota and total_octets >= quota:
|
||||||
|
logger.info(f"Auto-disabling client {clean_name} - quota reached: {total_octets} >= {quota}")
|
||||||
|
# We will trigger a toggle after we finish this loop to avoid re-reading config inside loop
|
||||||
|
enabled = False
|
||||||
|
needs_update = True
|
||||||
|
|
||||||
|
clients.append({
|
||||||
|
"clientId": clean_name,
|
||||||
|
"clientName": clean_name,
|
||||||
|
"enabled": enabled,
|
||||||
|
"creationDate": "",
|
||||||
|
"userData": {
|
||||||
|
"clientName": clean_name,
|
||||||
|
"token": secret,
|
||||||
|
"tg_link": tg_link,
|
||||||
|
"total_octets": total_octets,
|
||||||
|
"current_connections": user_stats.get('current_connections', 0),
|
||||||
|
"active_ips": user_stats.get('active_unique_ips', 0),
|
||||||
|
"quota": quota,
|
||||||
|
"expiry": user_stats.get('expiration_rfc3339')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if needs_update:
|
||||||
|
# Re-read and update config strictly at the end
|
||||||
|
for c in clients:
|
||||||
|
if not c['enabled']:
|
||||||
|
self.toggle_client(protocol_type, c['clientId'], False, restart=False)
|
||||||
|
self.ssh.run_sudo_command(f"docker restart {self.container_name}")
|
||||||
|
|
||||||
|
return clients
|
||||||
|
|
||||||
|
def _parse_users_from_config(self, config_text):
|
||||||
|
users = {}
|
||||||
|
lines = config_text.split('\n')
|
||||||
|
in_section = False
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped == '[access.users]':
|
||||||
|
in_section = True
|
||||||
|
continue
|
||||||
|
if in_section and stripped.startswith('['):
|
||||||
|
break
|
||||||
|
if in_section and stripped:
|
||||||
|
commented = stripped.startswith('#')
|
||||||
|
content = stripped.lstrip('#').strip()
|
||||||
|
if '=' in content:
|
||||||
|
if content.lower().startswith('format:'): continue
|
||||||
|
name, secret = content.split('=', 1)
|
||||||
|
name = name.strip().strip('"').strip()
|
||||||
|
secret = secret.strip().strip('"').strip()
|
||||||
|
full_name = ("# " + name) if commented else name
|
||||||
|
users[full_name] = secret
|
||||||
|
return users
|
||||||
|
|
||||||
|
def add_client(self, protocol_type, name, host='', port='', **kwargs):
|
||||||
|
username = re.sub(r'[^a-zA-Z0-9_.-]', '', name.replace(' ', '_'))
|
||||||
|
if not username: username = "user_" + uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
config_text = self._get_server_config()
|
||||||
|
current_users = self._parse_users_from_config(config_text)
|
||||||
|
idx = 1
|
||||||
|
base_username = username
|
||||||
|
while any(u.lstrip('#').strip() == username for u in current_users):
|
||||||
|
username = f"{base_username}_{idx}"
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
secret = kwargs.get('secret') or secrets.token_hex(16)
|
||||||
|
|
||||||
|
# 1. Update config file for persistence (but don't restart yet)
|
||||||
|
config_text = self._insert_into_section(config_text, "access.users", f'{username} = "{secret}"')
|
||||||
|
|
||||||
|
api_payload = {
|
||||||
|
"username": username,
|
||||||
|
"secret": secret
|
||||||
|
}
|
||||||
|
|
||||||
|
if kwargs.get('telemt_quota'):
|
||||||
|
val = int(kwargs['telemt_quota'])
|
||||||
|
config_text = self._insert_into_section(config_text, "access.user_data_quota", f'{username} = {val}')
|
||||||
|
api_payload['data_quota_bytes'] = val
|
||||||
|
|
||||||
|
if kwargs.get('telemt_max_ips'):
|
||||||
|
val = int(kwargs['telemt_max_ips'])
|
||||||
|
config_text = self._insert_into_section(config_text, "access.user_max_unique_ips", f'{username} = {val}')
|
||||||
|
api_payload['max_unique_ips'] = val
|
||||||
|
|
||||||
|
if kwargs.get('telemt_expiry'):
|
||||||
|
val = kwargs['telemt_expiry']
|
||||||
|
config_text = self._insert_into_section(config_text, "access.user_expirations", f'{username} = "{val}"')
|
||||||
|
api_payload['expiration_rfc3339'] = val
|
||||||
|
|
||||||
|
if kwargs.get('user_ad_tag'):
|
||||||
|
val = kwargs['user_ad_tag']
|
||||||
|
config_text = self._insert_into_section(config_text, "access.user_ad_tags", f'{username} = "{val}"')
|
||||||
|
api_payload['user_ad_tag'] = val
|
||||||
|
|
||||||
|
if kwargs.get('max_tcp_conns'):
|
||||||
|
val = int(kwargs['max_tcp_conns'])
|
||||||
|
config_text = self._insert_into_section(config_text, "access.user_max_tcp_conns", f'{username} = {val}')
|
||||||
|
api_payload['max_tcp_conns'] = val
|
||||||
|
|
||||||
|
# Save config to host
|
||||||
|
self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), f"{self._config_path()}")
|
||||||
|
|
||||||
|
# 2. Call API for immediate effect
|
||||||
|
self._api_request("POST", "/v1/users", data=api_payload)
|
||||||
|
|
||||||
|
# Fetch the official link from API (it includes TLS emulation padding like 'ee...' if enabled)
|
||||||
|
link = self.get_client_config(protocol_type, username, host, port)
|
||||||
|
|
||||||
|
# Extreme fallback if API is slow or 404
|
||||||
|
if link == "Not found":
|
||||||
|
link = f"tg://proxy?server={host}&port={port}&secret={secret}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"client_id": username,
|
||||||
|
"config": link,
|
||||||
|
"vpn_link": link
|
||||||
|
}
|
||||||
|
|
||||||
|
def edit_client(self, protocol_type, client_id, new_params):
|
||||||
|
"""Update existing client parameters via API and in config."""
|
||||||
|
config_text = self._get_server_config()
|
||||||
|
api_payload = {}
|
||||||
|
|
||||||
|
if 'telemt_quota' in new_params:
|
||||||
|
val = int(new_params['telemt_quota']) if new_params['telemt_quota'] else None
|
||||||
|
config_text = self._update_line_in_section(config_text, "access.user_data_quota", client_id, val)
|
||||||
|
api_payload['data_quota_bytes'] = val
|
||||||
|
|
||||||
|
if 'telemt_max_ips' in new_params:
|
||||||
|
val = int(new_params['telemt_max_ips']) if new_params['telemt_max_ips'] else None
|
||||||
|
config_text = self._update_line_in_section(config_text, "access.user_max_unique_ips", client_id, val)
|
||||||
|
api_payload['max_unique_ips'] = val
|
||||||
|
|
||||||
|
if 'telemt_expiry' in new_params:
|
||||||
|
val = new_params['telemt_expiry']
|
||||||
|
quoted_val = f'"{val}"' if val else None
|
||||||
|
config_text = self._update_line_in_section(config_text, "access.user_expirations", client_id, quoted_val)
|
||||||
|
api_payload['expiration_rfc3339'] = val
|
||||||
|
|
||||||
|
if 'secret' in new_params:
|
||||||
|
val = new_params['secret']
|
||||||
|
quoted_val = f'"{val}"' if val else None
|
||||||
|
config_text = self._update_line_in_section(config_text, "access.users", client_id, quoted_val)
|
||||||
|
api_payload['secret'] = val
|
||||||
|
|
||||||
|
if 'user_ad_tag' in new_params:
|
||||||
|
val = new_params['user_ad_tag']
|
||||||
|
quoted_val = f'"{val}"' if val else None
|
||||||
|
config_text = self._update_line_in_section(config_text, "access.user_ad_tags", client_id, quoted_val)
|
||||||
|
api_payload['user_ad_tag'] = val
|
||||||
|
|
||||||
|
if 'max_tcp_conns' in new_params:
|
||||||
|
val = int(new_params['max_tcp_conns']) if new_params['max_tcp_conns'] else None
|
||||||
|
config_text = self._update_line_in_section(config_text, "access.user_max_tcp_conns", client_id, val)
|
||||||
|
api_payload['max_tcp_conns'] = val
|
||||||
|
|
||||||
|
# Save config to host
|
||||||
|
self.ssh.upload_file_sudo(config_text.replace('\r\n', '\n'), f"{self._config_path()}")
|
||||||
|
|
||||||
|
# API call
|
||||||
|
self._api_request("PATCH", f"/v1/users/{client_id}", data=api_payload)
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
def _update_line_in_section(self, config_text, section_name, client_id, value):
|
||||||
|
lines = config_text.split('\n')
|
||||||
|
section_start = -1
|
||||||
|
section_end = -1
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.strip() == f"[{section_name}]":
|
||||||
|
section_start = i
|
||||||
|
elif section_start != -1 and line.strip().startswith('['):
|
||||||
|
section_end = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if section_end == -1: section_end = len(lines)
|
||||||
|
if section_start == -1:
|
||||||
|
if value is not None:
|
||||||
|
lines.append(f"[{section_name}]")
|
||||||
|
lines.append(f'{client_id} = {value}')
|
||||||
|
lines.append("")
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
found = False
|
||||||
|
for i in range(section_start + 1, section_end):
|
||||||
|
line = lines[i].strip().lstrip('#').strip()
|
||||||
|
if line.startswith(f"{client_id} ") or line.startswith(f"{client_id}="):
|
||||||
|
if value is None: lines.pop(i)
|
||||||
|
else: lines[i] = f'{client_id} = {value}'
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found and value is not None:
|
||||||
|
lines.insert(section_start + 1, f'{client_id} = {value}')
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
def _insert_into_section(self, config_text, section_name, line_to_insert):
|
||||||
|
lines = config_text.split('\n')
|
||||||
|
section_start = -1
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.strip() == f"[{section_name}]":
|
||||||
|
section_start = i
|
||||||
|
break
|
||||||
|
if section_start == -1:
|
||||||
|
lines.append(f"[{section_name}]")
|
||||||
|
lines.append(line_to_insert)
|
||||||
|
lines.append("")
|
||||||
|
else:
|
||||||
|
lines.insert(section_start + 1, line_to_insert)
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
def remove_client(self, protocol_type, client_id):
|
||||||
|
# 1. API
|
||||||
|
self._api_request("DELETE", f"/v1/users/{client_id}")
|
||||||
|
|
||||||
|
# 2. Config
|
||||||
|
config_text = self._get_server_config()
|
||||||
|
lines = config_text.split('\n')
|
||||||
|
new_lines = []
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip().lstrip('#').strip()
|
||||||
|
if stripped.startswith(f"{client_id} ") or stripped.startswith(f"{client_id}="):
|
||||||
|
continue
|
||||||
|
new_lines.append(line)
|
||||||
|
self.ssh.upload_file_sudo('\n'.join(new_lines).replace('\r\n', '\n'), f"{self._config_path()}")
|
||||||
|
|
||||||
|
def toggle_client(self, protocol_type, client_id, enable, restart=True):
|
||||||
|
# API doesn't have a direct "toggle", so we either set a huge quota or remove/re-add
|
||||||
|
# But for Telemt, commenting out in config is the persistent way.
|
||||||
|
# We'll use HUP after toggling in config.
|
||||||
|
|
||||||
|
config_text = self._get_server_config()
|
||||||
|
lines = config_text.split('\n')
|
||||||
|
new_lines = []
|
||||||
|
in_access_section = False
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith('[access.'): in_access_section = True
|
||||||
|
elif stripped.startswith('['): in_access_section = False
|
||||||
|
|
||||||
|
if in_access_section:
|
||||||
|
base_line = line.lstrip('#').strip()
|
||||||
|
if base_line.startswith(f"{client_id} ") or base_line.startswith(f"{client_id}="):
|
||||||
|
line = base_line if enable else f"# {base_line}"
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
self.ssh.upload_file_sudo('\n'.join(new_lines).replace('\r\n', '\n'), f"{self._config_path()}")
|
||||||
|
|
||||||
|
if enable:
|
||||||
|
# If enabling, we re-add via API since it might have been deleted from memory
|
||||||
|
secret = ""
|
||||||
|
users = self._parse_users_from_config('\n'.join(new_lines))
|
||||||
|
secret = users.get(client_id, "")
|
||||||
|
if secret:
|
||||||
|
self._api_request("POST", "/v1/users", data={"username": client_id, "secret": secret})
|
||||||
|
else:
|
||||||
|
# If disabling, we just delete from memory
|
||||||
|
self._api_request("DELETE", f"/v1/users/{client_id}")
|
||||||
|
|
||||||
|
if restart:
|
||||||
|
self.ssh.run_sudo_command(f"docker kill -s HUP {self.container_name} || docker restart {self.container_name}")
|
||||||
|
|
||||||
|
def get_client_config(self, protocol_type, client_id, host='', port=''):
|
||||||
|
resp = self._api_request("GET", f"/v1/users/{client_id}")
|
||||||
|
if resp and resp.get('ok'):
|
||||||
|
user = resp.get('data', {})
|
||||||
|
links = user.get('links', {})
|
||||||
|
if links.get('tls'): return links['tls'][0]
|
||||||
|
if links.get('secure'): return links['secure'][0]
|
||||||
|
if links.get('classic'): return links['classic'][0]
|
||||||
|
|
||||||
|
clients = self.get_clients(protocol_type)
|
||||||
|
c = next((c for c in clients if c['clientId'] == client_id), None)
|
||||||
|
if c:
|
||||||
|
secret = c.get('userData', {}).get('token', '')
|
||||||
|
if secret: return f"tg://proxy?server={host}&port={port}&secret={secret}"
|
||||||
|
return "Not found"
|
||||||
@@ -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,800 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
import base64
|
||||||
|
import shlex
|
||||||
|
from datetime import datetime
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class XrayManager:
|
||||||
|
"""Manages Xray (VLESS-Reality) protocol installation and client management."""
|
||||||
|
|
||||||
|
PROTOCOL = 'xray'
|
||||||
|
CONTAINER_NAME = 'amnezia-xray'
|
||||||
|
IMAGE_NAME = 'amneziavpn/amnezia-xray' # or we can build it
|
||||||
|
|
||||||
|
def __init__(self, ssh_manager, protocol='xray'):
|
||||||
|
self.ssh = ssh_manager
|
||||||
|
self.protocol = protocol or self.PROTOCOL
|
||||||
|
self.base_protocol = str(self.protocol).split('__', 1)[0]
|
||||||
|
self.instance = self._instance_index(self.protocol)
|
||||||
|
self.container_name = self._container_name(self.protocol)
|
||||||
|
self.image_name = self._image_name(self.protocol)
|
||||||
|
|
||||||
|
def _instance_index(self, protocol):
|
||||||
|
parts = str(protocol or '').split('__', 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
try:
|
||||||
|
return max(1, int(parts[1]))
|
||||||
|
except ValueError:
|
||||||
|
return 1
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def _container_name(self, protocol=None):
|
||||||
|
idx = self._instance_index(protocol or self.protocol)
|
||||||
|
base_name = self.CONTAINER_NAME
|
||||||
|
return base_name if idx <= 1 else f'{base_name}-{idx}'
|
||||||
|
|
||||||
|
def _image_name(self, protocol=None):
|
||||||
|
idx = self._instance_index(protocol or self.protocol)
|
||||||
|
base_name = self.IMAGE_NAME
|
||||||
|
return base_name if idx <= 1 else f'{base_name}-{idx}'
|
||||||
|
|
||||||
|
def _config_dir(self):
|
||||||
|
return '/opt/amnezia/xray' if self.instance <= 1 else f'/opt/amnezia/xray-{self.instance}'
|
||||||
|
|
||||||
|
def _config_path(self):
|
||||||
|
return f'{self._config_dir()}/server.json'
|
||||||
|
|
||||||
|
def _list_xray_files(self):
|
||||||
|
"""List filenames in the on-disk Xray config directory."""
|
||||||
|
out, _, code = self.ssh.run_sudo_command(f"ls -1 {self._config_dir()} 2>/dev/null")
|
||||||
|
if code != 0:
|
||||||
|
return []
|
||||||
|
return [f for f in out.strip().split('\n') if f]
|
||||||
|
|
||||||
|
def _detect_layout(self):
|
||||||
|
"""Pick which on-disk layout this installation uses.
|
||||||
|
|
||||||
|
'native' — official Amnezia client layout: xray_private.key,
|
||||||
|
xray_public.key, xray_short_id.key, xray_uuid.key plus a clientsTable
|
||||||
|
file without an extension.
|
||||||
|
'panel' — legacy web-panel layout: meta.json + clientsTable.json.
|
||||||
|
|
||||||
|
On a fresh node with no Xray files yet, defaults to 'native' so new
|
||||||
|
installs produce the same artifacts as the official client.
|
||||||
|
"""
|
||||||
|
if hasattr(self, '_cached_layout'):
|
||||||
|
return self._cached_layout
|
||||||
|
files = set(self._list_xray_files())
|
||||||
|
if {'xray_private.key', 'xray_public.key'} & files:
|
||||||
|
layout = 'native'
|
||||||
|
elif 'meta.json' in files:
|
||||||
|
layout = 'panel'
|
||||||
|
else:
|
||||||
|
layout = 'native'
|
||||||
|
self._cached_layout = layout
|
||||||
|
return layout
|
||||||
|
|
||||||
|
def _clients_table_filename(self):
|
||||||
|
return 'clientsTable' if self._detect_layout() == 'native' else 'clientsTable.json'
|
||||||
|
|
||||||
|
def _clients_table_path(self):
|
||||||
|
return f'{self._config_dir()}/{self._clients_table_filename()}'
|
||||||
|
|
||||||
|
def _read_remote_file(self, path):
|
||||||
|
"""Read a remote text file, preferring the running container's view."""
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {self.container_name} cat {path} 2>/dev/null"
|
||||||
|
)
|
||||||
|
if code != 0 or not out:
|
||||||
|
out, _, code = self.ssh.run_sudo_command(f"cat {path} 2>/dev/null")
|
||||||
|
if code != 0 or not out.strip():
|
||||||
|
return None
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _derive_pubkey_from_priv(self, priv_key):
|
||||||
|
"""Derive the Reality public key from a private key via the xray binary.
|
||||||
|
Used as a fallback when xray_public.key is missing or unreadable.
|
||||||
|
"""
|
||||||
|
if not priv_key:
|
||||||
|
return ''
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {self.container_name} /usr/bin/xray x25519 -i {priv_key}"
|
||||||
|
)
|
||||||
|
if code != 0 or not out.strip():
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker run --rm --entrypoint=\"\" {self.image_name} /usr/bin/xray x25519 -i {priv_key}"
|
||||||
|
)
|
||||||
|
if code != 0 or not out:
|
||||||
|
return ''
|
||||||
|
for line in out.split('\n'):
|
||||||
|
if 'Public' in line and ':' in line:
|
||||||
|
return line.split(':', 1)[1].strip()
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def _get_default_xray_uuid(self):
|
||||||
|
"""UUID of the install-time default client (xray_uuid.key) — meaningful only
|
||||||
|
for native-layout installs. Imports skip this UUID, mirroring the official
|
||||||
|
Amnezia client behaviour (see usersController.cpp::getXrayClients).
|
||||||
|
"""
|
||||||
|
if self._detect_layout() != 'native':
|
||||||
|
return ''
|
||||||
|
out = self._read_remote_file(f"{self._config_dir()}/xray_uuid.key")
|
||||||
|
return (out or '').strip()
|
||||||
|
|
||||||
|
# ===================== INSTALLATION =====================
|
||||||
|
|
||||||
|
def check_docker_installed(self):
|
||||||
|
out, err, code = self.ssh.run_command("docker --version 2>/dev/null")
|
||||||
|
if code != 0: return False
|
||||||
|
out2, _, code2 = self.ssh.run_command("systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null")
|
||||||
|
return 'active' in out2 or 'running' in out2.lower()
|
||||||
|
|
||||||
|
def check_container_running(self):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps --filter name=^{self.container_name}$ --format '{{{{.Status}}}}'"
|
||||||
|
)
|
||||||
|
return 'Up' in out
|
||||||
|
|
||||||
|
def check_protocol_installed(self):
|
||||||
|
out, _, _ = self.ssh.run_sudo_command(
|
||||||
|
f"docker ps -a --filter name=^{self.container_name}$ --format '{{{{.Names}}}}'"
|
||||||
|
)
|
||||||
|
return self.container_name in out.strip().split('\n')
|
||||||
|
|
||||||
|
def get_server_status(self, protocol):
|
||||||
|
exists = self.check_protocol_installed()
|
||||||
|
running = self.check_container_running()
|
||||||
|
clients = self.get_clients() if exists else []
|
||||||
|
meta = self._get_meta_json() if exists else {}
|
||||||
|
return {
|
||||||
|
'container_exists': exists,
|
||||||
|
'container_running': running,
|
||||||
|
'clients_count': len(clients),
|
||||||
|
'port': meta.get('port')
|
||||||
|
}
|
||||||
|
|
||||||
|
def install_protocol(self, port=443, site_name='yahoo.com'):
|
||||||
|
"""Full installation of Xray."""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if not self.check_docker_installed():
|
||||||
|
results.append("Installing Docker...")
|
||||||
|
# Using same install method as AWGManager or assume it's installed
|
||||||
|
pass
|
||||||
|
|
||||||
|
results.append("Removing old container if exists...")
|
||||||
|
if self.check_protocol_installed():
|
||||||
|
self.remove_container()
|
||||||
|
|
||||||
|
results.append("Building Docker image...")
|
||||||
|
config_dir = self._config_dir()
|
||||||
|
dockerfile_folder = f"/opt/amnezia/{self.container_name}"
|
||||||
|
dockerfile_content = f"""FROM alpine:3.15
|
||||||
|
RUN apk add --no-cache curl unzip bash openssl netcat-openbsd dumb-init rng-tools xz iptables ip6tables
|
||||||
|
RUN apk --update upgrade --no-cache
|
||||||
|
RUN mkdir -p {config_dir}
|
||||||
|
RUN curl -L -H "Cache-Control: no-cache" -o /root/xray.zip "https://github.com/XTLS/Xray-core/releases/download/v26.3.27/Xray-linux-64.zip" && \\
|
||||||
|
unzip /root/xray.zip -d /usr/bin/ && \\
|
||||||
|
chmod a+x /usr/bin/xray && \\
|
||||||
|
rm /root/xray.zip
|
||||||
|
|
||||||
|
# Tune network
|
||||||
|
RUN echo "fs.file-max = 51200" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.core.rmem_max = 67108864" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.core.wmem_max = 67108864" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.core.netdev_max_backlog = 250000" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.core.somaxconn = 4096" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_syncookies = 1" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_tw_reuse = 1" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_tw_recycle = 0" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_fin_timeout = 30" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_keepalive_time = 1200" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.ip_local_port_range = 10000 65000" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_max_syn_backlog = 8192" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_max_tw_buckets = 5000" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_fastopen = 3" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_mem = 25600 51200 102400" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_rmem = 4096 87380 67108864" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_wmem = 4096 65536 67108864" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_mtu_probing = 1" >> /etc/sysctl.conf && \\
|
||||||
|
echo "net.ipv4.tcp_congestion_control = hybla" >> /etc/sysctl.conf
|
||||||
|
|
||||||
|
RUN mkdir -p /etc/security && \\
|
||||||
|
echo "* soft nofile 51200" >> /etc/security/limits.conf && \\
|
||||||
|
echo "* hard nofile 51200" >> /etc/security/limits.conf
|
||||||
|
|
||||||
|
RUN echo '#!/bin/bash' > /opt/amnezia/start.sh && \\
|
||||||
|
echo 'sysctl -p /etc/sysctl.conf 2>/dev/null' >> /opt/amnezia/start.sh && \\
|
||||||
|
echo '/usr/bin/xray -config {config_dir}/server.json' >> /opt/amnezia/start.sh && \\
|
||||||
|
chmod a+x /opt/amnezia/start.sh
|
||||||
|
|
||||||
|
ENTRYPOINT [ "dumb-init", "/opt/amnezia/start.sh" ]
|
||||||
|
"""
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {dockerfile_folder}")
|
||||||
|
self.ssh.upload_file_sudo(dockerfile_content, f"{dockerfile_folder}/Dockerfile")
|
||||||
|
|
||||||
|
_, err, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker build --no-cache -t {self.image_name} {dockerfile_folder}", timeout=300
|
||||||
|
)
|
||||||
|
if code != 0: raise RuntimeError(f"Failed to build container: {err}")
|
||||||
|
|
||||||
|
results.append("Generating keys and config...")
|
||||||
|
# We generate a base config using a temp container or directly if host has openssl
|
||||||
|
|
||||||
|
# Xray keypair generation using a temporary run overriding the entrypoint
|
||||||
|
keypair_cmd = f"docker run --rm --entrypoint=\"\" {self.image_name} /usr/bin/xray x25519"
|
||||||
|
out_kp, err_kp, code_kp = self.ssh.run_sudo_command(keypair_cmd)
|
||||||
|
if code_kp != 0: raise RuntimeError(f"Failed to generate x25519 keys: {err_kp}")
|
||||||
|
|
||||||
|
priv_key = ""
|
||||||
|
pub_key = ""
|
||||||
|
for line in out_kp.split('\n'):
|
||||||
|
if "Private" in line: priv_key = line.split(":", 1)[1].strip()
|
||||||
|
if "Public" in line: pub_key = line.split(":", 1)[1].strip()
|
||||||
|
|
||||||
|
short_id_cmd = f"docker run --rm --entrypoint=\"\" {self.image_name} openssl rand -hex 8"
|
||||||
|
out_sid, _, _ = self.ssh.run_sudo_command(short_id_cmd)
|
||||||
|
short_id = out_sid.strip()
|
||||||
|
|
||||||
|
# Generate initial server.json with Stats and API enabled
|
||||||
|
server_json = {
|
||||||
|
"log": {"loglevel": "error"},
|
||||||
|
"stats": {},
|
||||||
|
"api": {
|
||||||
|
"services": ["StatsService", "LoggerService", "HandlerService"],
|
||||||
|
"tag": "api"
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"levels": {
|
||||||
|
"0": {"statsUserUplink": True, "statsUserDownlink": True}
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"statsInboundUplink": True, "statsInboundDownlink": True,
|
||||||
|
"statsOutboundUplink": True, "statsOutboundDownlink": True
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inbounds": [
|
||||||
|
{
|
||||||
|
"port": int(port),
|
||||||
|
"protocol": "vless",
|
||||||
|
"tag": "proxy",
|
||||||
|
"settings": {
|
||||||
|
"clients": [],
|
||||||
|
"decryption": "none"
|
||||||
|
},
|
||||||
|
"streamSettings": {
|
||||||
|
"network": "tcp",
|
||||||
|
"security": "reality",
|
||||||
|
"realitySettings": {
|
||||||
|
"dest": f"{site_name}:443",
|
||||||
|
"serverNames": [site_name],
|
||||||
|
"privateKey": priv_key,
|
||||||
|
"shortIds": [short_id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"port": 10085,
|
||||||
|
"protocol": "dokodemo-door",
|
||||||
|
"settings": {"address": "127.0.0.1"},
|
||||||
|
"tag": "api"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outbounds": [{"protocol": "freedom"}],
|
||||||
|
"routing": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"inboundTag": ["api"],
|
||||||
|
"outboundTag": "api",
|
||||||
|
"type": "field"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ssh.run_sudo_command(f"mkdir -p {config_dir}")
|
||||||
|
self.ssh.upload_file_sudo(json.dumps(server_json, indent=2), f"{config_dir}/server.json")
|
||||||
|
# Native layout — separate key files matching the official Amnezia client install.
|
||||||
|
# See client/server_scripts/xray/configure_container.sh for the canonical layout.
|
||||||
|
self.ssh.upload_file_sudo(priv_key + '\n', "/opt/amnezia/xray/xray_private.key")
|
||||||
|
self.ssh.upload_file_sudo(pub_key + '\n', "/opt/amnezia/xray/xray_public.key")
|
||||||
|
self.ssh.upload_file_sudo(short_id + '\n', "/opt/amnezia/xray/xray_short_id.key")
|
||||||
|
# xray_uuid.key marks the install-time "default" client whose ID gets skipped on
|
||||||
|
# auto-import. Panel installs do not reserve such a client, so we leave it empty.
|
||||||
|
self.ssh.upload_file_sudo('\n', "/opt/amnezia/xray/xray_uuid.key")
|
||||||
|
self.ssh.upload_file_sudo("[]", "/opt/amnezia/xray/clientsTable")
|
||||||
|
|
||||||
|
results.append("Starting container...")
|
||||||
|
run_cmd = f"""docker run -d \\
|
||||||
|
--restart always \\
|
||||||
|
--privileged \\
|
||||||
|
--cap-add=NET_ADMIN \\
|
||||||
|
-p {port}:{port}/tcp \\
|
||||||
|
-p {port}:{port}/udp \\
|
||||||
|
-v {config_dir}:{config_dir} \\
|
||||||
|
--name {self.container_name} \\
|
||||||
|
{self.image_name}"""
|
||||||
|
|
||||||
|
_, err, code = self.ssh.run_sudo_command(run_cmd)
|
||||||
|
if code != 0: raise RuntimeError(f"Failed to run container: {err}")
|
||||||
|
|
||||||
|
# Try to connect to network if needed
|
||||||
|
self.ssh.run_sudo_command(f"docker network connect amnezia-dns-net {self.container_name} || true")
|
||||||
|
|
||||||
|
results.append("Xray configured and running")
|
||||||
|
return {'status': 'success', 'protocol': self.protocol, 'port': port, 'log': results}
|
||||||
|
|
||||||
|
def remove_container(self):
|
||||||
|
self.ssh.run_sudo_command(f"docker stop {self.container_name}")
|
||||||
|
self.ssh.run_sudo_command(f"docker rm -fv {self.container_name}")
|
||||||
|
if self.instance <= 1:
|
||||||
|
self.ssh.run_sudo_command(f"docker rmi {self.image_name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ===================== CLIENT MANAGEMENT =====================
|
||||||
|
|
||||||
|
def _get_server_json(self):
|
||||||
|
"""Read server.json — tries inside container first, falls back to host path."""
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec {self.container_name} cat {self._config_path()}"
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
out, _, code = self.ssh.run_sudo_command(f"cat {self._config_path()}")
|
||||||
|
if code != 0 or not out.strip():
|
||||||
|
return None
|
||||||
|
return json.loads(out)
|
||||||
|
|
||||||
|
def _save_server_json(self, data):
|
||||||
|
return self._write_server_json(data, restart=True)
|
||||||
|
|
||||||
|
def _write_server_json(self, data, restart=True):
|
||||||
|
"""Write server.json into container via docker cp AND sync to host path."""
|
||||||
|
tmp_file = "/tmp/_xray_server.json"
|
||||||
|
self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker cp {tmp_file} {self.container_name}:{self._config_path()}"
|
||||||
|
)
|
||||||
|
# Also keep host copy in sync (handles both volume-mount and no-mount installs)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"cp {tmp_file} {self._config_path()} 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
if restart:
|
||||||
|
self.ssh.run_sudo_command(f"docker restart {self.container_name}")
|
||||||
|
|
||||||
|
def _get_vless_inbound(self, config):
|
||||||
|
for inbound in config.get('inbounds', []):
|
||||||
|
if inbound.get('protocol') == 'vless':
|
||||||
|
return inbound
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_vless_inbound_tag(self, config):
|
||||||
|
inbound = self._get_vless_inbound(config)
|
||||||
|
return inbound.get('tag') if inbound else None
|
||||||
|
|
||||||
|
def _run_xray_api_json(self, subcommand, payload):
|
||||||
|
tmp_name = f"/tmp/_xray_api_{uuid.uuid4().hex}.json"
|
||||||
|
container_tmp = tmp_name
|
||||||
|
try:
|
||||||
|
self.ssh.upload_file_sudo(json.dumps(payload, indent=2), tmp_name)
|
||||||
|
_, err, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker cp {tmp_name} {self.container_name}:{container_tmp}"
|
||||||
|
)
|
||||||
|
if code != 0:
|
||||||
|
return False, err
|
||||||
|
out, err, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec -i {self.container_name} /usr/bin/xray api {subcommand} "
|
||||||
|
f"-server=127.0.0.1:10085 {container_tmp}"
|
||||||
|
)
|
||||||
|
return code == 0, err or out
|
||||||
|
finally:
|
||||||
|
self.ssh.run_sudo_command(f"rm -f {tmp_name}")
|
||||||
|
self.ssh.run_sudo_command(f"docker exec -i {self.container_name} rm -f {container_tmp} 2>/dev/null || true")
|
||||||
|
|
||||||
|
def _xray_api_add_user(self, config, client):
|
||||||
|
tag = self._get_vless_inbound_tag(config)
|
||||||
|
if not tag:
|
||||||
|
return False
|
||||||
|
payload = {
|
||||||
|
"inbounds": [{
|
||||||
|
"tag": tag,
|
||||||
|
"protocol": "vless",
|
||||||
|
"settings": {
|
||||||
|
"clients": [client],
|
||||||
|
"decryption": "none",
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
ok, message = self._run_xray_api_json('adu', payload)
|
||||||
|
if not ok:
|
||||||
|
logger.warning(f"Xray API add user failed: {message}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _xray_api_remove_user(self, config, client_id):
|
||||||
|
tag = self._get_vless_inbound_tag(config)
|
||||||
|
if not tag:
|
||||||
|
return False
|
||||||
|
cmd = (
|
||||||
|
f"docker exec -i {self.container_name} /usr/bin/xray api rmu "
|
||||||
|
f"-server=127.0.0.1:10085 "
|
||||||
|
f"-tag={shlex.quote(tag)} {shlex.quote(client_id)}"
|
||||||
|
)
|
||||||
|
out, err, code = self.ssh.run_sudo_command(cmd)
|
||||||
|
if code != 0:
|
||||||
|
logger.warning(f"Xray API remove user failed: {err or out}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_meta_json(self):
|
||||||
|
"""Read protocol metadata. Supports both layouts.
|
||||||
|
|
||||||
|
Native layout pulls keys from xray_*.key files. Panel layout reads
|
||||||
|
meta.json. Either way, port and site_name come from server.json since
|
||||||
|
that is the authoritative runtime config — meta.json may go stale if
|
||||||
|
the user edits server.json directly via the panel.
|
||||||
|
"""
|
||||||
|
config = self._get_server_json() or {}
|
||||||
|
|
||||||
|
port = None
|
||||||
|
site_name = None
|
||||||
|
rs = {}
|
||||||
|
try:
|
||||||
|
ib = next(b for b in config.get('inbounds', []) if b.get('protocol') == 'vless')
|
||||||
|
port = ib.get('port')
|
||||||
|
rs = ib.get('streamSettings', {}).get('realitySettings', {}) or {}
|
||||||
|
names = rs.get('serverNames') or []
|
||||||
|
if names:
|
||||||
|
site_name = names[0]
|
||||||
|
except StopIteration:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self._detect_layout() == 'native':
|
||||||
|
priv = (self._read_remote_file(f"{self._config_dir()}/xray_private.key") or '').strip()
|
||||||
|
pub = (self._read_remote_file(f"{self._config_dir()}/xray_public.key") or '').strip()
|
||||||
|
sid = (self._read_remote_file(f"{self._config_dir()}/xray_short_id.key") or '').strip()
|
||||||
|
if not priv:
|
||||||
|
priv = rs.get('privateKey', '')
|
||||||
|
if not sid:
|
||||||
|
sids = rs.get('shortIds') or []
|
||||||
|
sid = sids[0] if sids else ''
|
||||||
|
if not pub:
|
||||||
|
pub = self._derive_pubkey_from_priv(priv)
|
||||||
|
return {
|
||||||
|
'private_key': priv,
|
||||||
|
'public_key': pub,
|
||||||
|
'short_id': sid,
|
||||||
|
'port': port,
|
||||||
|
'site_name': site_name or 'yahoo.com',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Panel (legacy) layout
|
||||||
|
out = self._read_remote_file(f"{self._config_dir()}/meta.json")
|
||||||
|
meta = {}
|
||||||
|
if out:
|
||||||
|
try:
|
||||||
|
meta = json.loads(out)
|
||||||
|
except Exception:
|
||||||
|
meta = {}
|
||||||
|
if port is not None:
|
||||||
|
meta['port'] = port
|
||||||
|
if site_name:
|
||||||
|
meta['site_name'] = site_name
|
||||||
|
if not meta.get('private_key'):
|
||||||
|
meta['private_key'] = rs.get('privateKey', '')
|
||||||
|
if not meta.get('short_id'):
|
||||||
|
sids = rs.get('shortIds') or []
|
||||||
|
if sids:
|
||||||
|
meta['short_id'] = sids[0]
|
||||||
|
if not meta.get('public_key') and meta.get('private_key'):
|
||||||
|
meta['public_key'] = self._derive_pubkey_from_priv(meta['private_key'])
|
||||||
|
return meta
|
||||||
|
|
||||||
|
def _get_clients_table(self):
|
||||||
|
"""Read clientsTable, trying both layout filenames."""
|
||||||
|
layout = self._detect_layout()
|
||||||
|
primary = 'clientsTable' if layout == 'native' else 'clientsTable.json'
|
||||||
|
fallback = 'clientsTable.json' if layout == 'native' else 'clientsTable'
|
||||||
|
for fname in (primary, fallback):
|
||||||
|
out = self._read_remote_file(f"{self._config_dir()}/{fname}")
|
||||||
|
if not out or not out.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return json.loads(out)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _save_clients_table(self, data):
|
||||||
|
"""Write clientsTable to the file matching the current layout, in both
|
||||||
|
the container and the host bind-mount.
|
||||||
|
"""
|
||||||
|
path = self._clients_table_path()
|
||||||
|
tmp_file = "/tmp/_xray_clients.json"
|
||||||
|
self.ssh.upload_file_sudo(json.dumps(data, indent=2), tmp_file)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"docker cp {tmp_file} {self.container_name}:{path}"
|
||||||
|
)
|
||||||
|
self.ssh.run_sudo_command(
|
||||||
|
f"cp {tmp_file} {path} 2>/dev/null || true"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _upgrade_config_for_stats(self, config, restart=True):
|
||||||
|
"""Injects API and Stats blocks into older Xray configs transparently."""
|
||||||
|
dirty = False
|
||||||
|
if 'stats' not in config:
|
||||||
|
config['stats'] = {}
|
||||||
|
dirty = True
|
||||||
|
if 'api' not in config:
|
||||||
|
config['api'] = {"services": ["StatsService", "LoggerService", "HandlerService"], "tag": "api"}
|
||||||
|
dirty = True
|
||||||
|
else:
|
||||||
|
services = config['api'].setdefault('services', [])
|
||||||
|
if 'HandlerService' not in services:
|
||||||
|
services.append('HandlerService')
|
||||||
|
dirty = True
|
||||||
|
if 'policy' not in config:
|
||||||
|
config['policy'] = {
|
||||||
|
"levels": {"0": {"statsUserUplink": True, "statsUserDownlink": True}},
|
||||||
|
"system": {"statsInboundUplink": True, "statsInboundDownlink": True, "statsOutboundUplink": True, "statsOutboundDownlink": True}
|
||||||
|
}
|
||||||
|
dirty = True
|
||||||
|
if 'routing' not in config:
|
||||||
|
config['routing'] = {"rules": [{"inboundTag": ["api"], "outboundTag": "api", "type": "field"}]}
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
has_api_inbound = any(ib.get('tag') == 'api' for ib in config.get('inbounds', []))
|
||||||
|
if not has_api_inbound:
|
||||||
|
config.setdefault('inbounds', []).append({
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"port": 10085,
|
||||||
|
"protocol": "dokodemo-door",
|
||||||
|
"settings": {"address": "127.0.0.1"},
|
||||||
|
"tag": "api"
|
||||||
|
})
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
for ib in config.get('inbounds', []):
|
||||||
|
if ib.get('protocol') == 'vless':
|
||||||
|
if not ib.get('tag'):
|
||||||
|
ib['tag'] = 'proxy'
|
||||||
|
dirty = True
|
||||||
|
for c in ib.get('settings', {}).get('clients', []):
|
||||||
|
if 'email' not in c:
|
||||||
|
c['email'] = c['id']
|
||||||
|
dirty = True
|
||||||
|
|
||||||
|
if dirty:
|
||||||
|
self._write_server_json(config, restart=restart)
|
||||||
|
return dirty
|
||||||
|
|
||||||
|
def _query_xray_stats(self):
|
||||||
|
"""Query Xray API for traffic stats using xray api command."""
|
||||||
|
out, _, code = self.ssh.run_sudo_command(
|
||||||
|
f"docker exec -i {self.container_name} /usr/bin/xray api statsquery -server=127.0.0.1:10085"
|
||||||
|
)
|
||||||
|
if code != 0 or not out.strip():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats_raw = json.loads(out)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
# Output format: {"stat": [{"name": "user>>>uid>>>traffic>>>downlink", "value": "123"}, ...]}
|
||||||
|
for item in stats_raw.get('stat', []):
|
||||||
|
name_parts = item.get('name', '').split('>>>')
|
||||||
|
if len(name_parts) == 4 and name_parts[0] == 'user':
|
||||||
|
uid = name_parts[1]
|
||||||
|
t_type = name_parts[3] # uplink or downlink
|
||||||
|
val = int(item.get('value', 0))
|
||||||
|
|
||||||
|
if uid not in results:
|
||||||
|
results[uid] = {'rx': 0, 'tx': 0}
|
||||||
|
|
||||||
|
if t_type == 'downlink':
|
||||||
|
results[uid]['rx'] = val
|
||||||
|
elif t_type == 'uplink':
|
||||||
|
results[uid]['tx'] = val
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _format_bytes(self, size):
|
||||||
|
# Format bytes to string like AWG (e.g., 1.50 MiB)
|
||||||
|
power = 2**10
|
||||||
|
n = 0
|
||||||
|
powers = {0: 'B', 1: 'KiB', 2: 'MiB', 3: 'GiB', 4: 'TiB'}
|
||||||
|
while size > power:
|
||||||
|
size /= power
|
||||||
|
n += 1
|
||||||
|
v = round(size, 2)
|
||||||
|
if v == int(v):
|
||||||
|
v = int(v)
|
||||||
|
return f"{v} {powers.get(n, 'B')}"
|
||||||
|
|
||||||
|
def get_clients(self, protocol=None):
|
||||||
|
config = self._get_server_json()
|
||||||
|
if not config:
|
||||||
|
return []
|
||||||
|
|
||||||
|
self._upgrade_config_for_stats(config, restart=False)
|
||||||
|
|
||||||
|
# Collect all client IDs currently registered in the Xray server config
|
||||||
|
xray_clients = []
|
||||||
|
for ib in config.get('inbounds', []):
|
||||||
|
if ib.get('protocol') == 'vless':
|
||||||
|
xray_clients.extend(ib.get('settings', {}).get('clients', []))
|
||||||
|
|
||||||
|
clients_table = self._get_clients_table()
|
||||||
|
table_ids = {c['clientId'] for c in clients_table}
|
||||||
|
|
||||||
|
# Auto-import clients present in server.json but missing from clientsTable
|
||||||
|
# (e.g. added via the native Amnezia phone/desktop app). Skip the install-time
|
||||||
|
# default UUID for native-layout installs — the official client treats it as
|
||||||
|
# the device of the user who installed the server, not a manageable client.
|
||||||
|
default_uuid = self._get_default_xray_uuid()
|
||||||
|
updated = False
|
||||||
|
for xc in xray_clients:
|
||||||
|
uid = xc.get('id')
|
||||||
|
if not uid or uid in table_ids or uid == default_uuid:
|
||||||
|
continue
|
||||||
|
clients_table.append({
|
||||||
|
'clientId': uid,
|
||||||
|
'userData': {
|
||||||
|
'clientName': f'Imported_{uid[:8]}',
|
||||||
|
'creationDate': datetime.now().isoformat(),
|
||||||
|
'enabled': True
|
||||||
|
}
|
||||||
|
})
|
||||||
|
table_ids.add(uid)
|
||||||
|
updated = True
|
||||||
|
logger.info(f"Auto-imported Xray client {uid[:8]} from server.json")
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
self._save_clients_table(clients_table)
|
||||||
|
|
||||||
|
stats = self._query_xray_stats()
|
||||||
|
|
||||||
|
for c in clients_table:
|
||||||
|
uid = c.get('clientId', '')
|
||||||
|
if uid in stats:
|
||||||
|
user_data = c.setdefault('userData', {})
|
||||||
|
rx = stats[uid]['rx']
|
||||||
|
tx = stats[uid]['tx']
|
||||||
|
if rx > 0 or tx > 0:
|
||||||
|
user_data['dataReceived'] = self._format_bytes(rx)
|
||||||
|
user_data['dataSent'] = self._format_bytes(tx)
|
||||||
|
user_data['dataReceivedBytes'] = rx
|
||||||
|
user_data['dataSentBytes'] = tx
|
||||||
|
|
||||||
|
return clients_table
|
||||||
|
|
||||||
|
def get_client_config(self, protocol, client_id, server_host, port):
|
||||||
|
clients = self._get_clients_table()
|
||||||
|
client = next((c for c in clients if c['clientId'] == client_id), None)
|
||||||
|
if not client: return None
|
||||||
|
|
||||||
|
meta = self._get_meta_json()
|
||||||
|
if not meta: return None
|
||||||
|
|
||||||
|
config = self._get_server_json()
|
||||||
|
sni = meta.get('site_name', 'yahoo.com')
|
||||||
|
if config:
|
||||||
|
try:
|
||||||
|
sni = config['inbounds'][0]['streamSettings']['realitySettings']['serverNames'][0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Format URL
|
||||||
|
# vless://{id}@{host}:{port}?type=tcp&security=reality&pbk={public_key}&sni={sni}&fp=chrome&sid={short_id}&spx=%2F&flow=xtls-rprx-vision#{name}
|
||||||
|
|
||||||
|
name = client.get('userData', {}).get('clientName', 'vpn')
|
||||||
|
encoded_name = urllib.parse.quote(name)
|
||||||
|
|
||||||
|
url = (
|
||||||
|
f"vless://{client_id}@{server_host}:{meta.get('port', port)}"
|
||||||
|
f"?type=tcp&security=reality&pbk={meta['public_key']}"
|
||||||
|
f"&sni={sni}&fp=chrome&sid={meta['short_id']}"
|
||||||
|
f"&spx=%2F&flow=xtls-rprx-vision#{encoded_name}"
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
|
||||||
|
def add_client(self, protocol, client_name, server_host, port):
|
||||||
|
client_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
config = self._get_server_json()
|
||||||
|
if not config: raise RuntimeError("Xray server config not found.")
|
||||||
|
|
||||||
|
self._upgrade_config_for_stats(config, restart=False)
|
||||||
|
|
||||||
|
inbound = self._get_vless_inbound(config)
|
||||||
|
if not inbound:
|
||||||
|
raise RuntimeError("Xray VLESS inbound not found.")
|
||||||
|
|
||||||
|
# Ensure clients structure exists
|
||||||
|
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
|
||||||
|
client = {
|
||||||
|
"id": client_id,
|
||||||
|
"flow": "xtls-rprx-vision",
|
||||||
|
"email": client_id
|
||||||
|
}
|
||||||
|
if not self._xray_api_add_user(config, client):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Xray runtime API is not available for hot user updates. "
|
||||||
|
"The server config was upgraded, but the container must be restarted once to enable HandlerService. "
|
||||||
|
"Restart the Xray container manually and try again."
|
||||||
|
)
|
||||||
|
clients_node.append(client)
|
||||||
|
self._write_server_json(config, restart=False)
|
||||||
|
|
||||||
|
# Update table
|
||||||
|
clients_table = self._get_clients_table()
|
||||||
|
clients_table.append({
|
||||||
|
'clientId': client_id,
|
||||||
|
'userData': {
|
||||||
|
'clientName': client_name,
|
||||||
|
'creationDate': datetime.now().isoformat(),
|
||||||
|
'enabled': True
|
||||||
|
}
|
||||||
|
})
|
||||||
|
self._save_clients_table(clients_table)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'client_id': client_id,
|
||||||
|
'config': self.get_client_config(protocol, client_id, server_host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
def toggle_client(self, protocol, client_id, enable):
|
||||||
|
config = self._get_server_json()
|
||||||
|
self._upgrade_config_for_stats(config, restart=False)
|
||||||
|
inbound = self._get_vless_inbound(config)
|
||||||
|
if not inbound:
|
||||||
|
raise RuntimeError("Xray VLESS inbound not found.")
|
||||||
|
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
|
||||||
|
|
||||||
|
# If toggling on and not present, we can re-add it from clientsTable
|
||||||
|
if enable:
|
||||||
|
if not any(c['id'] == client_id for c in clients_node):
|
||||||
|
client = {
|
||||||
|
"id": client_id,
|
||||||
|
"flow": "xtls-rprx-vision",
|
||||||
|
"email": client_id
|
||||||
|
}
|
||||||
|
if not self._xray_api_add_user(config, client):
|
||||||
|
raise RuntimeError("Xray runtime API failed to enable the client without restarting the container.")
|
||||||
|
clients_node.append(client)
|
||||||
|
else:
|
||||||
|
if not self._xray_api_remove_user(config, client_id):
|
||||||
|
raise RuntimeError("Xray runtime API failed to disable the client without restarting the container.")
|
||||||
|
inbound['settings']['clients'] = [c for c in clients_node if c['id'] != client_id]
|
||||||
|
|
||||||
|
self._write_server_json(config, restart=False)
|
||||||
|
|
||||||
|
clients_table = self._get_clients_table()
|
||||||
|
for c in clients_table:
|
||||||
|
if c['clientId'] == client_id:
|
||||||
|
c.setdefault('userData', {})['enabled'] = enable
|
||||||
|
self._save_clients_table(clients_table)
|
||||||
|
|
||||||
|
def remove_client(self, protocol, client_id):
|
||||||
|
config = self._get_server_json()
|
||||||
|
self._upgrade_config_for_stats(config, restart=False)
|
||||||
|
inbound = self._get_vless_inbound(config)
|
||||||
|
if not inbound:
|
||||||
|
raise RuntimeError("Xray VLESS inbound not found.")
|
||||||
|
clients_node = inbound.setdefault('settings', {}).setdefault('clients', [])
|
||||||
|
if not self._xray_api_remove_user(config, client_id):
|
||||||
|
raise RuntimeError("Xray runtime API failed to remove the client without restarting the container.")
|
||||||
|
inbound['settings']['clients'] = [c for c in clients_node if c['id'] != client_id]
|
||||||
|
self._write_server_json(config, restart=False)
|
||||||
|
|
||||||
|
clients_table = self._get_clients_table()
|
||||||
|
clients_table = [c for c in clients_table if c['clientId'] != client_id]
|
||||||
|
self._save_clients_table(clients_table)
|
||||||
|
return True
|
||||||
@@ -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,41 @@
|
|||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.12.1
|
||||||
|
bcrypt==5.0.0
|
||||||
|
blinker==1.9.0
|
||||||
|
certifi==2026.2.25
|
||||||
|
cffi==2.0.0
|
||||||
|
click==8.3.1
|
||||||
|
colorama==0.4.6
|
||||||
|
cryptography==44.0.0
|
||||||
|
fastapi==0.115.12
|
||||||
|
Flask==3.1.0
|
||||||
|
h11==0.16.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httptools==0.7.1
|
||||||
|
httpx==0.25.2
|
||||||
|
idna==3.11
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
MarkupSafe==3.0.3
|
||||||
|
multicolorcaptcha==1.2.0
|
||||||
|
paramiko==3.5.1
|
||||||
|
pillow==12.1.1
|
||||||
|
pycparser==3.0
|
||||||
|
pydantic==2.12.5
|
||||||
|
pydantic_core==2.41.5
|
||||||
|
PyNaCl==1.6.2
|
||||||
|
psycopg[binary]==3.2.9
|
||||||
|
psycopg_pool==3.2.6
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
python-multipart==0.0.20
|
||||||
|
python-telegram-bot==20.7
|
||||||
|
PyYAML==6.0.3
|
||||||
|
sniffio==1.3.1
|
||||||
|
starlette==0.46.2
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
uvicorn==0.34.0
|
||||||
|
watchfiles==1.1.1
|
||||||
|
websockets==16.0
|
||||||
|
Werkzeug==3.1.6
|
||||||
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
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
+1137
File diff suppressed because it is too large
Load Diff
@@ -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 = _('connect');
|
||||||
|
spinner.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteServer(serverId) {
|
||||||
|
if (!confirm(_('delete_confirm'))) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiCall(`/api/servers/${serverId}/delete`, 'POST');
|
||||||
|
showToast(_('server_deleted'), 'success');
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Edit server ========== */
|
||||||
|
|
||||||
|
function switchEditAuthTab(tab, btn) {
|
||||||
|
const tabs = btn.parentElement.querySelectorAll('.tab');
|
||||||
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById('editAuthPassword').classList.toggle('hidden', tab !== 'password');
|
||||||
|
document.getElementById('editAuthKey').classList.toggle('hidden', tab !== 'key');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditServer(btn) {
|
||||||
|
// Prefill from data-* on the parent server-card so we don't have to round-trip.
|
||||||
|
const card = btn.closest('.server-card');
|
||||||
|
if (!card) return;
|
||||||
|
document.getElementById('editServerIdx').value = card.dataset.idx;
|
||||||
|
document.getElementById('editServerName').value = card.dataset.name || '';
|
||||||
|
document.getElementById('editServerHost').value = card.dataset.host || '';
|
||||||
|
document.getElementById('editServerPort').value = card.dataset.port || '22';
|
||||||
|
document.getElementById('editServerUser').value = card.dataset.username || '';
|
||||||
|
document.getElementById('editServerPassword').value = '';
|
||||||
|
document.getElementById('editServerKey').value = '';
|
||||||
|
|
||||||
|
// Pre-select the tab matching the server's current auth method.
|
||||||
|
const authIsKey = card.dataset.auth === 'key';
|
||||||
|
const tabsEl = document.querySelector('#editServerForm .tabs');
|
||||||
|
const passTab = tabsEl.children[0];
|
||||||
|
const keyTab = tabsEl.children[1];
|
||||||
|
switchEditAuthTab(authIsKey ? 'key' : 'password', authIsKey ? keyTab : passTab);
|
||||||
|
|
||||||
|
openModal('editServerModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEditServer(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = document.getElementById('editServerIdx').value;
|
||||||
|
const btn = document.getElementById('editServerBtn');
|
||||||
|
const btnText = document.getElementById('editServerBtnText');
|
||||||
|
const spinner = document.getElementById('editServerSpinner');
|
||||||
|
btn.disabled = true;
|
||||||
|
btnText.textContent = _('saving') || 'Saving...';
|
||||||
|
spinner.classList.remove('hidden');
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name: document.getElementById('editServerName').value,
|
||||||
|
host: document.getElementById('editServerHost').value,
|
||||||
|
ssh_port: parseInt(document.getElementById('editServerPort').value) || 22,
|
||||||
|
username: document.getElementById('editServerUser').value,
|
||||||
|
// Empty -> null means "leave unchanged" on the backend.
|
||||||
|
password: document.getElementById('editServerPassword').value || null,
|
||||||
|
private_key: document.getElementById('editServerKey').value || null,
|
||||||
|
};
|
||||||
|
await apiCall(`/api/servers/${idx}/edit`, 'POST', body);
|
||||||
|
showToast(_('server_saved'), 'success');
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btnText.textContent = _('save');
|
||||||
|
spinner.classList.add('hidden');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Ping indicators ========== */
|
||||||
|
|
||||||
|
async function pingServer(idx) {
|
||||||
|
const dot = document.getElementById(`ping-${idx}`);
|
||||||
|
const ms = document.getElementById(`ping-ms-${idx}`);
|
||||||
|
if (!dot) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/servers/${idx}/ping`, { credentials: 'same-origin' });
|
||||||
|
const data = await res.json();
|
||||||
|
dot.classList.remove('ping-pending');
|
||||||
|
if (data.alive) {
|
||||||
|
dot.classList.add('ping-ok');
|
||||||
|
dot.title = `${data.ms} ms`;
|
||||||
|
if (ms) ms.textContent = `${data.ms} ms`;
|
||||||
|
} else {
|
||||||
|
dot.classList.add('ping-fail');
|
||||||
|
dot.title = data.error || _('offline');
|
||||||
|
if (ms) ms.textContent = _('offline');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
dot.classList.remove('ping-pending');
|
||||||
|
dot.classList.add('ping-fail');
|
||||||
|
if (ms) ms.textContent = _('offline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAllPings() {
|
||||||
|
// Browsers will fan these out concurrently; the page never blocks waiting
|
||||||
|
// for results because each card updates independently.
|
||||||
|
document.querySelectorAll('.server-card').forEach(card => {
|
||||||
|
const idx = card.dataset.idx;
|
||||||
|
if (idx !== undefined) pingServer(idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== Drag-and-drop reorder ========== */
|
||||||
|
|
||||||
|
let draggedCard = null;
|
||||||
|
|
||||||
|
function setupDragAndDrop() {
|
||||||
|
const grid = document.getElementById('serverGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
grid.addEventListener('dragstart', e => {
|
||||||
|
const card = e.target.closest('.server-card');
|
||||||
|
if (!card) return;
|
||||||
|
// Don't initiate drag from a button/link inside the card — those have
|
||||||
|
// their own click semantics (Edit/Delete/Manage).
|
||||||
|
if (e.target.closest('button, a')) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draggedCard = card;
|
||||||
|
card.classList.add('dragging');
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
// Some browsers require this for dragstart to fire with text payload.
|
||||||
|
try { e.dataTransfer.setData('text/plain', card.dataset.idx); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.addEventListener('dragend', e => {
|
||||||
|
const card = e.target.closest('.server-card');
|
||||||
|
if (card) card.classList.remove('dragging');
|
||||||
|
grid.querySelectorAll('.server-card.drag-over').forEach(c => c.classList.remove('drag-over'));
|
||||||
|
draggedCard = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
const card = e.target.closest('.server-card');
|
||||||
|
if (!card || card === draggedCard) return;
|
||||||
|
grid.querySelectorAll('.server-card.drag-over').forEach(c => {
|
||||||
|
if (c !== card) c.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
card.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.addEventListener('dragleave', e => {
|
||||||
|
const card = e.target.closest('.server-card');
|
||||||
|
if (card && !card.contains(e.relatedTarget)) card.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.addEventListener('drop', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = e.target.closest('.server-card');
|
||||||
|
grid.querySelectorAll('.server-card.drag-over').forEach(c => c.classList.remove('drag-over'));
|
||||||
|
if (!target || !draggedCard || target === draggedCard) return;
|
||||||
|
|
||||||
|
// Insert before/after target depending on cursor position relative
|
||||||
|
// to the target's vertical centre, which feels natural in a grid.
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const insertAfter = (e.clientY - rect.top) > rect.height / 2;
|
||||||
|
grid.insertBefore(draggedCard, insertAfter ? target.nextSibling : target);
|
||||||
|
|
||||||
|
const newOrder = Array.from(grid.querySelectorAll('.server-card'))
|
||||||
|
.map(c => parseInt(c.dataset.idx, 10));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiCall('/api/servers/reorder', 'POST', { order: newOrder });
|
||||||
|
showToast(_('servers_reordered'), 'success');
|
||||||
|
// Reload so onclick indices and per-card data-idx attrs realign with
|
||||||
|
// the new server array — simpler and safer than rewriting every handler.
|
||||||
|
setTimeout(() => window.location.reload(), 400);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, 'error');
|
||||||
|
// Rollback the visual move — backend rejected.
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
startAllPings();
|
||||||
|
setupDragAndDrop();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -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,423 @@
|
|||||||
|
{
|
||||||
|
"nav_servers": "Servers",
|
||||||
|
"nav_users": "Users",
|
||||||
|
"nav_settings": "Settings",
|
||||||
|
"nav_connections": "Connections",
|
||||||
|
"nav_logout": "Logout",
|
||||||
|
"theme_dark": "Dark",
|
||||||
|
"theme_light": "Light",
|
||||||
|
"back_to_servers": "← Back to servers",
|
||||||
|
"server_check": "Check",
|
||||||
|
"server_checking": "Checking...",
|
||||||
|
"protocols": "Protocols",
|
||||||
|
"install": "Install",
|
||||||
|
"uninstall": "Uninstall",
|
||||||
|
"run": "Running",
|
||||||
|
"stop": "Stopped",
|
||||||
|
"not_installed": "Not installed",
|
||||||
|
"installed": "Installed",
|
||||||
|
"add_server": "Add Server",
|
||||||
|
"no_servers": "No servers added",
|
||||||
|
"name": "Name",
|
||||||
|
"host": "Host",
|
||||||
|
"port": "Port",
|
||||||
|
"status": "Status",
|
||||||
|
"actions": "Actions",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"copy": "Copy",
|
||||||
|
"download": "Download",
|
||||||
|
"config": "Configuration",
|
||||||
|
"qr_code": "QR code",
|
||||||
|
"vpn_key": "VPN key",
|
||||||
|
"clients": "Clients",
|
||||||
|
"traffic": "Traffic",
|
||||||
|
"limit": "Limit",
|
||||||
|
"manage": "Manage",
|
||||||
|
"no_protocols": "No protocols",
|
||||||
|
"add_server_desc": "Add your first VPN server to get started. You'll need SSH credentials to connect.",
|
||||||
|
"ssh_host": "Host / IP",
|
||||||
|
"ssh_port": "SSH Port",
|
||||||
|
"ssh_user": "User",
|
||||||
|
"ssh_password": "Password",
|
||||||
|
"ssh_key": "SSH Key",
|
||||||
|
"ssh_key_placeholder": "Private SSH Key",
|
||||||
|
"ssh_key_hint": "Paste the content of your id_rsa or id_ed25519 file",
|
||||||
|
"connect": "Connect",
|
||||||
|
"connecting": "Connecting...",
|
||||||
|
"server_added": "Server added successfully",
|
||||||
|
"server_deleted": "Server deleted",
|
||||||
|
"delete_confirm": "Are you sure you want to delete this server?",
|
||||||
|
"success": "Success",
|
||||||
|
"error": "Error",
|
||||||
|
"info": "Info",
|
||||||
|
"login_title": "Login to Panel",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"captcha": "Captcha",
|
||||||
|
"captcha_placeholder": "Enter characters from image",
|
||||||
|
"captcha_hint": "Click to refresh image",
|
||||||
|
"login": "Login",
|
||||||
|
"logging_in": "Logging in...",
|
||||||
|
"login_error": "Login failed",
|
||||||
|
"check_done": "Check complete",
|
||||||
|
"check_error": "Connection error",
|
||||||
|
"ssh_ok": "SSH Connection",
|
||||||
|
"docker_ok": "Docker",
|
||||||
|
"docker_not_installed": "Docker not installed",
|
||||||
|
"awg_desc": "A newer version of the protocol based on awg-go. Supports advanced obfuscation with S3, S4 parameters.",
|
||||||
|
"awg_legacy_desc": "Original AWG version based on WireGuard kernel. Compatible with older client versions.",
|
||||||
|
"xray_desc": "Modern protocol that masks traffic as regular web traffic (XTLS-Reality). Resistant to deep packet analysis.",
|
||||||
|
"wireguard_desc": "Standard and fastest VPN protocol. Supported natively on all modern OS, but easily detected by DPI.",
|
||||||
|
"not_checked": "Not checked",
|
||||||
|
"connections": "Connections",
|
||||||
|
"add": "Add",
|
||||||
|
"loading_connections": "Loading connections...",
|
||||||
|
"no_connections": "No connections",
|
||||||
|
"no_connections_desc": "Add your first connection to generate a VPN configuration",
|
||||||
|
"install_protocol": "Install protocol",
|
||||||
|
"port_default_hint": "Default port: 55424. Make sure it's not busy",
|
||||||
|
"port_xray_hint": "Default port: 443 (recommended for Xray). Make sure it's not taken by another web server.",
|
||||||
|
"reinstall": "Reinstall",
|
||||||
|
"uninstall_confirm": "Uninstall {}? All connections and configurations will be lost.",
|
||||||
|
"stop_container_confirm": "Stop container {}?",
|
||||||
|
"start_container_confirm": "Start container {}?",
|
||||||
|
"stopping": "Stopping",
|
||||||
|
"starting": "Starting",
|
||||||
|
"stopped": "stopped",
|
||||||
|
"started": "started",
|
||||||
|
"server_config": "Server configuration",
|
||||||
|
"add_connection": "Add connection",
|
||||||
|
"connection_name": "Connection name",
|
||||||
|
"connection_name_placeholder": "Example: iPhone, Laptop",
|
||||||
|
"assign_to_user": "Assign to user (optional)",
|
||||||
|
"no_assign": "— No assignment —",
|
||||||
|
"create": "Create",
|
||||||
|
"creating": "Creating...",
|
||||||
|
"connection_created": "Connection \"{}\" created",
|
||||||
|
"delete_connection_confirm": "Delete this connection? The configuration will stop working.",
|
||||||
|
"connection_deleted": "Connection deleted",
|
||||||
|
"config_tab": "📄 Config",
|
||||||
|
"vpn_key_tab": "🔑 VPN-key",
|
||||||
|
"qr_code_tab": "📱 QR-code",
|
||||||
|
"copy_config": "📋 Copy",
|
||||||
|
"download_conf": "⬇ Download .conf",
|
||||||
|
"copy_key": "📋 Copy key",
|
||||||
|
"vpn_link_hint": "Paste this link into the AmneziaVPN app for automatic configuration",
|
||||||
|
"qr_hint": "Scan the QR code in the AmneziaVPN app",
|
||||||
|
"no_data": "No data",
|
||||||
|
"start_btn": "▶ Start",
|
||||||
|
"stop_btn": "⏹ Stop",
|
||||||
|
"config_btn": "⚙️ Config",
|
||||||
|
"done": "Done",
|
||||||
|
"installing": "Installing...",
|
||||||
|
"start_install": "Starting installation...",
|
||||||
|
"check_docker": "Checking Docker...",
|
||||||
|
"prepare_host": "Preparing host...",
|
||||||
|
"build_container": "Building container...",
|
||||||
|
"install_success": "Protocol installed successfully!",
|
||||||
|
"install_error": "Installation error: ",
|
||||||
|
"qr_error": "Failed to generate QR code",
|
||||||
|
"users_title": "Users",
|
||||||
|
"search_placeholder": "Search by name, email or Telegram...",
|
||||||
|
"add_user": "Add User",
|
||||||
|
"prev_page": "← Back",
|
||||||
|
"next_page": "Next →",
|
||||||
|
"page_info": "Page {} of {}",
|
||||||
|
"loading_users": "Loading users...",
|
||||||
|
"nothing_found": "Nothing found",
|
||||||
|
"search_empty_desc": "Try changing your query or create a new user",
|
||||||
|
"username_label": "Username",
|
||||||
|
"password_label": "Password",
|
||||||
|
"role_label": "Role",
|
||||||
|
"role_user_desc": "User — view only own connections",
|
||||||
|
"role_support_desc": "Support — server management",
|
||||||
|
"role_admin_desc": "Admin — full access",
|
||||||
|
"tg_id_label": "Telegram ID (opt.)",
|
||||||
|
"email_label": "Email (opt.)",
|
||||||
|
"traffic_limit_label": "Traffic limit (GB) (opt., 0 = ∞)",
|
||||||
|
"expiration_date_label": "Expiration date",
|
||||||
|
"description_label": "Description (opt.)",
|
||||||
|
"auto_conn_title": "Automatic connection creation (optional)",
|
||||||
|
"server_label": "Server",
|
||||||
|
"no_create_conn": "— Do not create connection —",
|
||||||
|
"protocol_label": "Protocol",
|
||||||
|
"edit_user_title": "Edit user: {}",
|
||||||
|
"edit_user_tg": "Telegram ID (optional)",
|
||||||
|
"edit_user_email": "Email (optional)",
|
||||||
|
"edit_user_limit": "Traffic limit (GB) (0 = ∞)",
|
||||||
|
"new_password_hint": "New password (leave empty if no change needed)",
|
||||||
|
"op_type_label": "Operation type",
|
||||||
|
"create_new_conn": "Create new connection",
|
||||||
|
"link_existing_conn": "Link existing",
|
||||||
|
"select_existing_conn": "Select existing connection",
|
||||||
|
"existing_conn_hint": "Only connections not linked to users will be shown",
|
||||||
|
"conn_name_panel": "Connection name (in panel)",
|
||||||
|
"user_conns_title": "User connections",
|
||||||
|
"no_free_conns": "No free connections",
|
||||||
|
"linking": "Linking...",
|
||||||
|
"conn_linked": "Connection linked",
|
||||||
|
"enabling": "Enabling",
|
||||||
|
"disabling": "Disabling",
|
||||||
|
"user_enabled": "User enabled",
|
||||||
|
"user_disabled": "User disabled",
|
||||||
|
"delete_user_confirm": "Delete user? All their connections will be deleted.",
|
||||||
|
"share_access": "Share access",
|
||||||
|
"enable_public_access": "Enable public access",
|
||||||
|
"share_password_label": "Password (optional)",
|
||||||
|
"share_password_hint": "If set, user must enter it",
|
||||||
|
"public_link_label": "Public link",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"out_of": "Of",
|
||||||
|
"settings_title": "Panel Settings",
|
||||||
|
"appearance": "🎨 Appearance",
|
||||||
|
"title_label": "Title",
|
||||||
|
"logo_label": "Logo (Emoji or symbol)",
|
||||||
|
"subtitle_label": "Subtitle",
|
||||||
|
"captcha_title": "🔐 Captcha",
|
||||||
|
"enable_captcha": "Enable captcha (multicolorcaptcha)",
|
||||||
|
"telegram_bot_title": "🤖 Telegram Bot",
|
||||||
|
"bot_token_label": "Bot Token (from @BotFather)",
|
||||||
|
"bot_status": "Status",
|
||||||
|
"bot_running": "✅ Running",
|
||||||
|
"bot_stopped": "⏹ Stopped",
|
||||||
|
"bot_stop_btn": "⏹ Stop",
|
||||||
|
"bot_start_btn": "▶️ Start",
|
||||||
|
"bot_hint": "After changing the token, save the settings — the bot will restart automatically. Users must have a Telegram ID in their profile.",
|
||||||
|
"api_docs_title": "🔌 API Documentation",
|
||||||
|
"api_docs_hint": "Use these interfaces to explore the panel's API capabilities",
|
||||||
|
"tunnels_title": "Tunnels",
|
||||||
|
"local_server": "Local Server",
|
||||||
|
"tunnel_install_enable": "Install & Enable",
|
||||||
|
"tunnel_enable": "Enable Tunnel",
|
||||||
|
"tunnel_running": "Tunnel running",
|
||||||
|
"tunnel_stop": "Stop",
|
||||||
|
"tunnel_stopping": "Stopping...",
|
||||||
|
"tunnel_stopped": "Tunnel stopped",
|
||||||
|
"tunnel_delete": "Delete",
|
||||||
|
"tunnel_deleted": "Tunnel deleted",
|
||||||
|
"tunnel_delete_confirm": "Stop and delete this panel-managed tunnel binary?",
|
||||||
|
"tunnel_starting": "Starting tunnel...",
|
||||||
|
"tunnel_started": "Tunnel started",
|
||||||
|
"tunnel_waiting_url": "Tunnel started. Waiting for public URL...",
|
||||||
|
"tunnel_no_public_url": "Public URL will appear here after the tunnel starts.",
|
||||||
|
"tunnels_hint": "Quick tunnels expose this local panel to the internet. Keep admin credentials strong and share public URLs only with trusted users.",
|
||||||
|
"ngrok_authtoken_placeholder": "ngrok authtoken (optional for some accounts)",
|
||||||
|
"import_users_title": "📤 Import Users",
|
||||||
|
"import_source_label": "Import / Sync Source",
|
||||||
|
"remnawave_url_label": "Remnawave URL",
|
||||||
|
"api_key_label": "API Key",
|
||||||
|
"enable_sync": "Enable synchronization",
|
||||||
|
"sync_hint": "Users will be created, disabled, and deleted automatically based on Remnawave data",
|
||||||
|
"sync_now_btn": "🔄 Sync now",
|
||||||
|
"delete_sync_btn": "🗑 Delete synchronization",
|
||||||
|
"auto_create_conns": "Create connections automatically",
|
||||||
|
"sync_server_label": "Server for new connections",
|
||||||
|
"save_changes": "💾 Save changes",
|
||||||
|
"sync_success": "Sync completed: {} users",
|
||||||
|
"sync_deleted": "Deleted {} synced users",
|
||||||
|
"delete_sync_confirm": "Are you sure you want to delete ALL synced users and their connections? This action is irreversible.",
|
||||||
|
"settings_saved": "Settings saved successfully",
|
||||||
|
"bot_started": "Bot started",
|
||||||
|
"bot_stopped_msg": "Bot stopped",
|
||||||
|
"sync_running": "Syncing...",
|
||||||
|
"deleting": "Deleting...",
|
||||||
|
"my_connections_title": "My Connections",
|
||||||
|
"show_config": "📄 Show config",
|
||||||
|
"no_connections_user_desc": "Contact administrator to create a VPN connection",
|
||||||
|
"share_title": "Connection Access",
|
||||||
|
"vpn_access": "VPN Access",
|
||||||
|
"user_label_share": "User",
|
||||||
|
"share_protected_desc": "This access is password protected. Please enter it to continue.",
|
||||||
|
"auth_success": "Auth success",
|
||||||
|
"loading_share_conns": "Loading connections...",
|
||||||
|
"no_active_conns": "You have no active connections yet.",
|
||||||
|
"show_settings_btn": "🔑 Show settings",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"active": "Active",
|
||||||
|
"site_description": "Amnezia Web Panel — management of VPN servers and protocols AmneziaWG",
|
||||||
|
"toggle_theme": "Change theme",
|
||||||
|
"copied_to_clipboard": "Copied to clipboard",
|
||||||
|
"share_not_found": "404 Not Found",
|
||||||
|
"share_not_found_desc": "Either the link is incorrect or access has been disabled.",
|
||||||
|
"invalid_captcha": "Invalid captcha",
|
||||||
|
"account_disabled": "Account disabled",
|
||||||
|
"invalid_login": "Invalid login or password",
|
||||||
|
"user_exists": "User with this name already exists",
|
||||||
|
"cannot_delete_self": "You cannot delete yourself",
|
||||||
|
"wrong_share_password": "Wrong password",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"select_lang": "Select Language",
|
||||||
|
"lang_ru": "Русский",
|
||||||
|
"lang_en": "English",
|
||||||
|
"lang_fr": "Français",
|
||||||
|
"lang_zh": "中文 (Chinese)",
|
||||||
|
"lang_fa": "فارسی (Persian)",
|
||||||
|
"backup_title": "Simple Backup",
|
||||||
|
"download_backup": "Download data.json",
|
||||||
|
"restore_backup": "Restore from file",
|
||||||
|
"restore_confirm": "Are you sure? Current data will be overwritten and the application will restart.",
|
||||||
|
"restore_success": "Restore successful! Restarting...",
|
||||||
|
"invalid_backup_file": "Invalid backup file or structure",
|
||||||
|
"config_unavailable": "Configuration unavailable",
|
||||||
|
"config_unavailable_desc": "This client was created via the native Amnezia app.\\nThe private key is stored only on the user's device and cannot be recovered by the server.",
|
||||||
|
"client_public_key": "Client public key:",
|
||||||
|
"telemt_desc": "MTProxy-based Telegram proxy with advanced obfuscation and TLS emulation support.",
|
||||||
|
"tls_emulation": "TLS Emulation",
|
||||||
|
"tls_domain": "Masking Domain",
|
||||||
|
"max_connections_limit": "Max Connections",
|
||||||
|
"max_connections_hint": "0 for unlimited. Limits total concurrent users.",
|
||||||
|
"traffic_reset_strategy_label": "Traffic reset strategy",
|
||||||
|
"traffic_reset_never": "Never reset",
|
||||||
|
"traffic_reset_daily": "Daily reset",
|
||||||
|
"traffic_reset_weekly": "Weekly reset",
|
||||||
|
"traffic_reset_monthly": "Monthly reset",
|
||||||
|
"traffic_reset_hint": "How often should user traffic be reset",
|
||||||
|
"traffic_used_total": "Total spent",
|
||||||
|
"traffic_reset_info": "Next reset: {}",
|
||||||
|
"services": "Services",
|
||||||
|
"dns_desc": "Personal DNS server to block ads and protect the privacy of your queries.",
|
||||||
|
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
|
||||||
|
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
|
||||||
|
"socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.",
|
||||||
|
"socks5_username": "Username",
|
||||||
|
"socks5_password": "Password",
|
||||||
|
"socks5_password_placeholder": "Leave empty to auto-generate",
|
||||||
|
"socks5_password_hint": "If left empty, a random 16-character password will be generated.",
|
||||||
|
"socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.",
|
||||||
|
"socks5_change_settings": "Change settings",
|
||||||
|
"socks5_settings_title": "SOCKS5 — Connection settings",
|
||||||
|
"socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.",
|
||||||
|
"socks5_settings_saved": "SOCKS5 settings saved",
|
||||||
|
"adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.",
|
||||||
|
"adguard_mode": "Installation mode",
|
||||||
|
"adguard_mode_sidebyside": "Side-by-side",
|
||||||
|
"adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)",
|
||||||
|
"adguard_mode_replace": "Replace AmneziaDNS",
|
||||||
|
"adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately",
|
||||||
|
"adguard_dns_port": "DNS port (53)",
|
||||||
|
"adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.",
|
||||||
|
"adguard_web_port": "Web UI port",
|
||||||
|
"adguard_dot_port": "DoT port",
|
||||||
|
"adguard_doh_port": "DoH port",
|
||||||
|
"adguard_expose_web": "Expose to internet",
|
||||||
|
"adguard_expose": "Expose to host",
|
||||||
|
"adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.",
|
||||||
|
"adguard_internal_ip": "Internal IP",
|
||||||
|
"adguard_web_ui": "Admin URL",
|
||||||
|
"adguard_open_ui": "Open Web UI",
|
||||||
|
"edit_server_title": "Edit server",
|
||||||
|
"edit_keep_credential": "Leave empty to keep the current value",
|
||||||
|
"server_saved": "Server settings saved",
|
||||||
|
"servers_reordered": "Servers reordered",
|
||||||
|
"ping_checking": "Checking connectivity...",
|
||||||
|
"offline": "offline",
|
||||||
|
"api_tokens_title": "API tokens",
|
||||||
|
"api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer <token>` header. Tokens have admin-equivalent rights and stop working if their owner is disabled or demoted.",
|
||||||
|
"api_tokens_empty": "No API tokens yet. Create one to integrate the panel with an external service.",
|
||||||
|
"api_tokens_create": "Create token",
|
||||||
|
"api_tokens_create_title": "Create API token",
|
||||||
|
"api_tokens_name_label": "Name",
|
||||||
|
"api_tokens_name_placeholder": "e.g. Linear integration, CI bot, monitoring",
|
||||||
|
"api_tokens_name_hint": "Used to identify this token in the list. The token value itself is generated for you.",
|
||||||
|
"api_tokens_name_required": "Token name is required",
|
||||||
|
"api_tokens_created_title": "Token created",
|
||||||
|
"api_tokens_value_label": "Token value",
|
||||||
|
"api_tokens_warning_title": "This is the only time you'll see this token.",
|
||||||
|
"api_tokens_warning_body": "Copy it now and store it in your integration's secret manager. The panel keeps only a hash — if you lose this value you'll need to revoke and recreate the token.",
|
||||||
|
"api_tokens_revoke": "Revoke",
|
||||||
|
"api_tokens_revoke_confirm": "Revoke token \"{}\"? Any integration using it will stop working.",
|
||||||
|
"api_tokens_revoked": "Token revoked",
|
||||||
|
"api_tokens_created": "Created",
|
||||||
|
"api_tokens_last_used": "Last used",
|
||||||
|
"api_tokens_owner": "Owner",
|
||||||
|
"api_tokens_never": "never",
|
||||||
|
"coming_soon": "Coming soon",
|
||||||
|
"promo_star_cta": "Star us on GitHub",
|
||||||
|
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
|
||||||
|
"revproxy_title": "Reverse Proxy",
|
||||||
|
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
|
||||||
|
"management": "Management",
|
||||||
|
"server_management": "Server Management",
|
||||||
|
"server_management_desc": "Perform system-level actions on your machine.",
|
||||||
|
"check_server_services": "Check the server services",
|
||||||
|
"reboot_server": "Reboot server",
|
||||||
|
"clear_server": "Clear server",
|
||||||
|
"remove_server": "Remove server from panel",
|
||||||
|
"ssl_title": "SSL / HTTPS Settings",
|
||||||
|
"enable_https": "Enable HTTPS",
|
||||||
|
"panel_port_label": "Panel Port",
|
||||||
|
"domain_label": "Domain name",
|
||||||
|
"cert_path_label": "SSL Certificate path (.pem)",
|
||||||
|
"key_path_label": "Private Key path (.pem)",
|
||||||
|
"or_paste_text": "OR paste certificate text",
|
||||||
|
"cert_text_label": "Certificate content (CRT)",
|
||||||
|
"key_text_label": "Private Key content (KEY)",
|
||||||
|
"ssl_hint": "After enabling, the panel will be accessible via https:// on the specified domain. Make sure the file paths or text data are correct.",
|
||||||
|
"about_title": "About & Updates",
|
||||||
|
"current_version": "Current version",
|
||||||
|
"check_updates": "Check for updates",
|
||||||
|
"checking_updates": "Checking...",
|
||||||
|
"update_available": "New version available",
|
||||||
|
"download_update": "Download update",
|
||||||
|
"up_to_date": "You have the latest version",
|
||||||
|
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
|
||||||
|
"warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.",
|
||||||
|
"warp_install_required": "Install WARP first",
|
||||||
|
"warp_connect": "Connect WARP",
|
||||||
|
"warp_disconnect": "Disconnect",
|
||||||
|
"warp_connected": "WARP connected",
|
||||||
|
"warp_disconnected": "WARP disconnected",
|
||||||
|
"warp_status_unknown": "WARP status is unknown.",
|
||||||
|
"warp_status_not_installed": "Cloudflare WARP CLI is not installed on this host.",
|
||||||
|
"warp_status_connected": "Cloudflare WARP is connected.",
|
||||||
|
"warp_status_connecting": "Cloudflare WARP is connecting.",
|
||||||
|
"warp_status_disconnected": "Cloudflare WARP is disconnected.",
|
||||||
|
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
|
||||||
|
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
|
||||||
|
"warp_status_error": "Cloudflare WARP returned an error.",
|
||||||
|
"install_starting": "Starting installation...",
|
||||||
|
"server_check_complete": "Check completed",
|
||||||
|
"server_connection_error": "Connection error",
|
||||||
|
"connections_load_error": "Error loading connections",
|
||||||
|
"connection_updated": "Connection updated",
|
||||||
|
"edit_connection": "Edit connection",
|
||||||
|
"enable_connection_progress": "Enabling connection...",
|
||||||
|
"disable_connection_progress": "Disabling connection...",
|
||||||
|
"connection_enabled": "Connection enabled",
|
||||||
|
"connection_disabled": "Connection disabled",
|
||||||
|
"gb": "GB",
|
||||||
|
"marketplace": "Templates",
|
||||||
|
"open_marketplace": "Open Templates",
|
||||||
|
"marketplace_desc": "Install protocol and service templates available for this server.",
|
||||||
|
"installed_apps": "Installed applications",
|
||||||
|
"installed_apps_hint": "Only installed protocols and services are shown here. Use Templates to add more.",
|
||||||
|
"no_installed_apps": "No installed applications yet",
|
||||||
|
"no_installed_apps_desc": "Open Templates to install protocols or services on this server.",
|
||||||
|
"install_another": "Install another",
|
||||||
|
"new_instance": "new instance",
|
||||||
|
"port_next_instance_hint": "Choose a free UDP port for the additional instance.",
|
||||||
|
"checking_server": "Checking server services...",
|
||||||
|
"web_servers": "Web servers",
|
||||||
|
"nginx_desc": "NGINX web server with Let's Encrypt HTTPS certificate and editable index.html.",
|
||||||
|
"nginx_domain": "Domain",
|
||||||
|
"nginx_email": "Let's Encrypt email",
|
||||||
|
"nginx_port_hint": "HTTPS port exposed on the server. Port 80/TCP must be free and reachable for Let's Encrypt validation.",
|
||||||
|
"nginx_install_hint": "The domain must already point to this server. Port 80/TCP is used temporarily/permanently for HTTP-01 certificate validation.",
|
||||||
|
"site": "Site",
|
||||||
|
"site_editor": "Site editor",
|
||||||
|
"site_saved": "Site saved",
|
||||||
|
"nginx_dns_hint": "Create this DNS record before installation:",
|
||||||
|
"backup": "Backup",
|
||||||
|
"backups": "Backups",
|
||||||
|
"backup_desc": "Backups are created on the server and contain valuable protocol files needed to restore or recreate it later.",
|
||||||
|
"create_backup": "Create backup",
|
||||||
|
"creating_backup": "Creating backup...",
|
||||||
|
"backup_created": "Backup created",
|
||||||
|
"no_backups": "No backups yet",
|
||||||
|
"no_backups_desc": "Create the first backup for this protocol.",
|
||||||
|
"refresh": "Refresh"
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
{
|
||||||
|
"nav_servers": "سرورها",
|
||||||
|
"nav_users": "کاربران",
|
||||||
|
"nav_settings": "تنظیمات",
|
||||||
|
"nav_connections": "اتصالها",
|
||||||
|
"nav_logout": "خروج",
|
||||||
|
"theme_dark": "تاریک",
|
||||||
|
"theme_light": "روشن",
|
||||||
|
"back_to_servers": "← بازگشت به سرورها",
|
||||||
|
"server_check": "بررسی",
|
||||||
|
"server_checking": "در حال بررسی...",
|
||||||
|
"protocols": "پروتکلها",
|
||||||
|
"install": "نصب",
|
||||||
|
"uninstall": "حذف نصب",
|
||||||
|
"run": "در حال اجرا",
|
||||||
|
"stop": "متوقف شده",
|
||||||
|
"not_installed": "نصب نشده",
|
||||||
|
"installed": "نصب شده",
|
||||||
|
"add_server": "افزودن سرور",
|
||||||
|
"no_servers": "هیچ سروری اضافه نشده است",
|
||||||
|
"name": "نام",
|
||||||
|
"host": "میزبان",
|
||||||
|
"port": "پورت",
|
||||||
|
"status": "وضعیت",
|
||||||
|
"actions": "عملکردها",
|
||||||
|
"edit": "ویرایش",
|
||||||
|
"delete": "حذف",
|
||||||
|
"save": "ذخیره",
|
||||||
|
"cancel": "لغو",
|
||||||
|
"loading": "در حال بارگذاری...",
|
||||||
|
"copy": "کپی",
|
||||||
|
"download": "دانلود",
|
||||||
|
"config": "پیکربندی",
|
||||||
|
"qr_code": "کد QR",
|
||||||
|
"vpn_key": "کلید VPN",
|
||||||
|
"clients": "کلاینتها",
|
||||||
|
"traffic": "ترافیک",
|
||||||
|
"limit": "محدودیت",
|
||||||
|
"manage": "مدیریت",
|
||||||
|
"no_protocols": "بدون پروتکل",
|
||||||
|
"add_server_desc": "اولین سرور VPN خود را اضافه کنید. برای اتصال به اطلاعات SSH نیاز دارید.",
|
||||||
|
"ssh_host": "میزبان / IP",
|
||||||
|
"ssh_port": "پورت SSH",
|
||||||
|
"ssh_user": "نام کاربری",
|
||||||
|
"ssh_password": "رمز عبور",
|
||||||
|
"ssh_key": "کلید SSH",
|
||||||
|
"ssh_key_placeholder": "کلید خصوصی SSH",
|
||||||
|
"ssh_key_hint": "محتوای فایل id_rsa یا id_ed25519 خود را اینجا قرار دهید",
|
||||||
|
"connect": "اتصال",
|
||||||
|
"connecting": "در حال اتصال...",
|
||||||
|
"server_added": "سرور با موفقیت اضافه شد",
|
||||||
|
"server_deleted": "سرور حذف شد",
|
||||||
|
"delete_confirm": "آیا از حذف این سرور مطمئن هستید؟",
|
||||||
|
"success": "موفقیت",
|
||||||
|
"error": "خطا",
|
||||||
|
"info": "اطلاعات",
|
||||||
|
"login_title": "ورود به پنل",
|
||||||
|
"username": "نام کاربری",
|
||||||
|
"password": "رمز عبور",
|
||||||
|
"captcha": "کد امنیتی",
|
||||||
|
"captcha_placeholder": "کاراکترهای تصویر را وارد کنید",
|
||||||
|
"captcha_hint": "برای نوسازی تصویر کلیک کنید",
|
||||||
|
"login": "ورود",
|
||||||
|
"logging_in": "در حال ورود...",
|
||||||
|
"login_error": "ورود ناموفق بود",
|
||||||
|
"check_done": "بررسی کامل شد",
|
||||||
|
"check_error": "خطای اتصال",
|
||||||
|
"ssh_ok": "اتصال SSH برقرار است",
|
||||||
|
"docker_ok": "داکر نصب است",
|
||||||
|
"docker_not_installed": "داکر نصب نیست",
|
||||||
|
"awg_desc": "نسخه جدید پروتکل بر پایه awg-go. پشتیبانی از مبهمسازی پیشرفته (S3, S4).",
|
||||||
|
"awg_legacy_desc": "نسخه اصلی AWG. سازگار با نسخههای قدیمی کلاینت.",
|
||||||
|
"xray_desc": "تغییر ظاهر ترافیک به ترافیک معمولی وب (XTLS-Reality). مقاوم در برابر فیلترینگ شدید.",
|
||||||
|
"not_checked": "بررسی نشده",
|
||||||
|
"connections": "اتصالها",
|
||||||
|
"add": "افزودن",
|
||||||
|
"loading_connections": "در حال بارگذاری اتصالها...",
|
||||||
|
"no_connections": "بدون اتصال",
|
||||||
|
"no_connections_desc": "اولین اتصال خود را برای ایجاد پیکربندی VPN اضافه کنید",
|
||||||
|
"install_protocol": "نصب پروتکل",
|
||||||
|
"port_default_hint": "پورت پیشفرض: 55424. مطمئن شوید این پورت آزاد است.",
|
||||||
|
"port_xray_hint": "پورت پیشنهادی: 443. مطمئن شوید توسط وبسرور دیگری اشغال نشده باشد.",
|
||||||
|
"reinstall": "نصب مجدد",
|
||||||
|
"uninstall_confirm": "حذف نصب {}؟ تمام اتصالها و پیکربندیها از بین خواهند رفت.",
|
||||||
|
"stop_container_confirm": "توقف کانتینر {}؟",
|
||||||
|
"start_container_confirm": "شروع کانتینر {}؟",
|
||||||
|
"stopping": "در حال توقف",
|
||||||
|
"starting": "در حال شروع",
|
||||||
|
"stopped": "متوقف شد",
|
||||||
|
"started": "شروع شد",
|
||||||
|
"server_config": "پیکربندی سرور",
|
||||||
|
"add_connection": "افزودن اتصال",
|
||||||
|
"connection_name": "نام اتصال",
|
||||||
|
"connection_name_placeholder": "مثال: iPhone, Laptop",
|
||||||
|
"assign_to_user": "تخصیص به کاربر (اختیاری)",
|
||||||
|
"no_assign": "— بدون تخصیص —",
|
||||||
|
"create": "ایجاد",
|
||||||
|
"creating": "در حال ایجاد...",
|
||||||
|
"connection_created": "اتصال \"{}\" ایجاد شد",
|
||||||
|
"delete_connection_confirm": "این اتصال حذف شود؟ پیکربندی دیگر کار نخواهد کرد.",
|
||||||
|
"connection_deleted": "اتصال حذف شد",
|
||||||
|
"config_tab": "📄 فایل",
|
||||||
|
"vpn_key_tab": "🔑 کلید-VPN",
|
||||||
|
"qr_code_tab": "📱 کد-QR",
|
||||||
|
"copy_config": "📋 کپی",
|
||||||
|
"download_conf": "⬇ دانلود .conf",
|
||||||
|
"copy_key": "📋 کپی کلید",
|
||||||
|
"vpn_link_hint": "این لینک را برای تنظیم خودکار در برنامه AmneziaVPN وارد کنید",
|
||||||
|
"qr_hint": "کد QR را در برنامه AmneziaVPN اسکن کنید",
|
||||||
|
"no_data": "بدون داده",
|
||||||
|
"start_btn": "▶ شروع",
|
||||||
|
"stop_btn": "⏹ توقف",
|
||||||
|
"config_btn": "⚙️ تنظیم",
|
||||||
|
"done": "انجام شد",
|
||||||
|
"installing": "در حال نصب...",
|
||||||
|
"start_install": "شروع نصب...",
|
||||||
|
"check_docker": "بررسی داکر...",
|
||||||
|
"prepare_host": "آمادهسازی میزبان...",
|
||||||
|
"build_container": "ساخت کانتینر...",
|
||||||
|
"install_success": "پروتکل با موفقیت نصب شد!",
|
||||||
|
"install_error": "خطا در نصب: ",
|
||||||
|
"qr_error": "ایجاد کد QR ناموفق بود",
|
||||||
|
"users_title": "کاربران",
|
||||||
|
"search_placeholder": "جستجو با نام، ایمیل یا تلگرام...",
|
||||||
|
"add_user": "افزودن کاربر",
|
||||||
|
"prev_page": "← قبلی",
|
||||||
|
"next_page": "بعدی →",
|
||||||
|
"page_info": "صفحه {} از {}",
|
||||||
|
"loading_users": "در حال بارگذاری کاربران...",
|
||||||
|
"nothing_found": "چیزی یافت نشد",
|
||||||
|
"search_empty_desc": "جستجوی دیگری را امتحان کنید یا کاربر جدید بسازید",
|
||||||
|
"username_label": "نام کاربری",
|
||||||
|
"password_label": "رمز عبور",
|
||||||
|
"role_label": "نقش",
|
||||||
|
"role_user_desc": "User — فقط مشاهده اتصالهای خود",
|
||||||
|
"role_support_desc": "Support — مدیریت سرورها",
|
||||||
|
"role_admin_desc": "Admin — دسترسی کامل",
|
||||||
|
"tg_id_label": "آیدی تلگرام (اختیاری)",
|
||||||
|
"email_label": "ایمیل (اختیاری)",
|
||||||
|
"traffic_limit_label": "محدودیت ترافیک (گیگابایت) (0 = نامحدود)",
|
||||||
|
"description_label": "توضیحات (اختیاری)",
|
||||||
|
"auto_conn_title": "ایجاد خودکار اتصال (اختیاری)",
|
||||||
|
"server_label": "سرور",
|
||||||
|
"no_create_conn": "— عدم ایجاد اتصال —",
|
||||||
|
"protocol_label": "پروتکل",
|
||||||
|
"edit_user_title": "ویرایش کاربر: {}",
|
||||||
|
"edit_user_tg": "آیدی تلگرام (اختیاری)",
|
||||||
|
"edit_user_email": "ایمیل (اختیاری)",
|
||||||
|
"edit_user_limit": "محدودیت ترافیک (گیگابایت) (0 = نامحدود)",
|
||||||
|
"new_password_hint": "رمز عبور جدید (اگر تغییری نمیخواهید خالی بگذارید)",
|
||||||
|
"op_type_label": "نوع عملیات",
|
||||||
|
"create_new_conn": "ایجاد اتصال جدید",
|
||||||
|
"link_existing_conn": "اتصال به موجود",
|
||||||
|
"select_existing_conn": "انتخاب اتصال موجود",
|
||||||
|
"existing_conn_hint": "فقط اتصالهای بدون کاربر نمایش داده میشوند",
|
||||||
|
"conn_name_panel": "نام اتصال (در پنل)",
|
||||||
|
"user_conns_title": "اتصالهای کاربر",
|
||||||
|
"no_free_conns": "اتصال آزاد موجود نیست",
|
||||||
|
"linking": "در حال اتصال...",
|
||||||
|
"conn_linked": "اتصال برقرار شد",
|
||||||
|
"enabling": "در حال فعالسازی",
|
||||||
|
"disabling": "در حال غیرفعالسازی",
|
||||||
|
"user_enabled": "کاربر فعال شد",
|
||||||
|
"user_disabled": "کاربر غیرفعال شد",
|
||||||
|
"delete_user_confirm": "کاربر حذف شود؟ تمام اتصالهای او حذف خواهند شد.",
|
||||||
|
"share_access": "اشتراکگذاری دسترسی",
|
||||||
|
"enable_public_access": "فعالسازی دسترسی عمومی",
|
||||||
|
"share_password_label": "رمز عبور (اختیاری)",
|
||||||
|
"share_password_hint": "در صورت تنظیم، کاربر باید آن را وارد کند",
|
||||||
|
"public_link_label": "لینک عمومی",
|
||||||
|
"disabled": "غیرفعال",
|
||||||
|
"out_of": "از",
|
||||||
|
"settings_title": "تنظیمات پنل",
|
||||||
|
"appearance": "🎨 ظاهر",
|
||||||
|
"title_label": "عنوان (Title)",
|
||||||
|
"logo_label": "لوگو (ایموجی یا نماد)",
|
||||||
|
"subtitle_label": "زیرعنوان (Subtitle)",
|
||||||
|
"captcha_title": "🔐 کد امنیتی",
|
||||||
|
"enable_captcha": "فعالسازی کد امنیتی",
|
||||||
|
"telegram_bot_title": "🤖 ربات تلگرام",
|
||||||
|
"bot_token_label": "توکن ربات (از @BotFather)",
|
||||||
|
"bot_status": "وضعیت",
|
||||||
|
"bot_running": "✅ در حال اجرا",
|
||||||
|
"bot_stopped": "⏹ متوقف شده",
|
||||||
|
"bot_stop_btn": "⏹ توقف",
|
||||||
|
"bot_start_btn": "▶️ شروع",
|
||||||
|
"bot_hint": "پس از تغییر توکن تنظیمات را ذخیره کنید تا ربات بازراهاندازی شود.",
|
||||||
|
"api_docs_title": "🔌 مستندات API",
|
||||||
|
"api_docs_hint": "از این رابطها برای بررسی قابلیتهای API استفاده کنید",
|
||||||
|
"tunnels_title": "Tunnels",
|
||||||
|
"local_server": "سرور محلی",
|
||||||
|
"tunnel_install_enable": "نصب و فعالسازی",
|
||||||
|
"tunnel_enable": "فعالسازی تونل",
|
||||||
|
"tunnel_running": "تونل در حال اجرا است",
|
||||||
|
"tunnel_stop": "توقف",
|
||||||
|
"tunnel_stopping": "در حال توقف...",
|
||||||
|
"tunnel_stopped": "تونل متوقف شد",
|
||||||
|
"tunnel_delete": "حذف",
|
||||||
|
"tunnel_deleted": "تونل حذف شد",
|
||||||
|
"tunnel_delete_confirm": "این فایل اجرایی تونل مدیریتشده توسط پنل متوقف و حذف شود؟",
|
||||||
|
"tunnel_starting": "در حال شروع تونل...",
|
||||||
|
"tunnel_started": "تونل شروع شد",
|
||||||
|
"tunnel_waiting_url": "تونل شروع شد. در انتظار آدرس عمومی...",
|
||||||
|
"tunnel_no_public_url": "آدرس عمومی پس از شروع تونل اینجا نمایش داده میشود.",
|
||||||
|
"tunnels_hint": "تونلهای سریع این پنل محلی را در اینترنت منتشر میکنند. رمز مدیر را قوی نگه دارید و آدرسهای عمومی را فقط با کاربران مورد اعتماد به اشتراک بگذارید.",
|
||||||
|
"ngrok_authtoken_placeholder": "ngrok authtoken (برای برخی حسابها اختیاری است)",
|
||||||
|
"import_users_title": "📤 وارد کردن کاربران",
|
||||||
|
"import_source_label": "منبع وارد کردن / همگامسازی",
|
||||||
|
"remnawave_url_label": "آدرس Remnawave",
|
||||||
|
"api_key_label": "کلید API",
|
||||||
|
"enable_sync": "فعالسازی همگامسازی",
|
||||||
|
"sync_hint": "کاربران طبق دادههای Remnawave بهطور خودکار مدیریت میشوند",
|
||||||
|
"sync_now_btn": "🔄 همگامسازی اکنون",
|
||||||
|
"delete_sync_btn": "🗑 حذف همگامسازی",
|
||||||
|
"auto_create_conns": "ایجاد خودکار اتصالها",
|
||||||
|
"sync_server_label": "سرور برای اتصالهای جدید",
|
||||||
|
"save_changes": "💾 ذخیره تغییرات",
|
||||||
|
"sync_success": "همگامسازی انجام شد: {} کاربر",
|
||||||
|
"sync_deleted": "{} کاربر همگامسازی شده حذف شدند",
|
||||||
|
"delete_sync_confirm": "آیا از حذف تمام کاربران همگامسازی شده مطمئن هستید؟",
|
||||||
|
"settings_saved": "تنظیمات با موفقیت ذخیره شد",
|
||||||
|
"bot_started": "ربات شروع به کار کرد",
|
||||||
|
"bot_stopped_msg": "ربات متوقف شد",
|
||||||
|
"sync_running": "در حال همگامسازی...",
|
||||||
|
"deleting": "در حال حذف...",
|
||||||
|
"my_connections_title": "اتصالهای من",
|
||||||
|
"show_config": "📄 نمایش پیکربندی",
|
||||||
|
"no_connections_user_desc": "برای ایجاد اتصال VPN با مدیر تماس بگیرید",
|
||||||
|
"share_title": "دسترسی به اتصال",
|
||||||
|
"vpn_access": "دسترسی VPN",
|
||||||
|
"user_label_share": "کاربر",
|
||||||
|
"share_protected_desc": "این دسترسی با رمز عبور محافظت شده است.",
|
||||||
|
"auth_success": "ورود موفقیتآمیز",
|
||||||
|
"loading_share_conns": "در حال بارگذاری...",
|
||||||
|
"no_active_conns": "هنوز هیچ اتصال فعالی ندارید.",
|
||||||
|
"show_settings_btn": "🔑 نمایش تنظیمات",
|
||||||
|
"copied": "کپی شد!",
|
||||||
|
"active": "فعال",
|
||||||
|
"site_description": "پنل وب آمنزیا — مدیریت سرورها و پروتکلهای AmneziaWG",
|
||||||
|
"toggle_theme": "تغییر تم",
|
||||||
|
"copied_to_clipboard": "در حافظه کپی شد",
|
||||||
|
"share_not_found": "404 یافت نشد",
|
||||||
|
"share_not_found_desc": "لینک اشتباه است یا دسترسی غیرفعال شده است.",
|
||||||
|
"invalid_captcha": "کد امنیتی اشتباه است",
|
||||||
|
"account_disabled": "حساب کاربری غیرفعال است",
|
||||||
|
"invalid_login": "نام کاربری یا رمز عبور اشتباه است",
|
||||||
|
"user_exists": "کاربری با این نام قبلاً ثبت شده است",
|
||||||
|
"cannot_delete_self": "شما نمیتوانید خودتان را حذف کنید",
|
||||||
|
"wrong_share_password": "رمز عبور اشتباه است",
|
||||||
|
"saving": "در حال ذخیره...",
|
||||||
|
"select_lang": "انتخاب زبان",
|
||||||
|
"lang_ru": "Русский",
|
||||||
|
"lang_en": "English",
|
||||||
|
"lang_fr": "Français",
|
||||||
|
"lang_zh": "中文 (Chinese)",
|
||||||
|
"lang_fa": "فارسی (Persian)",
|
||||||
|
"backup_title": "پشتیبانگیری ساده",
|
||||||
|
"download_backup": "دانلود data.json",
|
||||||
|
"restore_backup": "بازیابی از فایل",
|
||||||
|
"restore_confirm": "آیا مطمئن هستید؟ دادههای فعلی بازنویسی میشوند و برنامه دوباره راهاندازی خواهد شد.",
|
||||||
|
"restore_success": "بازیابی موفقیتآمیز بود! در حال راهاندازی مجدد...",
|
||||||
|
"invalid_backup_file": "فایل پشتیبان یا ساختار نامعتبر است",
|
||||||
|
"config_unavailable": "پیکربندی در دسترس نیست",
|
||||||
|
"config_unavailable_desc": "این کلاینت از طریق اپلیکیشن اصلی Amnezia ایجاد شده است.\\nکلید خصوصی فقط در دستگاه کاربر ذخیره میشود و توسط سرور قابل بازیابی نیست.",
|
||||||
|
"client_public_key": "کلید عمومی کلاینت:",
|
||||||
|
"management": "مدیریت",
|
||||||
|
"server_management": "مدیریت سرور",
|
||||||
|
"server_management_desc": "انجام اقدامات در سطح سیستم روی دستگاه شما.",
|
||||||
|
"check_server_services": "بررسی سرویسهای سرور",
|
||||||
|
"reboot_server": "راهاندازی مجدد سرور",
|
||||||
|
"clear_server": "پاکسازی سرور",
|
||||||
|
"remove_server": "حذف سرور از پنل",
|
||||||
|
"telemt_desc": "پروکسی تلگرام بر پایهی MTProxy با پوششهای پیشرفته.",
|
||||||
|
"dns_desc": "سرور DNS شخصی برای مسدود کردن تبلیغات و حفظ حریم خصوصی.",
|
||||||
|
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
|
||||||
|
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
|
||||||
|
"socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.",
|
||||||
|
"socks5_username": "Username",
|
||||||
|
"socks5_password": "Password",
|
||||||
|
"socks5_password_placeholder": "Leave empty to auto-generate",
|
||||||
|
"socks5_password_hint": "If left empty, a random 16-character password will be generated.",
|
||||||
|
"socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.",
|
||||||
|
"socks5_change_settings": "Change settings",
|
||||||
|
"socks5_settings_title": "SOCKS5 — Connection settings",
|
||||||
|
"socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.",
|
||||||
|
"socks5_settings_saved": "SOCKS5 settings saved",
|
||||||
|
"adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.",
|
||||||
|
"adguard_mode": "Installation mode",
|
||||||
|
"adguard_mode_sidebyside": "Side-by-side",
|
||||||
|
"adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)",
|
||||||
|
"adguard_mode_replace": "Replace AmneziaDNS",
|
||||||
|
"adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately",
|
||||||
|
"adguard_dns_port": "DNS port (53)",
|
||||||
|
"adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.",
|
||||||
|
"adguard_web_port": "Web UI port",
|
||||||
|
"adguard_dot_port": "DoT port",
|
||||||
|
"adguard_doh_port": "DoH port",
|
||||||
|
"adguard_expose_web": "Expose to internet",
|
||||||
|
"adguard_expose": "Expose to host",
|
||||||
|
"adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.",
|
||||||
|
"adguard_internal_ip": "Internal IP",
|
||||||
|
"adguard_web_ui": "Admin URL",
|
||||||
|
"adguard_open_ui": "Open Web UI",
|
||||||
|
"edit_server_title": "Edit server",
|
||||||
|
"edit_keep_credential": "Leave empty to keep the current value",
|
||||||
|
"server_saved": "Server settings saved",
|
||||||
|
"servers_reordered": "Servers reordered",
|
||||||
|
"ping_checking": "Checking connectivity...",
|
||||||
|
"offline": "offline",
|
||||||
|
"api_tokens_title": "API tokens",
|
||||||
|
"api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer <token>` header. Tokens have admin-equivalent rights and stop working if their owner is disabled or demoted.",
|
||||||
|
"api_tokens_empty": "No API tokens yet. Create one to integrate the panel with an external service.",
|
||||||
|
"api_tokens_create": "Create token",
|
||||||
|
"api_tokens_create_title": "Create API token",
|
||||||
|
"api_tokens_name_label": "Name",
|
||||||
|
"api_tokens_name_placeholder": "e.g. Linear integration, CI bot, monitoring",
|
||||||
|
"api_tokens_name_hint": "Used to identify this token in the list. The token value itself is generated for you.",
|
||||||
|
"api_tokens_name_required": "Token name is required",
|
||||||
|
"api_tokens_created_title": "Token created",
|
||||||
|
"api_tokens_value_label": "Token value",
|
||||||
|
"api_tokens_warning_title": "This is the only time you'll see this token.",
|
||||||
|
"api_tokens_warning_body": "Copy it now and store it in your integration's secret manager. The panel keeps only a hash — if you lose this value you'll need to revoke and recreate the token.",
|
||||||
|
"api_tokens_revoke": "Revoke",
|
||||||
|
"api_tokens_revoke_confirm": "Revoke token \"{}\"? Any integration using it will stop working.",
|
||||||
|
"api_tokens_revoked": "Token revoked",
|
||||||
|
"api_tokens_created": "Created",
|
||||||
|
"api_tokens_last_used": "Last used",
|
||||||
|
"api_tokens_owner": "Owner",
|
||||||
|
"api_tokens_never": "never",
|
||||||
|
"coming_soon": "Coming soon",
|
||||||
|
"promo_star_cta": "Star us on GitHub",
|
||||||
|
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
|
||||||
|
"revproxy_title": "Reverse Proxy",
|
||||||
|
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
|
||||||
|
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
|
||||||
|
"warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.",
|
||||||
|
"warp_install_required": "Install WARP first",
|
||||||
|
"warp_connect": "Connect WARP",
|
||||||
|
"warp_disconnect": "Disconnect",
|
||||||
|
"warp_connected": "WARP connected",
|
||||||
|
"warp_disconnected": "WARP disconnected",
|
||||||
|
"warp_status_unknown": "WARP status is unknown.",
|
||||||
|
"warp_status_not_installed": "Cloudflare WARP CLI is not installed on this host.",
|
||||||
|
"warp_status_connected": "Cloudflare WARP is connected.",
|
||||||
|
"warp_status_connecting": "Cloudflare WARP is connecting.",
|
||||||
|
"warp_status_disconnected": "Cloudflare WARP is disconnected.",
|
||||||
|
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
|
||||||
|
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
|
||||||
|
"warp_status_error": "Cloudflare WARP returned an error.",
|
||||||
|
"install_starting": "Starting installation...",
|
||||||
|
"server_check_complete": "Check completed",
|
||||||
|
"server_connection_error": "Connection error",
|
||||||
|
"connections_load_error": "Error loading connections",
|
||||||
|
"connection_updated": "Connection updated",
|
||||||
|
"edit_connection": "Edit connection",
|
||||||
|
"enable_connection_progress": "Enabling connection...",
|
||||||
|
"disable_connection_progress": "Disabling connection...",
|
||||||
|
"connection_enabled": "Connection enabled",
|
||||||
|
"connection_disabled": "Connection disabled",
|
||||||
|
"gb": "GB",
|
||||||
|
"marketplace": "Templates",
|
||||||
|
"open_marketplace": "Open Templates",
|
||||||
|
"marketplace_desc": "Install protocol and service templates available for this server.",
|
||||||
|
"installed_apps": "Installed applications",
|
||||||
|
"installed_apps_hint": "Only installed protocols and services are shown here. Use Templates to add more.",
|
||||||
|
"no_installed_apps": "No installed applications yet",
|
||||||
|
"no_installed_apps_desc": "Open Templates to install protocols or services on this server.",
|
||||||
|
"install_another": "Install another",
|
||||||
|
"new_instance": "new instance",
|
||||||
|
"port_next_instance_hint": "Choose a free UDP port for the additional instance.",
|
||||||
|
"web_servers": "Web servers",
|
||||||
|
"nginx_desc": "NGINX web server with Let's Encrypt HTTPS certificate and editable index.html.",
|
||||||
|
"nginx_domain": "Domain",
|
||||||
|
"nginx_email": "Let's Encrypt email",
|
||||||
|
"nginx_port_hint": "HTTPS port exposed on the server. Port 80/TCP must be free and reachable for Let's Encrypt validation.",
|
||||||
|
"nginx_install_hint": "The domain must already point to this server. Port 80/TCP is used for HTTP-01 certificate validation.",
|
||||||
|
"site": "Site",
|
||||||
|
"site_editor": "Site editor",
|
||||||
|
"site_saved": "Site saved",
|
||||||
|
"nginx_dns_hint": "Create this DNS record before installation:",
|
||||||
|
"backup": "Backup",
|
||||||
|
"backups": "Backups",
|
||||||
|
"backup_desc": "Backups are created on the server and contain valuable protocol files needed to restore or recreate it later.",
|
||||||
|
"create_backup": "Create backup",
|
||||||
|
"creating_backup": "Creating backup...",
|
||||||
|
"backup_created": "Backup created",
|
||||||
|
"no_backups": "No backups yet",
|
||||||
|
"no_backups_desc": "Create the first backup for this protocol.",
|
||||||
|
"refresh": "Refresh"
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
{
|
||||||
|
"nav_servers": "Serveurs",
|
||||||
|
"nav_users": "Utilisateurs",
|
||||||
|
"nav_settings": "Paramètres",
|
||||||
|
"nav_connections": "Connexions",
|
||||||
|
"nav_logout": "Déconnexion",
|
||||||
|
"theme_dark": "Sombre",
|
||||||
|
"theme_light": "Clair",
|
||||||
|
"back_to_servers": "← Retour aux serveurs",
|
||||||
|
"server_check": "Vérifier",
|
||||||
|
"server_checking": "Vérification...",
|
||||||
|
"protocols": "Protocoles",
|
||||||
|
"install": "Installer",
|
||||||
|
"uninstall": "Désinstaller",
|
||||||
|
"run": "En cours",
|
||||||
|
"stop": "Arrêté",
|
||||||
|
"not_installed": "Pas installé",
|
||||||
|
"installed": "Installé",
|
||||||
|
"add_server": "Ajouter un serveur",
|
||||||
|
"no_servers": "Aucun serveur ajouté",
|
||||||
|
"name": "Nom",
|
||||||
|
"host": "Hôte",
|
||||||
|
"port": "Port",
|
||||||
|
"status": "Statut",
|
||||||
|
"actions": "Actions",
|
||||||
|
"edit": "Modifier",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"loading": "Chargement...",
|
||||||
|
"copy": "Copier",
|
||||||
|
"download": "Télécharger",
|
||||||
|
"config": "Configuration",
|
||||||
|
"qr_code": "Code QR",
|
||||||
|
"vpn_key": "Clé VPN",
|
||||||
|
"clients": "Clients",
|
||||||
|
"traffic": "Trafic",
|
||||||
|
"limit": "Limite",
|
||||||
|
"manage": "Gérer",
|
||||||
|
"no_protocols": "Aucun protocole",
|
||||||
|
"add_server_desc": "Ajoutez votre premier serveur VPN pour commencer. Identifiants SSH requis.",
|
||||||
|
"ssh_host": "Hôte / IP",
|
||||||
|
"ssh_port": "Port SSH",
|
||||||
|
"ssh_user": "Utilisateur",
|
||||||
|
"ssh_password": "Mot de passe",
|
||||||
|
"ssh_key": "Clé SSH",
|
||||||
|
"ssh_key_placeholder": "Clé SSH privée",
|
||||||
|
"ssh_key_hint": "Collez le contenu de votre fichier id_rsa ou id_ed25519",
|
||||||
|
"connect": "Connecter",
|
||||||
|
"connecting": "Connexion...",
|
||||||
|
"server_added": "Serveur ajouté avec succès",
|
||||||
|
"server_deleted": "Serveur supprimé",
|
||||||
|
"delete_confirm": "Voulez-vous vraiment supprimer ce serveur ?",
|
||||||
|
"success": "Succès",
|
||||||
|
"error": "Erreur",
|
||||||
|
"info": "Info",
|
||||||
|
"login_title": "Connexion au Panneau",
|
||||||
|
"username": "Nom d'utilisateur",
|
||||||
|
"password": "Mot de passe",
|
||||||
|
"captcha": "Captcha",
|
||||||
|
"captcha_placeholder": "Entrez les caractères",
|
||||||
|
"captcha_hint": "Cliquez pour rafraîchir",
|
||||||
|
"login": "Connexion",
|
||||||
|
"logging_in": "Connexion...",
|
||||||
|
"login_error": "Échec de la connexion",
|
||||||
|
"check_done": "Vérification terminée",
|
||||||
|
"check_error": "Erreur de connexion",
|
||||||
|
"ssh_ok": "Connexion SSH",
|
||||||
|
"docker_ok": "Docker",
|
||||||
|
"docker_not_installed": "Docker non installé",
|
||||||
|
"awg_desc": "Version moderne basée sur awg-go. Obfuscation avancée (S3, S4).",
|
||||||
|
"awg_legacy_desc": "Version AWG originale. Compatible avec les anciens clients.",
|
||||||
|
"xray_desc": "Masque le trafic en trafic web normal (XTLS-Reality). Résiste au DPI.",
|
||||||
|
"not_checked": "Non vérifié",
|
||||||
|
"connections": "Connexions",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"loading_connections": "Chargement des connexions...",
|
||||||
|
"no_connections": "Aucune connexion",
|
||||||
|
"no_connections_desc": "Ajoutez votre première connexion pour générer un fichier VPN",
|
||||||
|
"install_protocol": "Installer le protocole",
|
||||||
|
"port_default_hint": "Port par défaut : 55424. Assurez-vous qu'il est libre.",
|
||||||
|
"port_xray_hint": "Port recommandé : 443. Assurez-vous qu'il n'est pas utilisé par un serveur web.",
|
||||||
|
"reinstall": "Réinstaller",
|
||||||
|
"uninstall_confirm": "Désinstaller {} ? Toutes les données seront perdues.",
|
||||||
|
"stop_container_confirm": "Arrêter le conteneur {} ?",
|
||||||
|
"start_container_confirm": "Démarrer le conteneur {} ?",
|
||||||
|
"stopping": "Arrêt",
|
||||||
|
"starting": "Démarrage",
|
||||||
|
"stopped": "arrêté",
|
||||||
|
"started": "démarré",
|
||||||
|
"server_config": "Configuration du serveur",
|
||||||
|
"add_connection": "Ajouter une connexion",
|
||||||
|
"connection_name": "Nom de la connexion",
|
||||||
|
"connection_name_placeholder": "Ex: iPhone, Laptop",
|
||||||
|
"assign_to_user": "Assigner à l'utilisateur (optionnel)",
|
||||||
|
"no_assign": "— Aucune assignation —",
|
||||||
|
"create": "Créer",
|
||||||
|
"creating": "Création...",
|
||||||
|
"connection_created": "Connexion \"{}\" créée",
|
||||||
|
"delete_connection_confirm": "Supprimer cette connexion ? Elle cessera de fonctionner.",
|
||||||
|
"connection_deleted": "Connexion supprimée",
|
||||||
|
"config_tab": "📄 Config",
|
||||||
|
"vpn_key_tab": "🔑 Clé-VPN",
|
||||||
|
"qr_code_tab": "📱 Code-QR",
|
||||||
|
"copy_config": "📋 Copier",
|
||||||
|
"download_conf": "⬇ Télécharger .conf",
|
||||||
|
"copy_key": "📋 Copier la clé",
|
||||||
|
"vpn_link_hint": "Collez ce lien dans AmneziaVPN pour une config automatique",
|
||||||
|
"qr_hint": "Scannez le code QR dans l'application AmneziaVPN",
|
||||||
|
"no_data": "Aucune donnée",
|
||||||
|
"start_btn": "▶ Start",
|
||||||
|
"stop_btn": "⏹ Stop",
|
||||||
|
"config_btn": "⚙️ Config",
|
||||||
|
"done": "Terminé",
|
||||||
|
"installing": "Installation...",
|
||||||
|
"start_install": "Début de l'installation...",
|
||||||
|
"check_docker": "Vérification de Docker...",
|
||||||
|
"prepare_host": "Préparation de l'hôte...",
|
||||||
|
"build_container": "Construction du conteneur...",
|
||||||
|
"install_success": "Protocole installé avec succès !",
|
||||||
|
"install_error": "Erreur d'installation : ",
|
||||||
|
"qr_error": "Échec de génération du QR code",
|
||||||
|
"users_title": "Utilisateurs",
|
||||||
|
"search_placeholder": "Recherche par nom, email ou Telegram...",
|
||||||
|
"add_user": "Ajouter Utilisateur",
|
||||||
|
"prev_page": "← Retour",
|
||||||
|
"next_page": "Suivant →",
|
||||||
|
"page_info": "Page {} sur {}",
|
||||||
|
"loading_users": "Chargement des utilisateurs...",
|
||||||
|
"nothing_found": "Rien trouvé",
|
||||||
|
"search_empty_desc": "Essayez une autre requête ou créez un utilisateur",
|
||||||
|
"username_label": "Nom d'utilisateur",
|
||||||
|
"password_label": "Mot de passe",
|
||||||
|
"role_label": "Rôle",
|
||||||
|
"role_user_desc": "User — voit ses propres connexions",
|
||||||
|
"role_support_desc": "Support — gestion des serveurs",
|
||||||
|
"role_admin_desc": "Admin — accès complet",
|
||||||
|
"tg_id_label": "ID Telegram (opt.)",
|
||||||
|
"email_label": "Email (opt.)",
|
||||||
|
"traffic_limit_label": "Limite trafic (Go) (0 = illimité)",
|
||||||
|
"description_label": "Description (opt.)",
|
||||||
|
"auto_conn_title": "Création auto de connexion (optionnel)",
|
||||||
|
"server_label": "Serveur",
|
||||||
|
"no_create_conn": "— Ne pas créer —",
|
||||||
|
"protocol_label": "Protocole",
|
||||||
|
"edit_user_title": "Modifier utilisateur : {}",
|
||||||
|
"edit_user_tg": "ID Telegram (optionnel)",
|
||||||
|
"edit_user_email": "Email (optionnel)",
|
||||||
|
"edit_user_limit": "Limite trafic (Go) (0 = illimité)",
|
||||||
|
"new_password_hint": "Nouveau mot de passe (laisser vide si inchangé)",
|
||||||
|
"op_type_label": "Type d'opération",
|
||||||
|
"create_new_conn": "Créer nouvelle connexion",
|
||||||
|
"link_existing_conn": "Lier existante",
|
||||||
|
"select_existing_conn": "Sélectionner connexion",
|
||||||
|
"existing_conn_hint": "Seules les connexions libres sont affichées",
|
||||||
|
"conn_name_panel": "Nom (dans le panneau)",
|
||||||
|
"user_conns_title": "Connexions utilisateur",
|
||||||
|
"no_free_conns": "Aucune connexion libre",
|
||||||
|
"linking": "Liaison...",
|
||||||
|
"conn_linked": "Connexion liée",
|
||||||
|
"enabling": "Activation",
|
||||||
|
"disabling": "Désactivation",
|
||||||
|
"user_enabled": "Utilisateur activé",
|
||||||
|
"user_disabled": "Utilisateur désactivé",
|
||||||
|
"delete_user_confirm": "Supprimer l'utilisateur ? Ses connexions seront supprimées.",
|
||||||
|
"share_access": "Partager l'accès",
|
||||||
|
"enable_public_access": "Activer l'accès public",
|
||||||
|
"share_password_label": "Mot de passe (optionnel)",
|
||||||
|
"share_password_hint": "Si défini, requis pour l'accès",
|
||||||
|
"public_link_label": "Lien public",
|
||||||
|
"disabled": "Désactivé",
|
||||||
|
"out_of": "sur",
|
||||||
|
"settings_title": "Paramètres du Panneau",
|
||||||
|
"appearance": "🎨 Apparence",
|
||||||
|
"title_label": "Titre",
|
||||||
|
"logo_label": "Logo (Emoji ou symbole)",
|
||||||
|
"subtitle_label": "Sous-titre",
|
||||||
|
"captcha_title": "🔐 Captcha",
|
||||||
|
"enable_captcha": "Activer captcha",
|
||||||
|
"telegram_bot_title": "🤖 Bot Telegram",
|
||||||
|
"bot_token_label": "Bot Token (@BotFather)",
|
||||||
|
"bot_status": "Statut",
|
||||||
|
"bot_running": "✅ En cours",
|
||||||
|
"bot_stopped": "⏹ Arrêté",
|
||||||
|
"bot_stop_btn": "⏹ Arrêter",
|
||||||
|
"bot_start_btn": "▶️ Démarrer",
|
||||||
|
"bot_hint": "Sauvegardez après modification du token. Le bot redémarrera.",
|
||||||
|
"api_docs_title": "🔌 Documentation API",
|
||||||
|
"api_docs_hint": "Utilisez ces interfaces pour explorer l'API",
|
||||||
|
"tunnels_title": "Tunnels",
|
||||||
|
"local_server": "Serveur local",
|
||||||
|
"tunnel_install_enable": "Installer et activer",
|
||||||
|
"tunnel_enable": "Activer le tunnel",
|
||||||
|
"tunnel_running": "Tunnel en cours",
|
||||||
|
"tunnel_stop": "Arrêter",
|
||||||
|
"tunnel_stopping": "Arrêt...",
|
||||||
|
"tunnel_stopped": "Tunnel arrêté",
|
||||||
|
"tunnel_delete": "Supprimer",
|
||||||
|
"tunnel_deleted": "Tunnel supprimé",
|
||||||
|
"tunnel_delete_confirm": "Arrêter et supprimer ce binaire de tunnel géré par le panneau ?",
|
||||||
|
"tunnel_starting": "Démarrage du tunnel...",
|
||||||
|
"tunnel_started": "Tunnel démarré",
|
||||||
|
"tunnel_waiting_url": "Tunnel démarré. En attente de l'URL publique...",
|
||||||
|
"tunnel_no_public_url": "L'URL publique apparaîtra ici après le démarrage du tunnel.",
|
||||||
|
"tunnels_hint": "Les tunnels rapides exposent ce panneau local sur Internet. Gardez des identifiants administrateur robustes et partagez les URL publiques uniquement avec des utilisateurs de confiance.",
|
||||||
|
"ngrok_authtoken_placeholder": "ngrok authtoken (optionnel pour certains comptes)",
|
||||||
|
"import_users_title": "📤 Import Utilisateurs",
|
||||||
|
"import_source_label": "Source Import / Sync",
|
||||||
|
"remnawave_url_label": "URL Remnawave",
|
||||||
|
"api_key_label": "Clé API",
|
||||||
|
"enable_sync": "Activer synchronisation",
|
||||||
|
"sync_hint": "Synchro automatique avec Remnawave",
|
||||||
|
"sync_now_btn": "🔄 Sync maintenant",
|
||||||
|
"delete_sync_btn": "🗑 Supprimer synchro",
|
||||||
|
"auto_create_conns": "Créer connexions auto",
|
||||||
|
"sync_server_label": "Serveur pour nouvelles connexions",
|
||||||
|
"save_changes": "💾 Enregistrer changements",
|
||||||
|
"sync_success": "Sync terminée : {} utilisateurs",
|
||||||
|
"sync_deleted": "Supprimé {} utilisateurs sync",
|
||||||
|
"delete_sync_confirm": "Supprimer TOUS les utilisateurs synchronisés ? Irréversible.",
|
||||||
|
"settings_saved": "Paramètres enregistrés",
|
||||||
|
"bot_started": "Bot démarré",
|
||||||
|
"bot_stopped_msg": "Bot arrêté",
|
||||||
|
"sync_running": "Synchronisation...",
|
||||||
|
"deleting": "Suppression...",
|
||||||
|
"my_connections_title": "Mes Connexions",
|
||||||
|
"show_config": "📄 Voir config",
|
||||||
|
"no_connections_user_desc": "Contactez l'admin pour une connexion",
|
||||||
|
"share_title": "Accès Connexion",
|
||||||
|
"vpn_access": "Accès VPN",
|
||||||
|
"user_label_share": "Utilisateur",
|
||||||
|
"share_protected_desc": "Accès protégé par mot de passe.",
|
||||||
|
"auth_success": "Succès Auth",
|
||||||
|
"loading_share_conns": "Chargement...",
|
||||||
|
"no_active_conns": "Aucune connexion active.",
|
||||||
|
"show_settings_btn": "🔑 Voir paramètres",
|
||||||
|
"copied": "Copié !",
|
||||||
|
"active": "Actif",
|
||||||
|
"site_description": "Panneau Web Amnezia — gestion serveurs VPN et AmneziaWG",
|
||||||
|
"toggle_theme": "Changer thème",
|
||||||
|
"copied_to_clipboard": "Copié dans le presse-papier",
|
||||||
|
"share_not_found": "404 Introuvable",
|
||||||
|
"share_not_found_desc": "Lien invalide ou accès désactivé.",
|
||||||
|
"invalid_captcha": "Captcha invalide",
|
||||||
|
"account_disabled": "Compte désactivé",
|
||||||
|
"invalid_login": "Login ou password incorrect",
|
||||||
|
"user_exists": "L'utilisateur existe déjà",
|
||||||
|
"cannot_delete_self": "Vous ne pouvez pas vous supprimer",
|
||||||
|
"wrong_share_password": "Mauvais mot de passe",
|
||||||
|
"saving": "Enregistrement...",
|
||||||
|
"select_lang": "Choisir la langue",
|
||||||
|
"lang_ru": "Русский",
|
||||||
|
"lang_en": "English",
|
||||||
|
"lang_fr": "Français",
|
||||||
|
"lang_zh": "中文 (Chinese)",
|
||||||
|
"lang_fa": "فارسی (Persian)",
|
||||||
|
"backup_title": "Sauvegarde Simple",
|
||||||
|
"download_backup": "Télécharger data.json",
|
||||||
|
"restore_backup": "Restaurer depuis un fichier",
|
||||||
|
"restore_confirm": "Êtes-vous sûr ? Les données actuelles seront écrasées et l'application redémarrera.",
|
||||||
|
"restore_success": "Restauration réussie ! Redémarrage...",
|
||||||
|
"invalid_backup_file": "Fichier de sauvegarde ou structure invalide",
|
||||||
|
"config_unavailable": "Configuration indisponible",
|
||||||
|
"config_unavailable_desc": "Ce client a été créé via l'application native Amnezia.\\nLa clé privée est stockée uniquement sur l'appareil de l'utilisateur et ne peut pas être récupérée par le serveur.",
|
||||||
|
"client_public_key": "Clé publique du client :",
|
||||||
|
"management": "Gestion",
|
||||||
|
"server_management": "Gestion du serveur",
|
||||||
|
"server_management_desc": "Effectuer des actions au niveau du système sur votre machine.",
|
||||||
|
"check_server_services": "Vérifier les services du serveur",
|
||||||
|
"reboot_server": "Redémarrer le serveur",
|
||||||
|
"clear_server": "Réinitialiser le serveur",
|
||||||
|
"remove_server": "Retirer le serveur du panneau",
|
||||||
|
"telemt_desc": "Proxy Telegram basé sur MTProxy avec obfuscation avancée.",
|
||||||
|
"dns_desc": "Serveur DNS personnel pour bloquer les publicités et protéger la vie privée.",
|
||||||
|
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
|
||||||
|
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
|
||||||
|
"socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.",
|
||||||
|
"socks5_username": "Username",
|
||||||
|
"socks5_password": "Password",
|
||||||
|
"socks5_password_placeholder": "Leave empty to auto-generate",
|
||||||
|
"socks5_password_hint": "If left empty, a random 16-character password will be generated.",
|
||||||
|
"socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.",
|
||||||
|
"socks5_change_settings": "Change settings",
|
||||||
|
"socks5_settings_title": "SOCKS5 — Connection settings",
|
||||||
|
"socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.",
|
||||||
|
"socks5_settings_saved": "SOCKS5 settings saved",
|
||||||
|
"adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.",
|
||||||
|
"adguard_mode": "Installation mode",
|
||||||
|
"adguard_mode_sidebyside": "Side-by-side",
|
||||||
|
"adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)",
|
||||||
|
"adguard_mode_replace": "Replace AmneziaDNS",
|
||||||
|
"adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately",
|
||||||
|
"adguard_dns_port": "DNS port (53)",
|
||||||
|
"adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.",
|
||||||
|
"adguard_web_port": "Web UI port",
|
||||||
|
"adguard_dot_port": "DoT port",
|
||||||
|
"adguard_doh_port": "DoH port",
|
||||||
|
"adguard_expose_web": "Expose to internet",
|
||||||
|
"adguard_expose": "Expose to host",
|
||||||
|
"adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.",
|
||||||
|
"adguard_internal_ip": "Internal IP",
|
||||||
|
"adguard_web_ui": "Admin URL",
|
||||||
|
"adguard_open_ui": "Open Web UI",
|
||||||
|
"edit_server_title": "Edit server",
|
||||||
|
"edit_keep_credential": "Leave empty to keep the current value",
|
||||||
|
"server_saved": "Server settings saved",
|
||||||
|
"servers_reordered": "Servers reordered",
|
||||||
|
"ping_checking": "Checking connectivity...",
|
||||||
|
"offline": "offline",
|
||||||
|
"api_tokens_title": "API tokens",
|
||||||
|
"api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer <token>` header. Tokens have admin-equivalent rights and stop working if their owner is disabled or demoted.",
|
||||||
|
"api_tokens_empty": "No API tokens yet. Create one to integrate the panel with an external service.",
|
||||||
|
"api_tokens_create": "Create token",
|
||||||
|
"api_tokens_create_title": "Create API token",
|
||||||
|
"api_tokens_name_label": "Name",
|
||||||
|
"api_tokens_name_placeholder": "e.g. Linear integration, CI bot, monitoring",
|
||||||
|
"api_tokens_name_hint": "Used to identify this token in the list. The token value itself is generated for you.",
|
||||||
|
"api_tokens_name_required": "Token name is required",
|
||||||
|
"api_tokens_created_title": "Token created",
|
||||||
|
"api_tokens_value_label": "Token value",
|
||||||
|
"api_tokens_warning_title": "This is the only time you'll see this token.",
|
||||||
|
"api_tokens_warning_body": "Copy it now and store it in your integration's secret manager. The panel keeps only a hash — if you lose this value you'll need to revoke and recreate the token.",
|
||||||
|
"api_tokens_revoke": "Revoke",
|
||||||
|
"api_tokens_revoke_confirm": "Revoke token \"{}\"? Any integration using it will stop working.",
|
||||||
|
"api_tokens_revoked": "Token revoked",
|
||||||
|
"api_tokens_created": "Created",
|
||||||
|
"api_tokens_last_used": "Last used",
|
||||||
|
"api_tokens_owner": "Owner",
|
||||||
|
"api_tokens_never": "never",
|
||||||
|
"coming_soon": "Coming soon",
|
||||||
|
"promo_star_cta": "Star us on GitHub",
|
||||||
|
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
|
||||||
|
"revproxy_title": "Reverse Proxy",
|
||||||
|
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
|
||||||
|
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
|
||||||
|
"warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.",
|
||||||
|
"warp_install_required": "Install WARP first",
|
||||||
|
"warp_connect": "Connect WARP",
|
||||||
|
"warp_disconnect": "Disconnect",
|
||||||
|
"warp_connected": "WARP connected",
|
||||||
|
"warp_disconnected": "WARP disconnected",
|
||||||
|
"warp_status_unknown": "WARP status is unknown.",
|
||||||
|
"warp_status_not_installed": "Cloudflare WARP CLI is not installed on this host.",
|
||||||
|
"warp_status_connected": "Cloudflare WARP is connected.",
|
||||||
|
"warp_status_connecting": "Cloudflare WARP is connecting.",
|
||||||
|
"warp_status_disconnected": "Cloudflare WARP is disconnected.",
|
||||||
|
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
|
||||||
|
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
|
||||||
|
"warp_status_error": "Cloudflare WARP returned an error.",
|
||||||
|
"install_starting": "Starting installation...",
|
||||||
|
"server_check_complete": "Check completed",
|
||||||
|
"server_connection_error": "Connection error",
|
||||||
|
"connections_load_error": "Error loading connections",
|
||||||
|
"connection_updated": "Connection updated",
|
||||||
|
"edit_connection": "Edit connection",
|
||||||
|
"enable_connection_progress": "Enabling connection...",
|
||||||
|
"disable_connection_progress": "Disabling connection...",
|
||||||
|
"connection_enabled": "Connection enabled",
|
||||||
|
"connection_disabled": "Connection disabled",
|
||||||
|
"gb": "GB",
|
||||||
|
"marketplace": "Templates",
|
||||||
|
"open_marketplace": "Open Templates",
|
||||||
|
"marketplace_desc": "Install protocol and service templates available for this server.",
|
||||||
|
"installed_apps": "Installed applications",
|
||||||
|
"installed_apps_hint": "Only installed protocols and services are shown here. Use Templates to add more.",
|
||||||
|
"no_installed_apps": "No installed applications yet",
|
||||||
|
"no_installed_apps_desc": "Open Templates to install protocols or services on this server.",
|
||||||
|
"install_another": "Install another",
|
||||||
|
"new_instance": "new instance",
|
||||||
|
"port_next_instance_hint": "Choose a free UDP port for the additional instance.",
|
||||||
|
"web_servers": "Serveurs web",
|
||||||
|
"nginx_desc": "Serveur web NGINX avec certificat HTTPS Let's Encrypt et index.html modifiable.",
|
||||||
|
"nginx_domain": "Domaine",
|
||||||
|
"nginx_email": "Email Let's Encrypt",
|
||||||
|
"nginx_port_hint": "Port HTTPS exposé sur le serveur. Le port 80/TCP doit être libre et accessible pour la validation Let's Encrypt.",
|
||||||
|
"nginx_install_hint": "Le domaine doit déjà pointer vers ce serveur. Le port 80/TCP est utilisé pour la validation HTTP-01 du certificat.",
|
||||||
|
"site": "Site",
|
||||||
|
"site_editor": "Éditeur du site",
|
||||||
|
"site_saved": "Site enregistré",
|
||||||
|
"nginx_dns_hint": "Creez cet enregistrement DNS avant l installation :",
|
||||||
|
"backup": "Backup",
|
||||||
|
"backups": "Backups",
|
||||||
|
"backup_desc": "Backups are created on the server and contain valuable protocol files needed to restore or recreate it later.",
|
||||||
|
"create_backup": "Create backup",
|
||||||
|
"creating_backup": "Creating backup...",
|
||||||
|
"backup_created": "Backup created",
|
||||||
|
"no_backups": "No backups yet",
|
||||||
|
"no_backups_desc": "Create the first backup for this protocol.",
|
||||||
|
"refresh": "Refresh"
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
{
|
||||||
|
"nav_servers": "Серверы",
|
||||||
|
"nav_users": "Пользователи",
|
||||||
|
"nav_settings": "Настройки",
|
||||||
|
"nav_connections": "Подключения",
|
||||||
|
"nav_logout": "Выход",
|
||||||
|
"theme_dark": "Темная",
|
||||||
|
"theme_light": "Светлая",
|
||||||
|
"back_to_servers": "← Назад к серверам",
|
||||||
|
"server_check": "Проверить",
|
||||||
|
"server_checking": "Проверка...",
|
||||||
|
"protocols": "Протоколы",
|
||||||
|
"install": "Установить",
|
||||||
|
"uninstall": "Удалить",
|
||||||
|
"run": "Работает",
|
||||||
|
"stop": "Остановлен",
|
||||||
|
"not_installed": "Не установлен",
|
||||||
|
"installed": "Установлен",
|
||||||
|
"add_server": "Добавить сервер",
|
||||||
|
"no_servers": "Серверы не добавлены",
|
||||||
|
"name": "Название",
|
||||||
|
"host": "Адрес",
|
||||||
|
"port": "Порт",
|
||||||
|
"status": "Статус",
|
||||||
|
"actions": "Действия",
|
||||||
|
"edit": "Редактировать",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"copy": "Копировать",
|
||||||
|
"download": "Скачать",
|
||||||
|
"config": "Конфигурация",
|
||||||
|
"qr_code": "QR-код",
|
||||||
|
"vpn_key": "VPN-ключ",
|
||||||
|
"clients": "Клиенты",
|
||||||
|
"traffic": "Трафик",
|
||||||
|
"limit": "Limit",
|
||||||
|
"manage": "Управление",
|
||||||
|
"no_protocols": "Нет протоколов",
|
||||||
|
"add_server_desc": "Добавьте свой первый VPN сервер для начала работы. Вам понадобятся SSH-данные для подключения.",
|
||||||
|
"ssh_host": "Хост / IP",
|
||||||
|
"ssh_port": "SSH Порт",
|
||||||
|
"ssh_user": "Пользователь",
|
||||||
|
"ssh_password": "Пароль",
|
||||||
|
"ssh_key": "SSH Ключ",
|
||||||
|
"ssh_key_placeholder": "Приватный SSH ключ",
|
||||||
|
"ssh_key_hint": "Вставьте содержимое вашего файла id_rsa или id_ed25519",
|
||||||
|
"connect": "Подключить",
|
||||||
|
"connecting": "Подключение...",
|
||||||
|
"server_added": "Сервер успешно добавлен",
|
||||||
|
"server_deleted": "Сервер удалён",
|
||||||
|
"delete_confirm": "Вы уверены, что хотите удалить этот сервер?",
|
||||||
|
"success": "Успешно",
|
||||||
|
"error": "Ошибка",
|
||||||
|
"info": "Информация",
|
||||||
|
"login_title": "Вход в панель",
|
||||||
|
"username": "Логин",
|
||||||
|
"password": "Пароль",
|
||||||
|
"captcha": "Капча",
|
||||||
|
"captcha_placeholder": "Введите символы с картинки",
|
||||||
|
"captcha_hint": "Нажмите для обновления картинки",
|
||||||
|
"login": "Войти",
|
||||||
|
"logging_in": "Вход...",
|
||||||
|
"login_error": "Ошибка входа",
|
||||||
|
"check_done": "Проверка завершена",
|
||||||
|
"check_error": "Ошибка подключения",
|
||||||
|
"ssh_ok": "SSH подключение",
|
||||||
|
"docker_ok": "Docker",
|
||||||
|
"docker_not_installed": "Docker не установлен",
|
||||||
|
"awg_desc": "Новая версия протокола на основе awg-go. Поддерживает расширенную обфускацию с параметрами S3, S4.",
|
||||||
|
"awg_legacy_desc": "Оригинальная версия AWG на базе ядра WireGuard. Совместима с клиентами старых версий.",
|
||||||
|
"xray_desc": "Современный протокол с маскировкой под обычный веб-трафик (XTLS-Reality). Устойчив к глубокому анализу пакетов.",
|
||||||
|
"wireguard_desc": "Стандартный и самый быстрый VPN-протокол. Встроен во все современные ОС, но легко блокируется DPI.",
|
||||||
|
"not_checked": "Не проверено",
|
||||||
|
"connections": "Подключения",
|
||||||
|
"add": "Добавить",
|
||||||
|
"loading_connections": "Загрузка подключений...",
|
||||||
|
"no_connections": "Нет подключений",
|
||||||
|
"no_connections_desc": "Добавьте первое подключение для генерации VPN конфигурации",
|
||||||
|
"install_protocol": "Установить протокол",
|
||||||
|
"port_default_hint": "Порт по умолчанию: 55424. Убедитесь, что он не занят",
|
||||||
|
"port_xray_hint": "Порт по умолчанию: 443 (рекомендуется для Xray). Убедитесь, что он не занят другим веб-сервером.",
|
||||||
|
"reinstall": "Переустановить",
|
||||||
|
"uninstall_confirm": "Удалить {}? Все подключения и конфигурации будут потеряны.",
|
||||||
|
"stop_container_confirm": "Остановить контейнер {}?",
|
||||||
|
"start_container_confirm": "Запустить контейнер {}?",
|
||||||
|
"stopping": "Остановка",
|
||||||
|
"starting": "Запуск",
|
||||||
|
"stopped": "остановлен",
|
||||||
|
"started": "запущен",
|
||||||
|
"server_config": "Конфигурация сервера",
|
||||||
|
"add_connection": "Добавить подключение",
|
||||||
|
"connection_name": "Имя подключения",
|
||||||
|
"connection_name_placeholder": "Например: iPhone, Laptop",
|
||||||
|
"assign_to_user": "Назначить пользователю (опционально)",
|
||||||
|
"no_assign": "— Без привязки —",
|
||||||
|
"create": "Создать",
|
||||||
|
"creating": "Создание...",
|
||||||
|
"connection_created": "Подключение \"{}\" создано",
|
||||||
|
"delete_connection_confirm": "Удалить это подключение? Конфигурация перестанет работать.",
|
||||||
|
"connection_deleted": "Подключение удалено",
|
||||||
|
"config_tab": "📄 Конфиг",
|
||||||
|
"vpn_key_tab": "🔑 VPN-ключ",
|
||||||
|
"qr_code_tab": "📱 QR-код",
|
||||||
|
"copy_config": "📋 Копировать",
|
||||||
|
"download_conf": "⬇ Скачать .conf",
|
||||||
|
"copy_key": "📋 Копировать ключ",
|
||||||
|
"vpn_link_hint": "Вставьте эту ссылку в приложение AmneziaVPN для автоматической настройки",
|
||||||
|
"qr_hint": "Отсканируйте QR-код в приложении AmneziaVPN",
|
||||||
|
"no_data": "Нет данных",
|
||||||
|
"start_btn": "▶ Старт",
|
||||||
|
"stop_btn": "⏹ Стоп",
|
||||||
|
"config_btn": "⚙️ Конфиг",
|
||||||
|
"done": "Готово",
|
||||||
|
"installing": "Установка...",
|
||||||
|
"start_install": "Начинаем установку...",
|
||||||
|
"check_docker": "Проверка Docker...",
|
||||||
|
"prepare_host": "Подготовка хоста...",
|
||||||
|
"build_container": "Сборка контейнера...",
|
||||||
|
"install_success": "Протокол установлен успешно!",
|
||||||
|
"install_error": "Ошибка установки: ",
|
||||||
|
"qr_error": "Не удалось сгенерировать QR-код",
|
||||||
|
"users_title": "Пользователи",
|
||||||
|
"search_placeholder": "Поиск по имени, email или Telegram...",
|
||||||
|
"add_user": "Добавить пользователя",
|
||||||
|
"prev_page": "← Назад",
|
||||||
|
"next_page": "Вперёд →",
|
||||||
|
"page_info": "Страница {} из {}",
|
||||||
|
"loading_users": "Загрузка пользователей...",
|
||||||
|
"nothing_found": "Ничего не найдено",
|
||||||
|
"search_empty_desc": "Попробуйте изменить запрос или создайте нового пользователя",
|
||||||
|
"username_label": "Имя пользователя",
|
||||||
|
"password_label": "Пароль",
|
||||||
|
"role_label": "Роль",
|
||||||
|
"role_user_desc": "User — только просмотр своих подключений",
|
||||||
|
"role_support_desc": "Support — управление серверами",
|
||||||
|
"role_admin_desc": "Admin — полный доступ",
|
||||||
|
"tg_id_label": "Telegram ID (опц.)",
|
||||||
|
"email_label": "Email (опц.)",
|
||||||
|
"traffic_limit_label": "Лимит трафика (ГБ) (опц., 0 = ∞)",
|
||||||
|
"expiration_date_label": "Срок действия (до)",
|
||||||
|
"description_label": "Описание (опц.)",
|
||||||
|
"auto_conn_title": "Автоматическое создание подключения (опционально)",
|
||||||
|
"server_label": "Сервер",
|
||||||
|
"no_create_conn": "— Не создавать подключение —",
|
||||||
|
"protocol_label": "Протокол",
|
||||||
|
"edit_user_title": "Редактировать пользователя: {}",
|
||||||
|
"edit_user_tg": "Telegram ID (опционально)",
|
||||||
|
"edit_user_email": "Email (опционально)",
|
||||||
|
"edit_user_limit": "Лимит трафика (ГБ) (0 = ∞)",
|
||||||
|
"new_password_hint": "Новый пароль (оставьте пустым, если не нужно менять)",
|
||||||
|
"op_type_label": "Тип операции",
|
||||||
|
"create_new_conn": "Создать новое подключение",
|
||||||
|
"link_existing_conn": "Привязать существующее",
|
||||||
|
"select_existing_conn": "Выберите существующее подключение",
|
||||||
|
"existing_conn_hint": "Будут показаны только не привязанные к пользователям подключения",
|
||||||
|
"conn_name_panel": "Имя подключения (в панели)",
|
||||||
|
"user_conns_title": "Подключения пользователя",
|
||||||
|
"no_free_conns": "Нет свободных подключений",
|
||||||
|
"linking": "Привязка...",
|
||||||
|
"conn_linked": "Подключение привязано",
|
||||||
|
"enabling": "Включение",
|
||||||
|
"disabling": "Отключение",
|
||||||
|
"user_enabled": "Пользователь включён",
|
||||||
|
"user_disabled": "Пользователь отключён",
|
||||||
|
"delete_user_confirm": "Удалить пользователя? Все его подключения будут удалены.",
|
||||||
|
"share_access": "Поделиться доступом",
|
||||||
|
"enable_public_access": "Включить публичный доступ",
|
||||||
|
"share_password_label": "Пароль (опционально)",
|
||||||
|
"share_password_hint": "Если установлен, пользователь должен будет его ввести",
|
||||||
|
"public_link_label": "Публичная ссылка",
|
||||||
|
"disabled": "Отключён",
|
||||||
|
"out_of": "Из",
|
||||||
|
"settings_title": "Настройки панели",
|
||||||
|
"appearance": "🎨 Внешний вид",
|
||||||
|
"title_label": "Заголовок (Title)",
|
||||||
|
"logo_label": "Логотип (Emoji или символ)",
|
||||||
|
"subtitle_label": "Подзаголовок (Subtitle)",
|
||||||
|
"captcha_title": "🔐 Капча",
|
||||||
|
"enable_captcha": "Включить капчу (multicolorcaptcha)",
|
||||||
|
"telegram_bot_title": "🤖 Telegram Bot",
|
||||||
|
"bot_token_label": "Bot Token (от @BotFather)",
|
||||||
|
"bot_status": "Статус",
|
||||||
|
"bot_running": "✅ Запущен",
|
||||||
|
"bot_stopped": "⏹ Остановлен",
|
||||||
|
"bot_stop_btn": "⏹ Остановить",
|
||||||
|
"bot_start_btn": "▶️ Запустить",
|
||||||
|
"bot_hint": "После изменения токена сохраните настройки — бот перезапустится автоматически. Пользователи должны иметь Telegram ID в профиле.",
|
||||||
|
"api_docs_title": "🔌 API Документация",
|
||||||
|
"api_docs_hint": "Используйте эти интерфейсы для изучения возможностей API панели",
|
||||||
|
"tunnels_title": "Туннели",
|
||||||
|
"local_server": "Локальный сервер",
|
||||||
|
"tunnel_install_enable": "Установить и включить",
|
||||||
|
"tunnel_enable": "Включить туннель",
|
||||||
|
"tunnel_running": "Туннель запущен",
|
||||||
|
"tunnel_stop": "Остановить",
|
||||||
|
"tunnel_stopping": "Остановка...",
|
||||||
|
"tunnel_stopped": "Туннель остановлен",
|
||||||
|
"tunnel_delete": "Удалить",
|
||||||
|
"tunnel_deleted": "Туннель удалён",
|
||||||
|
"tunnel_delete_confirm": "Остановить и удалить бинарный файл туннеля, установленный панелью?",
|
||||||
|
"tunnel_starting": "Запуск туннеля...",
|
||||||
|
"tunnel_started": "Туннель запущен",
|
||||||
|
"tunnel_waiting_url": "Туннель запущен. Ждем публичный адрес...",
|
||||||
|
"tunnel_no_public_url": "Публичный адрес появится здесь после запуска туннеля.",
|
||||||
|
"tunnels_hint": "Быстрые туннели публикуют локальную панель в интернете. Используйте надежный пароль администратора и передавайте публичные адреса только доверенным пользователям.",
|
||||||
|
"ngrok_authtoken_placeholder": "ngrok authtoken (для некоторых аккаунтов необязательно)",
|
||||||
|
"import_users_title": "📤 Импорт пользователей",
|
||||||
|
"import_source_label": "Источник импорта / Синхронизации",
|
||||||
|
"remnawave_url_label": "Remnawave URL",
|
||||||
|
"api_key_label": "API Key",
|
||||||
|
"enable_sync": "Включить синхронизацию",
|
||||||
|
"sync_hint": "Пользователи будут создаваться, отключаться и удаляться автоматически на основе данных из Remnawave",
|
||||||
|
"sync_now_btn": "🔄 Синхронизировать сейчас",
|
||||||
|
"delete_sync_btn": "🗑 Удалить синхронизацию",
|
||||||
|
"auto_create_conns": "Создавать подключения автоматически",
|
||||||
|
"sync_server_label": "Сервер для новых подключений",
|
||||||
|
"save_changes": "💾 Сохранить изменения",
|
||||||
|
"sync_success": "Синхронизация завершена: {} пользователей",
|
||||||
|
"sync_deleted": "Удалено {} синхронизированных пользователей",
|
||||||
|
"delete_sync_confirm": "Вы уверены, что хотите удалить ВСЕХ синхронизированных пользователей и их подключения? Это действие необратимо.",
|
||||||
|
"settings_saved": "Настройки успешно сохранены",
|
||||||
|
"bot_started": "Бот запущен",
|
||||||
|
"bot_stopped_msg": "Бот остановлен",
|
||||||
|
"sync_running": "Синхронизация...",
|
||||||
|
"deleting": "Удаление...",
|
||||||
|
"my_connections_title": "Мои подключения",
|
||||||
|
"show_config": "📄 Показать конфиг",
|
||||||
|
"no_connections_user_desc": "Обратитесь к администратору для создания VPN подключения",
|
||||||
|
"share_title": "Доступ к подключениям",
|
||||||
|
"vpn_access": "VPN Доступ",
|
||||||
|
"user_label_share": "Пользователь",
|
||||||
|
"share_protected_desc": "Этот доступ защищен паролем. Пожалуйста, введите его для продолжения.",
|
||||||
|
"auth_success": "Вход выполнен",
|
||||||
|
"loading_share_conns": "Загрузка подключений...",
|
||||||
|
"no_active_conns": "У вас пока нет активных подключений.",
|
||||||
|
"show_settings_btn": "🔑 Показать настройки",
|
||||||
|
"copied": "Скопировано!",
|
||||||
|
"active": "Активен",
|
||||||
|
"site_description": "Amnezia Web Panel — управление VPN серверами и протоколами AmneziaWG",
|
||||||
|
"toggle_theme": "Сменить тему",
|
||||||
|
"copied_to_clipboard": "Скопировано в буфер обмена",
|
||||||
|
"share_not_found": "404 Не Найдено",
|
||||||
|
"share_not_found_desc": "Либо ссылка неверна, либо доступ был отключен.",
|
||||||
|
"invalid_captcha": "Неверная капча",
|
||||||
|
"account_disabled": "Аккаунт отключён",
|
||||||
|
"invalid_login": "Неверный логин или пароль",
|
||||||
|
"user_exists": "Пользователь с таким именем уже существует",
|
||||||
|
"cannot_delete_self": "Нельзя удалить самого себя",
|
||||||
|
"wrong_share_password": "Неверный пароль",
|
||||||
|
"saving": "Сохранение...",
|
||||||
|
"select_lang": "Выберите язык",
|
||||||
|
"lang_ru": "Русский",
|
||||||
|
"lang_en": "English",
|
||||||
|
"lang_fr": "Français",
|
||||||
|
"lang_zh": "中文 (Chinese)",
|
||||||
|
"lang_fa": "فارسی (Persian)",
|
||||||
|
"backup_title": "Резервное копирование",
|
||||||
|
"download_backup": "Скачать data.json",
|
||||||
|
"restore_backup": "Восстановить из файла",
|
||||||
|
"restore_confirm": "Вы уверены? Текущие данные будут перезаписаны, и приложение перезагрузится.",
|
||||||
|
"restore_success": "Восстановление успешно! Перезагрузка...",
|
||||||
|
"invalid_backup_file": "Неверный файл резервной копии или структура",
|
||||||
|
"config_unavailable": "Конфигурация недоступна",
|
||||||
|
"config_unavailable_desc": "Этот клиент был создан через нативное приложение Amnezia.\\nПриватный ключ хранится только на устройстве пользователя и не может быть восстановлен сервером.",
|
||||||
|
"client_public_key": "Публичный ключ клиента:",
|
||||||
|
"telemt_desc": "Прокси для Telegram на базе MTProxy с продвинутой обфускацией и эмуляцией TLS.",
|
||||||
|
"tls_emulation": "TLS-эмуляция",
|
||||||
|
"tls_domain": "Домен маскировки",
|
||||||
|
"max_connections_limit": "Макс. соединений",
|
||||||
|
"max_connections_hint": "0 — без ограничений. Ограничивает одновременных пользователей.",
|
||||||
|
"traffic_reset_strategy_label": "Стратегия сброса трафика",
|
||||||
|
"traffic_reset_never": "Никогда не сбрасывать",
|
||||||
|
"traffic_reset_daily": "Сброс ежедневно",
|
||||||
|
"traffic_reset_weekly": "Сброс еженедельно",
|
||||||
|
"traffic_reset_monthly": "Сброс ежемесячно",
|
||||||
|
"traffic_reset_hint": "Как часто следует сбрасывать трафик пользователя",
|
||||||
|
"traffic_used_total": "Всего потрачено",
|
||||||
|
"traffic_reset_info": "Следующий сброс: {}",
|
||||||
|
"services": "Сервисы",
|
||||||
|
"dns_desc": "Персональный DNS-сервер для блокировки рекламы и защиты приватности ваших запросов.",
|
||||||
|
"dns_internal_hint": "Сервис будет запущен во внутренней сети (172.29.172.254) без привязки к портам хоста.",
|
||||||
|
"telemt_port_hint": "Порт для Telegram-прокси (обычно 443).",
|
||||||
|
"socks5_desc": "SOCKS5-прокси (3proxy) с одной учётной записью и аутентификацией по логину/паролю.",
|
||||||
|
"socks5_username": "Логин",
|
||||||
|
"socks5_password": "Пароль",
|
||||||
|
"socks5_password_placeholder": "Оставьте пустым для автогенерации",
|
||||||
|
"socks5_password_hint": "Если поле пустое, будет сгенерирован случайный пароль на 16 символов.",
|
||||||
|
"socks5_port_hint": "TCP-порт, на котором будет слушать прокси. В официальном клиенте Amnezia по умолчанию 38080.",
|
||||||
|
"socks5_change_settings": "Изменить настройки",
|
||||||
|
"socks5_settings_title": "SOCKS5 — Настройки подключения",
|
||||||
|
"socks5_port_change_hint": "Смена порта пересоздаёт контейнер — текущие соединения оборвутся.",
|
||||||
|
"socks5_settings_saved": "Настройки SOCKS5 сохранены",
|
||||||
|
"adguard_desc": "AdGuard Home — DNS-блокировщик рекламы с веб-интерфейсом администратора.",
|
||||||
|
"adguard_mode": "Режим установки",
|
||||||
|
"adguard_mode_sidebyside": "Рядом с AmneziaDNS",
|
||||||
|
"adguard_mode_sidebyside_hint": "поднимается параллельно AmneziaDNS на отдельном внутреннем IP (172.29.172.253)",
|
||||||
|
"adguard_mode_replace": "Заменить AmneziaDNS",
|
||||||
|
"adguard_mode_replace_hint": "удаляет контейнер amnezia-dns и забирает его IP — VPN-клиенты сразу пойдут в AdGuard",
|
||||||
|
"adguard_dns_port": "DNS-порт (53)",
|
||||||
|
"adguard_dns_port_hint": "Внутренний DNS-порт, на который ходят VPN-клиенты через docker-сеть.",
|
||||||
|
"adguard_web_port": "Порт Web UI",
|
||||||
|
"adguard_dot_port": "Порт DoT",
|
||||||
|
"adguard_doh_port": "Порт DoH",
|
||||||
|
"adguard_expose_web": "Опубликовать наружу",
|
||||||
|
"adguard_expose": "Опубликовать наружу",
|
||||||
|
"adguard_install_hint": "Первоначальная настройка AdGuard проходит через мастер на веб-интерфейсе после установки.",
|
||||||
|
"adguard_internal_ip": "Внутренний IP",
|
||||||
|
"adguard_web_ui": "Адрес панели",
|
||||||
|
"adguard_open_ui": "Открыть Web UI",
|
||||||
|
"edit_server_title": "Изменить сервер",
|
||||||
|
"edit_keep_credential": "Оставьте пустым, чтобы сохранить текущее значение",
|
||||||
|
"server_saved": "Настройки сервера сохранены",
|
||||||
|
"servers_reordered": "Порядок серверов изменён",
|
||||||
|
"ping_checking": "Проверяем соединение...",
|
||||||
|
"offline": "недоступен",
|
||||||
|
"api_tokens_title": "API-токены",
|
||||||
|
"api_tokens_hint": "Bearer-токены для внешних интеграций. Передавайте токен в заголовке `Authorization: Bearer <token>`. Токены имеют права администратора и перестают работать, если их владелец отключён или понижен в роли.",
|
||||||
|
"api_tokens_empty": "Пока нет ни одного API-токена. Создайте, чтобы подключить панель к внешнему сервису.",
|
||||||
|
"api_tokens_create": "Создать токен",
|
||||||
|
"api_tokens_create_title": "Создание API-токена",
|
||||||
|
"api_tokens_name_label": "Название",
|
||||||
|
"api_tokens_name_placeholder": "напр. Linear, CI-бот, мониторинг",
|
||||||
|
"api_tokens_name_hint": "Нужно, чтобы отличать токен в списке. Сам токен сгенерируется автоматически.",
|
||||||
|
"api_tokens_name_required": "Укажите название токена",
|
||||||
|
"api_tokens_created_title": "Токен создан",
|
||||||
|
"api_tokens_value_label": "Значение токена",
|
||||||
|
"api_tokens_warning_title": "Это единственный раз, когда вы увидите этот токен.",
|
||||||
|
"api_tokens_warning_body": "Скопируйте его сейчас и сохраните в менеджере секретов вашей интеграции. Панель хранит только хэш — если потеряете значение, токен придётся отозвать и создать заново.",
|
||||||
|
"api_tokens_revoke": "Отозвать",
|
||||||
|
"api_tokens_revoke_confirm": "Отозвать токен «{}»? Любая интеграция, использующая его, перестанет работать.",
|
||||||
|
"api_tokens_revoked": "Токен отозван",
|
||||||
|
"api_tokens_created": "Создан",
|
||||||
|
"api_tokens_last_used": "Последнее использование",
|
||||||
|
"api_tokens_owner": "Владелец",
|
||||||
|
"api_tokens_never": "никогда",
|
||||||
|
"coming_soon": "Скоро",
|
||||||
|
"promo_star_cta": "Поставить звезду на GitHub",
|
||||||
|
"aivpn_subtitle": "ИИ-подбор протокола, который сам выбирает правильный туннель под текущие условия. Поможете звездой — выйдет быстрее.",
|
||||||
|
"revproxy_title": "Reverse Proxy",
|
||||||
|
"revproxy_subtitle": "Высокопроизводительная маскировка трафика: прячем VPN за обычным сайтом, снижаем заметность для DPI.",
|
||||||
|
"management": "Управление",
|
||||||
|
"server_management": "Управление сервером",
|
||||||
|
"server_management_desc": "Выполнение системных действий на вашей машине.",
|
||||||
|
"check_server_services": "Проверить сервисы сервера",
|
||||||
|
"reboot_server": "Перезагрузить сервер",
|
||||||
|
"clear_server": "Очистить сервер",
|
||||||
|
"remove_server": "Удалить сервер из панели",
|
||||||
|
"ssl_title": "SSL / HTTPS Настройки",
|
||||||
|
"enable_https": "Включить HTTPS",
|
||||||
|
"panel_port_label": "Порт панели",
|
||||||
|
"domain_label": "Доменное имя",
|
||||||
|
"cert_path_label": "Путь к SSL сертификату (.pem)",
|
||||||
|
"key_path_label": "Путь к приватному ключу (.pem)",
|
||||||
|
"or_paste_text": "ИЛИ вставьте текст сертификатов",
|
||||||
|
"cert_text_label": "Содержимое сертификата (CRT)",
|
||||||
|
"key_text_label": "Содержимое ключа (KEY)",
|
||||||
|
"ssl_hint": "После включения панель будет доступна по https:// на указанном домене. Убедитесь, что пути к файлам или текстовые данные верны.",
|
||||||
|
"about_title": "О программе и обновления",
|
||||||
|
"current_version": "Текущая версия",
|
||||||
|
"check_updates": "Проверить обновления",
|
||||||
|
"checking_updates": "Проверка...",
|
||||||
|
"update_available": "Доступна новая версия",
|
||||||
|
"download_update": "Скачать обновление",
|
||||||
|
"up_to_date": "У вас установлена последняя версия",
|
||||||
|
"warp_hint": "WARP направляет исходящий трафик этого хоста через Cloudflare. Он не создаёт публичный URL панели как Quick Tunnel или ngrok.",
|
||||||
|
"warp_install_hint": "Сначала установите официальный клиент Cloudflare WARP, затем перезапустите панель.",
|
||||||
|
"warp_install_required": "Сначала установите WARP",
|
||||||
|
"warp_connect": "Подключить WARP",
|
||||||
|
"warp_disconnect": "Отключить",
|
||||||
|
"warp_connected": "WARP подключён",
|
||||||
|
"warp_disconnected": "WARP отключён",
|
||||||
|
"warp_status_unknown": "Статус WARP неизвестен.",
|
||||||
|
"warp_status_not_installed": "Cloudflare WARP CLI не установлен на этом хосте.",
|
||||||
|
"warp_status_connected": "Cloudflare WARP подключён.",
|
||||||
|
"warp_status_connecting": "Cloudflare WARP подключается.",
|
||||||
|
"warp_status_disconnected": "Cloudflare WARP отключён.",
|
||||||
|
"warp_status_not_registered": "Cloudflare WARP установлен, но ещё не зарегистрирован. Подключение попробует зарегистрировать его автоматически.",
|
||||||
|
"warp_status_service_unavailable": "Сервис Cloudflare WARP недоступен. Убедитесь, что warp-svc запущен.",
|
||||||
|
"warp_status_error": "Cloudflare WARP вернул ошибку.",
|
||||||
|
"install_starting": "Начинаем установку...",
|
||||||
|
"server_check_complete": "Проверка завершена",
|
||||||
|
"server_connection_error": "Ошибка подключения",
|
||||||
|
"connections_load_error": "Ошибка загрузки подключений",
|
||||||
|
"connection_updated": "Подключение обновлено",
|
||||||
|
"edit_connection": "Редактировать подключение",
|
||||||
|
"enable_connection_progress": "Включение подключения...",
|
||||||
|
"disable_connection_progress": "Отключение подключения...",
|
||||||
|
"connection_enabled": "Подключение включено",
|
||||||
|
"connection_disabled": "Подключение отключено",
|
||||||
|
"gb": "ГБ",
|
||||||
|
"marketplace": "Шаблоны",
|
||||||
|
"open_marketplace": "Открыть шаблоны",
|
||||||
|
"marketplace_desc": "Установите доступные шаблоны протоколов и сервисов для этого сервера.",
|
||||||
|
"installed_apps": "Установленные приложения",
|
||||||
|
"installed_apps_hint": "Здесь отображаются только установленные протоколы и сервисы. Чтобы добавить новые, откройте шаблоны.",
|
||||||
|
"no_installed_apps": "Пока нет установленных приложений",
|
||||||
|
"no_installed_apps_desc": "Откройте шаблоны, чтобы установить протоколы или сервисы на этот сервер.",
|
||||||
|
"install_another": "Установить ещё один",
|
||||||
|
"new_instance": "новый экземпляр",
|
||||||
|
"port_next_instance_hint": "Выберите свободный UDP-порт для дополнительного экземпляра.",
|
||||||
|
"checking_server": "Проверяем сервисы сервера...",
|
||||||
|
"web_servers": "Веб-серверы",
|
||||||
|
"nginx_desc": "NGINX веб-сервер с HTTPS-сертификатом Let's Encrypt и редактируемым index.html.",
|
||||||
|
"nginx_domain": "Домен",
|
||||||
|
"nginx_email": "Email для Let's Encrypt",
|
||||||
|
"nginx_port_hint": "HTTPS-порт на сервере. Порт 80/TCP должен быть свободен и доступен для проверки Let's Encrypt.",
|
||||||
|
"nginx_install_hint": "Домен уже должен указывать на этот сервер. Порт 80/TCP используется для HTTP-01 проверки сертификата.",
|
||||||
|
"site": "Сайт",
|
||||||
|
"site_editor": "Редактор сайта",
|
||||||
|
"site_saved": "Сайт сохранён",
|
||||||
|
"nginx_dns_hint": "Перед установкой создайте DNS-запись:",
|
||||||
|
"backup": "Backup",
|
||||||
|
"backups": "Бекапы",
|
||||||
|
"backup_desc": "Бекапы создаются на сервере и содержат ценные файлы протокола, нужные для восстановления или повторного запуска без потерь.",
|
||||||
|
"create_backup": "Создать бекап",
|
||||||
|
"creating_backup": "Создание бекапа...",
|
||||||
|
"backup_created": "Бекап создан",
|
||||||
|
"no_backups": "Бекапов пока нет",
|
||||||
|
"no_backups_desc": "Создайте первый бекап для этого протокола.",
|
||||||
|
"refresh": "Обновить"
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
{
|
||||||
|
"nav_servers": "服务器",
|
||||||
|
"nav_users": "用户列表",
|
||||||
|
"nav_settings": "设置",
|
||||||
|
"nav_connections": "连接管理",
|
||||||
|
"nav_logout": "登出",
|
||||||
|
"theme_dark": "深色模式",
|
||||||
|
"theme_light": "浅色模式",
|
||||||
|
"back_to_servers": "← 返回服务器列表",
|
||||||
|
"server_check": "检查状态",
|
||||||
|
"server_checking": "正在检查...",
|
||||||
|
"protocols": "协议",
|
||||||
|
"install": "安装",
|
||||||
|
"uninstall": "卸载",
|
||||||
|
"run": "运行中",
|
||||||
|
"stop": "已停止",
|
||||||
|
"not_installed": "未安装",
|
||||||
|
"installed": "已安装",
|
||||||
|
"add_server": "添加服务器",
|
||||||
|
"no_servers": "尚未添加服务器",
|
||||||
|
"name": "名称",
|
||||||
|
"host": "地址",
|
||||||
|
"port": "端口",
|
||||||
|
"status": "状态",
|
||||||
|
"actions": "操作",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除",
|
||||||
|
"save": "保存",
|
||||||
|
"cancel": "取消",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"copy": "复制",
|
||||||
|
"download": "下载",
|
||||||
|
"config": "配置",
|
||||||
|
"qr_code": "二维码",
|
||||||
|
"vpn_key": "VPN 密钥",
|
||||||
|
"clients": "客户端",
|
||||||
|
"traffic": "流量",
|
||||||
|
"limit": "限制",
|
||||||
|
"manage": "管理",
|
||||||
|
"no_protocols": "无协议",
|
||||||
|
"add_server_desc": "添加您的第一台 VPN 服务器。需要 SSH 凭据进行连接。",
|
||||||
|
"ssh_host": "主机 / IP",
|
||||||
|
"ssh_port": "SSH 端口",
|
||||||
|
"ssh_user": "用户名",
|
||||||
|
"ssh_password": "密码",
|
||||||
|
"ssh_key": "SSH 密钥",
|
||||||
|
"ssh_key_placeholder": "私钥 (Private Key)",
|
||||||
|
"ssh_key_hint": "粘贴 id_rsa 或 id_ed25519 文件的内容",
|
||||||
|
"connect": "连接",
|
||||||
|
"connecting": "正在连接...",
|
||||||
|
"server_added": "服务器添加成功",
|
||||||
|
"server_deleted": "服务器已删除",
|
||||||
|
"delete_confirm": "确定要删除此服务器吗?",
|
||||||
|
"success": "成功",
|
||||||
|
"error": "错误",
|
||||||
|
"info": "信息",
|
||||||
|
"login_title": "登录管理面板",
|
||||||
|
"username": "用户名",
|
||||||
|
"password": "密码",
|
||||||
|
"captcha": "验证码",
|
||||||
|
"captcha_placeholder": "输入图中字符",
|
||||||
|
"captcha_hint": "点击刷新验证码",
|
||||||
|
"login": "登录",
|
||||||
|
"logging_in": "登录中...",
|
||||||
|
"login_error": "登录失败",
|
||||||
|
"check_done": "检查完成",
|
||||||
|
"check_error": "连接错误",
|
||||||
|
"ssh_ok": "SSH 连接正常",
|
||||||
|
"docker_ok": "Docker 正常",
|
||||||
|
"docker_not_installed": "Docker 未安装",
|
||||||
|
"awg_desc": "基于 awg-go 的新版协议。支持 S3, S4 高级混淆。",
|
||||||
|
"awg_legacy_desc": "原始 AWG 版本。兼容旧版客户端。",
|
||||||
|
"xray_desc": "将流量伪装成普通网页流量 (XTLS-Reality),抗封锁能力强。",
|
||||||
|
"not_checked": "未检查",
|
||||||
|
"connections": "连接",
|
||||||
|
"add": "添加",
|
||||||
|
"loading_connections": "正在加载连接...",
|
||||||
|
"no_connections": "无连接",
|
||||||
|
"no_connections_desc": "添加首个连接以生成 VPN 配置文件",
|
||||||
|
"install_protocol": "安装协议",
|
||||||
|
"port_default_hint": "默认端口: 55424。请确保端口未被占用。",
|
||||||
|
"port_xray_hint": "推荐端口: 443。请确保未被其他 Web 服务器使用。",
|
||||||
|
"reinstall": "重新安装",
|
||||||
|
"uninstall_confirm": "确定卸载 {}?所有连接和配置都将丢失。",
|
||||||
|
"stop_container_confirm": "停止容器 {}?",
|
||||||
|
"start_container_confirm": "启动容器 {}?",
|
||||||
|
"stopping": "停止中",
|
||||||
|
"starting": "启动中",
|
||||||
|
"stopped": "已停止",
|
||||||
|
"started": "已启动",
|
||||||
|
"server_config": "服务端配置",
|
||||||
|
"add_connection": "添加连接",
|
||||||
|
"connection_name": "连接名称",
|
||||||
|
"connection_name_placeholder": "例如: iPhone, 笔记本电脑",
|
||||||
|
"assign_to_user": "分配给用户 (可选)",
|
||||||
|
"no_assign": "— 不分配 —",
|
||||||
|
"create": "创建",
|
||||||
|
"creating": "创建中...",
|
||||||
|
"connection_created": "连接 \"{}\" 已创建",
|
||||||
|
"delete_connection_confirm": "确定删除此连接?配置将失效。",
|
||||||
|
"connection_deleted": "连接已删除",
|
||||||
|
"config_tab": "📄 配置文件",
|
||||||
|
"vpn_key_tab": "🔑 VPN 密钥",
|
||||||
|
"qr_code_tab": "📱 二维码",
|
||||||
|
"copy_config": "📋 复制配置",
|
||||||
|
"download_conf": "⬇ 下载 .conf 文件",
|
||||||
|
"copy_key": "📋 复制密钥",
|
||||||
|
"vpn_link_hint": "将此链接粘贴到 AmneziaVPN 应用中即可自动配置",
|
||||||
|
"qr_hint": "使用 AmneziaVPN 应用扫描二维码",
|
||||||
|
"no_data": "无数据",
|
||||||
|
"start_btn": "▶ 启动",
|
||||||
|
"stop_btn": "⏹ 停止",
|
||||||
|
"config_btn": "⚙️ 配置",
|
||||||
|
"done": "完成",
|
||||||
|
"installing": "安装中...",
|
||||||
|
"start_install": "开始安装...",
|
||||||
|
"check_docker": "检查 Docker...",
|
||||||
|
"prepare_host": "准备环境...",
|
||||||
|
"build_container": "构建容器...",
|
||||||
|
"install_success": "协议安装成功!",
|
||||||
|
"install_error": "安装失败: ",
|
||||||
|
"qr_error": "无法生成二维码",
|
||||||
|
"users_title": "用户管理",
|
||||||
|
"search_placeholder": "搜索用户名、邮箱或 Telegram ID...",
|
||||||
|
"add_user": "添加用户",
|
||||||
|
"prev_page": "← 上一页",
|
||||||
|
"next_page": "下一页 →",
|
||||||
|
"page_info": "第 {} 页,共 {} 页",
|
||||||
|
"loading_users": "正在加载用户...",
|
||||||
|
"nothing_found": "未找到匹配项",
|
||||||
|
"search_empty_desc": "请尝试更改查询条件或创建新用户",
|
||||||
|
"username_label": "用户名",
|
||||||
|
"password_label": "密码",
|
||||||
|
"role_label": "角色",
|
||||||
|
"role_user_desc": "User — 仅查看自己的连接",
|
||||||
|
"role_support_desc": "Support — 服务器管理权限",
|
||||||
|
"role_admin_desc": "Admin — 全部权限",
|
||||||
|
"tg_id_label": "Telegram ID (可选)",
|
||||||
|
"email_label": "邮箱 (可选)",
|
||||||
|
"traffic_limit_label": "流量限制 (GB) (0 = 不限制)",
|
||||||
|
"description_label": "备注 (可选)",
|
||||||
|
"auto_conn_title": "自动创建连接 (可选)",
|
||||||
|
"server_label": "服务器",
|
||||||
|
"no_create_conn": "— 不自动创建 —",
|
||||||
|
"protocol_label": "协议",
|
||||||
|
"edit_user_title": "编辑用户: {}",
|
||||||
|
"edit_user_tg": "Telegram ID (可选)",
|
||||||
|
"edit_user_email": "邮箱 (可选)",
|
||||||
|
"edit_user_limit": "流量限制 (GB) (0 = 不限制)",
|
||||||
|
"new_password_hint": "新密码 (如不修改请留空)",
|
||||||
|
"op_type_label": "操作类型",
|
||||||
|
"create_new_conn": "创建新连接",
|
||||||
|
"link_existing_conn": "关联现有连接",
|
||||||
|
"select_existing_conn": "选择现有连接",
|
||||||
|
"existing_conn_hint": "仅显示尚未关联用户的连接",
|
||||||
|
"conn_name_panel": "连接名称 (面板显示)",
|
||||||
|
"user_conns_title": "用户连接列表",
|
||||||
|
"no_free_conns": "无可用空闲连接",
|
||||||
|
"linking": "关联中...",
|
||||||
|
"conn_linked": "连接关联成功",
|
||||||
|
"enabling": "启用中",
|
||||||
|
"disabling": "禁用中",
|
||||||
|
"user_enabled": "用户已启用",
|
||||||
|
"user_disabled": "用户已禁用",
|
||||||
|
"delete_user_confirm": "确定删除该用户?其所有连接都将被删除。",
|
||||||
|
"share_access": "分享访问权限",
|
||||||
|
"enable_public_access": "启用公开访问",
|
||||||
|
"share_password_label": "密码 (可选)",
|
||||||
|
"share_password_hint": "如设置,访问者需输入密码",
|
||||||
|
"public_link_label": "公开链接",
|
||||||
|
"disabled": "已禁用",
|
||||||
|
"out_of": "/",
|
||||||
|
"settings_title": "面板设置",
|
||||||
|
"appearance": "🎨 外观设置",
|
||||||
|
"title_label": "页面标题 (Title)",
|
||||||
|
"logo_label": "图标 (Emoji 或 字符)",
|
||||||
|
"subtitle_label": "副标题 (Subtitle)",
|
||||||
|
"captcha_title": "🔐 验证码",
|
||||||
|
"enable_captcha": "启用登录验证码",
|
||||||
|
"telegram_bot_title": "🤖 Telegram 机器人",
|
||||||
|
"bot_token_label": "Bot Token (来自 @BotFather)",
|
||||||
|
"bot_status": "状态",
|
||||||
|
"bot_running": "✅ 运行中",
|
||||||
|
"bot_stopped": "⏹ 已停止",
|
||||||
|
"bot_stop_btn": "⏹ 停止",
|
||||||
|
"bot_start_btn": "▶️ 启动",
|
||||||
|
"bot_hint": "修改 Token 后请保存,机器人将自动重启。用户需在个人资料中填写 Telegram ID。",
|
||||||
|
"api_docs_title": "🔌 API 文档",
|
||||||
|
"api_docs_hint": "使用这些接口了解面板功能",
|
||||||
|
"tunnels_title": "Tunnels",
|
||||||
|
"local_server": "本地服务器",
|
||||||
|
"tunnel_install_enable": "安装并启用",
|
||||||
|
"tunnel_enable": "启用隧道",
|
||||||
|
"tunnel_running": "隧道运行中",
|
||||||
|
"tunnel_stop": "停止",
|
||||||
|
"tunnel_stopping": "正在停止...",
|
||||||
|
"tunnel_stopped": "隧道已停止",
|
||||||
|
"tunnel_delete": "删除",
|
||||||
|
"tunnel_deleted": "隧道已删除",
|
||||||
|
"tunnel_delete_confirm": "停止并删除由面板管理的隧道二进制文件?",
|
||||||
|
"tunnel_starting": "正在启动隧道...",
|
||||||
|
"tunnel_started": "隧道已启动",
|
||||||
|
"tunnel_waiting_url": "隧道已启动,正在等待公网地址...",
|
||||||
|
"tunnel_no_public_url": "隧道启动后公网地址会显示在这里。",
|
||||||
|
"tunnels_hint": "快速隧道会将本地面板暴露到互联网。请使用强管理员密码,并仅与可信用户共享公网地址。",
|
||||||
|
"ngrok_authtoken_placeholder": "ngrok authtoken(某些账号可选)",
|
||||||
|
"import_users_title": "📤 导入用户",
|
||||||
|
"import_source_label": "导入 / 同步源",
|
||||||
|
"remnawave_url_label": "Remnawave 地址",
|
||||||
|
"api_key_label": "API 密钥",
|
||||||
|
"enable_sync": "开启同步",
|
||||||
|
"sync_hint": "将根据 Remnawave 数据自动创建、禁用和删除用户",
|
||||||
|
"sync_now_btn": "🔄 立即同步",
|
||||||
|
"delete_sync_btn": "🗑 删除同步数据",
|
||||||
|
"auto_create_conns": "自动创建连接",
|
||||||
|
"sync_server_label": "新连接的服务器",
|
||||||
|
"save_changes": "💾 保存修改",
|
||||||
|
"sync_success": "同步完成: {} 个用户已更新",
|
||||||
|
"sync_deleted": "已删除 {} 个同步用户",
|
||||||
|
"delete_sync_confirm": "确定要删除所有同步用户及其连接吗?此操作不可逆。",
|
||||||
|
"settings_saved": "设置已保存",
|
||||||
|
"bot_started": "机器人已启动",
|
||||||
|
"bot_stopped_msg": "机器人已停止",
|
||||||
|
"sync_running": "同步中...",
|
||||||
|
"deleting": "删除中...",
|
||||||
|
"my_connections_title": "我的连接",
|
||||||
|
"show_config": "📄 查看配置",
|
||||||
|
"no_connections_user_desc": "请联系管理员创建 VPN 连接",
|
||||||
|
"share_title": "访问连接",
|
||||||
|
"vpn_access": "VPN 访问",
|
||||||
|
"user_label_share": "用户",
|
||||||
|
"share_protected_desc": "此访问受密码保护。请输入密码以继续。",
|
||||||
|
"auth_success": "身份验证通过",
|
||||||
|
"loading_share_conns": "正在加载连接...",
|
||||||
|
"no_active_conns": "您目前没有活跃连接。",
|
||||||
|
"show_settings_btn": "🔑 显示设置",
|
||||||
|
"copied": "已复制!",
|
||||||
|
"active": "活跃",
|
||||||
|
"site_description": "Amnezia Web Panel — VPN 服务器及 AmneziaWG 协议管理工具",
|
||||||
|
"toggle_theme": "切换主题",
|
||||||
|
"copied_to_clipboard": "已复制到剪贴板",
|
||||||
|
"share_not_found": "404 页面未找到",
|
||||||
|
"share_not_found_desc": "链接无效或访问已被禁用。",
|
||||||
|
"invalid_captcha": "验证码错误",
|
||||||
|
"account_disabled": "账户已被禁用",
|
||||||
|
"invalid_login": "用户名或密码错误",
|
||||||
|
"user_exists": "该用户名已存在",
|
||||||
|
"cannot_delete_self": "您无法删除自己的账户",
|
||||||
|
"wrong_share_password": "密码错误",
|
||||||
|
"saving": "正在保存...",
|
||||||
|
"select_lang": "选择语言",
|
||||||
|
"lang_ru": "Русский",
|
||||||
|
"lang_en": "English",
|
||||||
|
"lang_fr": "Français",
|
||||||
|
"lang_zh": "中文 (Chinese)",
|
||||||
|
"lang_fa": "فارسی (Persian)",
|
||||||
|
"backup_title": "简易备份",
|
||||||
|
"download_backup": "下载 data.json",
|
||||||
|
"restore_backup": "从文件恢复",
|
||||||
|
"restore_confirm": "您确定吗?当前数据将被覆盖,应用程序将重新启动。",
|
||||||
|
"restore_success": "恢复成功!正在重启...",
|
||||||
|
"invalid_backup_file": "备份文件无效或结构错误",
|
||||||
|
"config_unavailable": "配置文件不可用",
|
||||||
|
"config_unavailable_desc": "此客户端是通过 Amnezia 原生应用创建的。\\n私钥仅存储在用户设备上,服务器无法恢复。",
|
||||||
|
"client_public_key": "客户端公钥:",
|
||||||
|
"management": "管理",
|
||||||
|
"server_management": "服务器管理",
|
||||||
|
"server_management_desc": "在您的机器上执行系统级别的操作。",
|
||||||
|
"check_server_services": "检查服务器服务",
|
||||||
|
"reboot_server": "重启服务器",
|
||||||
|
"clear_server": "清除服务器",
|
||||||
|
"remove_server": "从面板移除服务器",
|
||||||
|
"telemt_desc": "基于 MTProxy 的 Telegram 代理,支持高级混淆。",
|
||||||
|
"dns_desc": "防止广告和保护隐私的个人 DNS 服务器。",
|
||||||
|
"dns_internal_hint": "The service runs on the internal network (172.29.172.254) without binding to host ports.",
|
||||||
|
"telemt_port_hint": "Port for the Telegram proxy (typically 443).",
|
||||||
|
"socks5_desc": "Single-account SOCKS5 proxy (3proxy) with username/password authentication.",
|
||||||
|
"socks5_username": "Username",
|
||||||
|
"socks5_password": "Password",
|
||||||
|
"socks5_password_placeholder": "Leave empty to auto-generate",
|
||||||
|
"socks5_password_hint": "If left empty, a random 16-character password will be generated.",
|
||||||
|
"socks5_port_hint": "TCP port the proxy will listen on. The default in the official Amnezia client is 38080.",
|
||||||
|
"socks5_change_settings": "Change settings",
|
||||||
|
"socks5_settings_title": "SOCKS5 — Connection settings",
|
||||||
|
"socks5_port_change_hint": "Changing the port recreates the container; existing sessions will be dropped.",
|
||||||
|
"socks5_settings_saved": "SOCKS5 settings saved",
|
||||||
|
"adguard_desc": "AdGuard Home — DNS-based ad blocker with a web admin UI.",
|
||||||
|
"adguard_mode": "Installation mode",
|
||||||
|
"adguard_mode_sidebyside": "Side-by-side",
|
||||||
|
"adguard_mode_sidebyside_hint": "runs alongside AmneziaDNS on a separate internal IP (172.29.172.253)",
|
||||||
|
"adguard_mode_replace": "Replace AmneziaDNS",
|
||||||
|
"adguard_mode_replace_hint": "removes amnezia-dns and takes its IP — VPN clients use AdGuard immediately",
|
||||||
|
"adguard_dns_port": "DNS port (53)",
|
||||||
|
"adguard_dns_port_hint": "Internal DNS port served to VPN clients via the docker network.",
|
||||||
|
"adguard_web_port": "Web UI port",
|
||||||
|
"adguard_dot_port": "DoT port",
|
||||||
|
"adguard_doh_port": "DoH port",
|
||||||
|
"adguard_expose_web": "Expose to internet",
|
||||||
|
"adguard_expose": "Expose to host",
|
||||||
|
"adguard_install_hint": "Initial AdGuard setup runs through its built-in wizard via the web UI after install.",
|
||||||
|
"adguard_internal_ip": "Internal IP",
|
||||||
|
"adguard_web_ui": "Admin URL",
|
||||||
|
"adguard_open_ui": "Open Web UI",
|
||||||
|
"edit_server_title": "Edit server",
|
||||||
|
"edit_keep_credential": "Leave empty to keep the current value",
|
||||||
|
"server_saved": "Server settings saved",
|
||||||
|
"servers_reordered": "Servers reordered",
|
||||||
|
"ping_checking": "Checking connectivity...",
|
||||||
|
"offline": "offline",
|
||||||
|
"api_tokens_title": "API tokens",
|
||||||
|
"api_tokens_hint": "Bearer tokens for external integrations. Send the token in the `Authorization: Bearer <token>` header. Tokens have admin-equivalent rights and stop working if their owner is disabled or demoted.",
|
||||||
|
"api_tokens_empty": "No API tokens yet. Create one to integrate the panel with an external service.",
|
||||||
|
"api_tokens_create": "Create token",
|
||||||
|
"api_tokens_create_title": "Create API token",
|
||||||
|
"api_tokens_name_label": "Name",
|
||||||
|
"api_tokens_name_placeholder": "e.g. Linear integration, CI bot, monitoring",
|
||||||
|
"api_tokens_name_hint": "Used to identify this token in the list. The token value itself is generated for you.",
|
||||||
|
"api_tokens_name_required": "Token name is required",
|
||||||
|
"api_tokens_created_title": "Token created",
|
||||||
|
"api_tokens_value_label": "Token value",
|
||||||
|
"api_tokens_warning_title": "This is the only time you'll see this token.",
|
||||||
|
"api_tokens_warning_body": "Copy it now and store it in your integration's secret manager. The panel keeps only a hash — if you lose this value you'll need to revoke and recreate the token.",
|
||||||
|
"api_tokens_revoke": "Revoke",
|
||||||
|
"api_tokens_revoke_confirm": "Revoke token \"{}\"? Any integration using it will stop working.",
|
||||||
|
"api_tokens_revoked": "Token revoked",
|
||||||
|
"api_tokens_created": "Created",
|
||||||
|
"api_tokens_last_used": "Last used",
|
||||||
|
"api_tokens_owner": "Owner",
|
||||||
|
"api_tokens_never": "never",
|
||||||
|
"coming_soon": "Coming soon",
|
||||||
|
"promo_star_cta": "Star us on GitHub",
|
||||||
|
"aivpn_subtitle": "AI-driven protocol selection that picks the right tunnel for the moment. Land it sooner — drop a star.",
|
||||||
|
"revproxy_title": "Reverse Proxy",
|
||||||
|
"revproxy_subtitle": "High-performance traffic obfuscation: hide your VPN endpoint behind a polished public-facing site, drop the DPI footprint.",
|
||||||
|
"warp_hint": "WARP routes this host outbound through Cloudflare. It does not create a public panel URL like Quick Tunnel or ngrok.",
|
||||||
|
"warp_install_hint": "Install the official Cloudflare WARP client first, then restart the panel.",
|
||||||
|
"warp_install_required": "Install WARP first",
|
||||||
|
"warp_connect": "Connect WARP",
|
||||||
|
"warp_disconnect": "Disconnect",
|
||||||
|
"warp_connected": "WARP connected",
|
||||||
|
"warp_disconnected": "WARP disconnected",
|
||||||
|
"warp_status_unknown": "WARP status is unknown.",
|
||||||
|
"warp_status_not_installed": "Cloudflare WARP CLI is not installed on this host.",
|
||||||
|
"warp_status_connected": "Cloudflare WARP is connected.",
|
||||||
|
"warp_status_connecting": "Cloudflare WARP is connecting.",
|
||||||
|
"warp_status_disconnected": "Cloudflare WARP is disconnected.",
|
||||||
|
"warp_status_not_registered": "Cloudflare WARP is installed but not registered yet. Connect will try to register automatically.",
|
||||||
|
"warp_status_service_unavailable": "Cloudflare WARP service is not available. Make sure warp-svc is running.",
|
||||||
|
"warp_status_error": "Cloudflare WARP returned an error.",
|
||||||
|
"install_starting": "Starting installation...",
|
||||||
|
"server_check_complete": "Check completed",
|
||||||
|
"server_connection_error": "Connection error",
|
||||||
|
"connections_load_error": "Error loading connections",
|
||||||
|
"connection_updated": "Connection updated",
|
||||||
|
"edit_connection": "Edit connection",
|
||||||
|
"enable_connection_progress": "Enabling connection...",
|
||||||
|
"disable_connection_progress": "Disabling connection...",
|
||||||
|
"connection_enabled": "Connection enabled",
|
||||||
|
"connection_disabled": "Connection disabled",
|
||||||
|
"gb": "GB",
|
||||||
|
"marketplace": "Templates",
|
||||||
|
"open_marketplace": "Open Templates",
|
||||||
|
"marketplace_desc": "Install protocol and service templates available for this server.",
|
||||||
|
"installed_apps": "Installed applications",
|
||||||
|
"installed_apps_hint": "Only installed protocols and services are shown here. Use Templates to add more.",
|
||||||
|
"no_installed_apps": "No installed applications yet",
|
||||||
|
"no_installed_apps_desc": "Open Templates to install protocols or services on this server.",
|
||||||
|
"install_another": "Install another",
|
||||||
|
"new_instance": "new instance",
|
||||||
|
"port_next_instance_hint": "Choose a free UDP port for the additional instance.",
|
||||||
|
"web_servers": "Web servers",
|
||||||
|
"nginx_desc": "NGINX web server with Let's Encrypt HTTPS certificate and editable index.html.",
|
||||||
|
"nginx_domain": "Domain",
|
||||||
|
"nginx_email": "Let's Encrypt email",
|
||||||
|
"nginx_port_hint": "HTTPS port exposed on the server. Port 80/TCP must be free and reachable for Let's Encrypt validation.",
|
||||||
|
"nginx_install_hint": "The domain must already point to this server. Port 80/TCP is used for HTTP-01 certificate validation.",
|
||||||
|
"site": "Site",
|
||||||
|
"site_editor": "Site editor",
|
||||||
|
"site_saved": "Site saved",
|
||||||
|
"nginx_dns_hint": "Create this DNS record before installation:",
|
||||||
|
"backup": "Backup",
|
||||||
|
"backups": "Backups",
|
||||||
|
"backup_desc": "Backups are created on the server and contain valuable protocol files needed to restore or recreate it later.",
|
||||||
|
"create_backup": "Create backup",
|
||||||
|
"creating_backup": "Creating backup...",
|
||||||
|
"backup_created": "Backup created",
|
||||||
|
"no_backups": "No backups yet",
|
||||||
|
"no_backups_desc": "Create the first backup for this protocol.",
|
||||||
|
"refresh": "Refresh"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user