chore: настройка панелей

This commit is contained in:
protokey committed 2026-09-25 17:35:28 +04:00
1 parent 8532557ef6
commit 96213e182e
16 files changed
+529 -64

No files matched your search

+13 -3
View File
@@ -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_key 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_cert serverus_outbound serverus_release
help: help:
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/" @echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
@@ -20,8 +20,10 @@ help:
@echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения" @echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения"
@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> [domain=<domain>] [ssl=no] - настроить свежий сервер и поставить 3x-ui, доступы в $(SERVERUS_ACCESS_DIR)/<ip>/"
@echo "make serverus_key ip=<ip> action=add|del|stats name=<name> - ключ на панели, настроенной через serverus_xui" @echo "make serverus_key ip=<ip> action=add|del|stats name=<name> - ключ на панели, настроенной через serverus_xui"
@echo "make serverus_cert ip=<ip> [domain=<domain>] - сертификат панели на домен или на ip"
@echo "make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] - исходящий прокси, без proxy - напрямую"
@echo "make serverus_release ip=<ip> - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт" @echo "make serverus_release ip=<ip> - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт"
venv: venv:
@@ -84,12 +86,20 @@ tg_run:
serverus_xui: 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) $(if $(domain),--domain $(domain)) $(if $(filter no,$(ssl)),--no-ssl)
serverus_key: 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; } @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) key $(action) '$(name)' --out $(SERVERUS_ACCESS_DIR)/$(ip)
serverus_cert:
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_cert ip=<ip> [domain=<domain>]"; exit 1; }
$(SERVERUS) cert $(domain) --out $(SERVERUS_ACCESS_DIR)/$(ip)
serverus_outbound:
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp]"; exit 1; }
$(SERVERUS) outbound '$(proxy)' --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)
+12 -8
View File
@@ -27,7 +27,7 @@ Django приложение связывающее все части сайта
## Модули ## Модули
- `services.py` - сценарии поверх serverus: `install_point`, `reinstall`, `release_point`. - `services.py` - сценарии поверх serverus: `install_point`, `reinstall`, `release_point`, у готовой точки `set_domain` (новый сертификат) и `set_outbound_proxy`.
- `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`. - `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`.
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке). - `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
- `bot/texts.py` - кнопки и тексты. - `bot/texts.py` - кнопки и тексты.
@@ -35,16 +35,20 @@ Django приложение связывающее все части сайта
## Установка точки ## Установка точки
1. bootstrap обоих серверов (ключ, пароль root, обновление, ssh на случайном порту, nftables) 1. bootstrap обоих серверов (ключ, пароль root, обновление, ssh на случайном порту, nftables)
2. панель: 3x-ui с логином, паролем, портом и путем, которые сгенерировал App 2. панель: 3x-ui с логином, паролем, портом и путем, которые сгенерировал App, сертификат на домен или ip, подписка, подключение reality `main`, исходящий прокси точки
3. маскировка: перенаправление трафика на адрес панели 3. маскировка: перенаправление трафика на адрес панели
4. facts: `uname -a` для карточки сервера 4. facts: `uname -a` для карточки сервера
"Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке. "Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке.
# TODO: ## TODO
-[ ] для панели 3x-ui нужно активировать ssl, панель умеет сама создавать сертификаты для домена или ip адреса. - [x] для панели 3x-ui нужно активировать ssl, панель умеет сама создавать сертификаты для домена или ip адреса.
-[ ] можно ли поставить домен после того как настроили сервер на ip? я имею ввиду поставить домен и выпустить ssl сертификат от letsencrypt. Скрипт 3x-ui при ошибке молча оставлял панель на http и при повторной установке SSL не трогал. Теперь сертификат выпускает serverus и падает, если не вышло. На ip без публичного адреса сертификат не выпустить: `ssl=no`.
-[ ] что нужно сделать чтобы сервис мог нормально работать и раздавать прокси интернет через cloudflare? когда домен за прокси клауда. - [x] можно ли поставить домен после того как настроили сервер на ip? я имею ввиду поставить домен и выпустить ssl сертификат от letsencrypt.
-[ ] если используется и прокси сервер и домен за claudflare, то будет работать? Да: `services.set_domain(point, domain)`, в serverus - `XuiPanel.issue_certificate(domain)`. В боте кнопки пока нет.
-[ ] нужна функция установки прокси в исходящие (outbound) - [x] что нужно сделать чтобы сервис мог нормально работать и раздавать прокси интернет через cloudflare? когда домен за прокси клауда.
- [x] если используется и прокси сервер и домен за claudflare, то будет работать?
Решение: за Cloudflare не работаем. Reality через проксирование Cloudflare невозможен: Cloudflare сам завершает TLS.
- [x] нужна функция установки прокси в исходящие (outbound)
При установке ставится `point.outbound_proxy`, у готовой точки - `services.set_outbound_proxy(point, proxy)`. Поддерживаются socks5, http(s) и cf_warp.
+3 -4
View File
@@ -1,7 +1,7 @@
import ipaddress import ipaddress
import re import re
from urllib.parse import urlsplit
import serverus
from Telegram.interfaces import Interface, command, register, state, text from Telegram.interfaces import Interface, command, register, state, text
from .. import services, tasks from .. import services, tasks
@@ -22,7 +22,6 @@ POINT_REINSTALL = 'point.reinstall'
DRAFT_CREDENTIALS = {DRAFT_SERVER: 'server', DRAFT_MASK: 'mask'} DRAFT_CREDENTIALS = {DRAFT_SERVER: 'server', DRAFT_MASK: 'mask'}
DOMAIN_RE = re.compile(r'^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$') DOMAIN_RE = re.compile(r'^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$')
PROXY_SCHEMES = ('http', 'https', 'socks5')
HTML = 'HTML' HTML = 'HTML'
@@ -42,10 +41,10 @@ def is_domain(value: str) -> bool:
def is_proxy_url(value: str) -> bool: def is_proxy_url(value: str) -> bool:
try: try:
url = urlsplit(value) serverus.parse_proxy(value)
return url.scheme in PROXY_SCHEMES and bool(url.hostname) and bool(url.port)
except ValueError: except ValueError:
return False return False
return True
@register @register
+2 -2
View File
@@ -4,7 +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, generate_port, generate_username from serverus import CF_WARP, Host, generate_password, generate_port, generate_username
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
@@ -127,7 +127,7 @@ class Point(models.Model):
Создание: Point.objects.create_point. Поиск: методы PointQuerySet. Удаление: point.delete() удаляет и серверы. Создание: Point.objects.create_point. Поиск: методы PointQuerySet. Удаление: point.delete() удаляет и серверы.
""" """
CF_WARP = 'cf_warp' CF_WARP = CF_WARP
class Status(models.TextChoices): class Status(models.TextChoices):
INSTALLING = 'installing', 'устанавливается' INSTALLING = 'installing', 'устанавливается'
+25
View File
@@ -55,6 +55,29 @@ def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]:
return passwords, errors return passwords, errors
def set_domain(point: Point, domain: str):
"""Ставит или меняет домен готовой точки: выпускает сертификат панели на домен, без домена - на ip.
Домен должен уже указывать на панель или на сервер маскировки.
"""
if not domain and (point.mask or point.cloudflare):
raise ValueError('маскировка и cloudflare работают только с доменом')
with _installing(point):
_check(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain))
point.domain = domain
point.save(update_fields=['domain'])
def set_outbound_proxy(point: Point, proxy: str):
"""Меняет исходящий прокси готовой точки: protocol://user:password@host:port, Point.CF_WARP или '' - напрямую."""
if proxy and proxy != Point.CF_WARP:
serverus.parse_proxy(proxy)
with _installing(point):
_check(point.panel, serverus.XuiPanel(point.panel.host()).set_outbound(proxy))
point.outbound_proxy = proxy
point.save(update_fields=['outbound_proxy'])
def provision(server: Server): def provision(server: Server):
"""Базовая настройка сервера и специализация по его роли.""" """Базовая настройка сервера и специализация по его роли."""
_bootstrap(server) _bootstrap(server)
@@ -97,6 +120,8 @@ def _install_panel(server: Server):
result = _check(server, panel.create_inbound()) result = _check(server, panel.create_inbound())
point.panel_inbound_id = result.data.get('inbound_id') point.panel_inbound_id = result.data.get('inbound_id')
point.save(update_fields=['panel_inbound_id']) point.save(update_fields=['panel_inbound_id'])
if point.outbound_proxy:
_check(server, panel.set_outbound(point.outbound_proxy))
def _redirect_to_panel(server: Server): def _redirect_to_panel(server: Server):
+52
View File
@@ -160,6 +160,58 @@ class InstallTests(TestCase):
self.assertEqual(Server.objects.get(pk=panel.pk).private_key, private_key) self.assertEqual(Server.objects.get(pk=panel.pk).private_key, private_key)
def test_install_sets_outbound_proxy(self):
point = create_point(mask=None, outbound_proxy=Point.CF_WARP)
runner = FakeRunner()
with patch_runner(runner):
services.install_point(point)
self.assertEqual(runner.playbooks('10.0.0.1'), ['bootstrap', 'xui', 'xui_inbound', 'xui_outbound', 'facts'])
self.assertTrue(runner.call('xui_outbound', '10.0.0.1')[1]['warp_keys'])
class ReadyPointTests(TestCase):
"""Настройки готовой точки: домен и исходящий прокси."""
def setUp(self):
self.point = create_point(mask=None)
with patch_runner(FakeRunner()):
services.install_point(self.point)
def test_set_domain_issues_certificate(self):
runner = FakeRunner()
with patch_runner(runner):
services.set_domain(self.point, 'example.com')
self.assertEqual(runner.call('xui_cert', '10.0.0.1')[1], {'domain': 'example.com'})
self.point.refresh_from_db()
self.assertEqual((self.point.domain, self.point.status), ('example.com', Point.Status.READY))
def test_failed_certificate_keeps_old_domain(self):
with patch_runner(FakeRunner(fail='xui_cert')), self.assertRaises(services.ProvisionError):
services.set_domain(self.point, 'example.com')
self.point.refresh_from_db()
self.assertEqual((self.point.domain, self.point.status), ('', Point.Status.ERROR))
def test_mask_requires_domain(self):
Server.objects.create_server(self.point, Server.Role.MASK, Credentials('10.0.0.3', 'pw'))
with self.assertRaises(ValueError):
services.set_domain(self.point, '')
def test_set_outbound_proxy(self):
runner = FakeRunner()
with patch_runner(runner):
services.set_outbound_proxy(self.point, 'socks5://user:pw@proxy.example:1080')
outbound = runner.call('xui_outbound', '10.0.0.1')[1]['proxy_outbound']
self.assertEqual(outbound['settings']['servers'][0]['address'], 'proxy.example')
self.point.refresh_from_db()
self.assertEqual(self.point.outbound_proxy, 'socks5://user:pw@proxy.example:1080')
def test_invalid_proxy_is_rejected_before_run(self):
runner = FakeRunner()
with patch_runner(runner), self.assertRaises(ValueError):
services.set_outbound_proxy(self.point, 'ftp://proxy.example:21')
self.assertEqual(runner.calls, [])
class ReinstallTests(TestCase): class ReinstallTests(TestCase):
def setUp(self): def setUp(self):
self.point = create_point() self.point = create_point()
+6 -2
View File
@@ -59,7 +59,9 @@ server.release(generate_password())
Специализации - наследники `Server`: Специализации - наследники `Server`:
- `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).install(username, password, panel_port, web_base_path, sub_port, sub_path, domain='', ssl=True)` - ставит 3x-ui скриптом из README проекта в неинтерактивном режиме. Сертификат Let's Encrypt выпускает serverus через acme.sh (HTTP-01 на 80 порту): на домен или, без него, на ip (короткий, ~6 дней, продлевается по cron). Не выпустился - ошибка, панель на http не остается. `ssl=False` - без сертификата, для серверов без публичного адреса. Включает подписку на `sub_port` по пути `/sub_path/` под тем же сертификатом. Системный DNS переводит на DNS-over-TLS к Cloudflare (systemd-resolved, строгий режим, без резервных серверов), DNS в Xray - через системный резолвер: сам Xray DoT не умеет. Открывает 80, 443, порт панели и порт подписки. Возвращает `data`: `api_token`, `panel_host` (домен или ip сертификата), `ssl`.
- `XuiPanel(host).issue_certificate(domain='')` - сертификат панели и подписки на домен или на ip: так домен ставится, меняется или убирается после установки. Домен должен уже указывать на сервер (или на его маскировку).
- `XuiPanel(host).set_outbound(proxy='')` - весь исходящий трафик клиентов через прокси: `socks5://user:password@host:port`, `http(s)://...` или `CF_WARP` (устройство WARP регистрирует панель, выход по wireguard). Пустая строка - напрямую. Прокси ставится первым outbound Xray, перед применением панель его проверяет. `parse_proxy(url)` проверяет адрес заранее.
- `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`. - `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. **Сейчас скрипт - заглушка.**
@@ -94,8 +96,10 @@ api.delete_client('user-1')
## Ручная проверка на живом сервере ## Ручная проверка на живом сервере
``` ```
make serverus_xui ip=<ip> password=<root_password> # bootstrap + 3x-ui + подключение reality make serverus_xui ip=<ip> password=<root_password> [domain=<domain>] [ssl=no] # bootstrap + 3x-ui + подключение reality
make serverus_key ip=<ip> action=add|del|stats name=<name> # ключ на панели make serverus_key ip=<ip> action=add|del|stats name=<name> # ключ на панели
make serverus_cert ip=<ip> [domain=<domain>] # сертификат на домен или на ip
make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] # исходящий прокси, без proxy - напрямую
make serverus_release ip=<ip> # вернуть сервер make serverus_release ip=<ip> # вернуть сервер
``` ```
+3 -1
View File
@@ -5,10 +5,11 @@ 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 CF_WARP, XuiPanel, parse_proxy
from .xui_api import XuiApi, XuiApiError from .xui_api import XuiApi, XuiApiError
__all__ = [ __all__ = [
'CF_WARP',
'Host', 'Host',
'KeyPair', 'KeyPair',
'RedirectProxy', 'RedirectProxy',
@@ -22,5 +23,6 @@ __all__ = [
'generate_port', 'generate_port',
'generate_ssh_port', 'generate_ssh_port',
'generate_username', 'generate_username',
'parse_proxy',
'run_playbook', 'run_playbook',
] ]
+52 -5
View File
@@ -1,7 +1,9 @@
"""Ручная проверка сценариев на живом сервере. """Ручная проверка сценариев на живом сервере.
python -m serverus xui <ip> <root_password> --out <dir> python -m serverus xui <ip> <root_password> --out <dir> [--domain <domain>] [--no-ssl]
python -m serverus key add|del|stats <name> --out <dir> python -m serverus key add|del|stats <name> --out <dir>
python -m serverus cert [<domain>] --out <dir>
python -m serverus outbound [<proxy>] --out <dir>
python -m serverus release --out <dir> python -m serverus release --out <dir>
Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/access.json до начала настройки: Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/access.json до начала настройки:
@@ -29,24 +31,38 @@ def main(argv=None) -> int:
xui.add_argument('address') xui.add_argument('address')
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='папка для доступов к серверу')
xui.add_argument('--domain', default='', help='домен панели, без него сертификат на ip')
xui.add_argument('--no-ssl', action='store_true', help='панель на http: для серверов без публичного адреса')
key = commands.add_parser('key', help='выпуск, отзыв и статистика ключа на панели от команды xui') key = commands.add_parser('key', help='выпуск, отзыв и статистика ключа на панели от команды xui')
key.add_argument('action', choices=['add', 'del', 'stats']) key.add_argument('action', choices=['add', 'del', 'stats'])
key.add_argument('name', help='имя ключа (email клиента в панели)') key.add_argument('name', help='имя ключа (email клиента в панели)')
key.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui') key.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui')
cert = commands.add_parser('cert', help='сертификат панели на домен или, без домена, на ip')
cert.add_argument('domain', nargs='?', default='')
cert.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui')
outbound = commands.add_parser('outbound', help='исходящий прокси: socks5://..., http(s)://..., cf_warp')
outbound.add_argument('proxy', nargs='?', default='', help='без него трафик идет напрямую')
outbound.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)
if args.command == 'xui': if args.command == 'xui':
return install_xui(args.address, args.password, args.out) return install_xui(args.address, args.password, args.out, args.domain, not args.no_ssl)
if args.command == 'key': if args.command == 'key':
return manage_key(args.action, args.name, args.out) return manage_key(args.action, args.name, args.out)
if args.command == 'cert':
return issue_certificate(args.domain, args.out)
if args.command == 'outbound':
return set_outbound(args.proxy, args.out)
return release_server(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, domain: str, ssl: bool) -> int:
keys = generate_keypair() keys = generate_keypair()
panel_port = generate_port() panel_port = generate_port()
access = { access = {
@@ -62,6 +78,8 @@ def install_xui(address: str, password: str, out: Path) -> int:
'web_base_path': generate_password(18), 'web_base_path': generate_password(18),
'sub_port': generate_port(exclude={panel_port}), 'sub_port': generate_port(exclude={panel_port}),
'sub_path': generate_password(12), 'sub_path': generate_password(12),
'domain': domain,
'ssl': ssl,
}, },
} }
save(out, access) save(out, access)
@@ -81,6 +99,7 @@ def install_xui(address: str, password: str, out: Path) -> int:
if not result.ok: if not result.ok:
return 1 return 1
access['api_token'] = result.data['api_token'] access['api_token'] = result.data['api_token']
access['panel_host'] = result.data['panel_host']
save(out, access) save(out, access)
result = XuiPanel(host).create_inbound() result = XuiPanel(host).create_inbound()
@@ -106,7 +125,9 @@ def manage_key(action: str, name: str, out: Path) -> int:
if action == 'add': if action == 'add':
client = api.add_client(access['inbound']['inbound_id'], name) client = api.add_client(access['inbound']['inbound_id'], name)
panel = access['panel'] panel = access['panel']
print(f"subscription: https://{access['address']}:{panel['sub_port']}/{panel['sub_path']}/{client['subId']}") scheme = 'https' if panel.get('ssl', True) else 'http'
host = access.get('panel_host') or access['address']
print(f"subscription: {scheme}://{host}:{panel['sub_port']}/{panel['sub_path']}/{client['subId']}")
print(*api.get_links(name), sep='\n') print(*api.get_links(name), sep='\n')
elif action == 'del': elif action == 'del':
api.delete_client(name) api.delete_client(name)
@@ -119,6 +140,30 @@ def manage_key(action: str, name: str, out: Path) -> int:
return 0 return 0
def issue_certificate(domain: str, out: Path) -> int:
access = json.loads((out / ACCESS_FILE).read_text())
result = XuiPanel(load_host(access)).issue_certificate(domain)
report('cert', result)
if not result.ok:
return 1
access['panel'].update(domain=domain, ssl=True)
access['panel_host'] = result.data['panel_host']
save(out, access)
print(f"panel: {panel_url(access)}/")
return 0
def set_outbound(proxy: str, out: Path) -> int:
access = json.loads((out / ACCESS_FILE).read_text())
try:
result = XuiPanel(load_host(access)).set_outbound(proxy)
except ValueError as error:
print(error, file=sys.stderr)
return 1
report('outbound', result)
return 0 if result.ok else 1
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()
@@ -134,7 +179,9 @@ def release_server(out: Path) -> int:
def panel_url(access: dict) -> str: def panel_url(access: dict) -> str:
panel = access['panel'] panel = access['panel']
return f"https://{access['address']}:{panel['panel_port']}/{panel['web_base_path']}" scheme = 'https' if panel.get('ssl', True) else 'http'
host = access.get('panel_host') or access['address']
return f"{scheme}://{host}:{panel['panel_port']}/{panel['web_base_path']}"
def load_host(access: dict) -> Host: def load_host(access: dict) -> Host:
+64
View File
@@ -0,0 +1,64 @@
# vars: cert_host - домен или ip, на который выпускается сертификат
#
# Сертификат Let's Encrypt через acme.sh в режиме standalone: проверка HTTP-01 на 80 порту, он должен быть открыт.
# На ip выдается только короткий сертификат (~6 дней), acme.sh продлевает его по cron и перезапускает панель.
# Ставит сертификат панели и подписке. Если выпустить не удалось, падает, а не оставляет панель на http.
- name: Set certificate paths
ansible.builtin.set_fact:
cert_is_ip: "{{ cert_host is match('^[0-9.]+$') or ':' in cert_host }}"
cert_dir: "/root/cert/{{ cert_host }}"
acme: /root/.acme.sh/acme.sh
- name: Install acme.sh dependencies
ansible.builtin.apt:
name: [ curl, socat, cron ]
state: present
- name: Download acme.sh
ansible.builtin.get_url:
url: https://get.acme.sh
dest: /root/acme-install.sh
mode: "0700"
when: not acme is file
- name: Install acme.sh
ansible.builtin.shell: sh /root/acme-install.sh
args:
chdir: /root
creates: "{{ acme }}"
- name: Use Let's Encrypt
ansible.builtin.command: "{{ acme }} --set-default-ca --server letsencrypt"
changed_when: false
# код 2 - действующий сертификат уже есть, продлевать рано
- name: Issue certificate
ansible.builtin.command: >-
{{ acme }} --issue -d {{ cert_host }} --standalone --httpport 80 --server letsencrypt
{{ '--certificate-profile shortlived --days 6' if cert_is_ip else '' }}
register: cert_issue
changed_when: cert_issue.rc == 0
failed_when: cert_issue.rc not in [0, 2]
- name: Create certificate directory
ansible.builtin.file:
path: "{{ cert_dir }}"
state: directory
mode: "0700"
- name: Install certificate
ansible.builtin.command: >-
{{ acme }} --installcert -d {{ cert_host }}
--key-file {{ cert_dir }}/privkey.pem --fullchain-file {{ cert_dir }}/fullchain.pem
--reloadcmd "systemctl restart x-ui"
changed_when: true
- name: Check certificate
ansible.builtin.command: "openssl x509 -in {{ cert_dir }}/fullchain.pem -noout -checkend 3600 -ext subjectAltName"
register: cert_check
changed_when: false
failed_when: cert_check.rc != 0 or cert_host not in cert_check.stdout
- name: Use certificate for panel
ansible.builtin.command: "{{ xui_bin }} cert -webCert {{ cert_dir }}/fullchain.pem -webCertKey {{ cert_dir }}/privkey.pem"
changed_when: true
@@ -0,0 +1,24 @@
# vars: xui_settings_patch - поля настроек панели, которые нужно поменять. Нужен 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: xui_settings
failed_when: not xui_settings.json.success | default(false)
no_log: true
- name: Update panel settings
ansible.builtin.uri:
url: "{{ xui_api_url }}/setting/update"
method: POST
headers: "{{ xui_api_headers }}"
body_format: json
body: "{{ xui_settings.json.obj | combine(xui_settings_patch) }}"
validate_certs: false
register: xui_settings_update
failed_when: not xui_settings_update.json.success | default(false)
no_log: true
+20 -35
View File
@@ -1,7 +1,9 @@
# extravars: username, password, panel_port, web_base_path, sub_port, sub_path, domain (optional), version (optional) # extravars: username, password, panel_port, web_base_path, sub_port, sub_path, domain (optional), ssl (optional),
# version (optional)
# #
# Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме. # Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме, SSL скрипта выключен: он при ошибке молча оставляет
# Сертификат: на домен, если он задан, иначе на ip. Оба варианта требуют открытый 80 порт (acme). # панель на http. Сертификат выпускает tasks/xui_cert.yml на домен, если он задан, иначе на ip. Нужен открытый 80 порт.
# ssl=false - без сертификата, для серверов без публичного адреса.
# Системный DNS - DNS-over-TLS к Cloudflare, Xray резолвит через него. Подписка на sub_port под тем же сертификатом. # Системный DNS - DNS-over-TLS к Cloudflare, Xray резолвит через него. Подписка на sub_port под тем же сертификатом.
- name: Install 3x-ui panel - name: Install 3x-ui panel
hosts: target hosts: target
@@ -9,6 +11,8 @@
vars: vars:
xui_bin: /usr/local/x-ui/x-ui xui_bin: /usr/local/x-ui/x-ui
xui_domain: "{{ domain | default('') }}" xui_domain: "{{ domain | default('') }}"
xui_ssl: "{{ ssl | default(true) | bool }}"
cert_host: "{{ xui_domain or ansible_host }}"
tasks: tasks:
- ansible.builtin.import_tasks: tasks/firewall.yml - ansible.builtin.import_tasks: tasks/firewall.yml
vars: vars:
@@ -33,8 +37,7 @@
XUI_PASSWORD: "{{ password }}" XUI_PASSWORD: "{{ password }}"
XUI_PANEL_PORT: "{{ panel_port }}" XUI_PANEL_PORT: "{{ panel_port }}"
XUI_WEB_BASE_PATH: "{{ web_base_path }}" XUI_WEB_BASE_PATH: "{{ web_base_path }}"
XUI_SSL_MODE: "{{ 'domain' if xui_domain else 'ip' }}" XUI_SSL_MODE: none
XUI_DOMAIN: "{{ xui_domain }}"
XUI_SERVER_IP: "{{ ansible_host }}" XUI_SERVER_IP: "{{ ansible_host }}"
no_log: true no_log: true
@@ -45,6 +48,9 @@
-port {{ panel_port }} -webBasePath {{ web_base_path | quote }} -port {{ panel_port }} -webBasePath {{ web_base_path | quote }}
no_log: true no_log: true
- ansible.builtin.import_tasks: tasks/xui_cert.yml
when: xui_ssl
- name: Restart panel - name: Restart panel
ansible.builtin.systemd_service: ansible.builtin.systemd_service:
name: x-ui name: x-ui
@@ -59,37 +65,14 @@
- ansible.builtin.import_tasks: tasks/xui_api.yml - ansible.builtin.import_tasks: tasks/xui_api.yml
# настройки меняются только целиком: читаем все, меняем подписку, отправляем обратно - ansible.builtin.import_tasks: tasks/xui_settings.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: vars:
settings: "{{ panel_settings.json.obj }}" xui_settings_patch:
ansible.builtin.uri: subEnable: true
url: "{{ xui_api_url }}/setting/update" subPort: "{{ sub_port | int }}"
method: POST subPath: "/{{ sub_path }}/"
headers: "{{ xui_api_headers }}" subCertFile: "{{ cert_dir ~ '/fullchain.pem' if xui_ssl else '' }}"
body_format: json subKeyFile: "{{ cert_dir ~ '/privkey.pem' if xui_ssl else '' }}"
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 - name: Read Xray config
ansible.builtin.uri: ansible.builtin.uri:
@@ -131,4 +114,6 @@
aggregate: false aggregate: false
data: data:
api_token: "{{ api_token.stdout }}" api_token: "{{ api_token.stdout }}"
panel_host: "{{ cert_host if xui_ssl else ansible_host }}"
ssl: "{{ xui_ssl }}"
no_log: true no_log: true
+37
View File
@@ -0,0 +1,37 @@
# extravars: domain (optional)
#
# Выпускает сертификат панели и подписки на domain, без него - на ip. Так можно поставить домен после установки на ip
# или вернуться с домена на ip. Домен должен указывать на этот сервер (или на сервер маскировки перед ним),
# 80 порт должен быть доступен снаружи.
- name: Issue panel certificate
hosts: target
gather_facts: false
vars:
xui_bin: /usr/local/x-ui/x-ui
cert_host: "{{ domain | default('', true) or ansible_host }}"
tasks:
- ansible.builtin.import_tasks: tasks/xui_cert.yml
- name: Restart panel
ansible.builtin.systemd_service:
name: x-ui
state: restarted
- ansible.builtin.import_tasks: tasks/xui_api.yml
- ansible.builtin.import_tasks: tasks/xui_settings.yml
vars:
xui_settings_patch:
subCertFile: "{{ cert_dir }}/fullchain.pem"
subKeyFile: "{{ cert_dir }}/privkey.pem"
- name: Restart panel with subscription certificate
ansible.builtin.systemd_service:
name: x-ui
state: restarted
- name: Return panel host
ansible.builtin.set_stats:
aggregate: false
data:
panel_host: "{{ cert_host }}"
+113
View File
@@ -0,0 +1,113 @@
# extravars: proxy_outbound (outbound Xray или null), warp_keys ({private_key, public_key} или null)
#
# Ставит исходящий прокси первым outbound Xray: он становится выходом по умолчанию для всего трафика клиентов.
# warp_keys - регистрирует устройство Cloudflare WARP через панель и выходит через него по wireguard.
# Оба null - прокси убирается, трафик идет напрямую. Перед применением прокси проверяется через панель.
- name: Set outbound proxy
hosts: target
gather_facts: false
vars:
xui_bin: /usr/local/x-ui/x-ui
proxy_tags: [ proxy, warp ]
tasks:
- ansible.builtin.import_tasks: tasks/xui_api.yml
- 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)
- name: Use proxy outbound
ansible.builtin.set_fact:
new_outbound: "{{ proxy_outbound }}"
no_log: true
- name: Register Cloudflare WARP
when: warp_keys is not none
block:
- name: Register WARP device
ansible.builtin.uri:
url: "{{ xui_api_url }}/xray/warp/reg"
method: POST
headers: "{{ xui_api_headers }}"
body_format: form-urlencoded
body:
privateKey: "{{ warp_keys.private_key }}"
publicKey: "{{ warp_keys.public_key }}"
validate_certs: false
register: warp_reg
failed_when: not warp_reg.json.success | default(false)
no_log: true
- name: Read WARP config
ansible.builtin.set_fact:
warp: "{{ warp_reg.json.obj | from_json }}"
no_log: true
# reserved - байты client_id, в Jinja нет ord
- name: Decode WARP client id
ansible.builtin.command:
argv: [ python3, -c, "import base64, json, sys; print(json.dumps(list(base64.b64decode(sys.argv[1]))))", "{{ warp.config.config.client_id | default(warp.data.client_id) }}" ]
register: warp_reserved
changed_when: false
- name: Use WARP outbound
vars:
config: "{{ warp.config.config }}"
addresses: "{{ config.interface.addresses }}"
ansible.builtin.set_fact:
new_outbound:
tag: warp
protocol: wireguard
settings:
mtu: 1420
secretKey: "{{ warp.data.private_key }}"
address: "{{ ([addresses.v4 ~ '/32'] if addresses.v4 | default('') else []) + ([addresses.v6 ~ '/128'] if addresses.v6 | default('') else []) }}"
reserved: "{{ warp_reserved.stdout | from_json }}"
domainStrategy: ForceIPv4v6
noKernelTun: true
peers:
- publicKey: "{{ config.peers[0].public_key }}"
endpoint: "{{ config.peers[0].endpoint.host }}"
no_log: true
- name: Test outbound
when: new_outbound is not none
ansible.builtin.uri:
url: "{{ xui_api_url }}/xray/testOutbound"
method: POST
headers: "{{ xui_api_headers }}"
body_format: form-urlencoded
body:
outbound: "{{ new_outbound | to_json }}"
validate_certs: false
timeout: 60
register: outbound_test
failed_when: not outbound_test.json.success | default(false)
no_log: true
- name: Require working outbound
when: new_outbound is not none
ansible.builtin.assert:
that: outbound_test.json.obj.success | default(false)
fail_msg: "Outbound test failed: {{ outbound_test.json.obj.error | default(outbound_test.json.msg) }}"
- name: Apply outbound
vars:
config: "{{ (xray_setting.json.obj | from_json).xraySetting }}"
outbounds: "{{ ([new_outbound] if new_outbound is not none else []) + (config.outbounds | rejectattr('tag', 'in', proxy_tags) | list) }}"
ansible.builtin.uri:
url: "{{ xui_api_url }}/xray/update"
method: POST
headers: "{{ xui_api_headers }}"
body_format: form-urlencoded
body:
xraySetting: "{{ config | combine({'outbounds': outbounds}) | to_json }}"
validate_certs: false
register: xray_update
failed_when: not xray_update.json.success | default(false)
no_log: true
+47 -2
View File
@@ -5,8 +5,8 @@ import urllib.error
from unittest import mock from unittest import mock
from . import ( from . import (
Host, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port, CF_WARP, Host, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
generate_ssh_port, generate_username, run_playbook, generate_ssh_port, generate_username, parse_proxy, run_playbook,
) )
from .credentials import SERVICE_PORT_RANGE, SSH_PORT_RANGE from .credentials import SERVICE_PORT_RANGE, SSH_PORT_RANGE
@@ -79,8 +79,53 @@ class XuiPanelTests(unittest.TestCase):
'sub_port': 12000, 'sub_port': 12000,
'sub_path': 'sub', 'sub_path': 'sub',
'domain': 'example.com', 'domain': 'example.com',
'ssl': True,
}) })
def test_issue_certificate(self):
runner = FakeRunner()
XuiPanel(Host('1.2.3.4'), runner=runner).issue_certificate('example.com')
self.assertEqual(runner.calls[0][0::2], ('xui_cert', {'domain': 'example.com'}))
def test_outbound_proxy(self):
runner = FakeRunner()
XuiPanel(Host('1.2.3.4'), runner=runner).set_outbound('socks5://user:p%40ss@proxy.example:1080')
playbook, _, extravars = runner.calls[0]
self.assertEqual(playbook, 'xui_outbound')
self.assertIsNone(extravars['warp_keys'])
self.assertEqual(extravars['proxy_outbound'], {
'tag': 'proxy',
'protocol': 'socks',
'settings': {'servers': [{
'address': 'proxy.example', 'port': 1080, 'users': [{'user': 'user', 'pass': 'p@ss'}],
}]},
})
def test_outbound_warp(self):
runner = FakeRunner()
XuiPanel(Host('1.2.3.4'), runner=runner).set_outbound(CF_WARP)
extravars = runner.calls[0][2]
self.assertIsNone(extravars['proxy_outbound'])
keys = extravars['warp_keys']
self.assertEqual({len(keys['private_key']), len(keys['public_key'])}, {44})
def test_outbound_direct(self):
runner = FakeRunner()
XuiPanel(Host('1.2.3.4'), runner=runner).set_outbound('')
self.assertEqual(runner.calls[0][2], {'proxy_outbound': None, 'warp_keys': None})
def test_https_proxy_uses_tls(self):
outbound = parse_proxy('https://proxy.example:443')
self.assertEqual(outbound['protocol'], 'http')
self.assertNotIn('users', outbound['settings']['servers'][0])
self.assertEqual(outbound['streamSettings']['tlsSettings']['serverName'], 'proxy.example')
def test_bad_proxy_hides_credentials(self):
for url in ('ftp://user:secret@host:21', 'socks5://user:secret@host', 'garbage'):
with self.assertRaises(ValueError) as error:
parse_proxy(url)
self.assertNotIn('secret', str(error.exception))
def test_create_inbound_generates_reality_ids(self): def test_create_inbound_generates_reality_ids(self):
runner = FakeRunner() runner = FakeRunner()
XuiPanel(Host('1.2.3.4'), runner=runner).create_inbound() XuiPanel(Host('1.2.3.4'), runner=runner).create_inbound()
+56 -2
View File
@@ -1,9 +1,17 @@
import base64
import secrets import secrets
import urllib.parse
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from .runner import Result from .runner import Result
from .server import Server from .server import Server
MAIN_INBOUND = 'main' MAIN_INBOUND = 'main'
CF_WARP = 'cf_warp'
"""Исходящий прокси через Cloudflare WARP."""
PROXY_SCHEMES = {'socks': 'socks', 'socks5': 'socks', 'socks5h': 'socks', 'http': 'http', 'https': 'http'}
class XuiPanel(Server): class XuiPanel(Server):
@@ -11,11 +19,13 @@ class XuiPanel(Server):
def install( def install(
self, username: str, password: str, panel_port: int, web_base_path: str, sub_port: int, sub_path: str, self, username: str, password: str, panel_port: int, web_base_path: str, sub_port: int, sub_path: str,
domain: str = '', domain: str = '', ssl: bool = True,
) -> Result: ) -> Result:
"""Ставит панель, включает подписку на sub_port по пути /sub_path/ и DNS-over-TLS к Cloudflare. """Ставит панель, включает подписку на sub_port по пути /sub_path/ и DNS-over-TLS к Cloudflare.
Открывает порты 80 (acme), 443, panel_port и sub_port. Возвращает data: {'api_token': ...}. Сертификат Let's Encrypt на domain, без него - на ip; не удалось выпустить - ошибка. ssl=False - панель на http,
для серверов без публичного адреса. Открывает порты 80 (acme), 443, panel_port и sub_port.
Возвращает data: {'api_token', 'panel_host' (домен или ip из сертификата), 'ssl'}.
""" """
return self.run( return self.run(
'xui', 'xui',
@@ -26,8 +36,25 @@ class XuiPanel(Server):
sub_port=sub_port, sub_port=sub_port,
sub_path=sub_path, sub_path=sub_path,
domain=domain, domain=domain,
ssl=ssl,
) )
def issue_certificate(self, domain: str = '') -> Result:
"""Выпускает сертификат панели и подписки на domain, без него - на ip. Возвращает data: {'panel_host'}.
Так домен ставится или меняется после установки. Домен должен указывать на сервер или его маскировку.
"""
return self.run('xui_cert', domain=domain)
def set_outbound(self, proxy: str = '') -> Result:
"""Весь исходящий трафик клиентов через прокси. Перед применением прокси проверяется панелью.
proxy - socks5://user:password@host:port, http(s)://..., CF_WARP или пустая строка, чтобы выходить напрямую.
"""
warp_keys = generate_wireguard_keys() if proxy == CF_WARP else None
proxy_outbound = parse_proxy(proxy) if proxy and proxy != CF_WARP else None
return self.run('xui_outbound', proxy_outbound=proxy_outbound, warp_keys=warp_keys)
def create_inbound(self, remark: str = MAIN_INBOUND, target: str = '') -> Result: def create_inbound(self, remark: str = MAIN_INBOUND, target: str = '') -> Result:
"""Создает подключение vless + xhttp + reality на 443 порту, uTLS firefox, сброс трафика ежемесячно. """Создает подключение vless + xhttp + reality на 443 порту, uTLS firefox, сброс трафика ежемесячно.
@@ -47,3 +74,30 @@ class XuiPanel(Server):
def generate_short_ids(count: int = 4) -> list[str]: def generate_short_ids(count: int = 4) -> list[str]:
"""shortId reality: hex длиной от 2 до 16 символов.""" """shortId reality: hex длиной от 2 до 16 символов."""
return [secrets.token_hex(secrets.randbelow(8) + 1) for _ in range(count)] return [secrets.token_hex(secrets.randbelow(8) + 1) for _ in range(count)]
def parse_proxy(url: str) -> dict:
"""outbound Xray для прокси вида socks5://user:password@host:port или http(s)://... ValueError, если не разобрать."""
parts = urllib.parse.urlsplit(url)
protocol = PROXY_SCHEMES.get(parts.scheme)
if not protocol or not parts.hostname or not parts.port:
raise ValueError(f'expected socks5://user:password@host:port or http(s)://..., got {parts.scheme}://...')
server = {'address': parts.hostname, 'port': parts.port}
if parts.username:
server['users'] = [{
'user': urllib.parse.unquote(parts.username),
'pass': urllib.parse.unquote(parts.password or ''),
}]
outbound = {'tag': 'proxy', 'protocol': protocol, 'settings': {'servers': [server]}}
if parts.scheme == 'https':
outbound['streamSettings'] = {'security': 'tls', 'tlsSettings': {'serverName': parts.hostname}}
return outbound
def generate_wireguard_keys() -> dict:
key = X25519PrivateKey.generate()
private = key.private_bytes(
serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption(),
)
public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
return {'private_key': base64.b64encode(private).decode(), 'public_key': base64.b64encode(public).decode()}