From 602199d510d81dda014786a45fec60dc1605c015 Mon Sep 17 00:00:00 2001 From: protokey Date: Sat, 26 Sep 2026 14:12:01 +0400 Subject: [PATCH] =?UTF-8?q?chore:=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=20=D1=81=20=D1=81=D0=B0=D0=BC=D0=BE=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BD=D1=8B=D0=BC=D0=B8=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=82=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 11 +- README.md | 19 +++- env_example | 5 + src/App/bot/texts.py | 2 +- src/App/models.py | 11 ++ src/App/services.py | 17 ++- src/App/tests_services.py | 26 ++++- src/MyPointVPN/settings/project.py | 5 + src/serverus/README.md | 20 ++-- src/serverus/__init__.py | 6 +- src/serverus/__main__.py | 37 +++++-- src/serverus/local_ca.py | 120 ++++++++++++++++++++++ src/serverus/playbooks/bootstrap.yml | 16 ++- src/serverus/playbooks/release.yml | 19 +++- src/serverus/playbooks/tasks/dns.yml | 91 +++++++++++++++- src/serverus/playbooks/tasks/xui_api.yml | 2 +- src/serverus/playbooks/tasks/xui_cert.yml | 108 +++++++++++-------- src/serverus/playbooks/xui.yml | 9 +- src/serverus/playbooks/xui_inbound.yml | 3 +- src/serverus/server.py | 5 +- src/serverus/tests.py | 42 +++++++- src/serverus/xui.py | 26 +++-- src/serverus/xui_api.py | 32 +++--- 23 files changed, 524 insertions(+), 108 deletions(-) create mode 100644 src/serverus/local_ca.py diff --git a/Makefile b/Makefile index dc4ee71..fce6065 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,9 @@ MANAGE := cd src/ && ../$(PYTHON) manage.py SERVERUS := cd src/ && ../$(PYTHON) -m serverus # доступы к серверам, настроенным вручную через serverus (папка в .gitignore) SERVERUS_ACCESS_DIR := $(abspath extra/serverus) +# свой CA для серверов без Let's Encrypt (ca=yes), создается при первом запуске +SERVERUS_CA_DIR := $(SERVERUS_ACCESS_DIR)/ca +SERVERUS_CA = $(if $(filter yes,$(ca)),--ca $(SERVERUS_CA_DIR)) MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS)) @@ -20,9 +23,9 @@ help: @echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения" @echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)" @echo "make clean - удалить $(WHEELS_DIR)/" - @echo "make serverus_xui ip= password= [domain=] [ssl=no] - настроить свежий сервер и поставить 3x-ui, доступы в $(SERVERUS_ACCESS_DIR)//" + @echo "make serverus_xui ip= password= [domain=] [ssl=no] [ca=yes] - настроить свежий сервер и поставить 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_cert ip= [domain=] [ca=yes] - сертификат панели на домен или на ip" @echo "make serverus_outbound ip= [proxy=socks5://...|cf_warp] - исходящий прокси, без proxy - напрямую" @echo "make serverus_release ip= - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт" @@ -86,7 +89,7 @@ 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) $(if $(domain),--domain $(domain)) $(if $(filter no,$(ssl)),--no-ssl) + $(SERVERUS) xui $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(domain),--domain $(domain)) $(if $(filter no,$(ssl)),--no-ssl) $(SERVERUS_CA) serverus_key: @test -n "$(ip)" -a -n "$(action)" -a -n "$(name)" || { echo "Укажите ключ: make serverus_key ip= action=add|del|stats name="; exit 1; } @@ -94,7 +97,7 @@ serverus_key: serverus_cert: @test -n "$(ip)" || { echo "Укажите сервер: make serverus_cert ip= [domain=]"; exit 1; } - $(SERVERUS) cert $(domain) --out $(SERVERUS_ACCESS_DIR)/$(ip) + $(SERVERUS) cert $(domain) --out $(SERVERUS_ACCESS_DIR)/$(ip) $(SERVERUS_CA) serverus_outbound: @test -n "$(ip)" || { echo "Укажите сервер: make serverus_outbound ip= [proxy=socks5://...|cf_warp]"; exit 1; } diff --git a/README.md b/README.md index 52536e2..effd207 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,7 @@ id 0/0Mb On **Добавление сервера** -Сервер добавляется после установки ОС. Для бота требуется, чтобы ОС была debian-13. Свежий сервер: +Сервер добавляется после установки ОС. Для бота требуется, чтобы ОС была debian-12 или debian-13. Свежий сервер: - открыт публично 22 порт ssh - имеет ip @@ -334,3 +334,20 @@ id 0/0Mb On \# TODO: дописать документацию ### Модель данных + +Пользователи - приходят из телеграма, у них есть id, который уникален и проверен, его можно использовать для авторегистрации. И следует обязательно запоминать, так как боту недоступна информация о подключенных к нему пользователях, только в момент когда пользователь взаимодействует с ботом. + +- telegram user id - запоминаем id пользователя телеграм + +Точка - наша выдуманная абстракция, чтобы объединить в группу (кластер) 2 сервера. Домен. Прокси (или аккаунт cf_warp). + +- сервер 3x-ui - обязательно должен быть, ради него все и затевается +- сервер маскировки - если пользователь хочет особый режим, то он может добавить еще один сервер купленный в своей стране +- домен - если сервер маскировки указан, то домен обязательно нужно указать +- ssl сертификаты - если домен не указан, то сертификаты выпускаются на IP сервера 3x-ui, а сервера маскировки нет; а если домен есть то он привязывается к серверу маскировки и на него выпускаются ssl ключи, который хранятся на сервере 3x-ui, потому что сервер маскировки лишь прозрачно проксирует +- ssh ключи - доступ к серверам, находящимся под нашей ведомостью, всегда осуществляется асимметричным способом (ключ-файл) +- прокси - можно задать свои и с авторизацией, а можно воспользоваться cf_warp прокси, которые можно регистрировать автоматически через x-ui + +Сервер - реальные сервера с ip и доступом к root. Настраиваем безопасное окружение по всем канонам. + +Сервис - На серверах у нас живут сервисы. Поднимается или настраивается какой-то сервис, чтобы решить какую-то полезную задачу. И реализуют api для управления сервисом на сервере. Сейчас эту функцию выполняет Роль сервера, потому что подразумевается что сервис может быть 1. diff --git a/env_example b/env_example index 14cb084..19b0d57 100644 --- a/env_example +++ b/env_example @@ -18,3 +18,8 @@ RABBITMQ_USER= RABBITMQ_PASSWORD= # Адрес для Django и воркера dramatiq RABBITMQ_ADDRESS=127.0.0.1:5672 + +# Сертификаты панелей: letsencrypt или local (свой CA для локального стенда) +SERVERUS_CERTIFICATES=letsencrypt +# Папка своего CA, по умолчанию extra/serverus/ca +SERVERUS_CA_DIR= diff --git a/src/App/bot/texts.py b/src/App/bot/texts.py index 0886cae..4da4e45 100644 --- a/src/App/bot/texts.py +++ b/src/App/bot/texts.py @@ -47,7 +47,7 @@ FAQ_TEXT = '''Что это за бот? Бот настраивает ваш собственный VPN на вашем сервере: панель 3x-ui с подключениями vless + tls + reality. По желанию перед ней ставится сервер маскировки. Что нужно от меня? -Арендованный сервер с Debian 13 (от 2 ядер, 2 Гб памяти, 20 Гб диска): ip и пароль root. По желанию - второй такой же сервер для маскировки, домен и прокси на выходе. +Арендованный сервер с Debian 12 или 13 (от 2 ядер, 2 Гб памяти, 20 Гб диска): ip и пароль root. По желанию - второй такой же сервер для маскировки, домен и прокси на выходе. Зачем сервер маскировки? Клиенты подключаются к серверу маскировки, он пересылает трафик на панель. Адрес панели никто не видит. Для маскировки нужен домен. diff --git a/src/App/models.py b/src/App/models.py index 2da4ae8..938ad5e 100644 --- a/src/App/models.py +++ b/src/App/models.py @@ -167,6 +167,17 @@ class Point(models.Model): def is_busy(self) -> bool: return self.status in (self.Status.INSTALLING, self.Status.RELEASING) + @property + def panel_host(self) -> str: + """Адрес панели для https и подписки: домен, без него - ip панели. По ssh ходим только по ip.""" + return self.domain or self.panel.address + + def panel_url(self) -> str: + return f'https://{self.panel_host}:{self.panel_port}/{self.panel_path}' + + def subscription_url(self, sub_id: str) -> str: + return f'https://{self.panel_host}:{self.sub_port}/{self.sub_path}/{sub_id}' + def set_status(self, status: str, error: str = ''): self.status = status self.error = error diff --git a/src/App/services.py b/src/App/services.py index 95a9a58..cdc1d8c 100644 --- a/src/App/services.py +++ b/src/App/services.py @@ -2,6 +2,7 @@ from contextlib import contextmanager import serverus +from django.conf import settings from .models import Point, Server @@ -63,7 +64,7 @@ def set_domain(point: Point, domain: str): if not domain and point.mask: raise ValueError('маскировка работает только с доменом') with _installing(point): - _check(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain)) + _check(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain, local_ca())) point.domain = domain point.save(update_fields=['domain']) @@ -78,6 +79,19 @@ def set_outbound_proxy(point: Point, proxy: str): point.save(update_fields=['outbound_proxy']) +def local_ca() -> serverus.LocalCA | None: + """Свой CA, если панели работают не с Let's Encrypt (SERVERUS_CERTIFICATES=local).""" + if settings.SERVERUS_CERTIFICATES == 'local': + return serverus.LocalCA(settings.SERVERUS_CA_DIR) + return None + + +def xui_api(point: Point) -> serverus.XuiApi: + """API панели точки: по домену, без него - по ip панели.""" + ca = local_ca() + return serverus.XuiApi(point.panel_url(), point.panel_api_token, ca_file=str(ca.cert_path) if ca else None) + + def provision(server: Server): """Базовая настройка сервера и специализация по его роли.""" _bootstrap(server) @@ -114,6 +128,7 @@ def _install_panel(server: Server): sub_port=point.sub_port, sub_path=point.sub_path, domain=point.domain, + ca=local_ca(), )) point.panel_api_token = result.data.get('api_token', '') point.save(update_fields=['panel_api_token']) diff --git a/src/App/tests_services.py b/src/App/tests_services.py index 66fccb8..4b3825a 100644 --- a/src/App/tests_services.py +++ b/src/App/tests_services.py @@ -1,7 +1,8 @@ +import tempfile from unittest import mock import serverus -from django.test import TestCase +from django.test import TestCase, override_settings from Telegram.models import User as TelegramUser from . import services @@ -161,6 +162,29 @@ class InstallTests(TestCase): self.assertEqual(Server.objects.get(pk=panel.pk).private_key, private_key) + def test_install_with_local_ca(self): + point = create_point(mask=None) + runner = FakeRunner() + with tempfile.TemporaryDirectory() as directory, patch_runner(runner): + with override_settings(SERVERUS_CERTIFICATES='local', SERVERUS_CA_DIR=directory): + services.install_point(point) + api = services.xui_api(point) + self.assertIn('BEGIN CERTIFICATE', runner.call('xui', '10.0.0.1')[1]['cert_fullchain']) + self.assertIsNotNone(api.context) + + def test_letsencrypt_by_default(self): + point = create_point(mask=None) + runner = FakeRunner() + with patch_runner(runner): + services.install_point(point) + self.assertNotIn('cert_fullchain', runner.call('xui', '10.0.0.1')[1]) + + def test_panel_urls_use_domain_or_panel_ip(self): + point = create_point(mask=None) + self.assertTrue(point.panel_url().startswith(f'https://10.0.0.1:{point.panel_port}/')) + point.domain = 'example.com' + self.assertEqual(point.subscription_url('abc'), f'https://example.com:{point.sub_port}/{point.sub_path}/abc') + def test_install_sets_outbound_proxy(self): point = create_point(mask=None, outbound_proxy=Point.CF_WARP) runner = FakeRunner() diff --git a/src/MyPointVPN/settings/project.py b/src/MyPointVPN/settings/project.py index bcd4cb9..c1fa5e0 100644 --- a/src/MyPointVPN/settings/project.py +++ b/src/MyPointVPN/settings/project.py @@ -25,6 +25,11 @@ INSTALLED_APPS += [ # Свой Bot API сервер (сервис telegram в docker-compose.yaml). Пусто - api.telegram.org TELEGRAM_API_URL = os.environ.get('TELEGRAM_API_URL', '') +# Сертификаты панелей: letsencrypt или local - свой CA, для серверов без публичного адреса (локальный стенд). +# SERVERUS_CA_DIR - папка CA, он создается при первом выпуске. ca.pem нужно добавить в доверенные на клиентах. +SERVERUS_CERTIFICATES = os.environ.get('SERVERUS_CERTIFICATES', 'letsencrypt') +SERVERUS_CA_DIR = os.environ.get('SERVERUS_CA_DIR', str(BASE_DIR.parent / 'extra/serverus/ca')) + # Фоновые задачи: dramatiq поверх rabbitmq. Воркер: make dj_worker RABBITMQ_URL = 'amqp://{user}:{password}@{address}/{vhost}'.format( user=quote(os.environ.get('RABBITMQ_USER', 'guest'), safe=''), diff --git a/src/serverus/README.md b/src/serverus/README.md index 432dc6c..e0310e3 100644 --- a/src/serverus/README.md +++ b/src/serverus/README.md @@ -27,7 +27,7 @@ - 2 ядра процессора - 20Gb диска -Делается под Debian 13. +Делается под Debian 13, Debian 12 тоже поддерживается. ## Использование @@ -53,14 +53,14 @@ server.release(generate_password()) Каждая операция возвращает `Result(ok, status, data, error)`. Ключи, пароли и артефакты ansible живут во временной папке только на время запуска. Хранить ключи и пароли - задача вызывающей стороны. -- `bootstrap` - проверяет Debian 13, прописывает ключ, меняет пароль root, обновляет систему, включает nftables и переносит ssh на новый порт с входом только по ключу. Порт меняется в два шага с проверкой входа по ключу, чтобы не потерять доступ. +- `bootstrap` - проверяет Debian 12 или 13, прописывает ключ, меняет пароль root, обновляет систему (если что-то обновилось - перезагружает в конце), включает nftables и переносит ssh на новый порт с входом только по ключу. Порт меняется в два шага с проверкой входа по ключу, чтобы не потерять доступ. - `release` - ставит новый пароль root, сбрасывает nftables, убирает DNS-over-TLS, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys. - `facts` - `uname -a` и версия ОС. Специализации - наследники `Server`: -- `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).install(username, password, panel_port, web_base_path, sub_port, sub_path, domain='', ssl=True, ca=None)` - ставит 3x-ui версии `XUI_VERSION` (сейчас v3.8.5, API проверено на ней, обновлять вручную) скриптом из README проекта того же тега в неинтерактивном режиме. Сертификат на домен или, без него, на ip: от своего CA (`ca=LocalCA(...)`), иначе Let's Encrypt через acme.sh (HTTP-01 на 80 порту; на ip короткий, ~6 дней, продлевается по cron). Не выпустился - ошибка, панель на http не остается. `ssl=False` - без сертификата. Включает подписку на `sub_port` по пути `/sub_path/` под тем же сертификатом. Системный DNS - шифрованный до Cloudflare двумя путями: DNS-over-TLS в systemd-resolved (строгий режим) и запасной DNS-over-HTTPS через dnscrypt-proxy на случай, если 853 порт режут. resolved спрашивает оба и берет первый ответ, открытым текстом DNS не уходит. DNS в Xray - через системный резолвер: сам Xray DoT не умеет. Открывает 80, 443, порт панели и порт подписки. Возвращает `data`: `api_token`, `panel_host` (домен или ip сертификата), `ssl`. +- `XuiPanel(host).issue_certificate(domain='', ca=None)` - сертификат панели и подписки на домен или на 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, ports)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh `, который через nftables перенаправляет tcp-порты `ports` на те же порты target_ip с masquerade и MSS clamping. Точка передает 80, 443, порт панели и порт подписки; ssh остается на самом сервере, скрипт откажется перенаправлять его порт. Правила - `/etc/nftables.d/serverus-redirect.nft`, подключаются include из `/etc/nftables.conf`. Заодно включает bbr, fq и увеличенные буферы и таблицу conntrack. @@ -69,6 +69,10 @@ server.release(generate_password()) Плейбуки ходят в API панели с самого сервера на `127.0.0.1` со своим токеном `serverus`, который перевыпускается на каждом запуске. Токен `api_token`, отданный вызывающей стороне, при этом не меняется. +## Свой CA + +`LocalCA(directory)` - CA для серверов без Let's Encrypt, например на локальном стенде. Создается в `directory` при первом обращении, сертификаты на 100 лет. `ca.pem` нужно добавить в доверенные на клиентах, которые ходят в панель и подписку; `XuiApi(..., ca_file=...)` проверяет панель по нему. iOS и macOS не принимают сертификаты сервера длиннее 825 дней даже от своего CA. + ## Ключи `XuiApi(url, api_token)` - выпуск, отзыв и статистика ключей через HTTP API панели, без ssh. `url` - `https://<домен или ip>:/`, сертификат панели проверяется. Ошибки - `XuiApiError`. @@ -76,6 +80,7 @@ server.release(generate_password()) ```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.add_client(inbound_id, 'user-1', uuid=uuid, sub_id=sub_id) # восстановить после переустановки api.get_links('user-1') # ['vless://...'] api.get_traffic('user-1') # up, down, total, expiryTime, lastOnline api.delete_client('user-1') @@ -96,9 +101,9 @@ api.delete_client('user-1') ## Ручная проверка на живом сервере ``` -make serverus_xui ip= password= [domain=] [ssl=no] # bootstrap + 3x-ui + подключение reality +make serverus_xui ip= password= [domain=] [ssl=no] [ca=yes] # bootstrap + 3x-ui + подключение reality make serverus_key ip= action=add|del|stats name= # ключ на панели -make serverus_cert ip= [domain=] # сертификат на домен или на ip +make serverus_cert ip= [domain=] [ca=yes] # сертификат на домен или на ip make serverus_outbound ip= [proxy=socks5://...|cf_warp] # исходящий прокси, без proxy - напрямую make serverus_release ip= # вернуть сервер ``` @@ -125,5 +130,6 @@ make serverus_release ip= # вернуть се - подобрать цель - сгенерировать ключи и сертификаты - [x] нужен интерфейс выпуска, отзыва и получения статистики ключей -- [ ] проверить `xui` и `xui_inbound` на живом сервере: API панели проверено только по исходникам 3x-ui v3.8.5 +- [x] проверить `xui` и `xui_inbound` на живом сервере (Debian 13, свой CA): установка, DoT и запасной DoH, ключи, подписка, socks5 и WARP, отказ нерабочего прокси +- [ ] проверить `release` и сертификат Let's Encrypt на сервере с публичным адресом - [ ] выпуск и отзыв ключей в боте поверх `XuiApi` diff --git a/src/serverus/__init__.py b/src/serverus/__init__.py index 648e1c7..88ab118 100644 --- a/src/serverus/__init__.py +++ b/src/serverus/__init__.py @@ -2,19 +2,23 @@ from .credentials import ( KeyPair, generate_keypair, generate_password, generate_port, generate_ssh_port, generate_username, ) from .host import Host +from .local_ca import Certificate, LocalCA from .proxy import RedirectProxy from .runner import Result, run_playbook from .server import Server -from .xui import CF_WARP, XuiPanel, parse_proxy +from .xui import CF_WARP, XUI_VERSION, XuiPanel, parse_proxy from .xui_api import XuiApi, XuiApiError __all__ = [ 'CF_WARP', + 'Certificate', 'Host', 'KeyPair', + 'LocalCA', 'RedirectProxy', 'Result', 'Server', + 'XUI_VERSION', 'XuiApi', 'XuiApiError', 'XuiPanel', diff --git a/src/serverus/__main__.py b/src/serverus/__main__.py index 2019853..746c165 100644 --- a/src/serverus/__main__.py +++ b/src/serverus/__main__.py @@ -1,8 +1,8 @@ """Ручная проверка сценариев на живом сервере. - python -m serverus xui --out [--domain ] [--no-ssl] + python -m serverus xui --out [--domain ] [--no-ssl] [--ca ] python -m serverus key add|del|stats --out - python -m serverus cert [] --out + python -m serverus cert [] --out [--ca ] python -m serverus outbound [] --out python -m serverus release --out @@ -15,7 +15,7 @@ import sys from pathlib import Path from . import ( - Host, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port, + Host, LocalCA, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port, generate_ssh_port, generate_username, ) @@ -33,6 +33,7 @@ def main(argv=None) -> int: 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: для серверов без публичного адреса') + xui.add_argument('--ca', type=Path, help='папка своего CA вместо Let\'s Encrypt, CA создается, если его там нет') key = commands.add_parser('key', help='выпуск, отзыв и статистика ключа на панели от команды xui') key.add_argument('action', choices=['add', 'del', 'stats']) @@ -42,6 +43,7 @@ def main(argv=None) -> int: cert = commands.add_parser('cert', help='сертификат панели на домен или, без домена, на ip') cert.add_argument('domain', nargs='?', default='') cert.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui') + cert.add_argument('--ca', type=Path, help='папка своего CA вместо Let\'s Encrypt') outbound = commands.add_parser('outbound', help='исходящий прокси: socks5://..., http(s)://..., cf_warp') outbound.add_argument('proxy', nargs='?', default='', help='без него трафик идет напрямую') @@ -52,17 +54,24 @@ def main(argv=None) -> int: args = parser.parse_args(argv) if args.command == 'xui': - return install_xui(args.address, args.password, args.out, args.domain, not args.no_ssl) + return install_xui(args.address, args.password, args.out, args.domain, not args.no_ssl, args.ca) if args.command == 'key': return manage_key(args.action, args.name, args.out) if args.command == 'cert': - return issue_certificate(args.domain, args.out) + return issue_certificate(args.domain, args.out, args.ca) 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, domain: str, ssl: bool) -> int: +def install_xui(address: str, password: str, out: Path, domain: str, ssl: bool, ca_dir: Path | None) -> int: + access_file = out / ACCESS_FILE + if access_file.exists(): + access = json.loads(access_file.read_text()) + if access.get('bootstrapped'): + # повторный запуск после ошибки установки: сервер уже на ключе и новом порту + print('bootstrap: skipped, already done') + return install_panel(access, out, ca_dir) keys = generate_keypair() panel_port = generate_port() access = { @@ -88,13 +97,18 @@ def install_xui(address: str, password: str, out: Path, domain: str, ssl: bool) password, keys.public_key, access['root_password'], access['ssh_port'], ) access['known_hosts'] = result.known_hosts + access['bootstrapped'] = result.ok save(out, access) report('bootstrap', result) if not result.ok: return 1 + return install_panel(access, out, ca_dir) + +def install_panel(access: dict, out: Path, ca_dir: Path | None) -> int: host = load_host(access) - result = XuiPanel(host).install(**access['panel']) + access['ca_file'] = str(LocalCA(ca_dir).cert_path) if ca_dir else '' + result = XuiPanel(host).install(**access['panel'], ca=LocalCA(ca_dir) if ca_dir else None) report('xui', result) if not result.ok: return 1 @@ -114,13 +128,13 @@ def install_xui(address: str, password: str, out: Path, domain: str, ssl: bool) print(f"panel: {panel_url(access)}/") 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@{access['address']}") 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']) + api = XuiApi(panel_url(access), access['api_token'], ca_file=access.get('ca_file') or None) try: if action == 'add': client = api.add_client(access['inbound']['inbound_id'], name) @@ -140,13 +154,14 @@ def manage_key(action: str, name: str, out: Path) -> int: return 0 -def issue_certificate(domain: str, out: Path) -> int: +def issue_certificate(domain: str, out: Path, ca_dir: Path | None) -> int: access = json.loads((out / ACCESS_FILE).read_text()) - result = XuiPanel(load_host(access)).issue_certificate(domain) + result = XuiPanel(load_host(access)).issue_certificate(domain, LocalCA(ca_dir) if ca_dir else None) report('cert', result) if not result.ok: return 1 access['panel'].update(domain=domain, ssl=True) + access['ca_file'] = str(LocalCA(ca_dir).cert_path) if ca_dir else '' access['panel_host'] = result.data['panel_host'] save(out, access) print(f"panel: {panel_url(access)}/") diff --git a/src/serverus/local_ca.py b/src/serverus/local_ca.py new file mode 100644 index 0000000..a5c7a73 --- /dev/null +++ b/src/serverus/local_ca.py @@ -0,0 +1,120 @@ +import datetime +import ipaddress +from dataclasses import dataclass +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +VALIDITY = datetime.timedelta(days=365 * 100) +CA_CERT_FILE = 'ca.pem' +CA_KEY_FILE = 'ca.key' + + +@dataclass(frozen=True) +class Certificate: + fullchain: str + """Сертификат сервера и следом сертификат CA, PEM.""" + private_key: str + + def __repr__(self): + # ключ не должен попадать в логи + return 'Certificate(...)' + + +class LocalCA: + """Свой CA для серверов без Let's Encrypt, например в локальной сети. Сертификаты на 100 лет. + + CA создается при первом обращении в directory и дальше переиспользуется: ca.pem нужно добавить в доверенные + на клиентах, которые ходят в панель и подписку. Учти: iOS и macOS не принимают сертификаты сервера + длиннее 825 дней даже от своего CA. + """ + + def __init__(self, directory: Path | str): + self.directory = Path(directory) + self.cert_path = self.directory / CA_CERT_FILE + key_path = self.directory / CA_KEY_FILE + if not self.cert_path.exists(): + self._create(key_path) + self._cert = x509.load_pem_x509_certificate(self.cert_path.read_bytes()) + self._key = serialization.load_pem_private_key(key_path.read_bytes(), password=None) + + def __repr__(self): + return f'LocalCA({self.directory})' + + @property + def cert_pem(self) -> str: + return self.cert_path.read_text() + + def issue(self, host: str) -> Certificate: + """Сертификат на домен или ip.""" + key = ec.generate_private_key(ec.SECP256R1()) + now = _now() + try: + name = x509.IPAddress(ipaddress.ip_address(host)) + except ValueError: + name = x509.DNSName(host) + cert = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, host)])) + .issuer_name(self._cert.subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + VALIDITY) + .add_extension(x509.SubjectAlternativeName([name]), critical=False) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(self._key.public_key()), critical=False, + ) + .sign(self._key, hashes.SHA256()) + ) + return Certificate( + fullchain=_pem(cert) + self.cert_pem, + private_key=key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), + ).decode(), + ) + + def _create(self, key_path: Path): + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'serverus local CA')]) + now = _now() + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + VALIDITY) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, key_cert_sign=True, crl_sign=True, content_commitment=False, + key_encipherment=False, data_encipherment=False, key_agreement=False, encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) + .sign(key, hashes.SHA256()) + ) + self.directory.mkdir(parents=True, exist_ok=True) + key_path.touch(mode=0o600, exist_ok=True) + key_path.write_bytes(key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), + )) + self.cert_path.write_text(_pem(cert)) + + +def _pem(cert: x509.Certificate) -> str: + return cert.public_bytes(serialization.Encoding.PEM).decode() + + +def _now() -> datetime.datetime: + # запас на расхождение часов + return datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=1) diff --git a/src/serverus/playbooks/bootstrap.yml b/src/serverus/playbooks/bootstrap.yml index 1340e4e..28a112e 100644 --- a/src/serverus/playbooks/bootstrap.yml +++ b/src/serverus/playbooks/bootstrap.yml @@ -2,6 +2,8 @@ # # Порт ssh меняется в два шага, чтобы не потерять сервер: # сначала sshd слушает старый и новый порт, после проверки входа по ключу на новом порту старый закрывается. +# Если обновление системы что-то поставило, в конце сервер перезагружается: подхватывает новое ядро и библиотеки, +# заодно проверяется, что ssh и nftables переживают перезагрузку. В контейнере перезагрузки нет. - name: Prepare fresh server (password login) hosts: target @@ -9,12 +11,12 @@ vars: ansible_password: "{{ login_password }}" tasks: - - name: Require Debian 13 + - name: Require Debian 12 or 13 ansible.builtin.assert: that: - ansible_facts.distribution == 'Debian' - - ansible_facts.distribution_major_version == '13' - fail_msg: "Supported only Debian 13, got {{ ansible_facts.distribution }} {{ ansible_facts.distribution_version }}" + - ansible_facts.distribution_major_version in ['12', '13'] + fail_msg: "Supported only Debian 12 and 13, got {{ ansible_facts.distribution }} {{ ansible_facts.distribution_version }}" - name: Create .ssh directory ansible.builtin.file: @@ -35,6 +37,7 @@ ansible.builtin.apt: update_cache: true upgrade: dist + register: system_upgrade - ansible.builtin.import_tasks: tasks/firewall.yml vars: @@ -70,3 +73,10 @@ - name: Check hardened ssh ansible.builtin.wait_for_connection: timeout: 60 + + - name: Reboot after upgrade + ansible.builtin.reboot: + reboot_timeout: 600 + when: + - system_upgrade is changed + - ansible_facts.virtualization_type not in ['docker', 'podman', 'container', 'containerd'] diff --git a/src/serverus/playbooks/release.yml b/src/serverus/playbooks/release.yml index ee5e062..956926b 100644 --- a/src/serverus/playbooks/release.yml +++ b/src/serverus/playbooks/release.yml @@ -33,10 +33,25 @@ ansible.builtin.command: sysctl --system changed_when: false - - name: Remove DNS over TLS config + - name: Remove encrypted DNS config ansible.builtin.file: - path: /etc/systemd/resolved.conf.d/00-serverus.conf + path: "{{ item }}" state: absent + loop: + - /etc/systemd/resolved.conf.d/00-serverus.conf + - /etc/systemd/network/90-serverus-dns.netdev + - /etc/systemd/network/90-serverus-dns.network + + - name: Remove DNS fallback interface + ansible.builtin.command: ip link delete serverus-dns + register: dns_link + changed_when: dns_link.rc == 0 + failed_when: false + + - name: Stop dnscrypt-proxy + ansible.builtin.command: systemctl disable --now dnscrypt-proxy.socket dnscrypt-proxy.service + changed_when: false + failed_when: false # systemd-resolved остается: без него /etc/resolv.conf указывает в никуда - name: Restart systemd-resolved diff --git a/src/serverus/playbooks/tasks/dns.yml b/src/serverus/playbooks/tasks/dns.yml index 4565ff5..f93a46b 100644 --- a/src/serverus/playbooks/tasks/dns.yml +++ b/src/serverus/playbooks/tasks/dns.yml @@ -1,10 +1,74 @@ -# Системный резолвер: systemd-resolved, только DNS-over-TLS к Cloudflare. -# Строгий режим: без TLS запрос не уйдет, открытым текстом DNS не утекает. Резервных серверов нет. -- name: Install systemd-resolved +# Системный резолвер: systemd-resolved с двумя путями до Cloudflare, оба шифрованные. +# +# Основной - DNS-over-TLS (853), строгий режим: без TLS запрос не уйдет, открытым текстом DNS не утекает. +# Запасной - dnscrypt-proxy с DNS-over-HTTPS (443) на 127.0.2.1, если 853 режут. resolved спрашивает оба пути +# параллельно и берет первый успешный ответ. Режим TLS в resolved один на область, поэтому запасной путь живет +# на отдельном dummy интерфейсе serverus-dns со своими настройками. Резервных серверов в открытом виде нет. +# Адрес интерфейса из TEST-NET (192.0.2.0/24) с глобальной областью: интерфейс только с link-local адресом +# resolved для DNS не использует. Проверено: при закрытом 853 ответ приходит через DoH за доли секунды. +- name: Install DNS packages ansible.builtin.apt: - name: systemd-resolved + name: [ systemd-resolved, dnscrypt-proxy, bind9-dnsutils ] state: present +- name: Configure dnscrypt-proxy + ansible.builtin.copy: + dest: /etc/dnscrypt-proxy/dnscrypt-proxy.toml + mode: "0644" + content: | + # слушает сокет dnscrypt-proxy.socket (127.0.2.1:53), списки серверов не качает + listen_addresses = [] + server_names = ['cloudflare-1', 'cloudflare-2'] + ipv4_servers = true + ipv6_servers = false + doh_servers = true + dnscrypt_servers = false + require_dnssec = false + require_nolog = false + require_nofilter = false + cache = true + + [static] + [static.'cloudflare-1'] + stamp = 'sdns://AgMAAAAAAAAABzEuMS4xLjEAEmNsb3VkZmxhcmUtZG5zLmNvbQovZG5zLXF1ZXJ5' + [static.'cloudflare-2'] + stamp = 'sdns://AgMAAAAAAAAABzEuMC4wLjEAEmNsb3VkZmxhcmUtZG5zLmNvbQovZG5zLXF1ZXJ5' + register: dnscrypt_config + +- name: Restart dnscrypt-proxy + ansible.builtin.systemd_service: + name: dnscrypt-proxy + enabled: true + state: restarted + when: dnscrypt_config is changed + +- name: Create dummy interface for DNS fallback + ansible.builtin.copy: + dest: /etc/systemd/network/90-serverus-dns.netdev + mode: "0644" + content: | + [NetDev] + Name=serverus-dns + Kind=dummy + +- name: Route fallback DNS through dnscrypt-proxy + ansible.builtin.copy: + dest: /etc/systemd/network/90-serverus-dns.network + mode: "0644" + content: | + [Match] + Name=serverus-dns + + [Network] + Address=192.0.2.53/32 + LinkLocalAddressing=no + ConfigureWithoutCarrier=yes + DNS=127.0.2.1 + DNSOverTLS=no + DNSSEC=no + Domains=~. + DNSDefaultRoute=yes + - name: Create resolved drop-in directory ansible.builtin.file: path: /etc/systemd/resolved.conf.d @@ -21,6 +85,8 @@ FallbackDNS= DNSOverTLS=yes Domains=~. + LLMNR=no + MulticastDNS=no - name: Use resolved stub ansible.builtin.file: @@ -29,16 +95,31 @@ state: link force: true +# networkd управляет только serverus-dns: остальные интерфейсы без .network файлов он не трогает +- name: Start systemd-networkd + ansible.builtin.systemd_service: + name: systemd-networkd + enabled: true + state: restarted + - name: Restart systemd-resolved ansible.builtin.systemd_service: name: systemd-resolved enabled: true state: restarted -- name: Check DNS over TLS +- name: Check DNS ansible.builtin.command: resolvectl query deb.debian.org register: dns_check retries: 5 delay: 3 until: dns_check.rc == 0 changed_when: false + +- name: Check DNS over HTTPS fallback + ansible.builtin.command: dig +short +time=5 @127.0.2.1 deb.debian.org + register: doh_check + retries: 5 + delay: 3 + until: doh_check.rc == 0 and doh_check.stdout != '' + changed_when: false diff --git a/src/serverus/playbooks/tasks/xui_api.yml b/src/serverus/playbooks/tasks/xui_api.yml index 6d9280c..f854469 100644 --- a/src/serverus/playbooks/tasks/xui_api.yml +++ b/src/serverus/playbooks/tasks/xui_api.yml @@ -10,7 +10,7 @@ no_log: true - name: Read panel address - ansible.builtin.command: "{{ xui_bin }} setting -show true" + ansible.builtin.command: "{{ xui_bin }} setting -show" register: xui_panel_settings changed_when: false no_log: true diff --git a/src/serverus/playbooks/tasks/xui_cert.yml b/src/serverus/playbooks/tasks/xui_cert.yml index f596248..048448e 100644 --- a/src/serverus/playbooks/tasks/xui_cert.yml +++ b/src/serverus/playbooks/tasks/xui_cert.yml @@ -1,57 +1,83 @@ -# vars: cert_host - домен или ip, на который выпускается сертификат +# vars: cert_host - домен или ip, на который выпускается сертификат; +# cert_fullchain, cert_private_key (optional) - готовый сертификат, например от своего CA # -# Сертификат Let's Encrypt через acme.sh в режиме standalone: проверка HTTP-01 на 80 порту, он должен быть открыт. -# На ip выдается только короткий сертификат (~6 дней), acme.sh продлевает его по cron и перезапускает панель. -# Ставит сертификат панели и подписке. Если выпустить не удалось, падает, а не оставляет панель на http. +# Без готового сертификата - 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 }}" + cert_given: "{{ cert_fullchain | default('') != '' }}" 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: Upload certificate + when: cert_given + ansible.builtin.copy: + dest: "{{ cert_dir }}/{{ item.name }}" + content: "{{ item.content }}" + mode: "{{ item.mode }}" + loop: + - { name: fullchain.pem, content: "{{ cert_fullchain | default('') }}", mode: "0644" } + - { name: privkey.pem, content: "{{ cert_private_key | default('') }}", mode: "0600" } + loop_control: + label: "{{ item.name }}" + no_log: true + +# иначе cron acme.sh перезапишет загруженный сертификат своим при продлении +- name: Stop Let's Encrypt renewal + when: cert_given and acme is file + ansible.builtin.command: "{{ acme }} --remove -d {{ cert_host }}" + register: acme_remove + changed_when: acme_remove.rc == 0 + failed_when: false + +- name: Issue Let's Encrypt certificate + when: not cert_given + block: + - 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: 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" diff --git a/src/serverus/playbooks/xui.yml b/src/serverus/playbooks/xui.yml index 99054ec..dd6d73c 100644 --- a/src/serverus/playbooks/xui.yml +++ b/src/serverus/playbooks/xui.yml @@ -1,6 +1,7 @@ -# extravars: username, password, panel_port, web_base_path, sub_port, sub_path, domain (optional), ssl (optional), -# version (optional) +# extravars: username, password, panel_port, web_base_path, sub_port, sub_path, version, domain (optional), +# ssl (optional), cert_fullchain и cert_private_key (optional, готовый сертификат вместо Let's Encrypt) # +# version - тег релиза 3x-ui: скрипт установки берется из того же тега, панель ставится этой версии. # Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме, SSL скрипта выключен: он при ошибке молча оставляет # панель на http. Сертификат выпускает tasks/xui_cert.yml на домен, если он задан, иначе на ip. Нужен открытый 80 порт. # ssl=false - без сертификата, для серверов без публичного адреса. @@ -22,14 +23,14 @@ - name: Download install script ansible.builtin.get_url: - url: https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh + url: "https://raw.githubusercontent.com/mhsanaei/3x-ui/{{ version }}/install.sh" dest: /root/3x-ui-install.sh mode: "0700" force: true - name: Run install script ansible.builtin.command: - cmd: "bash /root/3x-ui-install.sh {{ version | default('') }}" + cmd: "bash /root/3x-ui-install.sh {{ version }}" creates: "{{ xui_bin }}" environment: XUI_NONINTERACTIVE: "1" diff --git a/src/serverus/playbooks/xui_inbound.yml b/src/serverus/playbooks/xui_inbound.yml index cdf964b..b51a6c5 100644 --- a/src/serverus/playbooks/xui_inbound.yml +++ b/src/serverus/playbooks/xui_inbound.yml @@ -122,7 +122,8 @@ - name: Return inbound vars: - stream: "{{ inbound.streamSettings | from_json }}" + # список отдает строку JSON, а ответ на добавление - уже объект + stream: "{{ inbound.streamSettings if inbound.streamSettings is mapping else inbound.streamSettings | from_json }}" ansible.builtin.set_stats: aggregate: false data: diff --git a/src/serverus/server.py b/src/serverus/server.py index 208a193..206e69e 100644 --- a/src/serverus/server.py +++ b/src/serverus/server.py @@ -22,10 +22,11 @@ class Server: return self.run('facts') def bootstrap(self, password: str, public_key: str, new_password: str, ssh_port: int) -> Result: - """Первичная настройка свежего сервера (Debian 13). + """Первичная настройка свежего сервера (Debian 12 или 13). Входит по паролю на host.port, прописывает public_key, меняет пароль root на new_password, обновляет систему, - переносит ssh на ssh_port с входом только по ключу и включает nftables. + переносит ssh на ssh_port с входом только по ключу и включает nftables. Если обновление что-то поставило, + в конце перезагружает сервер. Для проверки входа по ключу host.private_key должен быть парой к public_key. """ return self.run( diff --git a/src/serverus/tests.py b/src/serverus/tests.py index 82b5649..908cd5c 100644 --- a/src/serverus/tests.py +++ b/src/serverus/tests.py @@ -1,11 +1,13 @@ import io import json +import ssl +import tempfile import unittest import urllib.error from unittest import mock from . import ( - CF_WARP, Host, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port, + CF_WARP, XUI_VERSION, Host, LocalCA, 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 @@ -78,10 +80,25 @@ class XuiPanelTests(unittest.TestCase): 'web_base_path': 'base', 'sub_port': 12000, 'sub_path': 'sub', + 'version': XUI_VERSION, 'domain': 'example.com', 'ssl': True, }) + def test_install_with_local_ca(self): + runner = FakeRunner() + with tempfile.TemporaryDirectory() as directory: + XuiPanel(Host('1.2.3.4'), runner=runner).install('u', 'p', 1, 'b', 2, 's', ca=LocalCA(directory)) + extravars = runner.calls[0][2] + self.assertIn('BEGIN CERTIFICATE', extravars['cert_fullchain']) + self.assertIn('PRIVATE KEY', extravars['cert_private_key']) + + def test_install_without_ssl_skips_certificate(self): + runner = FakeRunner() + with tempfile.TemporaryDirectory() as directory: + XuiPanel(Host('1.2.3.4'), runner=runner).install('u', 'p', 1, 'b', 2, 's', ssl=False, ca=LocalCA(directory)) + self.assertNotIn('cert_fullchain', runner.calls[0][2]) + def test_issue_certificate(self): runner = FakeRunner() XuiPanel(Host('1.2.3.4'), runner=runner).issue_certificate('example.com') @@ -137,6 +154,23 @@ class XuiPanelTests(unittest.TestCase): int(short_id, 16) +class LocalCATests(unittest.TestCase): + def test_ca_is_created_once_and_verifies_certificates(self): + from cryptography import x509 + with tempfile.TemporaryDirectory() as directory: + ca = LocalCA(directory) + self.assertEqual(LocalCA(directory).cert_pem, ca.cert_pem) + for host in ('panel.local', '192.168.1.10'): + certificate = ca.issue(host) + leaf = x509.load_pem_x509_certificate(certificate.fullchain.encode()) + leaf.verify_directly_issued_by(x509.load_pem_x509_certificate(ca.cert_pem.encode())) + self.assertGreater((leaf.not_valid_after_utc - leaf.not_valid_before_utc).days, 365 * 99) + san = leaf.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + self.assertIn(host, [str(name) for name in san.get_values_for_type(x509.GeneralName)]) + self.assertNotIn('KEY', repr(certificate)) + ssl.create_default_context(cafile=str(ca.cert_path)) + + class FakeResponse(io.BytesIO): def __init__(self, payload): super().__init__(json.dumps(payload).encode()) @@ -170,6 +204,12 @@ class XuiApiTests(unittest.TestCase): 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_add_client_restores_uuid_and_sub_id(self): + self.responses = [{'success': True}, {'success': True, 'obj': {'client': {}}}] + self.api.add_client(3, 'user', uuid='uuid-1', sub_id='sub-1') + client = json.loads(self.requests[0].data)['client'] + self.assertEqual((client['id'], client['subId']), ('uuid-1', 'sub-1')) + def test_delete_client(self): self.responses = [{'success': True, 'obj': None}] self.api.delete_client('user') diff --git a/src/serverus/xui.py b/src/serverus/xui.py index 90b09c2..0c583c8 100644 --- a/src/serverus/xui.py +++ b/src/serverus/xui.py @@ -5,9 +5,12 @@ import urllib.parse from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from .local_ca import LocalCA from .runner import Result from .server import Server +XUI_VERSION = 'v3.8.5' +"""Версия 3x-ui. API панели проверено на ней, обновлять вручную после проверки.""" MAIN_INBOUND = 'main' CF_WARP = 'cf_warp' """Исходящий прокси через Cloudflare WARP.""" @@ -19,12 +22,12 @@ 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 = '', ssl: bool = True, + domain: str = '', ssl: bool = True, ca: LocalCA | None = None, ) -> Result: - """Ставит панель, включает подписку на sub_port по пути /sub_path/ и DNS-over-TLS к Cloudflare. + """Ставит панель версии XUI_VERSION, включает подписку на sub_port по пути /sub_path/ и DNS-over-TLS к Cloudflare. - Сертификат Let's Encrypt на domain, без него - на ip; не удалось выпустить - ошибка. ssl=False - панель на http, - для серверов без публичного адреса. Открывает порты 80 (acme), 443, panel_port и sub_port. + Сертификат на domain, без него - на ip: от ca, если он задан, иначе Let's Encrypt; не удалось выпустить - ошибка. + ssl=False - панель на http. Открывает порты 80 (acme), 443, panel_port и sub_port. Возвращает data: {'api_token', 'panel_host' (домен или ip из сертификата), 'ssl'}. """ return self.run( @@ -35,16 +38,25 @@ class XuiPanel(Server): web_base_path=web_base_path, sub_port=sub_port, sub_path=sub_path, + version=XUI_VERSION, domain=domain, ssl=ssl, + **(self._certificate(domain, ca) if ssl else {}), ) - def issue_certificate(self, domain: str = '') -> Result: + def issue_certificate(self, domain: str = '', ca: LocalCA | None = None) -> Result: """Выпускает сертификат панели и подписки на domain, без него - на ip. Возвращает data: {'panel_host'}. - Так домен ставится или меняется после установки. Домен должен указывать на сервер или его маскировку. + ca - свой CA вместо Let's Encrypt. Так домен ставится или меняется после установки. + Домен должен указывать на сервер или его маскировку. """ - return self.run('xui_cert', domain=domain) + return self.run('xui_cert', domain=domain, **self._certificate(domain, ca)) + + def _certificate(self, domain: str, ca: LocalCA | None) -> dict: + if ca is None: + return {} + certificate = ca.issue(domain or self.host.address) + return {'cert_fullchain': certificate.fullchain, 'cert_private_key': certificate.private_key} def set_outbound(self, proxy: str = '') -> Result: """Весь исходящий трафик клиентов через прокси. Перед применением прокси проверяется панелью. diff --git a/src/serverus/xui_api.py b/src/serverus/xui_api.py index 5393547..dea6d27 100644 --- a/src/serverus/xui_api.py +++ b/src/serverus/xui_api.py @@ -14,37 +14,41 @@ class XuiApi: Ходит в панель напрямую, без ssh: операции частые и должны быть быстрыми. url - адрес панели с web_base_path: https://<домен или ip>:/. - token - api_token из XuiPanel.install. Сертификат панели проверяется. + token - api_token из XuiPanel.install. Сертификат панели проверяется, ca_file - свой CA вместо системных. """ - def __init__(self, url: str, token: str, timeout: float = 30, context: ssl.SSLContext | None = None): + def __init__(self, url: str, token: str, timeout: float = 30, ca_file: str | None = None): self.url = url.rstrip('/') + '/panel/api' self._token = token self.timeout = timeout - self.context = context + self.context = ssl.create_default_context(cafile=ca_file) if ca_file else None 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, + self, inbound_id: int, email: str, *, uuid: str = '', sub_id: str = '', total_bytes: int = 0, + expiry_time: int = 0, limit_ip: int = 0, ) -> dict: """Выпускает ключ. email - уникальное имя ключа в панели. + uuid и sub_id - чтобы восстановить ключ после переустановки с прежними ссылками, без них панель генерирует новые. 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], - }) + client = { + 'email': email, + 'enable': True, + 'totalGB': total_bytes, + 'expiryTime': expiry_time, + 'limitIp': limit_ip, + } + if uuid: + client['id'] = uuid + if sub_id: + client['subId'] = sub_id + self._call('POST', 'clients/add', {'client': client, 'inboundIds': [inbound_id]}) return self.get_client(email) def delete_client(self, email: str):