chore: доделываем поднятие сервера

This commit is contained in:
protokey committed 2026-09-25 16:56:50 +04:00
1 parent adab286884
commit 8532557ef6
18 files changed
+748 -36

No files matched your search

+6 -1
View File
@@ -11,7 +11,7 @@ SERVERUS_ACCESS_DIR := $(abspath extra/serverus)
MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS)) PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS))
.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: help:
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/" @echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
@@ -21,6 +21,7 @@ help:
@echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)" @echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)"
@echo "make clean - удалить $(WHEELS_DIR)/" @echo "make clean - удалить $(WHEELS_DIR)/"
@echo "make serverus_xui ip=<ip> password=<root_password> - настроить свежий сервер и поставить 3x-ui, доступы в $(SERVERUS_ACCESS_DIR)/<ip>/" @echo "make serverus_xui ip=<ip> password=<root_password> - настроить свежий сервер и поставить 3x-ui, доступы в $(SERVERUS_ACCESS_DIR)/<ip>/"
@echo "make serverus_key ip=<ip> action=add|del|stats name=<name> - ключ на панели, настроенной через serverus_xui"
@echo "make serverus_release ip=<ip> - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт" @echo "make serverus_release ip=<ip> - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт"
venv: venv:
@@ -85,6 +86,10 @@ serverus_xui:
@test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip=<ip> password=<root_password>"; exit 1; } @test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip=<ip> password=<root_password>"; exit 1; }
$(SERVERUS) xui $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip) $(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=<ip> action=add|del|stats name=<name>"; exit 1; }
$(SERVERUS) key $(action) '$(name)' --out $(SERVERUS_ACCESS_DIR)/$(ip)
serverus_release: serverus_release:
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_release ip=<ip>"; exit 1; } @test -n "$(ip)" || { echo "Укажите сервер: make serverus_release ip=<ip>"; exit 1; }
$(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip) $(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip)
+8
View File
@@ -40,3 +40,11 @@ Django приложение связывающее все части сайта
4. facts: `uname -a` для карточки сервера 4. facts: `uname -a` для карточки сервера
"Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке. "Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке.
# TODO:
-[ ] для панели 3x-ui нужно активировать ssl, панель умеет сама создавать сертификаты для домена или ip адреса.
-[ ] можно ли поставить домен после того как настроили сервер на ip? я имею ввиду поставить домен и выпустить ssl сертификат от letsencrypt.
-[ ] что нужно сделать чтобы сервис мог нормально работать и раздавать прокси интернет через cloudflare? когда домен за прокси клауда.
-[ ] если используется и прокси сервер и домен за claudflare, то будет работать?
-[ ] нужна функция установки прокси в исходящие (outbound)
@@ -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),
]
+9 -7
View File
@@ -4,10 +4,7 @@ from dataclasses import dataclass
from django.contrib.auth.base_user import BaseUserManager from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models, transaction from django.db import models, transaction
from serverus import Host, generate_password from serverus import Host, generate_password, generate_port, generate_username
PANEL_PORT_RANGE = (10000, 20000)
"""Не пересекается с диапазоном ssh портов serverus."""
class UserManager(BaseUserManager): 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(): with transaction.atomic():
point = self.create( point = self.create(
owner=owner, owner=owner,
@@ -103,10 +100,12 @@ class PointManager(models.Manager.from_queryset(PointQuerySet)):
domain=domain, domain=domain,
cloudflare=cloudflare, cloudflare=cloudflare,
outbound_proxy=outbound_proxy, outbound_proxy=outbound_proxy,
panel_username=generate_password(12), panel_username=generate_username(),
panel_password=generate_password(), panel_password=generate_password(),
panel_port=low + secrets.randbelow(high - low), panel_port=panel_port,
panel_path=generate_password(18), 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) Server.objects.create_server(point, Server.Role.PANEL, panel)
if mask: if mask:
@@ -152,6 +151,9 @@ class Point(models.Model):
panel_port = models.PositiveIntegerField() panel_port = models.PositiveIntegerField()
panel_path = models.CharField(max_length=64) panel_path = models.CharField(max_length=64)
panel_api_token = models.CharField(max_length=255, blank=True) 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) created_at = models.DateTimeField(auto_now_add=True)
+7 -1
View File
@@ -82,15 +82,21 @@ def _bootstrap(server: Server):
def _install_panel(server: Server): def _install_panel(server: Server):
point = server.point 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, username=point.panel_username,
password=point.panel_password, password=point.panel_password,
panel_port=point.panel_port, panel_port=point.panel_port,
web_base_path=point.panel_path, web_base_path=point.panel_path,
sub_port=point.sub_port,
sub_path=point.sub_path,
domain=point.domain, domain=point.domain,
)) ))
point.panel_api_token = result.data.get('api_token', '') point.panel_api_token = result.data.get('api_token', '')
point.save(update_fields=['panel_api_token']) point.save(update_fields=['panel_api_token'])
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): def _redirect_to_panel(server: Server):
+17 -6
View File
@@ -5,7 +5,9 @@ from django.test import TestCase
from Telegram.models import User as TelegramUser from Telegram.models import User as TelegramUser
from . import services 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: class FakeRunner:
@@ -19,7 +21,11 @@ class FakeRunner:
self.calls.append((playbook, host, extravars)) self.calls.append((playbook, host, extravars))
if playbook == self.fail: if playbook == self.fail:
return serverus.Result(ok=False, status='failed', error='boom', known_hosts=f'kh {host.address}') 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}') return serverus.Result(ok=True, status='successful', data=data, known_hosts=f'kh {host.address}')
def playbooks(self, address): def playbooks(self, address):
@@ -48,9 +54,12 @@ class CreatePointTests(TestCase):
self.assertEqual(point.status, Point.Status.INSTALLING) 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.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')) 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(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.panel.default_root_password, 'panel-pw')
self.assertEqual(point.entry, point.mask) self.assertEqual(point.entry, point.mask)
@@ -94,7 +103,8 @@ class InstallTests(TestCase):
point.refresh_from_db() point.refresh_from_db()
self.assertEqual(point.status, Point.Status.READY) self.assertEqual(point.status, Point.Status.READY)
self.assertEqual(point.panel_api_token, 'token') 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']) self.assertEqual(runner.playbooks('10.0.0.2'), ['bootstrap', 'proxy', 'facts'])
host, extravars = runner.call('bootstrap', '10.0.0.1') 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((host.port, host.known_hosts), (panel.ssh_port, 'kh 10.0.0.1'))
self.assertEqual(extravars['domain'], 'example.com') self.assertEqual(extravars['domain'], 'example.com')
self.assertEqual(extravars['panel_port'], point.panel_port) 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') _, extravars = runner.call('proxy', '10.0.0.2')
self.assertEqual(extravars['target_ip'], '10.0.0.1') self.assertEqual(extravars['target_ip'], '10.0.0.1')
@@ -120,7 +131,7 @@ class InstallTests(TestCase):
runner = FakeRunner() runner = FakeRunner()
with patch_runner(runner): with patch_runner(runner):
services.install_point(point) 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() point.refresh_from_db()
self.assertEqual(point.status, Point.Status.READY) self.assertEqual(point.status, Point.Status.READY)
+45 -5
View File
@@ -54,14 +54,33 @@ server.release(generate_password())
Каждая операция возвращает `Result(ok, status, data, error)`. Ключи, пароли и артефакты ansible живут во временной папке только на время запуска. Хранить ключи и пароли - задача вызывающей стороны. Каждая операция возвращает `Result(ok, status, data, error)`. Ключи, пароли и артефакты ansible живут во временной папке только на время запуска. Хранить ключи и пароли - задача вызывающей стороны.
- `bootstrap` - проверяет Debian 13, прописывает ключ, меняет пароль root, обновляет систему, включает nftables и переносит ssh на новый порт с входом только по ключу. Порт меняется в два шага с проверкой входа по ключу, чтобы не потерять доступ. - `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` и версия ОС. - `facts` - `uname -a` и версия ОС.
Специализации - наследники `Server`: Специализации - наследники `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 <target_ip>`, который перенаправляет весь трафик, кроме ssh, на target_ip. **Сейчас скрипт - заглушка.** - `RedirectProxy(host).redirect(target_ip)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh <target_ip>`, который перенаправляет весь трафик, кроме 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>:<panel_port>/<web_base_path>`, сертификат панели проверяется. Ошибки - `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>:<sub_port>/<sub_path>/<subId>`.
## Отпечаток сервера ## Отпечаток сервера
`Result.known_hosts` - отпечаток ssh сервера. Его нужно сохранить и передавать в `Host(known_hosts=...)`: тогда подключение к подмененному серверу не пройдет. Пустой `known_hosts` - доверяем ключу при первом подключении. `Result.known_hosts` - отпечаток ssh сервера. Его нужно сохранить и передавать в `Host(known_hosts=...)`: тогда подключение к подмененному серверу не пройдет. Пустой `known_hosts` - доверяем ключу при первом подключении.
@@ -75,11 +94,32 @@ server.release(generate_password())
## Ручная проверка на живом сервере ## Ручная проверка на живом сервере
``` ```
make serverus_xui ip=<ip> password=<root_password> # bootstrap + 3x-ui make serverus_xui ip=<ip> password=<root_password> # bootstrap + 3x-ui + подключение reality
make serverus_key ip=<ip> action=add|del|stats name=<name> # ключ на панели
make serverus_release ip=<ip> # вернуть сервер make serverus_release ip=<ip> # вернуть сервер
``` ```
То же без Makefile: `python -m serverus xui <ip> <password> --out <dir>` и `python -m serverus release --out <dir>`. То же без Makefile: `python -m serverus xui <ip> <password> --out <dir>`, `python -m serverus key add <name> --out <dir>` и `python -m serverus release --out <dir>`.
Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в `extra/serverus/<ip>/` до начала настройки, так что доступ к серверу не теряется, даже если настройка упала. Доступы (ключ, пароль root, порт ssh, отпечаток, панель, токен API, подключение) пишутся в `extra/serverus/<ip>/` до начала настройки, так что доступ к серверу не теряется, даже если настройка упала.
Тесты: `python -m unittest serverus.tests` из `src/`. Плейбуки проверены на Debian 13 в контейнере с systemd. Тесты: `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`
+8 -1
View File
@@ -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 .host import Host
from .proxy import RedirectProxy from .proxy import RedirectProxy
from .runner import Result, run_playbook from .runner import Result, run_playbook
from .server import Server from .server import Server
from .xui import XuiPanel from .xui import XuiPanel
from .xui_api import XuiApi, XuiApiError
__all__ = [ __all__ = [
'Host', 'Host',
@@ -11,9 +14,13 @@ __all__ = [
'RedirectProxy', 'RedirectProxy',
'Result', 'Result',
'Server', 'Server',
'XuiApi',
'XuiApiError',
'XuiPanel', 'XuiPanel',
'generate_keypair', 'generate_keypair',
'generate_password', 'generate_password',
'generate_port',
'generate_ssh_port', 'generate_ssh_port',
'generate_username',
'run_playbook', 'run_playbook',
] ]
+57 -7
View File
@@ -1,6 +1,7 @@
"""Ручная проверка сценариев на живом сервере. """Ручная проверка сценариев на живом сервере.
python -m serverus xui <ip> <root_password> --out <dir> python -m serverus xui <ip> <root_password> --out <dir>
python -m serverus key add|del|stats <name> --out <dir>
python -m serverus release --out <dir> python -m serverus release --out <dir>
Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/access.json до начала настройки: Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/access.json до начала настройки:
@@ -11,11 +12,13 @@ import json
import sys import sys
from pathlib import Path 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' ACCESS_FILE = 'access.json'
KEY_FILE = 'id_ed25519' KEY_FILE = 'id_ed25519'
PANEL_PORT = 12345
def main(argv=None) -> int: def main(argv=None) -> int:
@@ -27,15 +30,25 @@ def main(argv=None) -> int:
xui.add_argument('password', help='текущий пароль root') xui.add_argument('password', help='текущий пароль root')
xui.add_argument('--out', type=Path, required=True, help='папка для доступов к серверу') 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 = commands.add_parser('release', help='вернуть сервер: вход по паролю на 22 порт, новый пароль root')
release.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui') release.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui')
args = parser.parse_args(argv) 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: def install_xui(address: str, password: str, out: Path) -> int:
keys = generate_keypair() keys = generate_keypair()
panel_port = generate_port()
access = { access = {
'address': address, 'address': address,
'ssh_port': generate_ssh_port(), 'ssh_port': generate_ssh_port(),
@@ -43,10 +56,12 @@ def install_xui(address: str, password: str, out: Path) -> int:
'private_key': keys.private_key, 'private_key': keys.private_key,
'known_hosts': '', 'known_hosts': '',
'panel': { 'panel': {
'username': 'admin', 'username': generate_username(),
'password': generate_password(16), 'password': generate_password(16),
'panel_port': PANEL_PORT, 'panel_port': panel_port,
'web_base_path': generate_password(18), 'web_base_path': generate_password(18),
'sub_port': generate_port(exclude={panel_port}),
'sub_path': generate_password(12),
}, },
} }
save(out, access) save(out, access)
@@ -65,15 +80,45 @@ def install_xui(address: str, password: str, out: Path) -> int:
report('xui', result) report('xui', result)
if not result.ok: if not result.ok:
return 1 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"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"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@{address}")
return 0 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: def release_server(out: Path) -> int:
access = json.loads((out / ACCESS_FILE).read_text()) access = json.loads((out / ACCESS_FILE).read_text())
new_password = generate_password() new_password = generate_password()
@@ -87,6 +132,11 @@ def release_server(out: Path) -> int:
return 0 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: def load_host(access: dict) -> Host:
return Host( return Host(
access['address'], port=access['ssh_port'], private_key=access['private_key'], access['address'], port=access['ssh_port'], private_key=access['private_key'],
+19 -2
View File
@@ -6,7 +6,10 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
PASSWORD_ALPHABET = string.ascii_letters + string.digits PASSWORD_ALPHABET = string.ascii_letters + string.digits
USERNAME_ALPHABET = string.ascii_lowercase + string.digits
SSH_PORT_RANGE = (20000, 60000) SSH_PORT_RANGE = (20000, 60000)
SERVICE_PORT_RANGE = (10000, 20000)
"""Порты панели, подписки и других сервисов. Не пересекается с диапазоном ssh портов."""
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -36,6 +39,20 @@ def generate_password(length: int = 32) -> str:
return ''.join(secrets.choice(PASSWORD_ALPHABET) for _ in range(length)) 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: def generate_ssh_port() -> int:
low, high = SSH_PORT_RANGE return generate_port(SSH_PORT_RANGE)
return low + secrets.randbelow(high - low)
+10
View File
@@ -31,6 +31,16 @@
ansible.builtin.command: sysctl --system ansible.builtin.command: sysctl --system
changed_when: false 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 - name: Remove sshd drop-in
ansible.builtin.file: ansible.builtin.file:
path: /etc/ssh/sshd_config.d/00-serverus.conf path: /etc/ssh/sshd_config.d/00-serverus.conf
+44
View File
@@ -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
+39
View File
@@ -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
+74 -2
View File
@@ -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 проекта в неинтерактивном режиме. # Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме.
# Сертификат: на домен, если он задан, иначе на ip. Оба варианта требуют открытый 80 порт (acme). # Сертификат: на домен, если он задан, иначе на ip. Оба варианта требуют открытый 80 порт (acme).
# Системный DNS - DNS-over-TLS к Cloudflare, Xray резолвит через него. Подписка на sub_port под тем же сертификатом.
- name: Install 3x-ui panel - name: Install 3x-ui panel
hosts: target hosts: target
gather_facts: false gather_facts: false
@@ -11,7 +12,9 @@
tasks: tasks:
- ansible.builtin.import_tasks: tasks/firewall.yml - ansible.builtin.import_tasks: tasks/firewall.yml
vars: 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 - name: Download install script
ansible.builtin.get_url: ansible.builtin.get_url:
@@ -54,6 +57,75 @@
changed_when: false changed_when: false
no_log: true 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 - name: Return panel access
ansible.builtin.set_stats: ansible.builtin.set_stats:
aggregate: false aggregate: false
+135
View File
@@ -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 }}"
+102 -2
View File
@@ -1,7 +1,14 @@
import io
import json
import unittest import unittest
import urllib.error
from unittest import mock
from . import Host, Result, Server, generate_keypair, generate_password, generate_ssh_port, run_playbook from . import (
from .credentials import SSH_PORT_RANGE 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: class FakeRunner:
@@ -29,6 +36,16 @@ class CredentialsTests(unittest.TestCase):
low, high = SSH_PORT_RANGE low, high = SSH_PORT_RANGE
self.assertTrue(low <= generate_ssh_port() < high) 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): def test_host_repr_hides_key(self):
self.assertNotIn('secret', repr(Host('1.2.3.4', private_key='secret'))) 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): class RunPlaybookTests(unittest.TestCase):
def test_facts_on_localhost(self): def test_facts_on_localhost(self):
result = run_playbook('facts', Host('127.0.0.1'), {'ansible_connection': 'local'}) result = run_playbook('facts', Host('127.0.0.1'), {'ansible_connection': 'local'})
+34 -2
View File
@@ -1,17 +1,49 @@
import secrets
from .runner import Result from .runner import Result
from .server import Server from .server import Server
MAIN_INBOUND = 'main'
class XuiPanel(Server): class XuiPanel(Server):
"""Сервер с панелью 3x-ui.""" """Сервер с панелью 3x-ui."""
def install(self, username: str, password: str, panel_port: int, web_base_path: str, domain: str = '') -> Result: def install(
"""Ставит панель и открывает порты 80 (acme), 443 и panel_port. Возвращает data: {'api_token': ...}.""" 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( return self.run(
'xui', 'xui',
username=username, username=username,
password=password, password=password,
panel_port=panel_port, panel_port=panel_port,
web_base_path=web_base_path, web_base_path=web_base_path,
sub_port=sub_port,
sub_path=sub_path,
domain=domain, 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)]
+95
View File
@@ -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>:<panel_port>/<web_base_path>.
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='')