From 96213e182eb50770312302ce98583ec911ebda33 Mon Sep 17 00:00:00 2001 From: protokey Date: Fri, 25 Sep 2026 17:35:28 +0400 Subject: [PATCH] =?UTF-8?q?chore:=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B0=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 16 ++- src/App/README.md | 20 ++-- src/App/bot/interface.py | 7 +- src/App/models.py | 4 +- src/App/services.py | 25 ++++ src/App/tests_services.py | 52 ++++++++ src/serverus/README.md | 8 +- src/serverus/__init__.py | 4 +- src/serverus/__main__.py | 57 ++++++++- src/serverus/playbooks/tasks/xui_cert.yml | 64 ++++++++++ src/serverus/playbooks/tasks/xui_settings.yml | 24 ++++ src/serverus/playbooks/xui.yml | 55 ++++----- src/serverus/playbooks/xui_cert.yml | 37 ++++++ src/serverus/playbooks/xui_outbound.yml | 113 ++++++++++++++++++ src/serverus/tests.py | 49 +++++++- src/serverus/xui.py | 58 ++++++++- 16 files changed, 529 insertions(+), 64 deletions(-) create mode 100644 src/serverus/playbooks/tasks/xui_cert.yml create mode 100644 src/serverus/playbooks/tasks/xui_settings.yml create mode 100644 src/serverus/playbooks/xui_cert.yml create mode 100644 src/serverus/playbooks/xui_outbound.yml diff --git a/Makefile b/Makefile index bbf9e5e..dc4ee71 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ SERVERUS_ACCESS_DIR := $(abspath extra/serverus) MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help 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: @echo "make install [ ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/" @@ -20,8 +20,10 @@ help: @echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения" @echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)" @echo "make clean - удалить $(WHEELS_DIR)/" - @echo "make serverus_xui ip= password= - настроить свежий сервер и поставить 3x-ui, доступы в $(SERVERUS_ACCESS_DIR)//" + @echo "make serverus_xui ip= password= [domain=] [ssl=no] - настроить свежий сервер и поставить 3x-ui, доступы в $(SERVERUS_ACCESS_DIR)//" @echo "make serverus_key ip= action=add|del|stats name= - ключ на панели, настроенной через serverus_xui" + @echo "make serverus_cert ip= [domain=] - сертификат панели на домен или на ip" + @echo "make serverus_outbound ip= [proxy=socks5://...|cf_warp] - исходящий прокси, без proxy - напрямую" @echo "make serverus_release ip= - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт" venv: @@ -84,12 +86,20 @@ tg_run: serverus_xui: @test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip= 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: @test -n "$(ip)" -a -n "$(action)" -a -n "$(name)" || { echo "Укажите ключ: make serverus_key ip= action=add|del|stats name="; exit 1; } $(SERVERUS) key $(action) '$(name)' --out $(SERVERUS_ACCESS_DIR)/$(ip) +serverus_cert: + @test -n "$(ip)" || { echo "Укажите сервер: make serverus_cert ip= [domain=]"; exit 1; } + $(SERVERUS) cert $(domain) --out $(SERVERUS_ACCESS_DIR)/$(ip) + +serverus_outbound: + @test -n "$(ip)" || { echo "Укажите сервер: make serverus_outbound ip= [proxy=socks5://...|cf_warp]"; exit 1; } + $(SERVERUS) outbound '$(proxy)' --out $(SERVERUS_ACCESS_DIR)/$(ip) + serverus_release: @test -n "$(ip)" || { echo "Укажите сервер: make serverus_release ip="; exit 1; } $(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip) diff --git a/src/App/README.md b/src/App/README.md index 992ab3b..c526e2e 100644 --- a/src/App/README.md +++ b/src/App/README.md @@ -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`. - `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке). - `bot/texts.py` - кнопки и тексты. @@ -35,16 +35,20 @@ Django приложение связывающее все части сайта ## Установка точки 1. bootstrap обоих серверов (ключ, пароль root, обновление, ssh на случайном порту, nftables) -2. панель: 3x-ui с логином, паролем, портом и путем, которые сгенерировал App +2. панель: 3x-ui с логином, паролем, портом и путем, которые сгенерировал App, сертификат на домен или ip, подписка, подключение reality `main`, исходящий прокси точки 3. маскировка: перенаправление трафика на адрес панели 4. facts: `uname -a` для карточки сервера "Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке. -# TODO: +## TODO --[ ] для панели 3x-ui нужно активировать ssl, панель умеет сама создавать сертификаты для домена или ip адреса. --[ ] можно ли поставить домен после того как настроили сервер на ip? я имею ввиду поставить домен и выпустить ssl сертификат от letsencrypt. --[ ] что нужно сделать чтобы сервис мог нормально работать и раздавать прокси интернет через cloudflare? когда домен за прокси клауда. --[ ] если используется и прокси сервер и домен за claudflare, то будет работать? --[ ] нужна функция установки прокси в исходящие (outbound) +- [x] для панели 3x-ui нужно активировать ssl, панель умеет сама создавать сертификаты для домена или ip адреса. + Скрипт 3x-ui при ошибке молча оставлял панель на http и при повторной установке SSL не трогал. Теперь сертификат выпускает serverus и падает, если не вышло. На ip без публичного адреса сертификат не выпустить: `ssl=no`. +- [x] можно ли поставить домен после того как настроили сервер на ip? я имею ввиду поставить домен и выпустить ssl сертификат от letsencrypt. + Да: `services.set_domain(point, domain)`, в serverus - `XuiPanel.issue_certificate(domain)`. В боте кнопки пока нет. +- [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. diff --git a/src/App/bot/interface.py b/src/App/bot/interface.py index 92e3086..176e640 100644 --- a/src/App/bot/interface.py +++ b/src/App/bot/interface.py @@ -1,7 +1,7 @@ import ipaddress import re -from urllib.parse import urlsplit +import serverus from Telegram.interfaces import Interface, command, register, state, text from .. import services, tasks @@ -22,7 +22,6 @@ POINT_REINSTALL = 'point.reinstall' 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}$') -PROXY_SCHEMES = ('http', 'https', 'socks5') HTML = 'HTML' @@ -42,10 +41,10 @@ def is_domain(value: str) -> bool: def is_proxy_url(value: str) -> bool: try: - url = urlsplit(value) - return url.scheme in PROXY_SCHEMES and bool(url.hostname) and bool(url.port) + serverus.parse_proxy(value) except ValueError: return False + return True @register diff --git a/src/App/models.py b/src/App/models.py index 196f652..7c1ab4a 100644 --- a/src/App/models.py +++ b/src/App/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from django.contrib.auth.base_user import BaseUserManager from django.contrib.auth.models import AbstractUser 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): @@ -127,7 +127,7 @@ class Point(models.Model): Создание: Point.objects.create_point. Поиск: методы PointQuerySet. Удаление: point.delete() удаляет и серверы. """ - CF_WARP = 'cf_warp' + CF_WARP = CF_WARP class Status(models.TextChoices): INSTALLING = 'installing', 'устанавливается' diff --git a/src/App/services.py b/src/App/services.py index 3094a05..de5d687 100644 --- a/src/App/services.py +++ b/src/App/services.py @@ -55,6 +55,29 @@ def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]: 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): """Базовая настройка сервера и специализация по его роли.""" _bootstrap(server) @@ -97,6 +120,8 @@ def _install_panel(server: Server): result = _check(server, panel.create_inbound()) point.panel_inbound_id = result.data.get('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): diff --git a/src/App/tests_services.py b/src/App/tests_services.py index 879fe5d..07067a5 100644 --- a/src/App/tests_services.py +++ b/src/App/tests_services.py @@ -160,6 +160,58 @@ class InstallTests(TestCase): 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): def setUp(self): self.point = create_point() diff --git a/src/serverus/README.md b/src/serverus/README.md index f11ef3e..328fa2a 100644 --- a/src/serverus/README.md +++ b/src/serverus/README.md @@ -59,7 +59,9 @@ server.release(generate_password()) Специализации - наследники `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`. - `RedirectProxy(host).redirect(target_ip)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh `, который перенаправляет весь трафик, кроме ssh, на target_ip. **Сейчас скрипт - заглушка.** @@ -94,8 +96,10 @@ api.delete_client('user-1') ## Ручная проверка на живом сервере ``` -make serverus_xui ip= password= # bootstrap + 3x-ui + подключение reality +make serverus_xui ip= password= [domain=] [ssl=no] # bootstrap + 3x-ui + подключение reality make serverus_key ip= action=add|del|stats name= # ключ на панели +make serverus_cert ip= [domain=] # сертификат на домен или на ip +make serverus_outbound ip= [proxy=socks5://...|cf_warp] # исходящий прокси, без proxy - напрямую make serverus_release ip= # вернуть сервер ``` diff --git a/src/serverus/__init__.py b/src/serverus/__init__.py index 9327c29..648e1c7 100644 --- a/src/serverus/__init__.py +++ b/src/serverus/__init__.py @@ -5,10 +5,11 @@ from .host import Host from .proxy import RedirectProxy from .runner import Result, run_playbook from .server import Server -from .xui import XuiPanel +from .xui import CF_WARP, XuiPanel, parse_proxy from .xui_api import XuiApi, XuiApiError __all__ = [ + 'CF_WARP', 'Host', 'KeyPair', 'RedirectProxy', @@ -22,5 +23,6 @@ __all__ = [ 'generate_port', 'generate_ssh_port', 'generate_username', + 'parse_proxy', 'run_playbook', ] diff --git a/src/serverus/__main__.py b/src/serverus/__main__.py index fd302e3..2019853 100644 --- a/src/serverus/__main__.py +++ b/src/serverus/__main__.py @@ -1,7 +1,9 @@ """Ручная проверка сценариев на живом сервере. - python -m serverus xui --out + python -m serverus xui --out [--domain ] [--no-ssl] python -m serverus key add|del|stats --out + python -m serverus cert [] --out + python -m serverus outbound [] --out python -m serverus release --out Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в /access.json до начала настройки: @@ -29,24 +31,38 @@ def main(argv=None) -> int: xui.add_argument('address') xui.add_argument('password', help='текущий пароль root') 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.add_argument('action', choices=['add', 'del', 'stats']) key.add_argument('name', help='имя ключа (email клиента в панели)') 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.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui') args = parser.parse_args(argv) 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': 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) -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() panel_port = generate_port() access = { @@ -62,6 +78,8 @@ def install_xui(address: str, password: str, out: Path) -> int: 'web_base_path': generate_password(18), 'sub_port': generate_port(exclude={panel_port}), 'sub_path': generate_password(12), + 'domain': domain, + 'ssl': ssl, }, } save(out, access) @@ -81,6 +99,7 @@ def install_xui(address: str, password: str, out: Path) -> int: if not result.ok: return 1 access['api_token'] = result.data['api_token'] + access['panel_host'] = result.data['panel_host'] save(out, access) result = XuiPanel(host).create_inbound() @@ -106,7 +125,9 @@ def manage_key(action: str, name: str, out: Path) -> int: 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']}") + 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') elif action == 'del': api.delete_client(name) @@ -119,6 +140,30 @@ def manage_key(action: str, name: str, out: Path) -> int: 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: access = json.loads((out / ACCESS_FILE).read_text()) new_password = generate_password() @@ -134,7 +179,9 @@ def release_server(out: Path) -> int: def panel_url(access: dict) -> str: 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: diff --git a/src/serverus/playbooks/tasks/xui_cert.yml b/src/serverus/playbooks/tasks/xui_cert.yml new file mode 100644 index 0000000..f596248 --- /dev/null +++ b/src/serverus/playbooks/tasks/xui_cert.yml @@ -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 diff --git a/src/serverus/playbooks/tasks/xui_settings.yml b/src/serverus/playbooks/tasks/xui_settings.yml new file mode 100644 index 0000000..43af131 --- /dev/null +++ b/src/serverus/playbooks/tasks/xui_settings.yml @@ -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 diff --git a/src/serverus/playbooks/xui.yml b/src/serverus/playbooks/xui.yml index b6eff09..99054ec 100644 --- a/src/serverus/playbooks/xui.yml +++ b/src/serverus/playbooks/xui.yml @@ -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 проекта в неинтерактивном режиме. -# Сертификат: на домен, если он задан, иначе на ip. Оба варианта требуют открытый 80 порт (acme). +# Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме, SSL скрипта выключен: он при ошибке молча оставляет +# панель на http. Сертификат выпускает tasks/xui_cert.yml на домен, если он задан, иначе на ip. Нужен открытый 80 порт. +# ssl=false - без сертификата, для серверов без публичного адреса. # Системный DNS - DNS-over-TLS к Cloudflare, Xray резолвит через него. Подписка на sub_port под тем же сертификатом. - name: Install 3x-ui panel hosts: target @@ -9,6 +11,8 @@ vars: xui_bin: /usr/local/x-ui/x-ui xui_domain: "{{ domain | default('') }}" + xui_ssl: "{{ ssl | default(true) | bool }}" + cert_host: "{{ xui_domain or ansible_host }}" tasks: - ansible.builtin.import_tasks: tasks/firewall.yml vars: @@ -33,8 +37,7 @@ XUI_PASSWORD: "{{ password }}" XUI_PANEL_PORT: "{{ panel_port }}" XUI_WEB_BASE_PATH: "{{ web_base_path }}" - XUI_SSL_MODE: "{{ 'domain' if xui_domain else 'ip' }}" - XUI_DOMAIN: "{{ xui_domain }}" + XUI_SSL_MODE: none XUI_SERVER_IP: "{{ ansible_host }}" no_log: true @@ -45,6 +48,9 @@ -port {{ panel_port }} -webBasePath {{ web_base_path | quote }} no_log: true + - ansible.builtin.import_tasks: tasks/xui_cert.yml + when: xui_ssl + - name: Restart panel ansible.builtin.systemd_service: name: x-ui @@ -59,37 +65,14 @@ - 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 + - ansible.builtin.import_tasks: tasks/xui_settings.yml 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 + xui_settings_patch: + subEnable: true + subPort: "{{ sub_port | int }}" + subPath: "/{{ sub_path }}/" + subCertFile: "{{ cert_dir ~ '/fullchain.pem' if xui_ssl else '' }}" + subKeyFile: "{{ cert_dir ~ '/privkey.pem' if xui_ssl else '' }}" - name: Read Xray config ansible.builtin.uri: @@ -131,4 +114,6 @@ aggregate: false data: api_token: "{{ api_token.stdout }}" + panel_host: "{{ cert_host if xui_ssl else ansible_host }}" + ssl: "{{ xui_ssl }}" no_log: true diff --git a/src/serverus/playbooks/xui_cert.yml b/src/serverus/playbooks/xui_cert.yml new file mode 100644 index 0000000..230cbf4 --- /dev/null +++ b/src/serverus/playbooks/xui_cert.yml @@ -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 }}" diff --git a/src/serverus/playbooks/xui_outbound.yml b/src/serverus/playbooks/xui_outbound.yml new file mode 100644 index 0000000..5913179 --- /dev/null +++ b/src/serverus/playbooks/xui_outbound.yml @@ -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 diff --git a/src/serverus/tests.py b/src/serverus/tests.py index 25c4694..82b5649 100644 --- a/src/serverus/tests.py +++ b/src/serverus/tests.py @@ -5,8 +5,8 @@ import urllib.error from unittest import mock from . import ( - Host, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port, - generate_ssh_port, generate_username, run_playbook, + CF_WARP, Host, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port, + generate_ssh_port, generate_username, parse_proxy, run_playbook, ) from .credentials import SERVICE_PORT_RANGE, SSH_PORT_RANGE @@ -79,8 +79,53 @@ class XuiPanelTests(unittest.TestCase): 'sub_port': 12000, 'sub_path': 'sub', '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): runner = FakeRunner() XuiPanel(Host('1.2.3.4'), runner=runner).create_inbound() diff --git a/src/serverus/xui.py b/src/serverus/xui.py index be1a0ac..90b09c2 100644 --- a/src/serverus/xui.py +++ b/src/serverus/xui.py @@ -1,9 +1,17 @@ +import base64 import secrets +import urllib.parse + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from .runner import Result from .server import Server MAIN_INBOUND = 'main' +CF_WARP = 'cf_warp' +"""Исходящий прокси через Cloudflare WARP.""" +PROXY_SCHEMES = {'socks': 'socks', 'socks5': 'socks', 'socks5h': 'socks', 'http': 'http', 'https': 'http'} class XuiPanel(Server): @@ -11,11 +19,13 @@ class XuiPanel(Server): def install( 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: """Ставит панель, включает подписку на 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( 'xui', @@ -26,8 +36,25 @@ class XuiPanel(Server): sub_port=sub_port, sub_path=sub_path, 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: """Создает подключение vless + xhttp + reality на 443 порту, uTLS firefox, сброс трафика ежемесячно. @@ -47,3 +74,30 @@ class XuiPanel(Server): 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)] + + +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()}