chore: доделываем поднятие сервера
This commit is contained in:
1 parent
adab286884
commit
8532557ef6
18 files changed
+749
-37
No files matched your search
@@ -11,7 +11,7 @@ SERVERUS_ACCESS_DIR := $(abspath extra/serverus)
|
|||||||
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help
|
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help
|
||||||
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
|
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
|
||||||
|
|
||||||
.PHONY: install freeze wheels sync sync-offline clean help venv dj_makemigrations dj_migrate dj_superuser dj_run dj_test dj_startapp dj_worker tg_run serverus_xui serverus_release
|
.PHONY: install freeze wheels sync sync-offline clean help venv dj_makemigrations dj_migrate dj_superuser dj_run dj_test dj_startapp dj_worker tg_run serverus_xui serverus_key serverus_release
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
|
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
|
||||||
@@ -21,6 +21,7 @@ help:
|
|||||||
@echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)"
|
@echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)"
|
||||||
@echo "make clean - удалить $(WHEELS_DIR)/"
|
@echo "make clean - удалить $(WHEELS_DIR)/"
|
||||||
@echo "make serverus_xui ip=<ip> password=<root_password> - настроить свежий сервер и поставить 3x-ui, доступы в $(SERVERUS_ACCESS_DIR)/<ip>/"
|
@echo "make serverus_xui ip=<ip> password=<root_password> - настроить свежий сервер и поставить 3x-ui, доступы в $(SERVERUS_ACCESS_DIR)/<ip>/"
|
||||||
|
@echo "make serverus_key ip=<ip> action=add|del|stats name=<name> - ключ на панели, настроенной через serverus_xui"
|
||||||
@echo "make serverus_release ip=<ip> - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт"
|
@echo "make serverus_release ip=<ip> - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт"
|
||||||
|
|
||||||
venv:
|
venv:
|
||||||
@@ -85,6 +86,10 @@ serverus_xui:
|
|||||||
@test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip=<ip> password=<root_password>"; exit 1; }
|
@test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip=<ip> password=<root_password>"; exit 1; }
|
||||||
$(SERVERUS) xui $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip)
|
$(SERVERUS) xui $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip)
|
||||||
|
|
||||||
|
serverus_key:
|
||||||
|
@test -n "$(ip)" -a -n "$(action)" -a -n "$(name)" || { echo "Укажите ключ: make serverus_key ip=<ip> action=add|del|stats name=<name>"; exit 1; }
|
||||||
|
$(SERVERUS) key $(action) '$(name)' --out $(SERVERUS_ACCESS_DIR)/$(ip)
|
||||||
|
|
||||||
serverus_release:
|
serverus_release:
|
||||||
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_release ip=<ip>"; exit 1; }
|
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_release ip=<ip>"; exit 1; }
|
||||||
$(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip)
|
$(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip)
|
||||||
@@ -40,3 +40,11 @@ Django приложение связывающее все части сайта
|
|||||||
4. facts: `uname -a` для карточки сервера
|
4. facts: `uname -a` для карточки сервера
|
||||||
|
|
||||||
"Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке.
|
"Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке.
|
||||||
|
|
||||||
|
# TODO:
|
||||||
|
|
||||||
|
-[ ] для панели 3x-ui нужно активировать ssl, панель умеет сама создавать сертификаты для домена или ip адреса.
|
||||||
|
-[ ] можно ли поставить домен после того как настроили сервер на ip? я имею ввиду поставить домен и выпустить ssl сертификат от letsencrypt.
|
||||||
|
-[ ] что нужно сделать чтобы сервис мог нормально работать и раздавать прокси интернет через cloudflare? когда домен за прокси клауда.
|
||||||
|
-[ ] если используется и прокси сервер и домен за claudflare, то будет работать?
|
||||||
|
-[ ] нужна функция установки прокси в исходящие (outbound)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
from serverus import generate_password, generate_port
|
||||||
|
|
||||||
|
|
||||||
|
def generate_subscription(apps, schema_editor):
|
||||||
|
# у уже установленных точек подписка появится после переустановки
|
||||||
|
Point = apps.get_model('App', 'Point')
|
||||||
|
for point in Point.objects.all():
|
||||||
|
point.sub_port = generate_port(exclude={point.panel_port})
|
||||||
|
point.sub_path = generate_password(12)
|
||||||
|
point.save(update_fields=['sub_port', 'sub_path'])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('App', '0003_server_default_root_password'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='point',
|
||||||
|
name='panel_inbound_id',
|
||||||
|
field=models.PositiveIntegerField(blank=True, help_text='Подключение reality в панели', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='point',
|
||||||
|
name='sub_port',
|
||||||
|
field=models.PositiveIntegerField(default=0, help_text='Порт подписки панели'),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='point',
|
||||||
|
name='sub_path',
|
||||||
|
field=models.CharField(default='', help_text='Путь подписки без слешей', max_length=64),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.RunPython(generate_subscription, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
+9
-7
@@ -4,10 +4,7 @@ from dataclasses import dataclass
|
|||||||
from django.contrib.auth.base_user import BaseUserManager
|
from django.contrib.auth.base_user import BaseUserManager
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from serverus import Host, generate_password
|
from serverus import Host, generate_password, generate_port, generate_username
|
||||||
|
|
||||||
PANEL_PORT_RANGE = (10000, 20000)
|
|
||||||
"""Не пересекается с диапазоном ssh портов serverus."""
|
|
||||||
|
|
||||||
|
|
||||||
class UserManager(BaseUserManager):
|
class UserManager(BaseUserManager):
|
||||||
@@ -95,7 +92,7 @@ class PointManager(models.Manager.from_queryset(PointQuerySet)):
|
|||||||
|
|
||||||
Точка без сервера панели не существует, поэтому панель обязательна. Имя точки и доступ к панели генерируются.
|
Точка без сервера панели не существует, поэтому панель обязательна. Имя точки и доступ к панели генерируются.
|
||||||
"""
|
"""
|
||||||
low, high = PANEL_PORT_RANGE
|
panel_port = generate_port()
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
point = self.create(
|
point = self.create(
|
||||||
owner=owner,
|
owner=owner,
|
||||||
@@ -103,10 +100,12 @@ class PointManager(models.Manager.from_queryset(PointQuerySet)):
|
|||||||
domain=domain,
|
domain=domain,
|
||||||
cloudflare=cloudflare,
|
cloudflare=cloudflare,
|
||||||
outbound_proxy=outbound_proxy,
|
outbound_proxy=outbound_proxy,
|
||||||
panel_username=generate_password(12),
|
panel_username=generate_username(),
|
||||||
panel_password=generate_password(),
|
panel_password=generate_password(),
|
||||||
panel_port=low + secrets.randbelow(high - low),
|
panel_port=panel_port,
|
||||||
panel_path=generate_password(18),
|
panel_path=generate_password(18),
|
||||||
|
sub_port=generate_port(exclude={panel_port}),
|
||||||
|
sub_path=generate_password(12),
|
||||||
)
|
)
|
||||||
Server.objects.create_server(point, Server.Role.PANEL, panel)
|
Server.objects.create_server(point, Server.Role.PANEL, panel)
|
||||||
if mask:
|
if mask:
|
||||||
@@ -152,6 +151,9 @@ class Point(models.Model):
|
|||||||
panel_port = models.PositiveIntegerField()
|
panel_port = models.PositiveIntegerField()
|
||||||
panel_path = models.CharField(max_length=64)
|
panel_path = models.CharField(max_length=64)
|
||||||
panel_api_token = models.CharField(max_length=255, blank=True)
|
panel_api_token = models.CharField(max_length=255, blank=True)
|
||||||
|
panel_inbound_id = models.PositiveIntegerField(null=True, blank=True, help_text='Подключение reality в панели')
|
||||||
|
sub_port = models.PositiveIntegerField(help_text='Порт подписки панели')
|
||||||
|
sub_path = models.CharField(max_length=64, help_text='Путь подписки без слешей')
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -82,15 +82,21 @@ def _bootstrap(server: Server):
|
|||||||
|
|
||||||
def _install_panel(server: Server):
|
def _install_panel(server: Server):
|
||||||
point = server.point
|
point = server.point
|
||||||
result = _check(server, serverus.XuiPanel(server.host()).install(
|
panel = serverus.XuiPanel(server.host())
|
||||||
|
result = _check(server, panel.install(
|
||||||
username=point.panel_username,
|
username=point.panel_username,
|
||||||
password=point.panel_password,
|
password=point.panel_password,
|
||||||
panel_port=point.panel_port,
|
panel_port=point.panel_port,
|
||||||
web_base_path=point.panel_path,
|
web_base_path=point.panel_path,
|
||||||
|
sub_port=point.sub_port,
|
||||||
|
sub_path=point.sub_path,
|
||||||
domain=point.domain,
|
domain=point.domain,
|
||||||
))
|
))
|
||||||
point.panel_api_token = result.data.get('api_token', '')
|
point.panel_api_token = result.data.get('api_token', '')
|
||||||
point.save(update_fields=['panel_api_token'])
|
point.save(update_fields=['panel_api_token'])
|
||||||
|
result = _check(server, panel.create_inbound())
|
||||||
|
point.panel_inbound_id = result.data.get('inbound_id')
|
||||||
|
point.save(update_fields=['panel_inbound_id'])
|
||||||
|
|
||||||
|
|
||||||
def _redirect_to_panel(server: Server):
|
def _redirect_to_panel(server: Server):
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ from django.test import TestCase
|
|||||||
from Telegram.models import User as TelegramUser
|
from Telegram.models import User as TelegramUser
|
||||||
|
|
||||||
from . import services
|
from . import services
|
||||||
from .models import PANEL_PORT_RANGE, Credentials, Point, Server
|
from serverus.credentials import SERVICE_PORT_RANGE
|
||||||
|
|
||||||
|
from .models import Credentials, Point, Server
|
||||||
|
|
||||||
|
|
||||||
class FakeRunner:
|
class FakeRunner:
|
||||||
@@ -19,7 +21,11 @@ class FakeRunner:
|
|||||||
self.calls.append((playbook, host, extravars))
|
self.calls.append((playbook, host, extravars))
|
||||||
if playbook == self.fail:
|
if playbook == self.fail:
|
||||||
return serverus.Result(ok=False, status='failed', error='boom', known_hosts=f'kh {host.address}')
|
return serverus.Result(ok=False, status='failed', error='boom', known_hosts=f'kh {host.address}')
|
||||||
data = {'facts': {'uname': 'Linux test'}, 'xui': {'api_token': 'token'}}.get(playbook, {})
|
data = {
|
||||||
|
'facts': {'uname': 'Linux test'},
|
||||||
|
'xui': {'api_token': 'token'},
|
||||||
|
'xui_inbound': {'inbound_id': 7},
|
||||||
|
}.get(playbook, {})
|
||||||
return serverus.Result(ok=True, status='successful', data=data, known_hosts=f'kh {host.address}')
|
return serverus.Result(ok=True, status='successful', data=data, known_hosts=f'kh {host.address}')
|
||||||
|
|
||||||
def playbooks(self, address):
|
def playbooks(self, address):
|
||||||
@@ -48,9 +54,12 @@ class CreatePointTests(TestCase):
|
|||||||
self.assertEqual(point.status, Point.Status.INSTALLING)
|
self.assertEqual(point.status, Point.Status.INSTALLING)
|
||||||
self.assertEqual((point.panel.address, point.panel.root_password), ('10.0.0.1', 'panel-pw'))
|
self.assertEqual((point.panel.address, point.panel.root_password), ('10.0.0.1', 'panel-pw'))
|
||||||
self.assertEqual((point.mask.address, point.mask.root_password), ('10.0.0.2', 'mask-pw'))
|
self.assertEqual((point.mask.address, point.mask.root_password), ('10.0.0.2', 'mask-pw'))
|
||||||
low, high = PANEL_PORT_RANGE
|
low, high = SERVICE_PORT_RANGE
|
||||||
self.assertTrue(low <= point.panel_port < high)
|
self.assertTrue(low <= point.panel_port < high)
|
||||||
self.assertTrue(point.panel_username and point.panel_password and point.panel_path)
|
self.assertTrue(low <= point.sub_port < high)
|
||||||
|
self.assertNotEqual(point.panel_port, point.sub_port)
|
||||||
|
self.assertEqual(len(point.panel_username), 6)
|
||||||
|
self.assertTrue(point.panel_password and point.panel_path and point.sub_path)
|
||||||
self.assertEqual(point.panel.default_root_password, 'panel-pw')
|
self.assertEqual(point.panel.default_root_password, 'panel-pw')
|
||||||
self.assertEqual(point.entry, point.mask)
|
self.assertEqual(point.entry, point.mask)
|
||||||
|
|
||||||
@@ -94,7 +103,8 @@ class InstallTests(TestCase):
|
|||||||
point.refresh_from_db()
|
point.refresh_from_db()
|
||||||
self.assertEqual(point.status, Point.Status.READY)
|
self.assertEqual(point.status, Point.Status.READY)
|
||||||
self.assertEqual(point.panel_api_token, 'token')
|
self.assertEqual(point.panel_api_token, 'token')
|
||||||
self.assertEqual(runner.playbooks('10.0.0.1'), ['bootstrap', 'xui', 'facts'])
|
self.assertEqual(point.panel_inbound_id, 7)
|
||||||
|
self.assertEqual(runner.playbooks('10.0.0.1'), ['bootstrap', 'xui', 'xui_inbound', 'facts'])
|
||||||
self.assertEqual(runner.playbooks('10.0.0.2'), ['bootstrap', 'proxy', 'facts'])
|
self.assertEqual(runner.playbooks('10.0.0.2'), ['bootstrap', 'proxy', 'facts'])
|
||||||
|
|
||||||
host, extravars = runner.call('bootstrap', '10.0.0.1')
|
host, extravars = runner.call('bootstrap', '10.0.0.1')
|
||||||
@@ -111,6 +121,7 @@ class InstallTests(TestCase):
|
|||||||
self.assertEqual((host.port, host.known_hosts), (panel.ssh_port, 'kh 10.0.0.1'))
|
self.assertEqual((host.port, host.known_hosts), (panel.ssh_port, 'kh 10.0.0.1'))
|
||||||
self.assertEqual(extravars['domain'], 'example.com')
|
self.assertEqual(extravars['domain'], 'example.com')
|
||||||
self.assertEqual(extravars['panel_port'], point.panel_port)
|
self.assertEqual(extravars['panel_port'], point.panel_port)
|
||||||
|
self.assertEqual((extravars['sub_port'], extravars['sub_path']), (point.sub_port, point.sub_path))
|
||||||
|
|
||||||
_, extravars = runner.call('proxy', '10.0.0.2')
|
_, extravars = runner.call('proxy', '10.0.0.2')
|
||||||
self.assertEqual(extravars['target_ip'], '10.0.0.1')
|
self.assertEqual(extravars['target_ip'], '10.0.0.1')
|
||||||
@@ -120,7 +131,7 @@ class InstallTests(TestCase):
|
|||||||
runner = FakeRunner()
|
runner = FakeRunner()
|
||||||
with patch_runner(runner):
|
with patch_runner(runner):
|
||||||
services.install_point(point)
|
services.install_point(point)
|
||||||
self.assertEqual([playbook for playbook, _, _ in runner.calls], ['bootstrap', 'xui', 'facts'])
|
self.assertEqual([playbook for playbook, _, _ in runner.calls], ['bootstrap', 'xui', 'xui_inbound', 'facts'])
|
||||||
point.refresh_from_db()
|
point.refresh_from_db()
|
||||||
self.assertEqual(point.status, Point.Status.READY)
|
self.assertEqual(point.status, Point.Status.READY)
|
||||||
|
|
||||||
|
|||||||
+46
-6
@@ -54,14 +54,33 @@ server.release(generate_password())
|
|||||||
Каждая операция возвращает `Result(ok, status, data, error)`. Ключи, пароли и артефакты ansible живут во временной папке только на время запуска. Хранить ключи и пароли - задача вызывающей стороны.
|
Каждая операция возвращает `Result(ok, status, data, error)`. Ключи, пароли и артефакты ansible живут во временной папке только на время запуска. Хранить ключи и пароли - задача вызывающей стороны.
|
||||||
|
|
||||||
- `bootstrap` - проверяет Debian 13, прописывает ключ, меняет пароль root, обновляет систему, включает nftables и переносит ssh на новый порт с входом только по ключу. Порт меняется в два шага с проверкой входа по ключу, чтобы не потерять доступ.
|
- `bootstrap` - проверяет Debian 13, прописывает ключ, меняет пароль root, обновляет систему, включает nftables и переносит ssh на новый порт с входом только по ключу. Порт меняется в два шага с проверкой входа по ключу, чтобы не потерять доступ.
|
||||||
- `release` - ставит новый пароль root, сбрасывает nftables, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys.
|
- `release` - ставит новый пароль root, сбрасывает nftables, убирает DNS-over-TLS, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys.
|
||||||
- `facts` - `uname -a` и версия ОС.
|
- `facts` - `uname -a` и версия ОС.
|
||||||
|
|
||||||
Специализации - наследники `Server`:
|
Специализации - наследники `Server`:
|
||||||
|
|
||||||
- `XuiPanel(host).install(username, password, panel_port, web_base_path, domain='')` - ставит 3x-ui скриптом из README проекта в неинтерактивном режиме, сертификат на домен или на ip. Открывает 80 (acme), 443 и порт панели. Возвращает `data['api_token']`.
|
- `XuiPanel(host).install(username, password, panel_port, web_base_path, sub_port, sub_path, domain='')` - ставит 3x-ui скриптом из README проекта в неинтерактивном режиме, сертификат на домен или на ip. Включает подписку на `sub_port` по пути `/sub_path/` под тем же сертификатом. Системный DNS переводит на DNS-over-TLS к Cloudflare (systemd-resolved, строгий режим, без резервных серверов), DNS в Xray - через системный резолвер: сам Xray DoT не умеет. Открывает 80 (acme), 443, порт панели и порт подписки. Возвращает `data['api_token']`.
|
||||||
|
- `XuiPanel(host).create_inbound(remark='main', target='')` - подключение vless + xhttp + reality на 443, uTLS firefox, сброс трафика ежемесячно. Цель reality подбирает сканер панели (TLS 1.3, h2, X25519, валидный сертификат, минимальная задержка) из `target` или из своих кандидатов. Ключи x25519 генерирует панель, shortId и путь xhttp - serverus. Существующее подключение с тем же `remark` не трогает. Возвращает `data`: `inbound_id`, `target`, `server_name`, `public_key`, `short_ids`, `path`.
|
||||||
- `RedirectProxy(host).redirect(target_ip)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh <target_ip>`, который перенаправляет весь трафик, кроме ssh, на target_ip. **Сейчас скрипт - заглушка.**
|
- `RedirectProxy(host).redirect(target_ip)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh <target_ip>`, который перенаправляет весь трафик, кроме ssh, на target_ip. **Сейчас скрипт - заглушка.**
|
||||||
|
|
||||||
|
Порты и логины: `generate_port(exclude=...)` - порт сервиса из 10000-20000 (ssh - 20000-60000), `generate_username()` - 6 символов.
|
||||||
|
|
||||||
|
Плейбуки ходят в API панели с самого сервера на `127.0.0.1` со своим токеном `serverus`, который перевыпускается на каждом запуске. Токен `api_token`, отданный вызывающей стороне, при этом не меняется.
|
||||||
|
|
||||||
|
## Ключи
|
||||||
|
|
||||||
|
`XuiApi(url, api_token)` - выпуск, отзыв и статистика ключей через HTTP API панели, без ssh. `url` - `https://<домен или ip>:<panel_port>/<web_base_path>`, сертификат панели проверяется. Ошибки - `XuiApiError`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
api = XuiApi(f'https://1.2.3.4:{panel_port}/{web_base_path}', api_token)
|
||||||
|
client = api.add_client(inbound_id, 'user-1') # uuid, subId, ...
|
||||||
|
api.get_links('user-1') # ['vless://...']
|
||||||
|
api.get_traffic('user-1') # up, down, total, expiryTime, lastOnline
|
||||||
|
api.delete_client('user-1')
|
||||||
|
```
|
||||||
|
|
||||||
|
Подписка ключа: `https://<домен или ip>:<sub_port>/<sub_path>/<subId>`.
|
||||||
|
|
||||||
## Отпечаток сервера
|
## Отпечаток сервера
|
||||||
|
|
||||||
`Result.known_hosts` - отпечаток ssh сервера. Его нужно сохранить и передавать в `Host(known_hosts=...)`: тогда подключение к подмененному серверу не пройдет. Пустой `known_hosts` - доверяем ключу при первом подключении.
|
`Result.known_hosts` - отпечаток ssh сервера. Его нужно сохранить и передавать в `Host(known_hosts=...)`: тогда подключение к подмененному серверу не пройдет. Пустой `known_hosts` - доверяем ключу при первом подключении.
|
||||||
@@ -75,11 +94,32 @@ server.release(generate_password())
|
|||||||
## Ручная проверка на живом сервере
|
## Ручная проверка на живом сервере
|
||||||
|
|
||||||
```
|
```
|
||||||
make serverus_xui ip=<ip> password=<root_password> # bootstrap + 3x-ui
|
make serverus_xui ip=<ip> password=<root_password> # bootstrap + 3x-ui + подключение reality
|
||||||
make serverus_release ip=<ip> # вернуть сервер
|
make serverus_key ip=<ip> action=add|del|stats name=<name> # ключ на панели
|
||||||
|
make serverus_release ip=<ip> # вернуть сервер
|
||||||
```
|
```
|
||||||
|
|
||||||
То же без Makefile: `python -m serverus xui <ip> <password> --out <dir>` и `python -m serverus release --out <dir>`.
|
То же без Makefile: `python -m serverus xui <ip> <password> --out <dir>`, `python -m serverus key add <name> --out <dir>` и `python -m serverus release --out <dir>`.
|
||||||
Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в `extra/serverus/<ip>/` до начала настройки, так что доступ к серверу не теряется, даже если настройка упала.
|
Доступы (ключ, пароль root, порт ssh, отпечаток, панель, токен API, подключение) пишутся в `extra/serverus/<ip>/` до начала настройки, так что доступ к серверу не теряется, даже если настройка упала.
|
||||||
|
|
||||||
Тесты: `python -m unittest serverus.tests` из `src/`. Плейбуки проверены на Debian 13 в контейнере с systemd.
|
Тесты: `python -m unittest serverus.tests` из `src/`. Плейбуки проверены на Debian 13 в контейнере с systemd.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [x] у сервера должен быть рандомный порт, а не 12345
|
||||||
|
- [x] у сервера должен быть рандомный юзернейм, пусть теже 4 символа или 6 для солидности
|
||||||
|
- [x] следует так же устанавливать рандомный порт подписки
|
||||||
|
- [x] нужно включить dns и настроить на использование dns-over-tls от cloudflare
|
||||||
|
- [x] нужно настроить исходящее подключение:
|
||||||
|
- примечание: main
|
||||||
|
- протокол: vless
|
||||||
|
- порт: 443
|
||||||
|
- сброс трафика: ежемесячно
|
||||||
|
- транспорт: xhttp
|
||||||
|
- безопасность: reality
|
||||||
|
- uTLS: firefox
|
||||||
|
- подобрать цель
|
||||||
|
- сгенерировать ключи и сертификаты
|
||||||
|
- [x] нужен интерфейс выпуска, отзыва и получения статистики ключей
|
||||||
|
- [ ] проверить `xui` и `xui_inbound` на живом сервере: API панели проверено только по исходникам 3x-ui v3.8.5
|
||||||
|
- [ ] выпуск и отзыв ключей в боте поверх `XuiApi`
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
from .credentials import KeyPair, generate_keypair, generate_password, generate_ssh_port
|
from .credentials import (
|
||||||
|
KeyPair, generate_keypair, generate_password, generate_port, generate_ssh_port, generate_username,
|
||||||
|
)
|
||||||
from .host import Host
|
from .host import Host
|
||||||
from .proxy import RedirectProxy
|
from .proxy import RedirectProxy
|
||||||
from .runner import Result, run_playbook
|
from .runner import Result, run_playbook
|
||||||
from .server import Server
|
from .server import Server
|
||||||
from .xui import XuiPanel
|
from .xui import XuiPanel
|
||||||
|
from .xui_api import XuiApi, XuiApiError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Host',
|
'Host',
|
||||||
@@ -11,9 +14,13 @@ __all__ = [
|
|||||||
'RedirectProxy',
|
'RedirectProxy',
|
||||||
'Result',
|
'Result',
|
||||||
'Server',
|
'Server',
|
||||||
|
'XuiApi',
|
||||||
|
'XuiApiError',
|
||||||
'XuiPanel',
|
'XuiPanel',
|
||||||
'generate_keypair',
|
'generate_keypair',
|
||||||
'generate_password',
|
'generate_password',
|
||||||
|
'generate_port',
|
||||||
'generate_ssh_port',
|
'generate_ssh_port',
|
||||||
|
'generate_username',
|
||||||
'run_playbook',
|
'run_playbook',
|
||||||
]
|
]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Ручная проверка сценариев на живом сервере.
|
"""Ручная проверка сценариев на живом сервере.
|
||||||
|
|
||||||
python -m serverus xui <ip> <root_password> --out <dir>
|
python -m serverus xui <ip> <root_password> --out <dir>
|
||||||
|
python -m serverus key add|del|stats <name> --out <dir>
|
||||||
python -m serverus release --out <dir>
|
python -m serverus release --out <dir>
|
||||||
|
|
||||||
Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/access.json до начала настройки:
|
Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/access.json до начала настройки:
|
||||||
@@ -11,11 +12,13 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import Host, Server, XuiPanel, generate_keypair, generate_password, generate_ssh_port
|
from . import (
|
||||||
|
Host, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
|
||||||
|
generate_ssh_port, generate_username,
|
||||||
|
)
|
||||||
|
|
||||||
ACCESS_FILE = 'access.json'
|
ACCESS_FILE = 'access.json'
|
||||||
KEY_FILE = 'id_ed25519'
|
KEY_FILE = 'id_ed25519'
|
||||||
PANEL_PORT = 12345
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
def main(argv=None) -> int:
|
||||||
@@ -27,15 +30,25 @@ def main(argv=None) -> int:
|
|||||||
xui.add_argument('password', help='текущий пароль root')
|
xui.add_argument('password', help='текущий пароль root')
|
||||||
xui.add_argument('--out', type=Path, required=True, help='папка для доступов к серверу')
|
xui.add_argument('--out', type=Path, required=True, help='папка для доступов к серверу')
|
||||||
|
|
||||||
|
key = commands.add_parser('key', help='выпуск, отзыв и статистика ключа на панели от команды xui')
|
||||||
|
key.add_argument('action', choices=['add', 'del', 'stats'])
|
||||||
|
key.add_argument('name', help='имя ключа (email клиента в панели)')
|
||||||
|
key.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui')
|
||||||
|
|
||||||
release = commands.add_parser('release', help='вернуть сервер: вход по паролю на 22 порт, новый пароль root')
|
release = commands.add_parser('release', help='вернуть сервер: вход по паролю на 22 порт, новый пароль root')
|
||||||
release.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui')
|
release.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui')
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
return install_xui(args.address, args.password, args.out) if args.command == 'xui' else release_server(args.out)
|
if args.command == 'xui':
|
||||||
|
return install_xui(args.address, args.password, args.out)
|
||||||
|
if args.command == 'key':
|
||||||
|
return manage_key(args.action, args.name, args.out)
|
||||||
|
return release_server(args.out)
|
||||||
|
|
||||||
|
|
||||||
def install_xui(address: str, password: str, out: Path) -> int:
|
def install_xui(address: str, password: str, out: Path) -> int:
|
||||||
keys = generate_keypair()
|
keys = generate_keypair()
|
||||||
|
panel_port = generate_port()
|
||||||
access = {
|
access = {
|
||||||
'address': address,
|
'address': address,
|
||||||
'ssh_port': generate_ssh_port(),
|
'ssh_port': generate_ssh_port(),
|
||||||
@@ -43,10 +56,12 @@ def install_xui(address: str, password: str, out: Path) -> int:
|
|||||||
'private_key': keys.private_key,
|
'private_key': keys.private_key,
|
||||||
'known_hosts': '',
|
'known_hosts': '',
|
||||||
'panel': {
|
'panel': {
|
||||||
'username': 'admin',
|
'username': generate_username(),
|
||||||
'password': generate_password(16),
|
'password': generate_password(16),
|
||||||
'panel_port': PANEL_PORT,
|
'panel_port': panel_port,
|
||||||
'web_base_path': generate_password(18),
|
'web_base_path': generate_password(18),
|
||||||
|
'sub_port': generate_port(exclude={panel_port}),
|
||||||
|
'sub_path': generate_password(12),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
save(out, access)
|
save(out, access)
|
||||||
@@ -65,15 +80,45 @@ def install_xui(address: str, password: str, out: Path) -> int:
|
|||||||
report('xui', result)
|
report('xui', result)
|
||||||
if not result.ok:
|
if not result.ok:
|
||||||
return 1
|
return 1
|
||||||
|
access['api_token'] = result.data['api_token']
|
||||||
|
save(out, access)
|
||||||
|
|
||||||
panel = access['panel']
|
result = XuiPanel(host).create_inbound()
|
||||||
|
report('inbound', result)
|
||||||
|
if not result.ok:
|
||||||
|
return 1
|
||||||
|
access['inbound'] = result.data
|
||||||
|
save(out, access)
|
||||||
|
|
||||||
|
panel, inbound = access['panel'], access['inbound']
|
||||||
print(f"uname: {Server(host).facts().data.get('uname', '?')}")
|
print(f"uname: {Server(host).facts().data.get('uname', '?')}")
|
||||||
print(f"panel: http(s)://{address}:{panel['panel_port']}/{panel['web_base_path']}/")
|
print(f"panel: {panel_url(access)}/")
|
||||||
print(f"panel login: {panel['username']} / {panel['password']}")
|
print(f"panel login: {panel['username']} / {panel['password']}")
|
||||||
|
print(f"inbound: {inbound['inbound_id']}, reality target {inbound['target']}")
|
||||||
print(f"ssh: ssh -i {out / KEY_FILE} -p {access['ssh_port']} root@{address}")
|
print(f"ssh: ssh -i {out / KEY_FILE} -p {access['ssh_port']} root@{address}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def manage_key(action: str, name: str, out: Path) -> int:
|
||||||
|
access = json.loads((out / ACCESS_FILE).read_text())
|
||||||
|
api = XuiApi(panel_url(access), access['api_token'])
|
||||||
|
try:
|
||||||
|
if action == 'add':
|
||||||
|
client = api.add_client(access['inbound']['inbound_id'], name)
|
||||||
|
panel = access['panel']
|
||||||
|
print(f"subscription: https://{access['address']}:{panel['sub_port']}/{panel['sub_path']}/{client['subId']}")
|
||||||
|
print(*api.get_links(name), sep='\n')
|
||||||
|
elif action == 'del':
|
||||||
|
api.delete_client(name)
|
||||||
|
print(f'{name}: deleted')
|
||||||
|
else:
|
||||||
|
print(json.dumps(api.get_traffic(name), indent=2))
|
||||||
|
except XuiApiError as error:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def release_server(out: Path) -> int:
|
def release_server(out: Path) -> int:
|
||||||
access = json.loads((out / ACCESS_FILE).read_text())
|
access = json.loads((out / ACCESS_FILE).read_text())
|
||||||
new_password = generate_password()
|
new_password = generate_password()
|
||||||
@@ -87,6 +132,11 @@ def release_server(out: Path) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def panel_url(access: dict) -> str:
|
||||||
|
panel = access['panel']
|
||||||
|
return f"https://{access['address']}:{panel['panel_port']}/{panel['web_base_path']}"
|
||||||
|
|
||||||
|
|
||||||
def load_host(access: dict) -> Host:
|
def load_host(access: dict) -> Host:
|
||||||
return Host(
|
return Host(
|
||||||
access['address'], port=access['ssh_port'], private_key=access['private_key'],
|
access['address'], port=access['ssh_port'], private_key=access['private_key'],
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ from cryptography.hazmat.primitives import serialization
|
|||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
PASSWORD_ALPHABET = string.ascii_letters + string.digits
|
PASSWORD_ALPHABET = string.ascii_letters + string.digits
|
||||||
|
USERNAME_ALPHABET = string.ascii_lowercase + string.digits
|
||||||
SSH_PORT_RANGE = (20000, 60000)
|
SSH_PORT_RANGE = (20000, 60000)
|
||||||
|
SERVICE_PORT_RANGE = (10000, 20000)
|
||||||
|
"""Порты панели, подписки и других сервисов. Не пересекается с диапазоном ssh портов."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -36,6 +39,20 @@ def generate_password(length: int = 32) -> str:
|
|||||||
return ''.join(secrets.choice(PASSWORD_ALPHABET) for _ in range(length))
|
return ''.join(secrets.choice(PASSWORD_ALPHABET) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def generate_username(length: int = 6) -> str:
|
||||||
|
"""Логин из строчных букв и цифр, начинается с буквы."""
|
||||||
|
first = secrets.choice(string.ascii_lowercase)
|
||||||
|
return first + ''.join(secrets.choice(USERNAME_ALPHABET) for _ in range(length - 1))
|
||||||
|
|
||||||
|
|
||||||
|
def generate_port(port_range: tuple[int, int] = SERVICE_PORT_RANGE, exclude=()) -> int:
|
||||||
|
"""Случайный порт из port_range, не совпадающий с портами из exclude."""
|
||||||
|
low, high = port_range
|
||||||
|
while True:
|
||||||
|
port = low + secrets.randbelow(high - low)
|
||||||
|
if port not in exclude:
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
def generate_ssh_port() -> int:
|
def generate_ssh_port() -> int:
|
||||||
low, high = SSH_PORT_RANGE
|
return generate_port(SSH_PORT_RANGE)
|
||||||
return low + secrets.randbelow(high - low)
|
|
||||||
@@ -31,6 +31,16 @@
|
|||||||
ansible.builtin.command: sysctl --system
|
ansible.builtin.command: sysctl --system
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Remove DNS over TLS config
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/systemd/resolved.conf.d/00-serverus.conf
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
# systemd-resolved остается: без него /etc/resolv.conf указывает в никуда
|
||||||
|
- name: Restart systemd-resolved
|
||||||
|
ansible.builtin.command: systemctl try-restart systemd-resolved
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
- name: Remove sshd drop-in
|
- name: Remove sshd drop-in
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: /etc/ssh/sshd_config.d/00-serverus.conf
|
path: /etc/ssh/sshd_config.d/00-serverus.conf
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Системный резолвер: systemd-resolved, только DNS-over-TLS к Cloudflare.
|
||||||
|
# Строгий режим: без TLS запрос не уйдет, открытым текстом DNS не утекает. Резервных серверов нет.
|
||||||
|
- name: Install systemd-resolved
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: systemd-resolved
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Create resolved drop-in directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/systemd/resolved.conf.d
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Configure DNS over TLS
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/resolved.conf.d/00-serverus.conf
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
[Resolve]
|
||||||
|
DNS=1.1.1.1#cloudflare-dns.com 1.0.0.1#cloudflare-dns.com 2606:4700:4700::1111#cloudflare-dns.com 2606:4700:4700::1001#cloudflare-dns.com
|
||||||
|
FallbackDNS=
|
||||||
|
DNSOverTLS=yes
|
||||||
|
Domains=~.
|
||||||
|
|
||||||
|
- name: Use resolved stub
|
||||||
|
ansible.builtin.file:
|
||||||
|
src: /run/systemd/resolve/stub-resolv.conf
|
||||||
|
dest: /etc/resolv.conf
|
||||||
|
state: link
|
||||||
|
force: true
|
||||||
|
|
||||||
|
- name: Restart systemd-resolved
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: systemd-resolved
|
||||||
|
enabled: true
|
||||||
|
state: restarted
|
||||||
|
|
||||||
|
- name: Check DNS over TLS
|
||||||
|
ansible.builtin.command: resolvectl query deb.debian.org
|
||||||
|
register: dns_check
|
||||||
|
retries: 5
|
||||||
|
delay: 3
|
||||||
|
until: dns_check.rc == 0
|
||||||
|
changed_when: false
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Готовит доступ к API панели 3x-ui с самого сервера: xui_api_url и xui_api_headers.
|
||||||
|
#
|
||||||
|
# Плейбук берет свой токен serverus и перевыпускает его на каждом запуске.
|
||||||
|
# Токен, отданный вызывающей стороне (install -> api_token), при этом не меняется.
|
||||||
|
- name: Issue serverus API token
|
||||||
|
ansible.builtin.shell: "{{ xui_bin }} setting -getApiToken -tokenName serverus | grep -Eo 'apiToken: .+' | awk '{print $2}'"
|
||||||
|
register: xui_api_token
|
||||||
|
changed_when: false
|
||||||
|
failed_when: xui_api_token.rc != 0 or not xui_api_token.stdout
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Read panel address
|
||||||
|
ansible.builtin.command: "{{ xui_bin }} setting -show true"
|
||||||
|
register: xui_panel_settings
|
||||||
|
changed_when: false
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Set API access
|
||||||
|
vars:
|
||||||
|
settings: "{{ xui_panel_settings.stdout }}"
|
||||||
|
scheme: "{{ 'http' if 'not secure with SSL' in settings else 'https' }}"
|
||||||
|
api_port: "{{ settings | regex_search('port: (\\d+)', '\\1') | first }}"
|
||||||
|
base_path: "{{ (settings | regex_search('webBasePath: (\\S*)', '\\1') | first).strip('/') }}"
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
xui_api_url: "{{ scheme }}://127.0.0.1:{{ api_port }}{{ '/' ~ base_path if base_path else '' }}/panel/api"
|
||||||
|
xui_api_headers:
|
||||||
|
Authorization: "Bearer {{ xui_api_token.stdout }}"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
# после перезапуска панели API поднимается не сразу
|
||||||
|
- name: Wait for panel API
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "{{ xui_api_url }}/server/status"
|
||||||
|
headers: "{{ xui_api_headers }}"
|
||||||
|
validate_certs: false
|
||||||
|
register: xui_api_status
|
||||||
|
retries: 10
|
||||||
|
delay: 3
|
||||||
|
until: xui_api_status.status == 200
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# extravars: username, password, panel_port, web_base_path, domain (optional), version (optional)
|
# extravars: username, password, panel_port, web_base_path, sub_port, sub_path, domain (optional), version (optional)
|
||||||
#
|
#
|
||||||
# Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме.
|
# Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме.
|
||||||
# Сертификат: на домен, если он задан, иначе на ip. Оба варианта требуют открытый 80 порт (acme).
|
# Сертификат: на домен, если он задан, иначе на ip. Оба варианта требуют открытый 80 порт (acme).
|
||||||
|
# Системный DNS - DNS-over-TLS к Cloudflare, Xray резолвит через него. Подписка на sub_port под тем же сертификатом.
|
||||||
- name: Install 3x-ui panel
|
- name: Install 3x-ui panel
|
||||||
hosts: target
|
hosts: target
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
@@ -11,7 +12,9 @@
|
|||||||
tasks:
|
tasks:
|
||||||
- ansible.builtin.import_tasks: tasks/firewall.yml
|
- ansible.builtin.import_tasks: tasks/firewall.yml
|
||||||
vars:
|
vars:
|
||||||
firewall_tcp_ports: [ "{{ ansible_port }}", 80, 443, "{{ panel_port }}" ]
|
firewall_tcp_ports: [ "{{ ansible_port }}", 80, 443, "{{ panel_port }}", "{{ sub_port }}" ]
|
||||||
|
|
||||||
|
- ansible.builtin.import_tasks: tasks/dns.yml
|
||||||
|
|
||||||
- name: Download install script
|
- name: Download install script
|
||||||
ansible.builtin.get_url:
|
ansible.builtin.get_url:
|
||||||
@@ -54,6 +57,75 @@
|
|||||||
changed_when: false
|
changed_when: false
|
||||||
no_log: true
|
no_log: true
|
||||||
|
|
||||||
|
- ansible.builtin.import_tasks: tasks/xui_api.yml
|
||||||
|
|
||||||
|
# настройки меняются только целиком: читаем все, меняем подписку, отправляем обратно
|
||||||
|
- name: Read panel settings
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "{{ xui_api_url }}/setting/all"
|
||||||
|
method: POST
|
||||||
|
headers: "{{ xui_api_headers }}"
|
||||||
|
validate_certs: false
|
||||||
|
register: panel_settings
|
||||||
|
failed_when: not panel_settings.json.success | default(false)
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Enable subscription
|
||||||
|
vars:
|
||||||
|
settings: "{{ panel_settings.json.obj }}"
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "{{ xui_api_url }}/setting/update"
|
||||||
|
method: POST
|
||||||
|
headers: "{{ xui_api_headers }}"
|
||||||
|
body_format: json
|
||||||
|
body: >-
|
||||||
|
{{ settings | combine({
|
||||||
|
'subEnable': true,
|
||||||
|
'subPort': sub_port | int,
|
||||||
|
'subPath': '/' ~ sub_path ~ '/',
|
||||||
|
'subCertFile': settings.webCertFile,
|
||||||
|
'subKeyFile': settings.webKeyFile,
|
||||||
|
}) }}
|
||||||
|
validate_certs: false
|
||||||
|
register: sub_update
|
||||||
|
failed_when: not sub_update.json.success | default(false)
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Read Xray config
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "{{ xui_api_url }}/xray/"
|
||||||
|
method: POST
|
||||||
|
headers: "{{ xui_api_headers }}"
|
||||||
|
validate_certs: false
|
||||||
|
register: xray_setting
|
||||||
|
failed_when: not xray_setting.json.success | default(false)
|
||||||
|
|
||||||
|
# Xray не умеет DoT: отправляем его запросы в системный резолвер, а тот ходит в Cloudflare по DoT
|
||||||
|
- name: Enable Xray DNS
|
||||||
|
vars:
|
||||||
|
config: "{{ (xray_setting.json.obj | from_json).xraySetting }}"
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "{{ xui_api_url }}/xray/update"
|
||||||
|
method: POST
|
||||||
|
headers: "{{ xui_api_headers }}"
|
||||||
|
body_format: form-urlencoded
|
||||||
|
body:
|
||||||
|
xraySetting: "{{ config | combine({'dns': {'servers': ['localhost'], 'queryStrategy': 'UseIP'}}) | to_json }}"
|
||||||
|
validate_certs: false
|
||||||
|
register: xray_update
|
||||||
|
failed_when: not xray_update.json.success | default(false)
|
||||||
|
|
||||||
|
# подписка слушает новый порт только после перезапуска панели
|
||||||
|
- name: Restart panel with subscription
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: x-ui
|
||||||
|
state: restarted
|
||||||
|
|
||||||
|
- name: Wait for subscription
|
||||||
|
ansible.builtin.wait_for:
|
||||||
|
port: "{{ sub_port }}"
|
||||||
|
timeout: 60
|
||||||
|
|
||||||
- name: Return panel access
|
- name: Return panel access
|
||||||
ansible.builtin.set_stats:
|
ansible.builtin.set_stats:
|
||||||
aggregate: false
|
aggregate: false
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# extravars: remark, short_ids, xhttp_path, target (optional)
|
||||||
|
#
|
||||||
|
# Создает в 3x-ui подключение vless + xhttp + reality на 443 порту с ежемесячным сбросом трафика.
|
||||||
|
# Цель reality подбирает сканер панели: TLS 1.3, h2, X25519 и валидный сертификат, из подходящих - с наименьшей задержкой.
|
||||||
|
# Без target сканируются кандидаты из настроек панели. Ключи x25519 генерирует панель.
|
||||||
|
# Если подключение с таким remark уже есть, оно не меняется: плейбук только возвращает его параметры.
|
||||||
|
- name: Create reality inbound
|
||||||
|
hosts: target
|
||||||
|
gather_facts: false
|
||||||
|
vars:
|
||||||
|
xui_bin: /usr/local/x-ui/x-ui
|
||||||
|
inbound_port: 443
|
||||||
|
fingerprint: firefox
|
||||||
|
tasks:
|
||||||
|
- ansible.builtin.import_tasks: tasks/xui_api.yml
|
||||||
|
|
||||||
|
- name: List inbounds
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "{{ xui_api_url }}/inbounds/list"
|
||||||
|
headers: "{{ xui_api_headers }}"
|
||||||
|
validate_certs: false
|
||||||
|
register: inbounds
|
||||||
|
failed_when: not inbounds.json.success | default(false)
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Find inbound
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
inbound: "{{ inbounds.json.obj | default([], true) | selectattr('remark', 'equalto', remark) | first | default(none) }}"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Create inbound
|
||||||
|
when: inbound is none
|
||||||
|
block:
|
||||||
|
- name: Scan reality targets
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "{{ xui_api_url }}/server/scanRealityTargets"
|
||||||
|
method: POST
|
||||||
|
headers: "{{ xui_api_headers }}"
|
||||||
|
body_format: form-urlencoded
|
||||||
|
body:
|
||||||
|
targets: "{{ target | default('') }}"
|
||||||
|
validate_certs: false
|
||||||
|
timeout: 120
|
||||||
|
register: scan
|
||||||
|
failed_when: not scan.json.success | default(false)
|
||||||
|
|
||||||
|
- name: Choose reality target
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
reality_target: >-
|
||||||
|
{{ scan.json.obj
|
||||||
|
| selectattr('feasible') | rejectattr('privateTarget')
|
||||||
|
| selectattr('tls13') | selectattr('h2') | selectattr('x25519') | selectattr('certValid')
|
||||||
|
| sort(attribute='latencyMs') | first | default(none) }}
|
||||||
|
|
||||||
|
- name: Require reality target
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: reality_target is not none
|
||||||
|
fail_msg: "No suitable reality target: {{ scan.json.obj | map(attribute='target') | join(', ') }}"
|
||||||
|
|
||||||
|
- name: Generate x25519 keys
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "{{ xui_api_url }}/server/getNewX25519Cert"
|
||||||
|
headers: "{{ xui_api_headers }}"
|
||||||
|
validate_certs: false
|
||||||
|
register: x25519
|
||||||
|
failed_when: not x25519.json.success | default(false)
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Add inbound
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "{{ xui_api_url }}/inbounds/add"
|
||||||
|
method: POST
|
||||||
|
headers: "{{ xui_api_headers }}"
|
||||||
|
body_format: json
|
||||||
|
body:
|
||||||
|
remark: "{{ remark }}"
|
||||||
|
enable: true
|
||||||
|
listen: ""
|
||||||
|
port: "{{ inbound_port }}"
|
||||||
|
protocol: vless
|
||||||
|
total: 0
|
||||||
|
expiryTime: 0
|
||||||
|
trafficReset: monthly
|
||||||
|
trafficResetDay: 1
|
||||||
|
settings: "{{ {'clients': [], 'decryption': 'none', 'fallbacks': []} | to_json }}"
|
||||||
|
streamSettings: >-
|
||||||
|
{{ {
|
||||||
|
'network': 'xhttp',
|
||||||
|
'security': 'reality',
|
||||||
|
'externalProxy': [],
|
||||||
|
'xhttpSettings': {'path': xhttp_path, 'host': '', 'mode': 'auto', 'xPaddingBytes': '100-1000'},
|
||||||
|
'realitySettings': {
|
||||||
|
'show': false,
|
||||||
|
'xver': 0,
|
||||||
|
'target': reality_target.host ~ ':' ~ reality_target.port,
|
||||||
|
'serverNames': [reality_target.host],
|
||||||
|
'privateKey': x25519.json.obj.privateKey,
|
||||||
|
'minClientVer': '',
|
||||||
|
'maxClientVer': '',
|
||||||
|
'maxTimediff': 0,
|
||||||
|
'shortIds': short_ids,
|
||||||
|
'mldsa65Seed': '',
|
||||||
|
'settings': {
|
||||||
|
'publicKey': x25519.json.obj.publicKey,
|
||||||
|
'fingerprint': fingerprint,
|
||||||
|
'serverName': '',
|
||||||
|
'spiderX': '/',
|
||||||
|
'mldsa65Verify': '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} | to_json }}
|
||||||
|
sniffing: "{{ {'enabled': true, 'destOverride': ['http', 'tls', 'quic', 'fakedns'], 'metadataOnly': false, 'routeOnly': false} | to_json }}"
|
||||||
|
validate_certs: false
|
||||||
|
register: added
|
||||||
|
failed_when: not added.json.success | default(false)
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Remember created inbound
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
inbound: "{{ added.json.obj }}"
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Return inbound
|
||||||
|
vars:
|
||||||
|
stream: "{{ inbound.streamSettings | from_json }}"
|
||||||
|
ansible.builtin.set_stats:
|
||||||
|
aggregate: false
|
||||||
|
data:
|
||||||
|
inbound_id: "{{ inbound.id }}"
|
||||||
|
port: "{{ inbound.port }}"
|
||||||
|
target: "{{ stream.realitySettings.target | default(stream.realitySettings.dest | default('')) }}"
|
||||||
|
server_name: "{{ stream.realitySettings.serverNames | first }}"
|
||||||
|
public_key: "{{ stream.realitySettings.settings.publicKey }}"
|
||||||
|
short_ids: "{{ stream.realitySettings.shortIds }}"
|
||||||
|
path: "{{ stream.xhttpSettings.path }}"
|
||||||
+102
-2
@@ -1,7 +1,14 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from . import Host, Result, Server, generate_keypair, generate_password, generate_ssh_port, run_playbook
|
from . import (
|
||||||
from .credentials import SSH_PORT_RANGE
|
Host, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
|
||||||
|
generate_ssh_port, generate_username, run_playbook,
|
||||||
|
)
|
||||||
|
from .credentials import SERVICE_PORT_RANGE, SSH_PORT_RANGE
|
||||||
|
|
||||||
|
|
||||||
class FakeRunner:
|
class FakeRunner:
|
||||||
@@ -29,6 +36,16 @@ class CredentialsTests(unittest.TestCase):
|
|||||||
low, high = SSH_PORT_RANGE
|
low, high = SSH_PORT_RANGE
|
||||||
self.assertTrue(low <= generate_ssh_port() < high)
|
self.assertTrue(low <= generate_ssh_port() < high)
|
||||||
|
|
||||||
|
def test_service_port_excludes_taken(self):
|
||||||
|
low, high = SERVICE_PORT_RANGE
|
||||||
|
taken = set(range(low, high - 1))
|
||||||
|
self.assertEqual(generate_port(exclude=taken), high - 1)
|
||||||
|
|
||||||
|
def test_username(self):
|
||||||
|
username = generate_username()
|
||||||
|
self.assertEqual(len(username), 6)
|
||||||
|
self.assertTrue(username[0].isalpha() and username.islower())
|
||||||
|
|
||||||
def test_host_repr_hides_key(self):
|
def test_host_repr_hides_key(self):
|
||||||
self.assertNotIn('secret', repr(Host('1.2.3.4', private_key='secret')))
|
self.assertNotIn('secret', repr(Host('1.2.3.4', private_key='secret')))
|
||||||
|
|
||||||
@@ -49,6 +66,89 @@ class ServerTests(unittest.TestCase):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class XuiPanelTests(unittest.TestCase):
|
||||||
|
def test_install_passes_subscription(self):
|
||||||
|
runner = FakeRunner()
|
||||||
|
XuiPanel(Host('1.2.3.4'), runner=runner).install('user', 'pw', 11000, 'base', 12000, 'sub', 'example.com')
|
||||||
|
self.assertEqual(runner.calls[0][0], 'xui')
|
||||||
|
self.assertEqual(runner.calls[0][2], {
|
||||||
|
'username': 'user',
|
||||||
|
'password': 'pw',
|
||||||
|
'panel_port': 11000,
|
||||||
|
'web_base_path': 'base',
|
||||||
|
'sub_port': 12000,
|
||||||
|
'sub_path': 'sub',
|
||||||
|
'domain': 'example.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_create_inbound_generates_reality_ids(self):
|
||||||
|
runner = FakeRunner()
|
||||||
|
XuiPanel(Host('1.2.3.4'), runner=runner).create_inbound()
|
||||||
|
playbook, _, extravars = runner.calls[0]
|
||||||
|
self.assertEqual((playbook, extravars['remark'], extravars['target']), ('xui_inbound', 'main', ''))
|
||||||
|
self.assertTrue(extravars['xhttp_path'].startswith('/'))
|
||||||
|
for short_id in extravars['short_ids']:
|
||||||
|
self.assertTrue(2 <= len(short_id) <= 16 and len(short_id) % 2 == 0)
|
||||||
|
int(short_id, 16)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse(io.BytesIO):
|
||||||
|
def __init__(self, payload):
|
||||||
|
super().__init__(json.dumps(payload).encode())
|
||||||
|
|
||||||
|
|
||||||
|
class XuiApiTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.api = XuiApi('https://1.2.3.4:11000/base/', 'secret')
|
||||||
|
self.requests = []
|
||||||
|
self.responses = []
|
||||||
|
patcher = mock.patch('urllib.request.urlopen', self.urlopen)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def urlopen(self, request, timeout, context):
|
||||||
|
self.requests.append(request)
|
||||||
|
response = self.responses.pop(0)
|
||||||
|
if isinstance(response, Exception):
|
||||||
|
raise response
|
||||||
|
return FakeResponse(response)
|
||||||
|
|
||||||
|
def test_add_client(self):
|
||||||
|
client = {'email': 'a b', 'uuid': 'uuid', 'subId': 'sub'}
|
||||||
|
self.responses = [{'success': True, 'obj': None}, {'success': True, 'obj': {'client': client}}]
|
||||||
|
self.assertEqual(self.api.add_client(3, 'a b', total_bytes=100), client)
|
||||||
|
add, get = self.requests
|
||||||
|
self.assertEqual((add.method, add.full_url), ('POST', 'https://1.2.3.4:11000/base/panel/api/clients/add'))
|
||||||
|
self.assertEqual(add.get_header('Authorization'), 'Bearer secret')
|
||||||
|
body = json.loads(add.data)
|
||||||
|
self.assertEqual(body['inboundIds'], [3])
|
||||||
|
self.assertEqual((body['client']['email'], body['client']['totalGB']), ('a b', 100))
|
||||||
|
self.assertEqual(get.full_url, 'https://1.2.3.4:11000/base/panel/api/clients/get/a%20b')
|
||||||
|
|
||||||
|
def test_delete_client(self):
|
||||||
|
self.responses = [{'success': True, 'obj': None}]
|
||||||
|
self.api.delete_client('user')
|
||||||
|
self.assertEqual(self.requests[0].full_url, 'https://1.2.3.4:11000/base/panel/api/clients/del/user')
|
||||||
|
|
||||||
|
def test_missing_traffic_raises(self):
|
||||||
|
self.responses = [{'success': True, 'obj': None}]
|
||||||
|
with self.assertRaises(XuiApiError):
|
||||||
|
self.api.get_traffic('ghost')
|
||||||
|
|
||||||
|
def test_panel_error_raises(self):
|
||||||
|
self.responses = [{'success': False, 'msg': 'email already in use'}]
|
||||||
|
with self.assertRaisesRegex(XuiApiError, 'email already in use'):
|
||||||
|
self.api.add_client(1, 'user')
|
||||||
|
|
||||||
|
def test_http_error_raises(self):
|
||||||
|
self.responses = [urllib.error.HTTPError('url', 401, 'Unauthorized', {}, None)]
|
||||||
|
with self.assertRaisesRegex(XuiApiError, '401'):
|
||||||
|
self.api.list_clients()
|
||||||
|
|
||||||
|
def test_repr_hides_token(self):
|
||||||
|
self.assertNotIn('secret', repr(self.api))
|
||||||
|
|
||||||
|
|
||||||
class RunPlaybookTests(unittest.TestCase):
|
class RunPlaybookTests(unittest.TestCase):
|
||||||
def test_facts_on_localhost(self):
|
def test_facts_on_localhost(self):
|
||||||
result = run_playbook('facts', Host('127.0.0.1'), {'ansible_connection': 'local'})
|
result = run_playbook('facts', Host('127.0.0.1'), {'ansible_connection': 'local'})
|
||||||
|
|||||||
+34
-2
@@ -1,17 +1,49 @@
|
|||||||
|
import secrets
|
||||||
|
|
||||||
from .runner import Result
|
from .runner import Result
|
||||||
from .server import Server
|
from .server import Server
|
||||||
|
|
||||||
|
MAIN_INBOUND = 'main'
|
||||||
|
|
||||||
|
|
||||||
class XuiPanel(Server):
|
class XuiPanel(Server):
|
||||||
"""Сервер с панелью 3x-ui."""
|
"""Сервер с панелью 3x-ui."""
|
||||||
|
|
||||||
def install(self, username: str, password: str, panel_port: int, web_base_path: str, domain: str = '') -> Result:
|
def install(
|
||||||
"""Ставит панель и открывает порты 80 (acme), 443 и panel_port. Возвращает data: {'api_token': ...}."""
|
self, username: str, password: str, panel_port: int, web_base_path: str, sub_port: int, sub_path: str,
|
||||||
|
domain: str = '',
|
||||||
|
) -> Result:
|
||||||
|
"""Ставит панель, включает подписку на sub_port по пути /sub_path/ и DNS-over-TLS к Cloudflare.
|
||||||
|
|
||||||
|
Открывает порты 80 (acme), 443, panel_port и sub_port. Возвращает data: {'api_token': ...}.
|
||||||
|
"""
|
||||||
return self.run(
|
return self.run(
|
||||||
'xui',
|
'xui',
|
||||||
username=username,
|
username=username,
|
||||||
password=password,
|
password=password,
|
||||||
panel_port=panel_port,
|
panel_port=panel_port,
|
||||||
web_base_path=web_base_path,
|
web_base_path=web_base_path,
|
||||||
|
sub_port=sub_port,
|
||||||
|
sub_path=sub_path,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def create_inbound(self, remark: str = MAIN_INBOUND, target: str = '') -> Result:
|
||||||
|
"""Создает подключение vless + xhttp + reality на 443 порту, uTLS firefox, сброс трафика ежемесячно.
|
||||||
|
|
||||||
|
target - цель reality (host:port) или список через запятую. Без него панель выбирает из своих кандидатов.
|
||||||
|
Уже созданное подключение с тем же remark не меняется.
|
||||||
|
Возвращает data: {'inbound_id', 'port', 'target', 'server_name', 'public_key', 'short_ids', 'path'}.
|
||||||
|
"""
|
||||||
|
return self.run(
|
||||||
|
'xui_inbound',
|
||||||
|
remark=remark,
|
||||||
|
target=target,
|
||||||
|
short_ids=generate_short_ids(),
|
||||||
|
xhttp_path=f'/{secrets.token_hex(8)}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_short_ids(count: int = 4) -> list[str]:
|
||||||
|
"""shortId reality: hex длиной от 2 до 16 символов."""
|
||||||
|
return [secrets.token_hex(secrets.randbelow(8) + 1) for _ in range(count)]
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
class XuiApiError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class XuiApi:
|
||||||
|
"""Ключи (клиенты) панели 3x-ui через ее HTTP API: выпуск, отзыв и статистика.
|
||||||
|
|
||||||
|
Ходит в панель напрямую, без ssh: операции частые и должны быть быстрыми.
|
||||||
|
url - адрес панели с web_base_path: https://<домен или ip>:<panel_port>/<web_base_path>.
|
||||||
|
token - api_token из XuiPanel.install. Сертификат панели проверяется.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, url: str, token: str, timeout: float = 30, context: ssl.SSLContext | None = None):
|
||||||
|
self.url = url.rstrip('/') + '/panel/api'
|
||||||
|
self._token = token
|
||||||
|
self.timeout = timeout
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
# токен не должен попадать в логи
|
||||||
|
return f'XuiApi({self.url})'
|
||||||
|
|
||||||
|
def add_client(
|
||||||
|
self, inbound_id: int, email: str, *, total_bytes: int = 0, expiry_time: int = 0, limit_ip: int = 0,
|
||||||
|
) -> dict:
|
||||||
|
"""Выпускает ключ. email - уникальное имя ключа в панели.
|
||||||
|
|
||||||
|
total_bytes - лимит трафика, expiry_time - срок в мс unix time, 0 - без ограничений.
|
||||||
|
Возвращает клиента панели, как get_client.
|
||||||
|
"""
|
||||||
|
self._call('POST', 'clients/add', {
|
||||||
|
'client': {
|
||||||
|
'email': email,
|
||||||
|
'enable': True,
|
||||||
|
'totalGB': total_bytes,
|
||||||
|
'expiryTime': expiry_time,
|
||||||
|
'limitIp': limit_ip,
|
||||||
|
},
|
||||||
|
'inboundIds': [inbound_id],
|
||||||
|
})
|
||||||
|
return self.get_client(email)
|
||||||
|
|
||||||
|
def delete_client(self, email: str):
|
||||||
|
"""Отзывает ключ: удаляет клиента и его статистику."""
|
||||||
|
self._call('POST', f'clients/del/{_quote(email)}')
|
||||||
|
|
||||||
|
def get_client(self, email: str) -> dict:
|
||||||
|
"""Клиент панели: uuid, subId, email, enable, totalGB, expiryTime и остальные поля."""
|
||||||
|
return self._required(self._call('GET', f'clients/get/{_quote(email)}'), email)['client']
|
||||||
|
|
||||||
|
def get_traffic(self, email: str) -> dict:
|
||||||
|
"""Статистика ключа: up, down (байты), total, expiryTime, enable, lastOnline (мс unix time)."""
|
||||||
|
return self._required(self._call('GET', f'clients/traffic/{_quote(email)}'), email)
|
||||||
|
|
||||||
|
def get_links(self, email: str) -> list[str]:
|
||||||
|
"""Ссылки подключения vless://... для ключа."""
|
||||||
|
return self._call('GET', f'clients/links/{_quote(email)}') or []
|
||||||
|
|
||||||
|
def list_clients(self) -> list[dict]:
|
||||||
|
return self._call('GET', 'clients/list') or []
|
||||||
|
|
||||||
|
def _call(self, method: str, path: str, body: dict | None = None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
request = urllib.request.Request(f'{self.url}/{path}', data=data, method=method, headers={
|
||||||
|
'Authorization': f'Bearer {self._token}',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
**({'Content-Type': 'application/json'} if data is not None else {}),
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout, context=self.context) as response:
|
||||||
|
payload = json.load(response)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
raise XuiApiError(f'{method} {path}: HTTP {error.code}') from error
|
||||||
|
except (urllib.error.URLError, TimeoutError, ValueError) as error:
|
||||||
|
raise XuiApiError(f'{method} {path}: {error}') from error
|
||||||
|
if not payload.get('success'):
|
||||||
|
raise XuiApiError(f"{method} {path}: {payload.get('msg') or 'failed'}")
|
||||||
|
return payload.get('obj')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _required(obj, email: str):
|
||||||
|
if not obj:
|
||||||
|
raise XuiApiError(f'client {email} not found')
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _quote(value: str) -> str:
|
||||||
|
return urllib.parse.quote(value, safe='')
|
||||||
Reference in new issue
Block a user