chore: реализована работа с самоподписными сертификатами
This commit is contained in:
1 parent
9bb3a2fc5d
commit
602199d510
23 files changed
+524
-108
No files matched your search
@@ -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; }
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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=
|
||||||
@@ -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>
|
||||||
Клиенты подключаются к серверу маскировки, он пересылает трафик на панель. Адрес панели никто не видит. Для маскировки нужен домен.
|
Клиенты подключаются к серверу маскировки, он пересылает трафик на панель. Адрес панели никто не видит. Для маскировки нужен домен.
|
||||||
|
|||||||
@@ -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
@@ -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'])
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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`
|
||||||
@@ -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
@@ -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)}/")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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']
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
Reference in new issue
Block a user