diff --git a/Makefile b/Makefile index 40d9c42..bbf9e5e 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ SERVERUS_ACCESS_DIR := $(abspath extra/serverus) MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS)) -.PHONY: install freeze wheels sync sync-offline clean help venv dj_makemigrations dj_migrate dj_superuser dj_run dj_test dj_startapp dj_worker tg_run serverus_xui serverus_release +.PHONY: install freeze wheels sync sync-offline clean help venv dj_makemigrations dj_migrate dj_superuser dj_run dj_test dj_startapp dj_worker tg_run serverus_xui serverus_key serverus_release help: @echo "make install [ ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/" @@ -21,6 +21,7 @@ help: @echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)" @echo "make clean - удалить $(WHEELS_DIR)/" @echo "make serverus_xui ip= password= - настроить свежий сервер и поставить 3x-ui, доступы в $(SERVERUS_ACCESS_DIR)//" + @echo "make serverus_key ip= action=add|del|stats name= - ключ на панели, настроенной через serverus_xui" @echo "make serverus_release ip= - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт" venv: @@ -85,6 +86,10 @@ serverus_xui: @test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip= password="; exit 1; } $(SERVERUS) xui $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip) +serverus_key: + @test -n "$(ip)" -a -n "$(action)" -a -n "$(name)" || { echo "Укажите ключ: make serverus_key ip= action=add|del|stats name="; exit 1; } + $(SERVERUS) key $(action) '$(name)' --out $(SERVERUS_ACCESS_DIR)/$(ip) + serverus_release: @test -n "$(ip)" || { echo "Укажите сервер: make serverus_release ip="; exit 1; } $(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip) diff --git a/src/App/README.md b/src/App/README.md index f34bec3..992ab3b 100644 --- a/src/App/README.md +++ b/src/App/README.md @@ -40,3 +40,11 @@ Django приложение связывающее все части сайта 4. facts: `uname -a` для карточки сервера "Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке. + +# TODO: + +-[ ] для панели 3x-ui нужно активировать ssl, панель умеет сама создавать сертификаты для домена или ip адреса. +-[ ] можно ли поставить домен после того как настроили сервер на ip? я имею ввиду поставить домен и выпустить ssl сертификат от letsencrypt. +-[ ] что нужно сделать чтобы сервис мог нормально работать и раздавать прокси интернет через cloudflare? когда домен за прокси клауда. +-[ ] если используется и прокси сервер и домен за claudflare, то будет работать? +-[ ] нужна функция установки прокси в исходящие (outbound) diff --git a/src/App/migrations/0004_point_subscription_inbound.py b/src/App/migrations/0004_point_subscription_inbound.py new file mode 100644 index 0000000..8e609cc --- /dev/null +++ b/src/App/migrations/0004_point_subscription_inbound.py @@ -0,0 +1,39 @@ +from django.db import migrations, models +from serverus import generate_password, generate_port + + +def generate_subscription(apps, schema_editor): + # у уже установленных точек подписка появится после переустановки + Point = apps.get_model('App', 'Point') + for point in Point.objects.all(): + point.sub_port = generate_port(exclude={point.panel_port}) + point.sub_path = generate_password(12) + point.save(update_fields=['sub_port', 'sub_path']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('App', '0003_server_default_root_password'), + ] + + operations = [ + migrations.AddField( + model_name='point', + name='panel_inbound_id', + field=models.PositiveIntegerField(blank=True, help_text='Подключение reality в панели', null=True), + ), + migrations.AddField( + model_name='point', + name='sub_port', + field=models.PositiveIntegerField(default=0, help_text='Порт подписки панели'), + preserve_default=False, + ), + migrations.AddField( + model_name='point', + name='sub_path', + field=models.CharField(default='', help_text='Путь подписки без слешей', max_length=64), + preserve_default=False, + ), + migrations.RunPython(generate_subscription, migrations.RunPython.noop), + ] diff --git a/src/App/models.py b/src/App/models.py index e01a77a..196f652 100644 --- a/src/App/models.py +++ b/src/App/models.py @@ -4,10 +4,7 @@ from dataclasses import dataclass from django.contrib.auth.base_user import BaseUserManager from django.contrib.auth.models import AbstractUser from django.db import models, transaction -from serverus import Host, generate_password - -PANEL_PORT_RANGE = (10000, 20000) -"""Не пересекается с диапазоном ssh портов serverus.""" +from serverus import Host, generate_password, generate_port, generate_username class UserManager(BaseUserManager): @@ -95,7 +92,7 @@ class PointManager(models.Manager.from_queryset(PointQuerySet)): Точка без сервера панели не существует, поэтому панель обязательна. Имя точки и доступ к панели генерируются. """ - low, high = PANEL_PORT_RANGE + panel_port = generate_port() with transaction.atomic(): point = self.create( owner=owner, @@ -103,10 +100,12 @@ class PointManager(models.Manager.from_queryset(PointQuerySet)): domain=domain, cloudflare=cloudflare, outbound_proxy=outbound_proxy, - panel_username=generate_password(12), + panel_username=generate_username(), panel_password=generate_password(), - panel_port=low + secrets.randbelow(high - low), + panel_port=panel_port, panel_path=generate_password(18), + sub_port=generate_port(exclude={panel_port}), + sub_path=generate_password(12), ) Server.objects.create_server(point, Server.Role.PANEL, panel) if mask: @@ -152,6 +151,9 @@ class Point(models.Model): panel_port = models.PositiveIntegerField() panel_path = models.CharField(max_length=64) panel_api_token = models.CharField(max_length=255, blank=True) + panel_inbound_id = models.PositiveIntegerField(null=True, blank=True, help_text='Подключение reality в панели') + sub_port = models.PositiveIntegerField(help_text='Порт подписки панели') + sub_path = models.CharField(max_length=64, help_text='Путь подписки без слешей') created_at = models.DateTimeField(auto_now_add=True) diff --git a/src/App/services.py b/src/App/services.py index 3aa1063..3094a05 100644 --- a/src/App/services.py +++ b/src/App/services.py @@ -82,15 +82,21 @@ def _bootstrap(server: Server): def _install_panel(server: Server): point = server.point - result = _check(server, serverus.XuiPanel(server.host()).install( + panel = serverus.XuiPanel(server.host()) + result = _check(server, panel.install( username=point.panel_username, password=point.panel_password, panel_port=point.panel_port, web_base_path=point.panel_path, + sub_port=point.sub_port, + sub_path=point.sub_path, domain=point.domain, )) point.panel_api_token = result.data.get('api_token', '') point.save(update_fields=['panel_api_token']) + result = _check(server, panel.create_inbound()) + point.panel_inbound_id = result.data.get('inbound_id') + point.save(update_fields=['panel_inbound_id']) def _redirect_to_panel(server: Server): diff --git a/src/App/tests_services.py b/src/App/tests_services.py index 5e7e640..879fe5d 100644 --- a/src/App/tests_services.py +++ b/src/App/tests_services.py @@ -5,7 +5,9 @@ from django.test import TestCase from Telegram.models import User as TelegramUser from . import services -from .models import PANEL_PORT_RANGE, Credentials, Point, Server +from serverus.credentials import SERVICE_PORT_RANGE + +from .models import Credentials, Point, Server class FakeRunner: @@ -19,7 +21,11 @@ class FakeRunner: self.calls.append((playbook, host, extravars)) if playbook == self.fail: return serverus.Result(ok=False, status='failed', error='boom', known_hosts=f'kh {host.address}') - data = {'facts': {'uname': 'Linux test'}, 'xui': {'api_token': 'token'}}.get(playbook, {}) + data = { + 'facts': {'uname': 'Linux test'}, + 'xui': {'api_token': 'token'}, + 'xui_inbound': {'inbound_id': 7}, + }.get(playbook, {}) return serverus.Result(ok=True, status='successful', data=data, known_hosts=f'kh {host.address}') def playbooks(self, address): @@ -48,9 +54,12 @@ class CreatePointTests(TestCase): self.assertEqual(point.status, Point.Status.INSTALLING) self.assertEqual((point.panel.address, point.panel.root_password), ('10.0.0.1', 'panel-pw')) self.assertEqual((point.mask.address, point.mask.root_password), ('10.0.0.2', 'mask-pw')) - low, high = PANEL_PORT_RANGE + low, high = SERVICE_PORT_RANGE self.assertTrue(low <= point.panel_port < high) - self.assertTrue(point.panel_username and point.panel_password and point.panel_path) + self.assertTrue(low <= point.sub_port < high) + self.assertNotEqual(point.panel_port, point.sub_port) + self.assertEqual(len(point.panel_username), 6) + self.assertTrue(point.panel_password and point.panel_path and point.sub_path) self.assertEqual(point.panel.default_root_password, 'panel-pw') self.assertEqual(point.entry, point.mask) @@ -94,7 +103,8 @@ class InstallTests(TestCase): point.refresh_from_db() self.assertEqual(point.status, Point.Status.READY) self.assertEqual(point.panel_api_token, 'token') - self.assertEqual(runner.playbooks('10.0.0.1'), ['bootstrap', 'xui', 'facts']) + self.assertEqual(point.panel_inbound_id, 7) + self.assertEqual(runner.playbooks('10.0.0.1'), ['bootstrap', 'xui', 'xui_inbound', 'facts']) self.assertEqual(runner.playbooks('10.0.0.2'), ['bootstrap', 'proxy', 'facts']) host, extravars = runner.call('bootstrap', '10.0.0.1') @@ -111,6 +121,7 @@ class InstallTests(TestCase): self.assertEqual((host.port, host.known_hosts), (panel.ssh_port, 'kh 10.0.0.1')) self.assertEqual(extravars['domain'], 'example.com') self.assertEqual(extravars['panel_port'], point.panel_port) + self.assertEqual((extravars['sub_port'], extravars['sub_path']), (point.sub_port, point.sub_path)) _, extravars = runner.call('proxy', '10.0.0.2') self.assertEqual(extravars['target_ip'], '10.0.0.1') @@ -120,7 +131,7 @@ class InstallTests(TestCase): runner = FakeRunner() with patch_runner(runner): services.install_point(point) - self.assertEqual([playbook for playbook, _, _ in runner.calls], ['bootstrap', 'xui', 'facts']) + self.assertEqual([playbook for playbook, _, _ in runner.calls], ['bootstrap', 'xui', 'xui_inbound', 'facts']) point.refresh_from_db() self.assertEqual(point.status, Point.Status.READY) diff --git a/src/serverus/README.md b/src/serverus/README.md index ccd0ff1..f11ef3e 100644 --- a/src/serverus/README.md +++ b/src/serverus/README.md @@ -54,14 +54,33 @@ server.release(generate_password()) Каждая операция возвращает `Result(ok, status, data, error)`. Ключи, пароли и артефакты ansible живут во временной папке только на время запуска. Хранить ключи и пароли - задача вызывающей стороны. - `bootstrap` - проверяет Debian 13, прописывает ключ, меняет пароль root, обновляет систему, включает nftables и переносит ssh на новый порт с входом только по ключу. Порт меняется в два шага с проверкой входа по ключу, чтобы не потерять доступ. -- `release` - ставит новый пароль root, сбрасывает nftables, удаляет drop-in sshd (возвращаются настройки провайдера) и authorized_keys. +- `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, domain='')` - ставит 3x-ui скриптом из README проекта в неинтерактивном режиме, сертификат на домен или на ip. Открывает 80 (acme), 443 и порт панели. Возвращает `data['api_token']`. +- `XuiPanel(host).install(username, password, panel_port, web_base_path, sub_port, sub_path, domain='')` - ставит 3x-ui скриптом из README проекта в неинтерактивном режиме, сертификат на домен или на ip. Включает подписку на `sub_port` по пути `/sub_path/` под тем же сертификатом. Системный DNS переводит на DNS-over-TLS к Cloudflare (systemd-resolved, строгий режим, без резервных серверов), DNS в Xray - через системный резолвер: сам Xray DoT не умеет. Открывает 80 (acme), 443, порт панели и порт подписки. Возвращает `data['api_token']`. +- `XuiPanel(host).create_inbound(remark='main', target='')` - подключение vless + xhttp + reality на 443, uTLS firefox, сброс трафика ежемесячно. Цель reality подбирает сканер панели (TLS 1.3, h2, X25519, валидный сертификат, минимальная задержка) из `target` или из своих кандидатов. Ключи x25519 генерирует панель, shortId и путь xhttp - serverus. Существующее подключение с тем же `remark` не трогает. Возвращает `data`: `inbound_id`, `target`, `server_name`, `public_key`, `short_ids`, `path`. - `RedirectProxy(host).redirect(target_ip)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh `, который перенаправляет весь трафик, кроме ssh, на target_ip. **Сейчас скрипт - заглушка.** +Порты и логины: `generate_port(exclude=...)` - порт сервиса из 10000-20000 (ssh - 20000-60000), `generate_username()` - 6 символов. + +Плейбуки ходят в API панели с самого сервера на `127.0.0.1` со своим токеном `serverus`, который перевыпускается на каждом запуске. Токен `api_token`, отданный вызывающей стороне, при этом не меняется. + +## Ключи + +`XuiApi(url, api_token)` - выпуск, отзыв и статистика ключей через HTTP API панели, без ssh. `url` - `https://<домен или ip>:/`, сертификат панели проверяется. Ошибки - `XuiApiError`. + +```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.get_links('user-1') # ['vless://...'] +api.get_traffic('user-1') # up, down, total, expiryTime, lastOnline +api.delete_client('user-1') +``` + +Подписка ключа: `https://<домен или ip>://`. + ## Отпечаток сервера `Result.known_hosts` - отпечаток ssh сервера. Его нужно сохранить и передавать в `Host(known_hosts=...)`: тогда подключение к подмененному серверу не пройдет. Пустой `known_hosts` - доверяем ключу при первом подключении. @@ -75,11 +94,32 @@ server.release(generate_password()) ## Ручная проверка на живом сервере ``` -make serverus_xui ip= password= # bootstrap + 3x-ui -make serverus_release ip= # вернуть сервер +make serverus_xui ip= password= # bootstrap + 3x-ui + подключение reality +make serverus_key ip= action=add|del|stats name= # ключ на панели +make serverus_release ip= # вернуть сервер ``` -То же без Makefile: `python -m serverus xui --out ` и `python -m serverus release --out `. -Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в `extra/serverus//` до начала настройки, так что доступ к серверу не теряется, даже если настройка упала. +То же без Makefile: `python -m serverus xui --out `, `python -m serverus key add --out ` и `python -m serverus release --out `. +Доступы (ключ, пароль root, порт ssh, отпечаток, панель, токен API, подключение) пишутся в `extra/serverus//` до начала настройки, так что доступ к серверу не теряется, даже если настройка упала. Тесты: `python -m unittest serverus.tests` из `src/`. Плейбуки проверены на Debian 13 в контейнере с systemd. + +## TODO + +- [x] у сервера должен быть рандомный порт, а не 12345 +- [x] у сервера должен быть рандомный юзернейм, пусть теже 4 символа или 6 для солидности +- [x] следует так же устанавливать рандомный порт подписки +- [x] нужно включить dns и настроить на использование dns-over-tls от cloudflare +- [x] нужно настроить исходящее подключение: + - примечание: main + - протокол: vless + - порт: 443 + - сброс трафика: ежемесячно + - транспорт: xhttp + - безопасность: reality + - uTLS: firefox + - подобрать цель + - сгенерировать ключи и сертификаты +- [x] нужен интерфейс выпуска, отзыва и получения статистики ключей +- [ ] проверить `xui` и `xui_inbound` на живом сервере: API панели проверено только по исходникам 3x-ui v3.8.5 +- [ ] выпуск и отзыв ключей в боте поверх `XuiApi` diff --git a/src/serverus/__init__.py b/src/serverus/__init__.py index 31567fd..9327c29 100644 --- a/src/serverus/__init__.py +++ b/src/serverus/__init__.py @@ -1,9 +1,12 @@ -from .credentials import KeyPair, generate_keypair, generate_password, generate_ssh_port +from .credentials import ( + KeyPair, generate_keypair, generate_password, generate_port, generate_ssh_port, generate_username, +) from .host import Host from .proxy import RedirectProxy from .runner import Result, run_playbook from .server import Server from .xui import XuiPanel +from .xui_api import XuiApi, XuiApiError __all__ = [ 'Host', @@ -11,9 +14,13 @@ __all__ = [ 'RedirectProxy', 'Result', 'Server', + 'XuiApi', + 'XuiApiError', 'XuiPanel', 'generate_keypair', 'generate_password', + 'generate_port', 'generate_ssh_port', + 'generate_username', 'run_playbook', ] diff --git a/src/serverus/__main__.py b/src/serverus/__main__.py index d1290f8..fd302e3 100644 --- a/src/serverus/__main__.py +++ b/src/serverus/__main__.py @@ -1,6 +1,7 @@ """Ручная проверка сценариев на живом сервере. python -m serverus xui --out + python -m serverus key add|del|stats --out python -m serverus release --out Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в /access.json до начала настройки: @@ -11,11 +12,13 @@ import json import sys from pathlib import Path -from . import Host, Server, XuiPanel, generate_keypair, generate_password, generate_ssh_port +from . import ( + Host, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port, + generate_ssh_port, generate_username, +) ACCESS_FILE = 'access.json' KEY_FILE = 'id_ed25519' -PANEL_PORT = 12345 def main(argv=None) -> int: @@ -27,15 +30,25 @@ def main(argv=None) -> int: xui.add_argument('password', help='текущий пароль root') xui.add_argument('--out', type=Path, required=True, help='папка для доступов к серверу') + key = commands.add_parser('key', help='выпуск, отзыв и статистика ключа на панели от команды xui') + key.add_argument('action', choices=['add', 'del', 'stats']) + key.add_argument('name', help='имя ключа (email клиента в панели)') + key.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui') + release = commands.add_parser('release', help='вернуть сервер: вход по паролю на 22 порт, новый пароль root') release.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui') args = parser.parse_args(argv) - return install_xui(args.address, args.password, args.out) if args.command == 'xui' else release_server(args.out) + if args.command == 'xui': + return install_xui(args.address, args.password, args.out) + if args.command == 'key': + return manage_key(args.action, args.name, args.out) + return release_server(args.out) def install_xui(address: str, password: str, out: Path) -> int: keys = generate_keypair() + panel_port = generate_port() access = { 'address': address, 'ssh_port': generate_ssh_port(), @@ -43,10 +56,12 @@ def install_xui(address: str, password: str, out: Path) -> int: 'private_key': keys.private_key, 'known_hosts': '', 'panel': { - 'username': 'admin', + 'username': generate_username(), 'password': generate_password(16), - 'panel_port': PANEL_PORT, + 'panel_port': panel_port, 'web_base_path': generate_password(18), + 'sub_port': generate_port(exclude={panel_port}), + 'sub_path': generate_password(12), }, } save(out, access) @@ -65,15 +80,45 @@ def install_xui(address: str, password: str, out: Path) -> int: report('xui', result) if not result.ok: return 1 + access['api_token'] = result.data['api_token'] + save(out, access) - panel = access['panel'] + result = XuiPanel(host).create_inbound() + report('inbound', result) + if not result.ok: + return 1 + access['inbound'] = result.data + save(out, access) + + panel, inbound = access['panel'], access['inbound'] print(f"uname: {Server(host).facts().data.get('uname', '?')}") - print(f"panel: http(s)://{address}:{panel['panel_port']}/{panel['web_base_path']}/") + 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}") 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']) + try: + if action == 'add': + client = api.add_client(access['inbound']['inbound_id'], name) + panel = access['panel'] + print(f"subscription: https://{access['address']}:{panel['sub_port']}/{panel['sub_path']}/{client['subId']}") + print(*api.get_links(name), sep='\n') + elif action == 'del': + api.delete_client(name) + print(f'{name}: deleted') + else: + print(json.dumps(api.get_traffic(name), indent=2)) + except XuiApiError as error: + print(error, file=sys.stderr) + return 1 + return 0 + + def release_server(out: Path) -> int: access = json.loads((out / ACCESS_FILE).read_text()) new_password = generate_password() @@ -87,6 +132,11 @@ def release_server(out: Path) -> int: return 0 +def panel_url(access: dict) -> str: + panel = access['panel'] + return f"https://{access['address']}:{panel['panel_port']}/{panel['web_base_path']}" + + def load_host(access: dict) -> Host: return Host( access['address'], port=access['ssh_port'], private_key=access['private_key'], diff --git a/src/serverus/credentials.py b/src/serverus/credentials.py index 0350cc3..885a3c1 100644 --- a/src/serverus/credentials.py +++ b/src/serverus/credentials.py @@ -6,7 +6,10 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey PASSWORD_ALPHABET = string.ascii_letters + string.digits +USERNAME_ALPHABET = string.ascii_lowercase + string.digits SSH_PORT_RANGE = (20000, 60000) +SERVICE_PORT_RANGE = (10000, 20000) +"""Порты панели, подписки и других сервисов. Не пересекается с диапазоном ssh портов.""" @dataclass(frozen=True) @@ -36,6 +39,20 @@ def generate_password(length: int = 32) -> str: return ''.join(secrets.choice(PASSWORD_ALPHABET) for _ in range(length)) +def generate_username(length: int = 6) -> str: + """Логин из строчных букв и цифр, начинается с буквы.""" + first = secrets.choice(string.ascii_lowercase) + return first + ''.join(secrets.choice(USERNAME_ALPHABET) for _ in range(length - 1)) + + +def generate_port(port_range: tuple[int, int] = SERVICE_PORT_RANGE, exclude=()) -> int: + """Случайный порт из port_range, не совпадающий с портами из exclude.""" + low, high = port_range + while True: + port = low + secrets.randbelow(high - low) + if port not in exclude: + return port + + def generate_ssh_port() -> int: - low, high = SSH_PORT_RANGE - return low + secrets.randbelow(high - low) + return generate_port(SSH_PORT_RANGE) diff --git a/src/serverus/playbooks/release.yml b/src/serverus/playbooks/release.yml index 4e0df71..ad3d84d 100644 --- a/src/serverus/playbooks/release.yml +++ b/src/serverus/playbooks/release.yml @@ -31,6 +31,16 @@ ansible.builtin.command: sysctl --system changed_when: false + - name: Remove DNS over TLS config + ansible.builtin.file: + path: /etc/systemd/resolved.conf.d/00-serverus.conf + state: absent + + # systemd-resolved остается: без него /etc/resolv.conf указывает в никуда + - name: Restart systemd-resolved + ansible.builtin.command: systemctl try-restart systemd-resolved + changed_when: false + - name: Remove sshd drop-in ansible.builtin.file: path: /etc/ssh/sshd_config.d/00-serverus.conf diff --git a/src/serverus/playbooks/tasks/dns.yml b/src/serverus/playbooks/tasks/dns.yml new file mode 100644 index 0000000..4565ff5 --- /dev/null +++ b/src/serverus/playbooks/tasks/dns.yml @@ -0,0 +1,44 @@ +# Системный резолвер: systemd-resolved, только DNS-over-TLS к Cloudflare. +# Строгий режим: без TLS запрос не уйдет, открытым текстом DNS не утекает. Резервных серверов нет. +- name: Install systemd-resolved + ansible.builtin.apt: + name: systemd-resolved + state: present + +- name: Create resolved drop-in directory + ansible.builtin.file: + path: /etc/systemd/resolved.conf.d + state: directory + mode: "0755" + +- name: Configure DNS over TLS + ansible.builtin.copy: + dest: /etc/systemd/resolved.conf.d/00-serverus.conf + mode: "0644" + content: | + [Resolve] + DNS=1.1.1.1#cloudflare-dns.com 1.0.0.1#cloudflare-dns.com 2606:4700:4700::1111#cloudflare-dns.com 2606:4700:4700::1001#cloudflare-dns.com + FallbackDNS= + DNSOverTLS=yes + Domains=~. + +- name: Use resolved stub + ansible.builtin.file: + src: /run/systemd/resolve/stub-resolv.conf + dest: /etc/resolv.conf + state: link + force: true + +- name: Restart systemd-resolved + ansible.builtin.systemd_service: + name: systemd-resolved + enabled: true + state: restarted + +- name: Check DNS over TLS + ansible.builtin.command: resolvectl query deb.debian.org + register: dns_check + retries: 5 + delay: 3 + until: dns_check.rc == 0 + changed_when: false diff --git a/src/serverus/playbooks/tasks/xui_api.yml b/src/serverus/playbooks/tasks/xui_api.yml new file mode 100644 index 0000000..6d9280c --- /dev/null +++ b/src/serverus/playbooks/tasks/xui_api.yml @@ -0,0 +1,39 @@ +# Готовит доступ к API панели 3x-ui с самого сервера: xui_api_url и xui_api_headers. +# +# Плейбук берет свой токен serverus и перевыпускает его на каждом запуске. +# Токен, отданный вызывающей стороне (install -> api_token), при этом не меняется. +- name: Issue serverus API token + ansible.builtin.shell: "{{ xui_bin }} setting -getApiToken -tokenName serverus | grep -Eo 'apiToken: .+' | awk '{print $2}'" + register: xui_api_token + changed_when: false + failed_when: xui_api_token.rc != 0 or not xui_api_token.stdout + no_log: true + +- name: Read panel address + ansible.builtin.command: "{{ xui_bin }} setting -show true" + register: xui_panel_settings + changed_when: false + no_log: true + +- name: Set API access + vars: + settings: "{{ xui_panel_settings.stdout }}" + scheme: "{{ 'http' if 'not secure with SSL' in settings else 'https' }}" + api_port: "{{ settings | regex_search('port: (\\d+)', '\\1') | first }}" + base_path: "{{ (settings | regex_search('webBasePath: (\\S*)', '\\1') | first).strip('/') }}" + ansible.builtin.set_fact: + xui_api_url: "{{ scheme }}://127.0.0.1:{{ api_port }}{{ '/' ~ base_path if base_path else '' }}/panel/api" + xui_api_headers: + Authorization: "Bearer {{ xui_api_token.stdout }}" + no_log: true + +# после перезапуска панели API поднимается не сразу +- name: Wait for panel API + ansible.builtin.uri: + url: "{{ xui_api_url }}/server/status" + headers: "{{ xui_api_headers }}" + validate_certs: false + register: xui_api_status + retries: 10 + delay: 3 + until: xui_api_status.status == 200 diff --git a/src/serverus/playbooks/xui.yml b/src/serverus/playbooks/xui.yml index 155f1dc..b6eff09 100644 --- a/src/serverus/playbooks/xui.yml +++ b/src/serverus/playbooks/xui.yml @@ -1,7 +1,8 @@ -# extravars: username, password, panel_port, web_base_path, domain (optional), version (optional) +# extravars: username, password, panel_port, web_base_path, sub_port, sub_path, domain (optional), version (optional) # # Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме. # Сертификат: на домен, если он задан, иначе на ip. Оба варианта требуют открытый 80 порт (acme). +# Системный DNS - DNS-over-TLS к Cloudflare, Xray резолвит через него. Подписка на sub_port под тем же сертификатом. - name: Install 3x-ui panel hosts: target gather_facts: false @@ -11,7 +12,9 @@ tasks: - ansible.builtin.import_tasks: tasks/firewall.yml vars: - firewall_tcp_ports: [ "{{ ansible_port }}", 80, 443, "{{ panel_port }}" ] + firewall_tcp_ports: [ "{{ ansible_port }}", 80, 443, "{{ panel_port }}", "{{ sub_port }}" ] + + - ansible.builtin.import_tasks: tasks/dns.yml - name: Download install script ansible.builtin.get_url: @@ -54,6 +57,75 @@ changed_when: false no_log: true + - ansible.builtin.import_tasks: tasks/xui_api.yml + + # настройки меняются только целиком: читаем все, меняем подписку, отправляем обратно + - name: Read panel settings + ansible.builtin.uri: + url: "{{ xui_api_url }}/setting/all" + method: POST + headers: "{{ xui_api_headers }}" + validate_certs: false + register: panel_settings + failed_when: not panel_settings.json.success | default(false) + no_log: true + + - name: Enable subscription + vars: + settings: "{{ panel_settings.json.obj }}" + ansible.builtin.uri: + url: "{{ xui_api_url }}/setting/update" + method: POST + headers: "{{ xui_api_headers }}" + body_format: json + body: >- + {{ settings | combine({ + 'subEnable': true, + 'subPort': sub_port | int, + 'subPath': '/' ~ sub_path ~ '/', + 'subCertFile': settings.webCertFile, + 'subKeyFile': settings.webKeyFile, + }) }} + validate_certs: false + register: sub_update + failed_when: not sub_update.json.success | default(false) + no_log: true + + - name: Read Xray config + ansible.builtin.uri: + url: "{{ xui_api_url }}/xray/" + method: POST + headers: "{{ xui_api_headers }}" + validate_certs: false + register: xray_setting + failed_when: not xray_setting.json.success | default(false) + + # Xray не умеет DoT: отправляем его запросы в системный резолвер, а тот ходит в Cloudflare по DoT + - name: Enable Xray DNS + vars: + config: "{{ (xray_setting.json.obj | from_json).xraySetting }}" + ansible.builtin.uri: + url: "{{ xui_api_url }}/xray/update" + method: POST + headers: "{{ xui_api_headers }}" + body_format: form-urlencoded + body: + xraySetting: "{{ config | combine({'dns': {'servers': ['localhost'], 'queryStrategy': 'UseIP'}}) | to_json }}" + validate_certs: false + register: xray_update + failed_when: not xray_update.json.success | default(false) + + # подписка слушает новый порт только после перезапуска панели + - name: Restart panel with subscription + ansible.builtin.systemd_service: + name: x-ui + state: restarted + + - name: Wait for subscription + ansible.builtin.wait_for: + port: "{{ sub_port }}" + timeout: 60 + - name: Return panel access ansible.builtin.set_stats: aggregate: false diff --git a/src/serverus/playbooks/xui_inbound.yml b/src/serverus/playbooks/xui_inbound.yml new file mode 100644 index 0000000..cdf964b --- /dev/null +++ b/src/serverus/playbooks/xui_inbound.yml @@ -0,0 +1,135 @@ +# extravars: remark, short_ids, xhttp_path, target (optional) +# +# Создает в 3x-ui подключение vless + xhttp + reality на 443 порту с ежемесячным сбросом трафика. +# Цель reality подбирает сканер панели: TLS 1.3, h2, X25519 и валидный сертификат, из подходящих - с наименьшей задержкой. +# Без target сканируются кандидаты из настроек панели. Ключи x25519 генерирует панель. +# Если подключение с таким remark уже есть, оно не меняется: плейбук только возвращает его параметры. +- name: Create reality inbound + hosts: target + gather_facts: false + vars: + xui_bin: /usr/local/x-ui/x-ui + inbound_port: 443 + fingerprint: firefox + tasks: + - ansible.builtin.import_tasks: tasks/xui_api.yml + + - name: List inbounds + ansible.builtin.uri: + url: "{{ xui_api_url }}/inbounds/list" + headers: "{{ xui_api_headers }}" + validate_certs: false + register: inbounds + failed_when: not inbounds.json.success | default(false) + no_log: true + + - name: Find inbound + ansible.builtin.set_fact: + inbound: "{{ inbounds.json.obj | default([], true) | selectattr('remark', 'equalto', remark) | first | default(none) }}" + no_log: true + + - name: Create inbound + when: inbound is none + block: + - name: Scan reality targets + ansible.builtin.uri: + url: "{{ xui_api_url }}/server/scanRealityTargets" + method: POST + headers: "{{ xui_api_headers }}" + body_format: form-urlencoded + body: + targets: "{{ target | default('') }}" + validate_certs: false + timeout: 120 + register: scan + failed_when: not scan.json.success | default(false) + + - name: Choose reality target + ansible.builtin.set_fact: + reality_target: >- + {{ scan.json.obj + | selectattr('feasible') | rejectattr('privateTarget') + | selectattr('tls13') | selectattr('h2') | selectattr('x25519') | selectattr('certValid') + | sort(attribute='latencyMs') | first | default(none) }} + + - name: Require reality target + ansible.builtin.assert: + that: reality_target is not none + fail_msg: "No suitable reality target: {{ scan.json.obj | map(attribute='target') | join(', ') }}" + + - name: Generate x25519 keys + ansible.builtin.uri: + url: "{{ xui_api_url }}/server/getNewX25519Cert" + headers: "{{ xui_api_headers }}" + validate_certs: false + register: x25519 + failed_when: not x25519.json.success | default(false) + no_log: true + + - name: Add inbound + ansible.builtin.uri: + url: "{{ xui_api_url }}/inbounds/add" + method: POST + headers: "{{ xui_api_headers }}" + body_format: json + body: + remark: "{{ remark }}" + enable: true + listen: "" + port: "{{ inbound_port }}" + protocol: vless + total: 0 + expiryTime: 0 + trafficReset: monthly + trafficResetDay: 1 + settings: "{{ {'clients': [], 'decryption': 'none', 'fallbacks': []} | to_json }}" + streamSettings: >- + {{ { + 'network': 'xhttp', + 'security': 'reality', + 'externalProxy': [], + 'xhttpSettings': {'path': xhttp_path, 'host': '', 'mode': 'auto', 'xPaddingBytes': '100-1000'}, + 'realitySettings': { + 'show': false, + 'xver': 0, + 'target': reality_target.host ~ ':' ~ reality_target.port, + 'serverNames': [reality_target.host], + 'privateKey': x25519.json.obj.privateKey, + 'minClientVer': '', + 'maxClientVer': '', + 'maxTimediff': 0, + 'shortIds': short_ids, + 'mldsa65Seed': '', + 'settings': { + 'publicKey': x25519.json.obj.publicKey, + 'fingerprint': fingerprint, + 'serverName': '', + 'spiderX': '/', + 'mldsa65Verify': '', + }, + }, + } | to_json }} + sniffing: "{{ {'enabled': true, 'destOverride': ['http', 'tls', 'quic', 'fakedns'], 'metadataOnly': false, 'routeOnly': false} | to_json }}" + validate_certs: false + register: added + failed_when: not added.json.success | default(false) + no_log: true + + - name: Remember created inbound + ansible.builtin.set_fact: + inbound: "{{ added.json.obj }}" + no_log: true + + - name: Return inbound + vars: + stream: "{{ inbound.streamSettings | from_json }}" + ansible.builtin.set_stats: + aggregate: false + data: + inbound_id: "{{ inbound.id }}" + port: "{{ inbound.port }}" + target: "{{ stream.realitySettings.target | default(stream.realitySettings.dest | default('')) }}" + server_name: "{{ stream.realitySettings.serverNames | first }}" + public_key: "{{ stream.realitySettings.settings.publicKey }}" + short_ids: "{{ stream.realitySettings.shortIds }}" + path: "{{ stream.xhttpSettings.path }}" diff --git a/src/serverus/tests.py b/src/serverus/tests.py index 7ae1248..25c4694 100644 --- a/src/serverus/tests.py +++ b/src/serverus/tests.py @@ -1,7 +1,14 @@ +import io +import json import unittest +import urllib.error +from unittest import mock -from . import Host, Result, Server, generate_keypair, generate_password, generate_ssh_port, run_playbook -from .credentials import SSH_PORT_RANGE +from . import ( + Host, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port, + generate_ssh_port, generate_username, run_playbook, +) +from .credentials import SERVICE_PORT_RANGE, SSH_PORT_RANGE class FakeRunner: @@ -29,6 +36,16 @@ class CredentialsTests(unittest.TestCase): low, high = SSH_PORT_RANGE self.assertTrue(low <= generate_ssh_port() < high) + def test_service_port_excludes_taken(self): + low, high = SERVICE_PORT_RANGE + taken = set(range(low, high - 1)) + self.assertEqual(generate_port(exclude=taken), high - 1) + + def test_username(self): + username = generate_username() + self.assertEqual(len(username), 6) + self.assertTrue(username[0].isalpha() and username.islower()) + def test_host_repr_hides_key(self): self.assertNotIn('secret', repr(Host('1.2.3.4', private_key='secret'))) @@ -49,6 +66,89 @@ class ServerTests(unittest.TestCase): }) +class XuiPanelTests(unittest.TestCase): + def test_install_passes_subscription(self): + runner = FakeRunner() + XuiPanel(Host('1.2.3.4'), runner=runner).install('user', 'pw', 11000, 'base', 12000, 'sub', 'example.com') + self.assertEqual(runner.calls[0][0], 'xui') + self.assertEqual(runner.calls[0][2], { + 'username': 'user', + 'password': 'pw', + 'panel_port': 11000, + 'web_base_path': 'base', + 'sub_port': 12000, + 'sub_path': 'sub', + 'domain': 'example.com', + }) + + def test_create_inbound_generates_reality_ids(self): + runner = FakeRunner() + XuiPanel(Host('1.2.3.4'), runner=runner).create_inbound() + playbook, _, extravars = runner.calls[0] + self.assertEqual((playbook, extravars['remark'], extravars['target']), ('xui_inbound', 'main', '')) + self.assertTrue(extravars['xhttp_path'].startswith('/')) + for short_id in extravars['short_ids']: + self.assertTrue(2 <= len(short_id) <= 16 and len(short_id) % 2 == 0) + int(short_id, 16) + + +class FakeResponse(io.BytesIO): + def __init__(self, payload): + super().__init__(json.dumps(payload).encode()) + + +class XuiApiTests(unittest.TestCase): + def setUp(self): + self.api = XuiApi('https://1.2.3.4:11000/base/', 'secret') + self.requests = [] + self.responses = [] + patcher = mock.patch('urllib.request.urlopen', self.urlopen) + patcher.start() + self.addCleanup(patcher.stop) + + def urlopen(self, request, timeout, context): + self.requests.append(request) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return FakeResponse(response) + + def test_add_client(self): + client = {'email': 'a b', 'uuid': 'uuid', 'subId': 'sub'} + self.responses = [{'success': True, 'obj': None}, {'success': True, 'obj': {'client': client}}] + self.assertEqual(self.api.add_client(3, 'a b', total_bytes=100), client) + add, get = self.requests + self.assertEqual((add.method, add.full_url), ('POST', 'https://1.2.3.4:11000/base/panel/api/clients/add')) + self.assertEqual(add.get_header('Authorization'), 'Bearer secret') + body = json.loads(add.data) + self.assertEqual(body['inboundIds'], [3]) + 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_delete_client(self): + self.responses = [{'success': True, 'obj': None}] + self.api.delete_client('user') + self.assertEqual(self.requests[0].full_url, 'https://1.2.3.4:11000/base/panel/api/clients/del/user') + + def test_missing_traffic_raises(self): + self.responses = [{'success': True, 'obj': None}] + with self.assertRaises(XuiApiError): + self.api.get_traffic('ghost') + + def test_panel_error_raises(self): + self.responses = [{'success': False, 'msg': 'email already in use'}] + with self.assertRaisesRegex(XuiApiError, 'email already in use'): + self.api.add_client(1, 'user') + + def test_http_error_raises(self): + self.responses = [urllib.error.HTTPError('url', 401, 'Unauthorized', {}, None)] + with self.assertRaisesRegex(XuiApiError, '401'): + self.api.list_clients() + + def test_repr_hides_token(self): + self.assertNotIn('secret', repr(self.api)) + + class RunPlaybookTests(unittest.TestCase): def test_facts_on_localhost(self): result = run_playbook('facts', Host('127.0.0.1'), {'ansible_connection': 'local'}) diff --git a/src/serverus/xui.py b/src/serverus/xui.py index 7de7e03..be1a0ac 100644 --- a/src/serverus/xui.py +++ b/src/serverus/xui.py @@ -1,17 +1,49 @@ +import secrets + from .runner import Result from .server import Server +MAIN_INBOUND = 'main' + class XuiPanel(Server): """Сервер с панелью 3x-ui.""" - def install(self, username: str, password: str, panel_port: int, web_base_path: str, domain: str = '') -> Result: - """Ставит панель и открывает порты 80 (acme), 443 и panel_port. Возвращает data: {'api_token': ...}.""" + def install( + self, username: str, password: str, panel_port: int, web_base_path: str, sub_port: int, sub_path: str, + domain: str = '', + ) -> Result: + """Ставит панель, включает подписку на sub_port по пути /sub_path/ и DNS-over-TLS к Cloudflare. + + Открывает порты 80 (acme), 443, panel_port и sub_port. Возвращает data: {'api_token': ...}. + """ return self.run( 'xui', username=username, password=password, panel_port=panel_port, web_base_path=web_base_path, + sub_port=sub_port, + sub_path=sub_path, domain=domain, ) + + def create_inbound(self, remark: str = MAIN_INBOUND, target: str = '') -> Result: + """Создает подключение vless + xhttp + reality на 443 порту, uTLS firefox, сброс трафика ежемесячно. + + target - цель reality (host:port) или список через запятую. Без него панель выбирает из своих кандидатов. + Уже созданное подключение с тем же remark не меняется. + Возвращает data: {'inbound_id', 'port', 'target', 'server_name', 'public_key', 'short_ids', 'path'}. + """ + return self.run( + 'xui_inbound', + remark=remark, + target=target, + short_ids=generate_short_ids(), + xhttp_path=f'/{secrets.token_hex(8)}', + ) + + +def generate_short_ids(count: int = 4) -> list[str]: + """shortId reality: hex длиной от 2 до 16 символов.""" + return [secrets.token_hex(secrets.randbelow(8) + 1) for _ in range(count)] diff --git a/src/serverus/xui_api.py b/src/serverus/xui_api.py new file mode 100644 index 0000000..5393547 --- /dev/null +++ b/src/serverus/xui_api.py @@ -0,0 +1,95 @@ +import json +import ssl +import urllib.error +import urllib.parse +import urllib.request + + +class XuiApiError(Exception): + pass + + +class XuiApi: + """Ключи (клиенты) панели 3x-ui через ее HTTP API: выпуск, отзыв и статистика. + + Ходит в панель напрямую, без ssh: операции частые и должны быть быстрыми. + url - адрес панели с web_base_path: https://<домен или ip>:/. + token - api_token из XuiPanel.install. Сертификат панели проверяется. + """ + + def __init__(self, url: str, token: str, timeout: float = 30, context: ssl.SSLContext | None = None): + self.url = url.rstrip('/') + '/panel/api' + self._token = token + self.timeout = timeout + self.context = context + + 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, + ) -> dict: + """Выпускает ключ. email - уникальное имя ключа в панели. + + 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], + }) + return self.get_client(email) + + def delete_client(self, email: str): + """Отзывает ключ: удаляет клиента и его статистику.""" + self._call('POST', f'clients/del/{_quote(email)}') + + def get_client(self, email: str) -> dict: + """Клиент панели: uuid, subId, email, enable, totalGB, expiryTime и остальные поля.""" + return self._required(self._call('GET', f'clients/get/{_quote(email)}'), email)['client'] + + def get_traffic(self, email: str) -> dict: + """Статистика ключа: up, down (байты), total, expiryTime, enable, lastOnline (мс unix time).""" + return self._required(self._call('GET', f'clients/traffic/{_quote(email)}'), email) + + def get_links(self, email: str) -> list[str]: + """Ссылки подключения vless://... для ключа.""" + return self._call('GET', f'clients/links/{_quote(email)}') or [] + + def list_clients(self) -> list[dict]: + return self._call('GET', 'clients/list') or [] + + def _call(self, method: str, path: str, body: dict | None = None): + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request(f'{self.url}/{path}', data=data, method=method, headers={ + 'Authorization': f'Bearer {self._token}', + 'Accept': 'application/json', + **({'Content-Type': 'application/json'} if data is not None else {}), + }) + try: + with urllib.request.urlopen(request, timeout=self.timeout, context=self.context) as response: + payload = json.load(response) + except urllib.error.HTTPError as error: + raise XuiApiError(f'{method} {path}: HTTP {error.code}') from error + except (urllib.error.URLError, TimeoutError, ValueError) as error: + raise XuiApiError(f'{method} {path}: {error}') from error + if not payload.get('success'): + raise XuiApiError(f"{method} {path}: {payload.get('msg') or 'failed'}") + return payload.get('obj') + + @staticmethod + def _required(obj, email: str): + if not obj: + raise XuiApiError(f'client {email} not found') + return obj + + +def _quote(value: str) -> str: + return urllib.parse.quote(value, safe='')