chore: реализована работа с самоподписными сертификатами

This commit is contained in:
protokey committed 2026-09-26 14:12:01 +04:00
1 parent 9bb3a2fc5d
commit 602199d510
23 files changed
+524 -108

No files matched your search

+7 -4
View File
@@ -7,6 +7,9 @@ MANAGE := cd src/ && ../$(PYTHON) manage.py
SERVERUS := cd src/ && ../$(PYTHON) -m serverus SERVERUS := cd src/ && ../$(PYTHON) -m serverus
# доступы к серверам, настроенным вручную через serverus (папка в .gitignore) # доступы к серверам, настроенным вручную через serverus (папка в .gitignore)
SERVERUS_ACCESS_DIR := $(abspath extra/serverus) 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 MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS)) PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
@@ -20,9 +23,9 @@ help:
@echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения" @echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения"
@echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)" @echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)"
@echo "make clean - удалить $(WHEELS_DIR)/" @echo "make clean - удалить $(WHEELS_DIR)/"
@echo "make serverus_xui ip=<ip> password=<root_password> [domain=<domain>] [ssl=no] - настроить свежий сервер и поставить 3x-ui, доступы в $(SERVERUS_ACCESS_DIR)/<ip>/" @echo "make serverus_xui ip=<ip> password=<root_password> [domain=<domain>] [ssl=no] [ca=yes] - настроить свежий сервер и поставить 3x-ui, доступы в $(SERVERUS_ACCESS_DIR)/<ip>/"
@echo "make serverus_key ip=<ip> action=add|del|stats name=<name> - ключ на панели, настроенной через serverus_xui" @echo "make serverus_key ip=<ip> action=add|del|stats name=<name> - ключ на панели, настроенной через serverus_xui"
@echo "make serverus_cert ip=<ip> [domain=<domain>] - сертификат панели на домен или на ip" @echo "make serverus_cert ip=<ip> [domain=<domain>] [ca=yes] - сертификат панели на домен или на ip"
@echo "make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] - исходящий прокси, без proxy - напрямую" @echo "make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] - исходящий прокси, без proxy - напрямую"
@echo "make serverus_release ip=<ip> - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт" @echo "make serverus_release ip=<ip> - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт"
@@ -86,7 +89,7 @@ tg_run:
serverus_xui: serverus_xui:
@test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip=<ip> password=<root_password>"; exit 1; } @test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip=<ip> password=<root_password>"; exit 1; }
$(SERVERUS) xui $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip) $(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: serverus_key:
@test -n "$(ip)" -a -n "$(action)" -a -n "$(name)" || { echo "Укажите ключ: make serverus_key ip=<ip> action=add|del|stats name=<name>"; exit 1; } @test -n "$(ip)" -a -n "$(action)" -a -n "$(name)" || { echo "Укажите ключ: make serverus_key ip=<ip> action=add|del|stats name=<name>"; exit 1; }
@@ -94,7 +97,7 @@ serverus_key:
serverus_cert: serverus_cert:
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_cert ip=<ip> [domain=<domain>]"; exit 1; } @test -n "$(ip)" || { echo "Укажите сервер: make serverus_cert ip=<ip> [domain=<domain>]"; exit 1; }
$(SERVERUS) cert $(domain) --out $(SERVERUS_ACCESS_DIR)/$(ip) $(SERVERUS) cert $(domain) --out $(SERVERUS_ACCESS_DIR)/$(ip) $(SERVERUS_CA)
serverus_outbound: serverus_outbound:
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp]"; exit 1; } @test -n "$(ip)" || { echo "Укажите сервер: make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp]"; exit 1; }
+18 -1
View File
@@ -301,7 +301,7 @@ id 0/0Mb On
**Добавление сервера** **Добавление сервера**
Сервер добавляется после установки ОС. Для бота требуется, чтобы ОС была debian-13. Свежий сервер: Сервер добавляется после установки ОС. Для бота требуется, чтобы ОС была debian-12 или debian-13. Свежий сервер:
- открыт публично 22 порт ssh - открыт публично 22 порт ssh
- имеет ip - имеет ip
@@ -334,3 +334,20 @@ id 0/0Mb On
\# TODO: дописать документацию \# 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.
+5
View File
@@ -18,3 +18,8 @@ RABBITMQ_USER=
RABBITMQ_PASSWORD= RABBITMQ_PASSWORD=
# Адрес для Django и воркера dramatiq # Адрес для Django и воркера dramatiq
RABBITMQ_ADDRESS=127.0.0.1:5672 RABBITMQ_ADDRESS=127.0.0.1:5672
# Сертификаты панелей: letsencrypt или local (свой CA для локального стенда)
SERVERUS_CERTIFICATES=letsencrypt
# Папка своего CA, по умолчанию extra/serverus/ca
SERVERUS_CA_DIR=
+1 -1
View File
@@ -47,7 +47,7 @@ FAQ_TEXT = '''<b>Что это за бот?</b>
Бот настраивает ваш собственный VPN на вашем сервере: панель 3x-ui с подключениями vless + tls + reality. По желанию перед ней ставится сервер маскировки. Бот настраивает ваш собственный VPN на вашем сервере: панель 3x-ui с подключениями vless + tls + reality. По желанию перед ней ставится сервер маскировки.
<b>Что нужно от меня?</b> <b>Что нужно от меня?</b>
Арендованный сервер с Debian 13 (от 2 ядер, 2 Гб памяти, 20 Гб диска): ip и пароль root. По желанию - второй такой же сервер для маскировки, домен и прокси на выходе. Арендованный сервер с Debian 12 или 13 (от 2 ядер, 2 Гб памяти, 20 Гб диска): ip и пароль root. По желанию - второй такой же сервер для маскировки, домен и прокси на выходе.
<b>Зачем сервер маскировки?</b> <b>Зачем сервер маскировки?</b>
Клиенты подключаются к серверу маскировки, он пересылает трафик на панель. Адрес панели никто не видит. Для маскировки нужен домен. Клиенты подключаются к серверу маскировки, он пересылает трафик на панель. Адрес панели никто не видит. Для маскировки нужен домен.
+11
View File
@@ -167,6 +167,17 @@ class Point(models.Model):
def is_busy(self) -> bool: def is_busy(self) -> bool:
return self.status in (self.Status.INSTALLING, self.Status.RELEASING) 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 = ''): def set_status(self, status: str, error: str = ''):
self.status = status self.status = status
self.error = error self.error = error
+16 -1
View File
@@ -2,6 +2,7 @@
from contextlib import contextmanager from contextlib import contextmanager
import serverus import serverus
from django.conf import settings
from .models import Point, Server from .models import Point, Server
@@ -63,7 +64,7 @@ def set_domain(point: Point, domain: str):
if not domain and point.mask: if not domain and point.mask:
raise ValueError('маскировка работает только с доменом') raise ValueError('маскировка работает только с доменом')
with _installing(point): 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.domain = domain
point.save(update_fields=['domain']) point.save(update_fields=['domain'])
@@ -78,6 +79,19 @@ def set_outbound_proxy(point: Point, proxy: str):
point.save(update_fields=['outbound_proxy']) 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): def provision(server: Server):
"""Базовая настройка сервера и специализация по его роли.""" """Базовая настройка сервера и специализация по его роли."""
_bootstrap(server) _bootstrap(server)
@@ -114,6 +128,7 @@ def _install_panel(server: Server):
sub_port=point.sub_port, sub_port=point.sub_port,
sub_path=point.sub_path, sub_path=point.sub_path,
domain=point.domain, domain=point.domain,
ca=local_ca(),
)) ))
point.panel_api_token = result.data.get('api_token', '') point.panel_api_token = result.data.get('api_token', '')
point.save(update_fields=['panel_api_token']) point.save(update_fields=['panel_api_token'])
+25 -1
View File
@@ -1,7 +1,8 @@
import tempfile
from unittest import mock from unittest import mock
import serverus import serverus
from django.test import TestCase from django.test import TestCase, override_settings
from Telegram.models import User as TelegramUser from Telegram.models import User as TelegramUser
from . import services from . import services
@@ -161,6 +162,29 @@ class InstallTests(TestCase):
self.assertEqual(Server.objects.get(pk=panel.pk).private_key, private_key) self.assertEqual(Server.objects.get(pk=panel.pk).private_key, private_key)
def test_install_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): def test_install_sets_outbound_proxy(self):
point = create_point(mask=None, outbound_proxy=Point.CF_WARP) point = create_point(mask=None, outbound_proxy=Point.CF_WARP)
runner = FakeRunner() runner = FakeRunner()
+5
View File
@@ -25,6 +25,11 @@ INSTALLED_APPS += [
# Свой Bot API сервер (сервис telegram в docker-compose.yaml). Пусто - api.telegram.org # Свой Bot API сервер (сервис telegram в docker-compose.yaml). Пусто - api.telegram.org
TELEGRAM_API_URL = os.environ.get('TELEGRAM_API_URL', '') 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 # Фоновые задачи: dramatiq поверх rabbitmq. Воркер: make dj_worker
RABBITMQ_URL = 'amqp://{user}:{password}@{address}/{vhost}'.format( RABBITMQ_URL = 'amqp://{user}:{password}@{address}/{vhost}'.format(
user=quote(os.environ.get('RABBITMQ_USER', 'guest'), safe=''), user=quote(os.environ.get('RABBITMQ_USER', 'guest'), safe=''),
+13 -7
View File
@@ -27,7 +27,7 @@
- 2 ядра процессора - 2 ядра процессора
- 20Gb диска - 20Gb диска
Делается под Debian 13. Делается под Debian 13, Debian 12 тоже поддерживается.
## Использование ## Использование
@@ -53,14 +53,14 @@ server.release(generate_password())
Каждая операция возвращает `Result(ok, status, data, error)`. Ключи, пароли и артефакты ansible живут во временной папке только на время запуска. Хранить ключи и пароли - задача вызывающей стороны. Каждая операция возвращает `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. - `release` - ставит новый пароль root, сбрасывает nftables, убирает DNS-over-TLS, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys.
- `facts` - `uname -a` и версия ОС. - `facts` - `uname -a` и версия ОС.
Специализации - наследники `Server`: Специализации - наследники `Server`:
- `XuiPanel(host).install(username, password, panel_port, web_base_path, 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).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='')` - сертификат панели и подписки на домен или на ip: так домен ставится, меняется или убирается после установки. Домен должен уже указывать на сервер (или на его маскировку). - `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).set_outbound(proxy='')` - весь исходящий трафик клиентов через прокси: `socks5://user:password@host:port`, `http(s)://...` или `CF_WARP` (устройство WARP регистрирует панель, выход по wireguard). Пустая строка - напрямую. Прокси ставится первым outbound Xray, перед применением панель его проверяет. `parse_proxy(url)` проверяет адрес заранее.
- `XuiPanel(host).create_inbound(remark='main', target='')` - подключение vless + xhttp + reality на 443, uTLS firefox, сброс трафика ежемесячно. Цель reality подбирает сканер панели (TLS 1.3, h2, X25519, валидный сертификат, минимальная задержка) из `target` или из своих кандидатов. Ключи x25519 генерирует панель, shortId и путь xhttp - serverus. Существующее подключение с тем же `remark` не трогает. Возвращает `data`: `inbound_id`, `target`, `server_name`, `public_key`, `short_ids`, `path`. - `XuiPanel(host).create_inbound(remark='main', target='')` - подключение vless + xhttp + reality на 443, uTLS firefox, сброс трафика ежемесячно. Цель reality подбирает сканер панели (TLS 1.3, h2, X25519, валидный сертификат, минимальная задержка) из `target` или из своих кандидатов. Ключи x25519 генерирует панель, shortId и путь xhttp - serverus. Существующее подключение с тем же `remark` не трогает. Возвращает `data`: `inbound_id`, `target`, `server_name`, `public_key`, `short_ids`, `path`.
- `RedirectProxy(host).redirect(target_ip, ports)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh <target_ip> <ports>`, который через nftables перенаправляет tcp-порты `ports` на те же порты target_ip с masquerade и MSS clamping. Точка передает 80, 443, порт панели и порт подписки; ssh остается на самом сервере, скрипт откажется перенаправлять его порт. Правила - `/etc/nftables.d/serverus-redirect.nft`, подключаются include из `/etc/nftables.conf`. Заодно включает bbr, fq и увеличенные буферы и таблицу conntrack. - `RedirectProxy(host).redirect(target_ip, ports)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh <target_ip> <ports>`, который через 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`, отданный вызывающей стороне, при этом не меняется. Плейбуки ходят в 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>:<panel_port>/<web_base_path>`, сертификат панели проверяется. Ошибки - `XuiApiError`. `XuiApi(url, api_token)` - выпуск, отзыв и статистика ключей через HTTP API панели, без ssh. `url` - `https://<домен или ip>:<panel_port>/<web_base_path>`, сертификат панели проверяется. Ошибки - `XuiApiError`.
@@ -76,6 +80,7 @@ server.release(generate_password())
```python ```python
api = XuiApi(f'https://1.2.3.4:{panel_port}/{web_base_path}', api_token) 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, ... 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_links('user-1') # ['vless://...']
api.get_traffic('user-1') # up, down, total, expiryTime, lastOnline api.get_traffic('user-1') # up, down, total, expiryTime, lastOnline
api.delete_client('user-1') api.delete_client('user-1')
@@ -96,9 +101,9 @@ api.delete_client('user-1')
## Ручная проверка на живом сервере ## Ручная проверка на живом сервере
``` ```
make serverus_xui ip=<ip> password=<root_password> [domain=<domain>] [ssl=no] # bootstrap + 3x-ui + подключение reality make serverus_xui ip=<ip> password=<root_password> [domain=<domain>] [ssl=no] [ca=yes] # bootstrap + 3x-ui + подключение reality
make serverus_key ip=<ip> action=add|del|stats name=<name> # ключ на панели make serverus_key ip=<ip> action=add|del|stats name=<name> # ключ на панели
make serverus_cert ip=<ip> [domain=<domain>] # сертификат на домен или на ip make serverus_cert ip=<ip> [domain=<domain>] [ca=yes] # сертификат на домен или на ip
make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] # исходящий прокси, без proxy - напрямую make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] # исходящий прокси, без proxy - напрямую
make serverus_release ip=<ip> # вернуть сервер make serverus_release ip=<ip> # вернуть сервер
``` ```
@@ -125,5 +130,6 @@ make serverus_release ip=<ip> # вернуть се
- подобрать цель - подобрать цель
- сгенерировать ключи и сертификаты - сгенерировать ключи и сертификаты
- [x] нужен интерфейс выпуска, отзыва и получения статистики ключей - [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` - [ ] выпуск и отзыв ключей в боте поверх `XuiApi`
+5 -1
View File
@@ -2,19 +2,23 @@ from .credentials import (
KeyPair, generate_keypair, generate_password, generate_port, generate_ssh_port, generate_username, KeyPair, generate_keypair, generate_password, generate_port, generate_ssh_port, generate_username,
) )
from .host import Host from .host import Host
from .local_ca import Certificate, LocalCA
from .proxy import RedirectProxy from .proxy import RedirectProxy
from .runner import Result, run_playbook from .runner import Result, run_playbook
from .server import Server from .server import Server
from .xui import CF_WARP, XuiPanel, parse_proxy from .xui import CF_WARP, XUI_VERSION, XuiPanel, parse_proxy
from .xui_api import XuiApi, XuiApiError from .xui_api import XuiApi, XuiApiError
__all__ = [ __all__ = [
'CF_WARP', 'CF_WARP',
'Certificate',
'Host', 'Host',
'KeyPair', 'KeyPair',
'LocalCA',
'RedirectProxy', 'RedirectProxy',
'Result', 'Result',
'Server', 'Server',
'XUI_VERSION',
'XuiApi', 'XuiApi',
'XuiApiError', 'XuiApiError',
'XuiPanel', 'XuiPanel',
+26 -11
View File
@@ -1,8 +1,8 @@
"""Ручная проверка сценариев на живом сервере. """Ручная проверка сценариев на живом сервере.
python -m serverus xui <ip> <root_password> --out <dir> [--domain <domain>] [--no-ssl] python -m serverus xui <ip> <root_password> --out <dir> [--domain <domain>] [--no-ssl] [--ca <dir>]
python -m serverus key add|del|stats <name> --out <dir> python -m serverus key add|del|stats <name> --out <dir>
python -m serverus cert [<domain>] --out <dir> python -m serverus cert [<domain>] --out <dir> [--ca <dir>]
python -m serverus outbound [<proxy>] --out <dir> python -m serverus outbound [<proxy>] --out <dir>
python -m serverus release --out <dir> python -m serverus release --out <dir>
@@ -15,7 +15,7 @@ import sys
from pathlib import Path from pathlib import Path
from . import ( 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, 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('--out', type=Path, required=True, help='папка для доступов к серверу')
xui.add_argument('--domain', default='', help='домен панели, без него сертификат на ip') xui.add_argument('--domain', default='', help='домен панели, без него сертификат на ip')
xui.add_argument('--no-ssl', action='store_true', help='панель на http: для серверов без публичного адреса') 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 = commands.add_parser('key', help='выпуск, отзыв и статистика ключа на панели от команды xui')
key.add_argument('action', choices=['add', 'del', 'stats']) 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 = commands.add_parser('cert', help='сертификат панели на домен или, без домена, на ip')
cert.add_argument('domain', nargs='?', default='') cert.add_argument('domain', nargs='?', default='')
cert.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui') 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 = commands.add_parser('outbound', help='исходящий прокси: socks5://..., http(s)://..., cf_warp')
outbound.add_argument('proxy', nargs='?', default='', help='без него трафик идет напрямую') outbound.add_argument('proxy', nargs='?', default='', help='без него трафик идет напрямую')
@@ -52,17 +54,24 @@ def main(argv=None) -> int:
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.command == 'xui': if args.command == 'xui':
return install_xui(args.address, args.password, args.out, 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': if args.command == 'key':
return manage_key(args.action, args.name, args.out) return manage_key(args.action, args.name, args.out)
if args.command == 'cert': if args.command == 'cert':
return issue_certificate(args.domain, args.out) return issue_certificate(args.domain, args.out, args.ca)
if args.command == 'outbound': if args.command == 'outbound':
return set_outbound(args.proxy, args.out) return set_outbound(args.proxy, args.out)
return release_server(args.out) return release_server(args.out)
def install_xui(address: str, password: str, out: Path, 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() keys = generate_keypair()
panel_port = generate_port() panel_port = generate_port()
access = { 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'], password, keys.public_key, access['root_password'], access['ssh_port'],
) )
access['known_hosts'] = result.known_hosts access['known_hosts'] = result.known_hosts
access['bootstrapped'] = result.ok
save(out, access) save(out, access)
report('bootstrap', result) report('bootstrap', result)
if not result.ok: if not result.ok:
return 1 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) 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) report('xui', result)
if not result.ok: if not result.ok:
return 1 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: {panel_url(access)}/")
print(f"panel login: {panel['username']} / {panel['password']}") print(f"panel login: {panel['username']} / {panel['password']}")
print(f"inbound: {inbound['inbound_id']}, reality target {inbound['target']}") print(f"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 return 0
def manage_key(action: str, name: str, out: Path) -> int: def manage_key(action: str, name: str, out: Path) -> int:
access = json.loads((out / ACCESS_FILE).read_text()) 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: try:
if action == 'add': if action == 'add':
client = api.add_client(access['inbound']['inbound_id'], name) 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 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()) 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) report('cert', result)
if not result.ok: if not result.ok:
return 1 return 1
access['panel'].update(domain=domain, ssl=True) 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'] access['panel_host'] = result.data['panel_host']
save(out, access) save(out, access)
print(f"panel: {panel_url(access)}/") print(f"panel: {panel_url(access)}/")
+120
View File
@@ -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)
+13 -3
View File
@@ -2,6 +2,8 @@
# #
# Порт ssh меняется в два шага, чтобы не потерять сервер: # Порт ssh меняется в два шага, чтобы не потерять сервер:
# сначала sshd слушает старый и новый порт, после проверки входа по ключу на новом порту старый закрывается. # сначала sshd слушает старый и новый порт, после проверки входа по ключу на новом порту старый закрывается.
# Если обновление системы что-то поставило, в конце сервер перезагружается: подхватывает новое ядро и библиотеки,
# заодно проверяется, что ssh и nftables переживают перезагрузку. В контейнере перезагрузки нет.
- name: Prepare fresh server (password login) - name: Prepare fresh server (password login)
hosts: target hosts: target
@@ -9,12 +11,12 @@
vars: vars:
ansible_password: "{{ login_password }}" ansible_password: "{{ login_password }}"
tasks: tasks:
- name: Require Debian 13 - name: Require Debian 12 or 13
ansible.builtin.assert: ansible.builtin.assert:
that: that:
- ansible_facts.distribution == 'Debian' - ansible_facts.distribution == 'Debian'
- ansible_facts.distribution_major_version == '13' - ansible_facts.distribution_major_version in ['12', '13']
fail_msg: "Supported only Debian 13, got {{ ansible_facts.distribution }} {{ ansible_facts.distribution_version }}" fail_msg: "Supported only Debian 12 and 13, got {{ ansible_facts.distribution }} {{ ansible_facts.distribution_version }}"
- name: Create .ssh directory - name: Create .ssh directory
ansible.builtin.file: ansible.builtin.file:
@@ -35,6 +37,7 @@
ansible.builtin.apt: ansible.builtin.apt:
update_cache: true update_cache: true
upgrade: dist upgrade: dist
register: system_upgrade
- ansible.builtin.import_tasks: tasks/firewall.yml - ansible.builtin.import_tasks: tasks/firewall.yml
vars: vars:
@@ -70,3 +73,10 @@
- name: Check hardened ssh - name: Check hardened ssh
ansible.builtin.wait_for_connection: ansible.builtin.wait_for_connection:
timeout: 60 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']
+17 -2
View File
@@ -33,10 +33,25 @@
ansible.builtin.command: sysctl --system ansible.builtin.command: sysctl --system
changed_when: false changed_when: false
- name: Remove DNS over TLS config - name: Remove encrypted DNS config
ansible.builtin.file: ansible.builtin.file:
path: /etc/systemd/resolved.conf.d/00-serverus.conf path: "{{ item }}"
state: absent 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 указывает в никуда # systemd-resolved остается: без него /etc/resolv.conf указывает в никуда
- name: Restart systemd-resolved - name: Restart systemd-resolved
+86 -5
View File
@@ -1,10 +1,74 @@
# Системный резолвер: systemd-resolved, только DNS-over-TLS к Cloudflare. # Системный резолвер: systemd-resolved с двумя путями до Cloudflare, оба шифрованные.
# Строгий режим: без TLS запрос не уйдет, открытым текстом DNS не утекает. Резервных серверов нет. #
- name: Install systemd-resolved # Основной - 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: ansible.builtin.apt:
name: systemd-resolved name: [ systemd-resolved, dnscrypt-proxy, bind9-dnsutils ]
state: present 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 - name: Create resolved drop-in directory
ansible.builtin.file: ansible.builtin.file:
path: /etc/systemd/resolved.conf.d path: /etc/systemd/resolved.conf.d
@@ -21,6 +85,8 @@
FallbackDNS= FallbackDNS=
DNSOverTLS=yes DNSOverTLS=yes
Domains=~. Domains=~.
LLMNR=no
MulticastDNS=no
- name: Use resolved stub - name: Use resolved stub
ansible.builtin.file: ansible.builtin.file:
@@ -29,16 +95,31 @@
state: link state: link
force: true 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 - name: Restart systemd-resolved
ansible.builtin.systemd_service: ansible.builtin.systemd_service:
name: systemd-resolved name: systemd-resolved
enabled: true enabled: true
state: restarted state: restarted
- name: Check DNS over TLS - name: Check DNS
ansible.builtin.command: resolvectl query deb.debian.org ansible.builtin.command: resolvectl query deb.debian.org
register: dns_check register: dns_check
retries: 5 retries: 5
delay: 3 delay: 3
until: dns_check.rc == 0 until: dns_check.rc == 0
changed_when: false 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
+1 -1
View File
@@ -10,7 +10,7 @@
no_log: true no_log: true
- name: Read panel address - name: Read panel address
ansible.builtin.command: "{{ xui_bin }} setting -show true" ansible.builtin.command: "{{ xui_bin }} setting -show"
register: xui_panel_settings register: xui_panel_settings
changed_when: false changed_when: false
no_log: true no_log: true
+67 -41
View File
@@ -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 порту, он должен быть открыт. # Без готового сертификата - Let's Encrypt через acme.sh в режиме standalone: проверка HTTP-01 на 80 порту,
# На ip выдается только короткий сертификат (~6 дней), acme.sh продлевает его по cron и перезапускает панель. # он должен быть открыт. На ip выдается только короткий сертификат (~6 дней), acme.sh продлевает его по cron
# Ставит сертификат панели и подписке. Если выпустить не удалось, падает, а не оставляет панель на http. # и перезапускает панель. Ставит сертификат панели. Если сертификата нет, падает, а не оставляет панель на http.
- name: Set certificate paths - name: Set certificate paths
ansible.builtin.set_fact: ansible.builtin.set_fact:
cert_is_ip: "{{ cert_host is match('^[0-9.]+$') or ':' in cert_host }}" cert_is_ip: "{{ cert_host is match('^[0-9.]+$') or ':' in cert_host }}"
cert_dir: "/root/cert/{{ cert_host }}" cert_dir: "/root/cert/{{ cert_host }}"
cert_given: "{{ cert_fullchain | default('') != '' }}"
acme: /root/.acme.sh/acme.sh 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 - name: Create certificate directory
ansible.builtin.file: ansible.builtin.file:
path: "{{ cert_dir }}" path: "{{ cert_dir }}"
state: directory state: directory
mode: "0700" mode: "0700"
- name: Install certificate - name: Upload certificate
ansible.builtin.command: >- when: cert_given
{{ acme }} --installcert -d {{ cert_host }} ansible.builtin.copy:
--key-file {{ cert_dir }}/privkey.pem --fullchain-file {{ cert_dir }}/fullchain.pem dest: "{{ cert_dir }}/{{ item.name }}"
--reloadcmd "systemctl restart x-ui" content: "{{ item.content }}"
changed_when: true 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 - name: Check certificate
ansible.builtin.command: "openssl x509 -in {{ cert_dir }}/fullchain.pem -noout -checkend 3600 -ext subjectAltName" ansible.builtin.command: "openssl x509 -in {{ cert_dir }}/fullchain.pem -noout -checkend 3600 -ext subjectAltName"
+5 -4
View File
@@ -1,6 +1,7 @@
# extravars: username, password, panel_port, web_base_path, sub_port, sub_path, domain (optional), ssl (optional), # extravars: username, password, panel_port, web_base_path, sub_port, sub_path, version, domain (optional),
# version (optional) # ssl (optional), cert_fullchain и cert_private_key (optional, готовый сертификат вместо Let's Encrypt)
# #
# version - тег релиза 3x-ui: скрипт установки берется из того же тега, панель ставится этой версии.
# Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме, SSL скрипта выключен: он при ошибке молча оставляет # Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме, SSL скрипта выключен: он при ошибке молча оставляет
# панель на http. Сертификат выпускает tasks/xui_cert.yml на домен, если он задан, иначе на ip. Нужен открытый 80 порт. # панель на http. Сертификат выпускает tasks/xui_cert.yml на домен, если он задан, иначе на ip. Нужен открытый 80 порт.
# ssl=false - без сертификата, для серверов без публичного адреса. # ssl=false - без сертификата, для серверов без публичного адреса.
@@ -22,14 +23,14 @@
- name: Download install script - name: Download install script
ansible.builtin.get_url: 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 dest: /root/3x-ui-install.sh
mode: "0700" mode: "0700"
force: true force: true
- name: Run install script - name: Run install script
ansible.builtin.command: ansible.builtin.command:
cmd: "bash /root/3x-ui-install.sh {{ version | default('') }}" cmd: "bash /root/3x-ui-install.sh {{ version }}"
creates: "{{ xui_bin }}" creates: "{{ xui_bin }}"
environment: environment:
XUI_NONINTERACTIVE: "1" XUI_NONINTERACTIVE: "1"
+2 -1
View File
@@ -122,7 +122,8 @@
- name: Return inbound - name: Return inbound
vars: vars:
stream: "{{ inbound.streamSettings | from_json }}" # список отдает строку JSON, а ответ на добавление - уже объект
stream: "{{ inbound.streamSettings if inbound.streamSettings is mapping else inbound.streamSettings | from_json }}"
ansible.builtin.set_stats: ansible.builtin.set_stats:
aggregate: false aggregate: false
data: data:
+3 -2
View File
@@ -22,10 +22,11 @@ class Server:
return self.run('facts') return self.run('facts')
def bootstrap(self, password: str, public_key: str, new_password: str, ssh_port: int) -> Result: 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, обновляет систему, Входит по паролю на host.port, прописывает public_key, меняет пароль root на new_password, обновляет систему,
переносит ssh на ssh_port с входом только по ключу и включает nftables. переносит ssh на ssh_port с входом только по ключу и включает nftables. Если обновление что-то поставило,
в конце перезагружает сервер.
Для проверки входа по ключу host.private_key должен быть парой к public_key. Для проверки входа по ключу host.private_key должен быть парой к public_key.
""" """
return self.run( return self.run(
+41 -1
View File
@@ -1,11 +1,13 @@
import io import io
import json import json
import ssl
import tempfile
import unittest import unittest
import urllib.error import urllib.error
from unittest import mock from unittest import mock
from . import ( 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, generate_ssh_port, generate_username, parse_proxy, run_playbook,
) )
from .credentials import SERVICE_PORT_RANGE, SSH_PORT_RANGE from .credentials import SERVICE_PORT_RANGE, SSH_PORT_RANGE
@@ -78,10 +80,25 @@ class XuiPanelTests(unittest.TestCase):
'web_base_path': 'base', 'web_base_path': 'base',
'sub_port': 12000, 'sub_port': 12000,
'sub_path': 'sub', 'sub_path': 'sub',
'version': XUI_VERSION,
'domain': 'example.com', 'domain': 'example.com',
'ssl': True, '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): def test_issue_certificate(self):
runner = FakeRunner() runner = FakeRunner()
XuiPanel(Host('1.2.3.4'), runner=runner).issue_certificate('example.com') XuiPanel(Host('1.2.3.4'), runner=runner).issue_certificate('example.com')
@@ -137,6 +154,23 @@ class XuiPanelTests(unittest.TestCase):
int(short_id, 16) 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): class FakeResponse(io.BytesIO):
def __init__(self, payload): def __init__(self, payload):
super().__init__(json.dumps(payload).encode()) 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((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') 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): def test_delete_client(self):
self.responses = [{'success': True, 'obj': None}] self.responses = [{'success': True, 'obj': None}]
self.api.delete_client('user') self.api.delete_client('user')
+19 -7
View File
@@ -5,9 +5,12 @@ import urllib.parse
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from .local_ca import LocalCA
from .runner import Result from .runner import Result
from .server import Server from .server import Server
XUI_VERSION = 'v3.8.5'
"""Версия 3x-ui. API панели проверено на ней, обновлять вручную после проверки."""
MAIN_INBOUND = 'main' MAIN_INBOUND = 'main'
CF_WARP = 'cf_warp' CF_WARP = 'cf_warp'
"""Исходящий прокси через Cloudflare WARP.""" """Исходящий прокси через Cloudflare WARP."""
@@ -19,12 +22,12 @@ class XuiPanel(Server):
def install( def install(
self, username: str, password: str, panel_port: int, web_base_path: str, sub_port: int, sub_path: str, self, username: str, password: str, panel_port: int, web_base_path: str, sub_port: int, sub_path: str,
domain: str = '', ssl: bool = True, domain: str = '', ssl: bool = True, ca: LocalCA | None = None,
) -> Result: ) -> 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, Сертификат на domain, без него - на ip: от ca, если он задан, иначе Let's Encrypt; не удалось выпустить - ошибка.
для серверов без публичного адреса. Открывает порты 80 (acme), 443, panel_port и sub_port. ssl=False - панель на http. Открывает порты 80 (acme), 443, panel_port и sub_port.
Возвращает data: {'api_token', 'panel_host' (домен или ip из сертификата), 'ssl'}. Возвращает data: {'api_token', 'panel_host' (домен или ip из сертификата), 'ssl'}.
""" """
return self.run( return self.run(
@@ -35,16 +38,25 @@ class XuiPanel(Server):
web_base_path=web_base_path, web_base_path=web_base_path,
sub_port=sub_port, sub_port=sub_port,
sub_path=sub_path, sub_path=sub_path,
version=XUI_VERSION,
domain=domain, domain=domain,
ssl=ssl, 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'}. """Выпускает сертификат панели и подписки на 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: def set_outbound(self, proxy: str = '') -> Result:
"""Весь исходящий трафик клиентов через прокси. Перед применением прокси проверяется панелью. """Весь исходящий трафик клиентов через прокси. Перед применением прокси проверяется панелью.
+18 -14
View File
@@ -14,37 +14,41 @@ class XuiApi:
Ходит в панель напрямую, без ssh: операции частые и должны быть быстрыми. Ходит в панель напрямую, без ssh: операции частые и должны быть быстрыми.
url - адрес панели с web_base_path: https://<домен или ip>:<panel_port>/<web_base_path>. url - адрес панели с web_base_path: https://<домен или ip>:<panel_port>/<web_base_path>.
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.url = url.rstrip('/') + '/panel/api'
self._token = token self._token = token
self.timeout = timeout self.timeout = timeout
self.context = context self.context = ssl.create_default_context(cafile=ca_file) if ca_file else None
def __repr__(self): def __repr__(self):
# токен не должен попадать в логи # токен не должен попадать в логи
return f'XuiApi({self.url})' return f'XuiApi({self.url})'
def add_client( 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: ) -> dict:
"""Выпускает ключ. email - уникальное имя ключа в панели. """Выпускает ключ. email - уникальное имя ключа в панели.
uuid и sub_id - чтобы восстановить ключ после переустановки с прежними ссылками, без них панель генерирует новые.
total_bytes - лимит трафика, expiry_time - срок в мс unix time, 0 - без ограничений. total_bytes - лимит трафика, expiry_time - срок в мс unix time, 0 - без ограничений.
Возвращает клиента панели, как get_client. Возвращает клиента панели, как get_client.
""" """
self._call('POST', 'clients/add', { client = {
'client': { 'email': email,
'email': email, 'enable': True,
'enable': True, 'totalGB': total_bytes,
'totalGB': total_bytes, 'expiryTime': expiry_time,
'expiryTime': expiry_time, 'limitIp': limit_ip,
'limitIp': limit_ip, }
}, if uuid:
'inboundIds': [inbound_id], 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) return self.get_client(email)
def delete_client(self, email: str): def delete_client(self, email: str):