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 (папка в .gitignore)
SERVERUS_ACCESS_DIR := $(abspath extra/serverus)
# свой CA для серверов без Let's Encrypt (ca=yes), создается при первом запуске
SERVERUS_CA_DIR := $(SERVERUS_ACCESS_DIR)/ca
SERVERUS_CA = $(if $(filter yes,$(ca)),--ca $(SERVERUS_CA_DIR))
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
@@ -20,9 +23,9 @@ help:
@echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения"
@echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)"
@echo "make clean - удалить $(WHEELS_DIR)/"
@echo "make serverus_xui ip=<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_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_release ip=<ip> - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт"
@@ -86,7 +89,7 @@ tg_run:
serverus_xui:
@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:
@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:
@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:
@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
- имеет ip
@@ -334,3 +334,20 @@ id 0/0Mb On
\# TODO: дописать документацию
### Модель данных
Пользователи - приходят из телеграма, у них есть id, который уникален и проверен, его можно использовать для авторегистрации. И следует обязательно запоминать, так как боту недоступна информация о подключенных к нему пользователях, только в момент когда пользователь взаимодействует с ботом.
- telegram user id - запоминаем id пользователя телеграм
Точка - наша выдуманная абстракция, чтобы объединить в группу (кластер) 2 сервера. Домен. Прокси (или аккаунт cf_warp).
- сервер 3x-ui - обязательно должен быть, ради него все и затевается
- сервер маскировки - если пользователь хочет особый режим, то он может добавить еще один сервер купленный в своей стране
- домен - если сервер маскировки указан, то домен обязательно нужно указать
- ssl сертификаты - если домен не указан, то сертификаты выпускаются на IP сервера 3x-ui, а сервера маскировки нет; а если домен есть то он привязывается к серверу маскировки и на него выпускаются ssl ключи, который хранятся на сервере 3x-ui, потому что сервер маскировки лишь прозрачно проксирует
- ssh ключи - доступ к серверам, находящимся под нашей ведомостью, всегда осуществляется асимметричным способом (ключ-файл)
- прокси - можно задать свои и с авторизацией, а можно воспользоваться cf_warp прокси, которые можно регистрировать автоматически через x-ui
Сервер - реальные сервера с ip и доступом к root. Настраиваем безопасное окружение по всем канонам.
Сервис - На серверах у нас живут сервисы. Поднимается или настраивается какой-то сервис, чтобы решить какую-то полезную задачу. И реализуют api для управления сервисом на сервере. Сейчас эту функцию выполняет Роль сервера, потому что подразумевается что сервис может быть 1.
+5
View File
@@ -18,3 +18,8 @@ RABBITMQ_USER=
RABBITMQ_PASSWORD=
# Адрес для Django и воркера dramatiq
RABBITMQ_ADDRESS=127.0.0.1:5672
# Сертификаты панелей: letsencrypt или local (свой CA для локального стенда)
SERVERUS_CERTIFICATES=letsencrypt
# Папка своего CA, по умолчанию extra/serverus/ca
SERVERUS_CA_DIR=
+1 -1
View File
@@ -47,7 +47,7 @@ FAQ_TEXT = '''<b>Что это за бот?</b>
Бот настраивает ваш собственный VPN на вашем сервере: панель 3x-ui с подключениями vless + tls + reality. По желанию перед ней ставится сервер маскировки.
<b>Что нужно от меня?</b>
Арендованный сервер с Debian 13 (от 2 ядер, 2 Гб памяти, 20 Гб диска): ip и пароль root. По желанию - второй такой же сервер для маскировки, домен и прокси на выходе.
Арендованный сервер с Debian 12 или 13 (от 2 ядер, 2 Гб памяти, 20 Гб диска): ip и пароль root. По желанию - второй такой же сервер для маскировки, домен и прокси на выходе.
<b>Зачем сервер маскировки?</b>
Клиенты подключаются к серверу маскировки, он пересылает трафик на панель. Адрес панели никто не видит. Для маскировки нужен домен.
+11
View File
@@ -167,6 +167,17 @@ class Point(models.Model):
def is_busy(self) -> bool:
return self.status in (self.Status.INSTALLING, self.Status.RELEASING)
@property
def panel_host(self) -> str:
"""Адрес панели для https и подписки: домен, без него - ip панели. По ssh ходим только по ip."""
return self.domain or self.panel.address
def panel_url(self) -> str:
return f'https://{self.panel_host}:{self.panel_port}/{self.panel_path}'
def subscription_url(self, sub_id: str) -> str:
return f'https://{self.panel_host}:{self.sub_port}/{self.sub_path}/{sub_id}'
def set_status(self, status: str, error: str = ''):
self.status = status
self.error = error
+16 -1
View File
@@ -2,6 +2,7 @@
from contextlib import contextmanager
import serverus
from django.conf import settings
from .models import Point, Server
@@ -63,7 +64,7 @@ def set_domain(point: Point, domain: str):
if not domain and point.mask:
raise ValueError('маскировка работает только с доменом')
with _installing(point):
_check(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain))
_check(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain, local_ca()))
point.domain = domain
point.save(update_fields=['domain'])
@@ -78,6 +79,19 @@ def set_outbound_proxy(point: Point, proxy: str):
point.save(update_fields=['outbound_proxy'])
def local_ca() -> serverus.LocalCA | None:
"""Свой CA, если панели работают не с Let's Encrypt (SERVERUS_CERTIFICATES=local)."""
if settings.SERVERUS_CERTIFICATES == 'local':
return serverus.LocalCA(settings.SERVERUS_CA_DIR)
return None
def xui_api(point: Point) -> serverus.XuiApi:
"""API панели точки: по домену, без него - по ip панели."""
ca = local_ca()
return serverus.XuiApi(point.panel_url(), point.panel_api_token, ca_file=str(ca.cert_path) if ca else None)
def provision(server: Server):
"""Базовая настройка сервера и специализация по его роли."""
_bootstrap(server)
@@ -114,6 +128,7 @@ def _install_panel(server: Server):
sub_port=point.sub_port,
sub_path=point.sub_path,
domain=point.domain,
ca=local_ca(),
))
point.panel_api_token = result.data.get('api_token', '')
point.save(update_fields=['panel_api_token'])
+25 -1
View File
@@ -1,7 +1,8 @@
import tempfile
from unittest import mock
import serverus
from django.test import TestCase
from django.test import TestCase, override_settings
from Telegram.models import User as TelegramUser
from . import services
@@ -161,6 +162,29 @@ class InstallTests(TestCase):
self.assertEqual(Server.objects.get(pk=panel.pk).private_key, private_key)
def test_install_with_local_ca(self):
point = create_point(mask=None)
runner = FakeRunner()
with tempfile.TemporaryDirectory() as directory, patch_runner(runner):
with override_settings(SERVERUS_CERTIFICATES='local', SERVERUS_CA_DIR=directory):
services.install_point(point)
api = services.xui_api(point)
self.assertIn('BEGIN CERTIFICATE', runner.call('xui', '10.0.0.1')[1]['cert_fullchain'])
self.assertIsNotNone(api.context)
def test_letsencrypt_by_default(self):
point = create_point(mask=None)
runner = FakeRunner()
with patch_runner(runner):
services.install_point(point)
self.assertNotIn('cert_fullchain', runner.call('xui', '10.0.0.1')[1])
def test_panel_urls_use_domain_or_panel_ip(self):
point = create_point(mask=None)
self.assertTrue(point.panel_url().startswith(f'https://10.0.0.1:{point.panel_port}/'))
point.domain = 'example.com'
self.assertEqual(point.subscription_url('abc'), f'https://example.com:{point.sub_port}/{point.sub_path}/abc')
def test_install_sets_outbound_proxy(self):
point = create_point(mask=None, outbound_proxy=Point.CF_WARP)
runner = FakeRunner()
+5
View File
@@ -25,6 +25,11 @@ INSTALLED_APPS += [
# Свой Bot API сервер (сервис telegram в docker-compose.yaml). Пусто - api.telegram.org
TELEGRAM_API_URL = os.environ.get('TELEGRAM_API_URL', '')
# Сертификаты панелей: letsencrypt или local - свой CA, для серверов без публичного адреса (локальный стенд).
# SERVERUS_CA_DIR - папка CA, он создается при первом выпуске. ca.pem нужно добавить в доверенные на клиентах.
SERVERUS_CERTIFICATES = os.environ.get('SERVERUS_CERTIFICATES', 'letsencrypt')
SERVERUS_CA_DIR = os.environ.get('SERVERUS_CA_DIR', str(BASE_DIR.parent / 'extra/serverus/ca'))
# Фоновые задачи: dramatiq поверх rabbitmq. Воркер: make dj_worker
RABBITMQ_URL = 'amqp://{user}:{password}@{address}/{vhost}'.format(
user=quote(os.environ.get('RABBITMQ_USER', 'guest'), safe=''),
+13 -7
View File
@@ -27,7 +27,7 @@
- 2 ядра процессора
- 20Gb диска
Делается под Debian 13.
Делается под Debian 13, Debian 12 тоже поддерживается.
## Использование
@@ -53,14 +53,14 @@ server.release(generate_password())
Каждая операция возвращает `Result(ok, status, data, error)`. Ключи, пароли и артефакты ansible живут во временной папке только на время запуска. Хранить ключи и пароли - задача вызывающей стороны.
- `bootstrap` - проверяет Debian 13, прописывает ключ, меняет пароль root, обновляет систему, включает nftables и переносит ssh на новый порт с входом только по ключу. Порт меняется в два шага с проверкой входа по ключу, чтобы не потерять доступ.
- `bootstrap` - проверяет Debian 12 или 13, прописывает ключ, меняет пароль root, обновляет систему (если что-то обновилось - перезагружает в конце), включает nftables и переносит ssh на новый порт с входом только по ключу. Порт меняется в два шага с проверкой входа по ключу, чтобы не потерять доступ.
- `release` - ставит новый пароль root, сбрасывает nftables, убирает DNS-over-TLS, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys.
- `facts` - `uname -a` и версия ОС.
Специализации - наследники `Server`:
- `XuiPanel(host).install(username, password, panel_port, web_base_path, sub_port, sub_path, domain='', ssl=True)` - ставит 3x-ui скриптом из README проекта в неинтерактивном режиме. Сертификат Let's Encrypt выпускает serverus через acme.sh (HTTP-01 на 80 порту): на домен или, без него, на ip (короткий, ~6 дней, продлевается по cron). Не выпустился - ошибка, панель на http не остается. `ssl=False` - без сертификата, для серверов без публичного адреса. Включает подписку на `sub_port` по пути `/sub_path/` под тем же сертификатом. Системный DNS переводит на DNS-over-TLS к Cloudflare (systemd-resolved, строгий режим, без резервных серверов), DNS в Xray - через системный резолвер: сам Xray DoT не умеет. Открывает 80, 443, порт панели и порт подписки. Возвращает `data`: `api_token`, `panel_host` (домен или ip сертификата), `ssl`.
- `XuiPanel(host).issue_certificate(domain='')` - сертификат панели и подписки на домен или на ip: так домен ставится, меняется или убирается после установки. Домен должен уже указывать на сервер (или на его маскировку).
- `XuiPanel(host).install(username, password, panel_port, web_base_path, sub_port, sub_path, domain='', ssl=True, ca=None)` - ставит 3x-ui версии `XUI_VERSION` (сейчас v3.8.5, API проверено на ней, обновлять вручную) скриптом из README проекта того же тега в неинтерактивном режиме. Сертификат на домен или, без него, на ip: от своего CA (`ca=LocalCA(...)`), иначе Let's Encrypt через acme.sh (HTTP-01 на 80 порту; на ip короткий, ~6 дней, продлевается по cron). Не выпустился - ошибка, панель на http не остается. `ssl=False` - без сертификата. Включает подписку на `sub_port` по пути `/sub_path/` под тем же сертификатом. Системный DNS - шифрованный до Cloudflare двумя путями: DNS-over-TLS в systemd-resolved (строгий режим) и запасной DNS-over-HTTPS через dnscrypt-proxy на случай, если 853 порт режут. resolved спрашивает оба и берет первый ответ, открытым текстом DNS не уходит. DNS в Xray - через системный резолвер: сам Xray DoT не умеет. Открывает 80, 443, порт панели и порт подписки. Возвращает `data`: `api_token`, `panel_host` (домен или ip сертификата), `ssl`.
- `XuiPanel(host).issue_certificate(domain='', ca=None)` - сертификат панели и подписки на домен или на ip: так домен ставится, меняется или убирается после установки. Домен должен уже указывать на сервер (или на его маскировку).
- `XuiPanel(host).set_outbound(proxy='')` - весь исходящий трафик клиентов через прокси: `socks5://user:password@host:port`, `http(s)://...` или `CF_WARP` (устройство WARP регистрирует панель, выход по wireguard). Пустая строка - напрямую. Прокси ставится первым outbound Xray, перед применением панель его проверяет. `parse_proxy(url)` проверяет адрес заранее.
- `XuiPanel(host).create_inbound(remark='main', target='')` - подключение vless + xhttp + reality на 443, uTLS firefox, сброс трафика ежемесячно. Цель reality подбирает сканер панели (TLS 1.3, h2, X25519, валидный сертификат, минимальная задержка) из `target` или из своих кандидатов. Ключи x25519 генерирует панель, shortId и путь xhttp - serverus. Существующее подключение с тем же `remark` не трогает. Возвращает `data`: `inbound_id`, `target`, `server_name`, `public_key`, `short_ids`, `path`.
- `RedirectProxy(host).redirect(target_ip, ports)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh <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`, отданный вызывающей стороне, при этом не меняется.
## Свой 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`.
@@ -76,6 +80,7 @@ server.release(generate_password())
```python
api = XuiApi(f'https://1.2.3.4:{panel_port}/{web_base_path}', api_token)
client = api.add_client(inbound_id, 'user-1') # uuid, subId, ...
api.add_client(inbound_id, 'user-1', uuid=uuid, sub_id=sub_id) # восстановить после переустановки
api.get_links('user-1') # ['vless://...']
api.get_traffic('user-1') # up, down, total, expiryTime, lastOnline
api.delete_client('user-1')
@@ -96,9 +101,9 @@ api.delete_client('user-1')
## Ручная проверка на живом сервере
```
make serverus_xui ip=<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_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_release ip=<ip> # вернуть сервер
```
@@ -125,5 +130,6 @@ make serverus_release ip=<ip> # вернуть се
- подобрать цель
- сгенерировать ключи и сертификаты
- [x] нужен интерфейс выпуска, отзыва и получения статистики ключей
- [ ] проверить `xui` и `xui_inbound` на живом сервере: API панели проверено только по исходникам 3x-ui v3.8.5
- [x] проверить `xui` и `xui_inbound` на живом сервере (Debian 13, свой CA): установка, DoT и запасной DoH, ключи, подписка, socks5 и WARP, отказ нерабочего прокси
- [ ] проверить `release` и сертификат Let's Encrypt на сервере с публичным адресом
- [ ] выпуск и отзыв ключей в боте поверх `XuiApi`
+5 -1
View File
@@ -2,19 +2,23 @@ from .credentials import (
KeyPair, generate_keypair, generate_password, generate_port, generate_ssh_port, generate_username,
)
from .host import Host
from .local_ca import Certificate, LocalCA
from .proxy import RedirectProxy
from .runner import Result, run_playbook
from .server import Server
from .xui import CF_WARP, XuiPanel, parse_proxy
from .xui import CF_WARP, XUI_VERSION, XuiPanel, parse_proxy
from .xui_api import XuiApi, XuiApiError
__all__ = [
'CF_WARP',
'Certificate',
'Host',
'KeyPair',
'LocalCA',
'RedirectProxy',
'Result',
'Server',
'XUI_VERSION',
'XuiApi',
'XuiApiError',
'XuiPanel',
+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 cert [<domain>] --out <dir>
python -m serverus cert [<domain>] --out <dir> [--ca <dir>]
python -m serverus outbound [<proxy>] --out <dir>
python -m serverus release --out <dir>
@@ -15,7 +15,7 @@ import sys
from pathlib import Path
from . import (
Host, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
Host, LocalCA, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
generate_ssh_port, generate_username,
)
@@ -33,6 +33,7 @@ def main(argv=None) -> int:
xui.add_argument('--out', type=Path, required=True, help='папка для доступов к серверу')
xui.add_argument('--domain', default='', help='домен панели, без него сертификат на ip')
xui.add_argument('--no-ssl', action='store_true', help='панель на http: для серверов без публичного адреса')
xui.add_argument('--ca', type=Path, help='папка своего CA вместо Let\'s Encrypt, CA создается, если его там нет')
key = commands.add_parser('key', help='выпуск, отзыв и статистика ключа на панели от команды xui')
key.add_argument('action', choices=['add', 'del', 'stats'])
@@ -42,6 +43,7 @@ def main(argv=None) -> int:
cert = commands.add_parser('cert', help='сертификат панели на домен или, без домена, на ip')
cert.add_argument('domain', nargs='?', default='')
cert.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui')
cert.add_argument('--ca', type=Path, help='папка своего CA вместо Let\'s Encrypt')
outbound = commands.add_parser('outbound', help='исходящий прокси: socks5://..., http(s)://..., cf_warp')
outbound.add_argument('proxy', nargs='?', default='', help='без него трафик идет напрямую')
@@ -52,17 +54,24 @@ def main(argv=None) -> int:
args = parser.parse_args(argv)
if args.command == 'xui':
return install_xui(args.address, args.password, args.out, args.domain, not args.no_ssl)
return install_xui(args.address, args.password, args.out, args.domain, not args.no_ssl, args.ca)
if args.command == 'key':
return manage_key(args.action, args.name, args.out)
if args.command == 'cert':
return issue_certificate(args.domain, args.out)
return issue_certificate(args.domain, args.out, args.ca)
if args.command == 'outbound':
return set_outbound(args.proxy, args.out)
return release_server(args.out)
def install_xui(address: str, password: str, out: Path, domain: str, ssl: bool) -> int:
def install_xui(address: str, password: str, out: Path, domain: str, ssl: bool, ca_dir: Path | None) -> int:
access_file = out / ACCESS_FILE
if access_file.exists():
access = json.loads(access_file.read_text())
if access.get('bootstrapped'):
# повторный запуск после ошибки установки: сервер уже на ключе и новом порту
print('bootstrap: skipped, already done')
return install_panel(access, out, ca_dir)
keys = generate_keypair()
panel_port = generate_port()
access = {
@@ -88,13 +97,18 @@ def install_xui(address: str, password: str, out: Path, domain: str, ssl: bool)
password, keys.public_key, access['root_password'], access['ssh_port'],
)
access['known_hosts'] = result.known_hosts
access['bootstrapped'] = result.ok
save(out, access)
report('bootstrap', result)
if not result.ok:
return 1
return install_panel(access, out, ca_dir)
def install_panel(access: dict, out: Path, ca_dir: Path | None) -> int:
host = load_host(access)
result = XuiPanel(host).install(**access['panel'])
access['ca_file'] = str(LocalCA(ca_dir).cert_path) if ca_dir else ''
result = XuiPanel(host).install(**access['panel'], ca=LocalCA(ca_dir) if ca_dir else None)
report('xui', result)
if not result.ok:
return 1
@@ -114,13 +128,13 @@ def install_xui(address: str, password: str, out: Path, domain: str, ssl: bool)
print(f"panel: {panel_url(access)}/")
print(f"panel login: {panel['username']} / {panel['password']}")
print(f"inbound: {inbound['inbound_id']}, reality target {inbound['target']}")
print(f"ssh: ssh -i {out / KEY_FILE} -p {access['ssh_port']} root@{address}")
print(f"ssh: ssh -i {out / KEY_FILE} -p {access['ssh_port']} root@{access['address']}")
return 0
def manage_key(action: str, name: str, out: Path) -> int:
access = json.loads((out / ACCESS_FILE).read_text())
api = XuiApi(panel_url(access), access['api_token'])
api = XuiApi(panel_url(access), access['api_token'], ca_file=access.get('ca_file') or None)
try:
if action == 'add':
client = api.add_client(access['inbound']['inbound_id'], name)
@@ -140,13 +154,14 @@ def manage_key(action: str, name: str, out: Path) -> int:
return 0
def issue_certificate(domain: str, out: Path) -> int:
def issue_certificate(domain: str, out: Path, ca_dir: Path | None) -> int:
access = json.loads((out / ACCESS_FILE).read_text())
result = XuiPanel(load_host(access)).issue_certificate(domain)
result = XuiPanel(load_host(access)).issue_certificate(domain, LocalCA(ca_dir) if ca_dir else None)
report('cert', result)
if not result.ok:
return 1
access['panel'].update(domain=domain, ssl=True)
access['ca_file'] = str(LocalCA(ca_dir).cert_path) if ca_dir else ''
access['panel_host'] = result.data['panel_host']
save(out, access)
print(f"panel: {panel_url(access)}/")
+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 меняется в два шага, чтобы не потерять сервер:
# сначала sshd слушает старый и новый порт, после проверки входа по ключу на новом порту старый закрывается.
# Если обновление системы что-то поставило, в конце сервер перезагружается: подхватывает новое ядро и библиотеки,
# заодно проверяется, что ssh и nftables переживают перезагрузку. В контейнере перезагрузки нет.
- name: Prepare fresh server (password login)
hosts: target
@@ -9,12 +11,12 @@
vars:
ansible_password: "{{ login_password }}"
tasks:
- name: Require Debian 13
- name: Require Debian 12 or 13
ansible.builtin.assert:
that:
- ansible_facts.distribution == 'Debian'
- ansible_facts.distribution_major_version == '13'
fail_msg: "Supported only Debian 13, got {{ ansible_facts.distribution }} {{ ansible_facts.distribution_version }}"
- ansible_facts.distribution_major_version in ['12', '13']
fail_msg: "Supported only Debian 12 and 13, got {{ ansible_facts.distribution }} {{ ansible_facts.distribution_version }}"
- name: Create .ssh directory
ansible.builtin.file:
@@ -35,6 +37,7 @@
ansible.builtin.apt:
update_cache: true
upgrade: dist
register: system_upgrade
- ansible.builtin.import_tasks: tasks/firewall.yml
vars:
@@ -70,3 +73,10 @@
- name: Check hardened ssh
ansible.builtin.wait_for_connection:
timeout: 60
- name: Reboot after upgrade
ansible.builtin.reboot:
reboot_timeout: 600
when:
- system_upgrade is changed
- ansible_facts.virtualization_type not in ['docker', 'podman', 'container', 'containerd']
+17 -2
View File
@@ -33,10 +33,25 @@
ansible.builtin.command: sysctl --system
changed_when: false
- name: Remove DNS over TLS config
- name: Remove encrypted DNS config
ansible.builtin.file:
path: /etc/systemd/resolved.conf.d/00-serverus.conf
path: "{{ item }}"
state: absent
loop:
- /etc/systemd/resolved.conf.d/00-serverus.conf
- /etc/systemd/network/90-serverus-dns.netdev
- /etc/systemd/network/90-serverus-dns.network
- name: Remove DNS fallback interface
ansible.builtin.command: ip link delete serverus-dns
register: dns_link
changed_when: dns_link.rc == 0
failed_when: false
- name: Stop dnscrypt-proxy
ansible.builtin.command: systemctl disable --now dnscrypt-proxy.socket dnscrypt-proxy.service
changed_when: false
failed_when: false
# systemd-resolved остается: без него /etc/resolv.conf указывает в никуда
- name: Restart systemd-resolved
+86 -5
View File
@@ -1,10 +1,74 @@
# Системный резолвер: systemd-resolved, только DNS-over-TLS к Cloudflare.
# Строгий режим: без TLS запрос не уйдет, открытым текстом DNS не утекает. Резервных серверов нет.
- name: Install systemd-resolved
# Системный резолвер: systemd-resolved с двумя путями до Cloudflare, оба шифрованные.
#
# Основной - DNS-over-TLS (853), строгий режим: без TLS запрос не уйдет, открытым текстом DNS не утекает.
# Запасной - dnscrypt-proxy с DNS-over-HTTPS (443) на 127.0.2.1, если 853 режут. resolved спрашивает оба пути
# параллельно и берет первый успешный ответ. Режим TLS в resolved один на область, поэтому запасной путь живет
# на отдельном dummy интерфейсе serverus-dns со своими настройками. Резервных серверов в открытом виде нет.
# Адрес интерфейса из TEST-NET (192.0.2.0/24) с глобальной областью: интерфейс только с link-local адресом
# resolved для DNS не использует. Проверено: при закрытом 853 ответ приходит через DoH за доли секунды.
- name: Install DNS packages
ansible.builtin.apt:
name: systemd-resolved
name: [ systemd-resolved, dnscrypt-proxy, bind9-dnsutils ]
state: present
- name: Configure dnscrypt-proxy
ansible.builtin.copy:
dest: /etc/dnscrypt-proxy/dnscrypt-proxy.toml
mode: "0644"
content: |
# слушает сокет dnscrypt-proxy.socket (127.0.2.1:53), списки серверов не качает
listen_addresses = []
server_names = ['cloudflare-1', 'cloudflare-2']
ipv4_servers = true
ipv6_servers = false
doh_servers = true
dnscrypt_servers = false
require_dnssec = false
require_nolog = false
require_nofilter = false
cache = true
[static]
[static.'cloudflare-1']
stamp = 'sdns://AgMAAAAAAAAABzEuMS4xLjEAEmNsb3VkZmxhcmUtZG5zLmNvbQovZG5zLXF1ZXJ5'
[static.'cloudflare-2']
stamp = 'sdns://AgMAAAAAAAAABzEuMC4wLjEAEmNsb3VkZmxhcmUtZG5zLmNvbQovZG5zLXF1ZXJ5'
register: dnscrypt_config
- name: Restart dnscrypt-proxy
ansible.builtin.systemd_service:
name: dnscrypt-proxy
enabled: true
state: restarted
when: dnscrypt_config is changed
- name: Create dummy interface for DNS fallback
ansible.builtin.copy:
dest: /etc/systemd/network/90-serverus-dns.netdev
mode: "0644"
content: |
[NetDev]
Name=serverus-dns
Kind=dummy
- name: Route fallback DNS through dnscrypt-proxy
ansible.builtin.copy:
dest: /etc/systemd/network/90-serverus-dns.network
mode: "0644"
content: |
[Match]
Name=serverus-dns
[Network]
Address=192.0.2.53/32
LinkLocalAddressing=no
ConfigureWithoutCarrier=yes
DNS=127.0.2.1
DNSOverTLS=no
DNSSEC=no
Domains=~.
DNSDefaultRoute=yes
- name: Create resolved drop-in directory
ansible.builtin.file:
path: /etc/systemd/resolved.conf.d
@@ -21,6 +85,8 @@
FallbackDNS=
DNSOverTLS=yes
Domains=~.
LLMNR=no
MulticastDNS=no
- name: Use resolved stub
ansible.builtin.file:
@@ -29,16 +95,31 @@
state: link
force: true
# networkd управляет только serverus-dns: остальные интерфейсы без .network файлов он не трогает
- name: Start systemd-networkd
ansible.builtin.systemd_service:
name: systemd-networkd
enabled: true
state: restarted
- name: Restart systemd-resolved
ansible.builtin.systemd_service:
name: systemd-resolved
enabled: true
state: restarted
- name: Check DNS over TLS
- name: Check DNS
ansible.builtin.command: resolvectl query deb.debian.org
register: dns_check
retries: 5
delay: 3
until: dns_check.rc == 0
changed_when: false
- name: Check DNS over HTTPS fallback
ansible.builtin.command: dig +short +time=5 @127.0.2.1 deb.debian.org
register: doh_check
retries: 5
delay: 3
until: doh_check.rc == 0 and doh_check.stdout != ''
changed_when: false
+1 -1
View File
@@ -10,7 +10,7 @@
no_log: true
- name: Read panel address
ansible.builtin.command: "{{ xui_bin }} setting -show true"
ansible.builtin.command: "{{ xui_bin }} setting -show"
register: xui_panel_settings
changed_when: false
no_log: true
+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 порту, он должен быть открыт.
# На ip выдается только короткий сертификат (~6 дней), acme.sh продлевает его по cron и перезапускает панель.
# Ставит сертификат панели и подписке. Если выпустить не удалось, падает, а не оставляет панель на http.
# Без готового сертификата - Let's Encrypt через acme.sh в режиме standalone: проверка HTTP-01 на 80 порту,
# он должен быть открыт. На ip выдается только короткий сертификат (~6 дней), acme.sh продлевает его по cron
# и перезапускает панель. Ставит сертификат панели. Если сертификата нет, падает, а не оставляет панель на http.
- name: Set certificate paths
ansible.builtin.set_fact:
cert_is_ip: "{{ cert_host is match('^[0-9.]+$') or ':' in cert_host }}"
cert_dir: "/root/cert/{{ cert_host }}"
cert_given: "{{ cert_fullchain | default('') != '' }}"
acme: /root/.acme.sh/acme.sh
- name: Install acme.sh dependencies
ansible.builtin.apt:
name: [ curl, socat, cron ]
state: present
- name: Download acme.sh
ansible.builtin.get_url:
url: https://get.acme.sh
dest: /root/acme-install.sh
mode: "0700"
when: not acme is file
- name: Install acme.sh
ansible.builtin.shell: sh /root/acme-install.sh
args:
chdir: /root
creates: "{{ acme }}"
- name: Use Let's Encrypt
ansible.builtin.command: "{{ acme }} --set-default-ca --server letsencrypt"
changed_when: false
# код 2 - действующий сертификат уже есть, продлевать рано
- name: Issue certificate
ansible.builtin.command: >-
{{ acme }} --issue -d {{ cert_host }} --standalone --httpport 80 --server letsencrypt
{{ '--certificate-profile shortlived --days 6' if cert_is_ip else '' }}
register: cert_issue
changed_when: cert_issue.rc == 0
failed_when: cert_issue.rc not in [0, 2]
- name: Create certificate directory
ansible.builtin.file:
path: "{{ cert_dir }}"
state: directory
mode: "0700"
- name: Install certificate
ansible.builtin.command: >-
{{ acme }} --installcert -d {{ cert_host }}
--key-file {{ cert_dir }}/privkey.pem --fullchain-file {{ cert_dir }}/fullchain.pem
--reloadcmd "systemctl restart x-ui"
changed_when: true
- name: Upload certificate
when: cert_given
ansible.builtin.copy:
dest: "{{ cert_dir }}/{{ item.name }}"
content: "{{ item.content }}"
mode: "{{ item.mode }}"
loop:
- { name: fullchain.pem, content: "{{ cert_fullchain | default('') }}", mode: "0644" }
- { name: privkey.pem, content: "{{ cert_private_key | default('') }}", mode: "0600" }
loop_control:
label: "{{ item.name }}"
no_log: true
# иначе cron acme.sh перезапишет загруженный сертификат своим при продлении
- name: Stop Let's Encrypt renewal
when: cert_given and acme is file
ansible.builtin.command: "{{ acme }} --remove -d {{ cert_host }}"
register: acme_remove
changed_when: acme_remove.rc == 0
failed_when: false
- name: Issue Let's Encrypt certificate
when: not cert_given
block:
- name: Install acme.sh dependencies
ansible.builtin.apt:
name: [ curl, socat, cron ]
state: present
- name: Download acme.sh
ansible.builtin.get_url:
url: https://get.acme.sh
dest: /root/acme-install.sh
mode: "0700"
when: not acme is file
- name: Install acme.sh
ansible.builtin.shell: sh /root/acme-install.sh
args:
chdir: /root
creates: "{{ acme }}"
- name: Use Let's Encrypt
ansible.builtin.command: "{{ acme }} --set-default-ca --server letsencrypt"
changed_when: false
# код 2 - действующий сертификат уже есть, продлевать рано
- name: Issue certificate
ansible.builtin.command: >-
{{ acme }} --issue -d {{ cert_host }} --standalone --httpport 80 --server letsencrypt
{{ '--certificate-profile shortlived --days 6' if cert_is_ip else '' }}
register: cert_issue
changed_when: cert_issue.rc == 0
failed_when: cert_issue.rc not in [0, 2]
- name: Install certificate
ansible.builtin.command: >-
{{ acme }} --installcert -d {{ cert_host }}
--key-file {{ cert_dir }}/privkey.pem --fullchain-file {{ cert_dir }}/fullchain.pem
--reloadcmd "systemctl restart x-ui"
changed_when: true
- name: Check certificate
ansible.builtin.command: "openssl x509 -in {{ cert_dir }}/fullchain.pem -noout -checkend 3600 -ext subjectAltName"
+5 -4
View File
@@ -1,6 +1,7 @@
# extravars: username, password, panel_port, web_base_path, sub_port, sub_path, domain (optional), ssl (optional),
# version (optional)
# extravars: username, password, panel_port, web_base_path, sub_port, sub_path, version, domain (optional),
# ssl (optional), cert_fullchain и cert_private_key (optional, готовый сертификат вместо Let's Encrypt)
#
# version - тег релиза 3x-ui: скрипт установки берется из того же тега, панель ставится этой версии.
# Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме, SSL скрипта выключен: он при ошибке молча оставляет
# панель на http. Сертификат выпускает tasks/xui_cert.yml на домен, если он задан, иначе на ip. Нужен открытый 80 порт.
# ssl=false - без сертификата, для серверов без публичного адреса.
@@ -22,14 +23,14 @@
- name: Download install script
ansible.builtin.get_url:
url: https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh
url: "https://raw.githubusercontent.com/mhsanaei/3x-ui/{{ version }}/install.sh"
dest: /root/3x-ui-install.sh
mode: "0700"
force: true
- name: Run install script
ansible.builtin.command:
cmd: "bash /root/3x-ui-install.sh {{ version | default('') }}"
cmd: "bash /root/3x-ui-install.sh {{ version }}"
creates: "{{ xui_bin }}"
environment:
XUI_NONINTERACTIVE: "1"
+2 -1
View File
@@ -122,7 +122,8 @@
- name: Return inbound
vars:
stream: "{{ inbound.streamSettings | from_json }}"
# список отдает строку JSON, а ответ на добавление - уже объект
stream: "{{ inbound.streamSettings if inbound.streamSettings is mapping else inbound.streamSettings | from_json }}"
ansible.builtin.set_stats:
aggregate: false
data:
+3 -2
View File
@@ -22,10 +22,11 @@ class Server:
return self.run('facts')
def bootstrap(self, password: str, public_key: str, new_password: str, ssh_port: int) -> Result:
"""Первичная настройка свежего сервера (Debian 13).
"""Первичная настройка свежего сервера (Debian 12 или 13).
Входит по паролю на host.port, прописывает public_key, меняет пароль root на new_password, обновляет систему,
переносит ssh на ssh_port с входом только по ключу и включает nftables.
переносит ssh на ssh_port с входом только по ключу и включает nftables. Если обновление что-то поставило,
в конце перезагружает сервер.
Для проверки входа по ключу host.private_key должен быть парой к public_key.
"""
return self.run(
+41 -1
View File
@@ -1,11 +1,13 @@
import io
import json
import ssl
import tempfile
import unittest
import urllib.error
from unittest import mock
from . import (
CF_WARP, Host, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
CF_WARP, XUI_VERSION, Host, LocalCA, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
generate_ssh_port, generate_username, parse_proxy, run_playbook,
)
from .credentials import SERVICE_PORT_RANGE, SSH_PORT_RANGE
@@ -78,10 +80,25 @@ class XuiPanelTests(unittest.TestCase):
'web_base_path': 'base',
'sub_port': 12000,
'sub_path': 'sub',
'version': XUI_VERSION,
'domain': 'example.com',
'ssl': True,
})
def test_install_with_local_ca(self):
runner = FakeRunner()
with tempfile.TemporaryDirectory() as directory:
XuiPanel(Host('1.2.3.4'), runner=runner).install('u', 'p', 1, 'b', 2, 's', ca=LocalCA(directory))
extravars = runner.calls[0][2]
self.assertIn('BEGIN CERTIFICATE', extravars['cert_fullchain'])
self.assertIn('PRIVATE KEY', extravars['cert_private_key'])
def test_install_without_ssl_skips_certificate(self):
runner = FakeRunner()
with tempfile.TemporaryDirectory() as directory:
XuiPanel(Host('1.2.3.4'), runner=runner).install('u', 'p', 1, 'b', 2, 's', ssl=False, ca=LocalCA(directory))
self.assertNotIn('cert_fullchain', runner.calls[0][2])
def test_issue_certificate(self):
runner = FakeRunner()
XuiPanel(Host('1.2.3.4'), runner=runner).issue_certificate('example.com')
@@ -137,6 +154,23 @@ class XuiPanelTests(unittest.TestCase):
int(short_id, 16)
class LocalCATests(unittest.TestCase):
def test_ca_is_created_once_and_verifies_certificates(self):
from cryptography import x509
with tempfile.TemporaryDirectory() as directory:
ca = LocalCA(directory)
self.assertEqual(LocalCA(directory).cert_pem, ca.cert_pem)
for host in ('panel.local', '192.168.1.10'):
certificate = ca.issue(host)
leaf = x509.load_pem_x509_certificate(certificate.fullchain.encode())
leaf.verify_directly_issued_by(x509.load_pem_x509_certificate(ca.cert_pem.encode()))
self.assertGreater((leaf.not_valid_after_utc - leaf.not_valid_before_utc).days, 365 * 99)
san = leaf.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
self.assertIn(host, [str(name) for name in san.get_values_for_type(x509.GeneralName)])
self.assertNotIn('KEY', repr(certificate))
ssl.create_default_context(cafile=str(ca.cert_path))
class FakeResponse(io.BytesIO):
def __init__(self, payload):
super().__init__(json.dumps(payload).encode())
@@ -170,6 +204,12 @@ class XuiApiTests(unittest.TestCase):
self.assertEqual((body['client']['email'], body['client']['totalGB']), ('a b', 100))
self.assertEqual(get.full_url, 'https://1.2.3.4:11000/base/panel/api/clients/get/a%20b')
def test_add_client_restores_uuid_and_sub_id(self):
self.responses = [{'success': True}, {'success': True, 'obj': {'client': {}}}]
self.api.add_client(3, 'user', uuid='uuid-1', sub_id='sub-1')
client = json.loads(self.requests[0].data)['client']
self.assertEqual((client['id'], client['subId']), ('uuid-1', 'sub-1'))
def test_delete_client(self):
self.responses = [{'success': True, 'obj': None}]
self.api.delete_client('user')
+19 -7
View File
@@ -5,9 +5,12 @@ import urllib.parse
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from .local_ca import LocalCA
from .runner import Result
from .server import Server
XUI_VERSION = 'v3.8.5'
"""Версия 3x-ui. API панели проверено на ней, обновлять вручную после проверки."""
MAIN_INBOUND = 'main'
CF_WARP = 'cf_warp'
"""Исходящий прокси через Cloudflare WARP."""
@@ -19,12 +22,12 @@ class XuiPanel(Server):
def install(
self, username: str, password: str, panel_port: int, web_base_path: str, sub_port: int, sub_path: str,
domain: str = '', ssl: bool = True,
domain: str = '', ssl: bool = True, ca: LocalCA | None = None,
) -> Result:
"""Ставит панель, включает подписку на sub_port по пути /sub_path/ и DNS-over-TLS к Cloudflare.
"""Ставит панель версии XUI_VERSION, включает подписку на sub_port по пути /sub_path/ и DNS-over-TLS к Cloudflare.
Сертификат Let's Encrypt на domain, без него - на ip; не удалось выпустить - ошибка. ssl=False - панель на http,
для серверов без публичного адреса. Открывает порты 80 (acme), 443, panel_port и sub_port.
Сертификат на domain, без него - на ip: от ca, если он задан, иначе Let's Encrypt; не удалось выпустить - ошибка.
ssl=False - панель на http. Открывает порты 80 (acme), 443, panel_port и sub_port.
Возвращает data: {'api_token', 'panel_host' (домен или ip из сертификата), 'ssl'}.
"""
return self.run(
@@ -35,16 +38,25 @@ class XuiPanel(Server):
web_base_path=web_base_path,
sub_port=sub_port,
sub_path=sub_path,
version=XUI_VERSION,
domain=domain,
ssl=ssl,
**(self._certificate(domain, ca) if ssl else {}),
)
def issue_certificate(self, domain: str = '') -> Result:
def issue_certificate(self, domain: str = '', ca: LocalCA | None = None) -> Result:
"""Выпускает сертификат панели и подписки на domain, без него - на ip. Возвращает data: {'panel_host'}.
Так домен ставится или меняется после установки. Домен должен указывать на сервер или его маскировку.
ca - свой CA вместо Let's Encrypt. Так домен ставится или меняется после установки.
Домен должен указывать на сервер или его маскировку.
"""
return self.run('xui_cert', domain=domain)
return self.run('xui_cert', domain=domain, **self._certificate(domain, ca))
def _certificate(self, domain: str, ca: LocalCA | None) -> dict:
if ca is None:
return {}
certificate = ca.issue(domain or self.host.address)
return {'cert_fullchain': certificate.fullchain, 'cert_private_key': certificate.private_key}
def set_outbound(self, proxy: str = '') -> Result:
"""Весь исходящий трафик клиентов через прокси. Перед применением прокси проверяется панелью.
+18 -14
View File
@@ -14,37 +14,41 @@ class XuiApi:
Ходит в панель напрямую, без ssh: операции частые и должны быть быстрыми.
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._token = token
self.timeout = timeout
self.context = context
self.context = ssl.create_default_context(cafile=ca_file) if ca_file else None
def __repr__(self):
# токен не должен попадать в логи
return f'XuiApi({self.url})'
def add_client(
self, inbound_id: int, email: str, *, total_bytes: int = 0, expiry_time: int = 0, limit_ip: int = 0,
self, inbound_id: int, email: str, *, uuid: str = '', sub_id: str = '', total_bytes: int = 0,
expiry_time: int = 0, limit_ip: int = 0,
) -> dict:
"""Выпускает ключ. email - уникальное имя ключа в панели.
uuid и sub_id - чтобы восстановить ключ после переустановки с прежними ссылками, без них панель генерирует новые.
total_bytes - лимит трафика, expiry_time - срок в мс unix time, 0 - без ограничений.
Возвращает клиента панели, как get_client.
"""
self._call('POST', 'clients/add', {
'client': {
'email': email,
'enable': True,
'totalGB': total_bytes,
'expiryTime': expiry_time,
'limitIp': limit_ip,
},
'inboundIds': [inbound_id],
})
client = {
'email': email,
'enable': True,
'totalGB': total_bytes,
'expiryTime': expiry_time,
'limitIp': limit_ip,
}
if uuid:
client['id'] = uuid
if sub_id:
client['subId'] = sub_id
self._call('POST', 'clients/add', {'client': client, 'inboundIds': [inbound_id]})
return self.get_client(email)
def delete_client(self, email: str):