chore: настройка панелей

This commit is contained in:
protokey committed 2026-09-25 17:35:28 +04:00
1 parent 8532557ef6
commit 96213e182e
16 files changed
+529 -64

No files matched your search

+13 -3
View File
@@ -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_key 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_cert serverus_outbound serverus_release
help:
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
@@ -20,8 +20,10 @@ help:
@echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения"
@echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)"
@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> [domain=<domain>] [ssl=no] - настроить свежий сервер и поставить 3x-ui, доступы в $(SERVERUS_ACCESS_DIR)/<ip>/"
@echo "make serverus_key ip=<ip> action=add|del|stats name=<name> - ключ на панели, настроенной через serverus_xui"
@echo "make serverus_cert ip=<ip> [domain=<domain>] - сертификат панели на домен или на ip"
@echo "make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] - исходящий прокси, без proxy - напрямую"
@echo "make serverus_release ip=<ip> - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт"
venv:
@@ -84,12 +86,20 @@ tg_run:
serverus_xui:
@test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip=<ip> password=<root_password>"; exit 1; }
$(SERVERUS) xui $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip)
$(SERVERUS) xui $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(domain),--domain $(domain)) $(if $(filter no,$(ssl)),--no-ssl)
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_cert:
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_cert ip=<ip> [domain=<domain>]"; exit 1; }
$(SERVERUS) cert $(domain) --out $(SERVERUS_ACCESS_DIR)/$(ip)
serverus_outbound:
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp]"; exit 1; }
$(SERVERUS) outbound '$(proxy)' --out $(SERVERUS_ACCESS_DIR)/$(ip)
serverus_release:
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_release ip=<ip>"; exit 1; }
$(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip)
+12 -8
View File
@@ -27,7 +27,7 @@ Django приложение связывающее все части сайта
## Модули
- `services.py` - сценарии поверх serverus: `install_point`, `reinstall`, `release_point`.
- `services.py` - сценарии поверх serverus: `install_point`, `reinstall`, `release_point`, у готовой точки `set_domain` (новый сертификат) и `set_outbound_proxy`.
- `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`.
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
- `bot/texts.py` - кнопки и тексты.
@@ -35,16 +35,20 @@ Django приложение связывающее все части сайта
## Установка точки
1. bootstrap обоих серверов (ключ, пароль root, обновление, ssh на случайном порту, nftables)
2. панель: 3x-ui с логином, паролем, портом и путем, которые сгенерировал App
2. панель: 3x-ui с логином, паролем, портом и путем, которые сгенерировал App, сертификат на домен или ip, подписка, подключение reality `main`, исходящий прокси точки
3. маскировка: перенаправление трафика на адрес панели
4. facts: `uname -a` для карточки сервера
"Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке.
# TODO:
## TODO
-[ ] для панели 3x-ui нужно активировать ssl, панель умеет сама создавать сертификаты для домена или ip адреса.
-[ ] можно ли поставить домен после того как настроили сервер на ip? я имею ввиду поставить домен и выпустить ssl сертификат от letsencrypt.
-[ ] что нужно сделать чтобы сервис мог нормально работать и раздавать прокси интернет через cloudflare? когда домен за прокси клауда.
-[ ] если используется и прокси сервер и домен за claudflare, то будет работать?
-[ ] нужна функция установки прокси в исходящие (outbound)
- [x] для панели 3x-ui нужно активировать ssl, панель умеет сама создавать сертификаты для домена или ip адреса.
Скрипт 3x-ui при ошибке молча оставлял панель на http и при повторной установке SSL не трогал. Теперь сертификат выпускает serverus и падает, если не вышло. На ip без публичного адреса сертификат не выпустить: `ssl=no`.
- [x] можно ли поставить домен после того как настроили сервер на ip? я имею ввиду поставить домен и выпустить ssl сертификат от letsencrypt.
Да: `services.set_domain(point, domain)`, в serverus - `XuiPanel.issue_certificate(domain)`. В боте кнопки пока нет.
- [x] что нужно сделать чтобы сервис мог нормально работать и раздавать прокси интернет через cloudflare? когда домен за прокси клауда.
- [x] если используется и прокси сервер и домен за claudflare, то будет работать?
Решение: за Cloudflare не работаем. Reality через проксирование Cloudflare невозможен: Cloudflare сам завершает TLS.
- [x] нужна функция установки прокси в исходящие (outbound)
При установке ставится `point.outbound_proxy`, у готовой точки - `services.set_outbound_proxy(point, proxy)`. Поддерживаются socks5, http(s) и cf_warp.
+3 -4
View File
@@ -1,7 +1,7 @@
import ipaddress
import re
from urllib.parse import urlsplit
import serverus
from Telegram.interfaces import Interface, command, register, state, text
from .. import services, tasks
@@ -22,7 +22,6 @@ POINT_REINSTALL = 'point.reinstall'
DRAFT_CREDENTIALS = {DRAFT_SERVER: 'server', DRAFT_MASK: 'mask'}
DOMAIN_RE = re.compile(r'^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$')
PROXY_SCHEMES = ('http', 'https', 'socks5')
HTML = 'HTML'
@@ -42,10 +41,10 @@ def is_domain(value: str) -> bool:
def is_proxy_url(value: str) -> bool:
try:
url = urlsplit(value)
return url.scheme in PROXY_SCHEMES and bool(url.hostname) and bool(url.port)
serverus.parse_proxy(value)
except ValueError:
return False
return True
@register
+2 -2
View File
@@ -4,7 +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, generate_port, generate_username
from serverus import CF_WARP, Host, generate_password, generate_port, generate_username
class UserManager(BaseUserManager):
@@ -127,7 +127,7 @@ class Point(models.Model):
Создание: Point.objects.create_point. Поиск: методы PointQuerySet. Удаление: point.delete() удаляет и серверы.
"""
CF_WARP = 'cf_warp'
CF_WARP = CF_WARP
class Status(models.TextChoices):
INSTALLING = 'installing', 'устанавливается'
+25
View File
@@ -55,6 +55,29 @@ def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]:
return passwords, errors
def set_domain(point: Point, domain: str):
"""Ставит или меняет домен готовой точки: выпускает сертификат панели на домен, без домена - на ip.
Домен должен уже указывать на панель или на сервер маскировки.
"""
if not domain and (point.mask or point.cloudflare):
raise ValueError('маскировка и cloudflare работают только с доменом')
with _installing(point):
_check(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain))
point.domain = domain
point.save(update_fields=['domain'])
def set_outbound_proxy(point: Point, proxy: str):
"""Меняет исходящий прокси готовой точки: protocol://user:password@host:port, Point.CF_WARP или '' - напрямую."""
if proxy and proxy != Point.CF_WARP:
serverus.parse_proxy(proxy)
with _installing(point):
_check(point.panel, serverus.XuiPanel(point.panel.host()).set_outbound(proxy))
point.outbound_proxy = proxy
point.save(update_fields=['outbound_proxy'])
def provision(server: Server):
"""Базовая настройка сервера и специализация по его роли."""
_bootstrap(server)
@@ -97,6 +120,8 @@ def _install_panel(server: Server):
result = _check(server, panel.create_inbound())
point.panel_inbound_id = result.data.get('inbound_id')
point.save(update_fields=['panel_inbound_id'])
if point.outbound_proxy:
_check(server, panel.set_outbound(point.outbound_proxy))
def _redirect_to_panel(server: Server):
+52
View File
@@ -160,6 +160,58 @@ class InstallTests(TestCase):
self.assertEqual(Server.objects.get(pk=panel.pk).private_key, private_key)
def test_install_sets_outbound_proxy(self):
point = create_point(mask=None, outbound_proxy=Point.CF_WARP)
runner = FakeRunner()
with patch_runner(runner):
services.install_point(point)
self.assertEqual(runner.playbooks('10.0.0.1'), ['bootstrap', 'xui', 'xui_inbound', 'xui_outbound', 'facts'])
self.assertTrue(runner.call('xui_outbound', '10.0.0.1')[1]['warp_keys'])
class ReadyPointTests(TestCase):
"""Настройки готовой точки: домен и исходящий прокси."""
def setUp(self):
self.point = create_point(mask=None)
with patch_runner(FakeRunner()):
services.install_point(self.point)
def test_set_domain_issues_certificate(self):
runner = FakeRunner()
with patch_runner(runner):
services.set_domain(self.point, 'example.com')
self.assertEqual(runner.call('xui_cert', '10.0.0.1')[1], {'domain': 'example.com'})
self.point.refresh_from_db()
self.assertEqual((self.point.domain, self.point.status), ('example.com', Point.Status.READY))
def test_failed_certificate_keeps_old_domain(self):
with patch_runner(FakeRunner(fail='xui_cert')), self.assertRaises(services.ProvisionError):
services.set_domain(self.point, 'example.com')
self.point.refresh_from_db()
self.assertEqual((self.point.domain, self.point.status), ('', Point.Status.ERROR))
def test_mask_requires_domain(self):
Server.objects.create_server(self.point, Server.Role.MASK, Credentials('10.0.0.3', 'pw'))
with self.assertRaises(ValueError):
services.set_domain(self.point, '')
def test_set_outbound_proxy(self):
runner = FakeRunner()
with patch_runner(runner):
services.set_outbound_proxy(self.point, 'socks5://user:pw@proxy.example:1080')
outbound = runner.call('xui_outbound', '10.0.0.1')[1]['proxy_outbound']
self.assertEqual(outbound['settings']['servers'][0]['address'], 'proxy.example')
self.point.refresh_from_db()
self.assertEqual(self.point.outbound_proxy, 'socks5://user:pw@proxy.example:1080')
def test_invalid_proxy_is_rejected_before_run(self):
runner = FakeRunner()
with patch_runner(runner), self.assertRaises(ValueError):
services.set_outbound_proxy(self.point, 'ftp://proxy.example:21')
self.assertEqual(runner.calls, [])
class ReinstallTests(TestCase):
def setUp(self):
self.point = create_point()
+6 -2
View File
@@ -59,7 +59,9 @@ server.release(generate_password())
Специализации - наследники `Server`:
- `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).install(username, password, panel_port, web_base_path, sub_port, sub_path, domain='', ssl=True)` - ставит 3x-ui скриптом из README проекта в неинтерактивном режиме. Сертификат Let's Encrypt выпускает serverus через acme.sh (HTTP-01 на 80 порту): на домен или, без него, на ip (короткий, ~6 дней, продлевается по cron). Не выпустился - ошибка, панель на http не остается. `ssl=False` - без сертификата, для серверов без публичного адреса. Включает подписку на `sub_port` по пути `/sub_path/` под тем же сертификатом. Системный DNS переводит на DNS-over-TLS к Cloudflare (systemd-resolved, строгий режим, без резервных серверов), DNS в Xray - через системный резолвер: сам Xray DoT не умеет. Открывает 80, 443, порт панели и порт подписки. Возвращает `data`: `api_token`, `panel_host` (домен или ip сертификата), `ssl`.
- `XuiPanel(host).issue_certificate(domain='')` - сертификат панели и подписки на домен или на ip: так домен ставится, меняется или убирается после установки. Домен должен уже указывать на сервер (или на его маскировку).
- `XuiPanel(host).set_outbound(proxy='')` - весь исходящий трафик клиентов через прокси: `socks5://user:password@host:port`, `http(s)://...` или `CF_WARP` (устройство WARP регистрирует панель, выход по wireguard). Пустая строка - напрямую. Прокси ставится первым outbound Xray, перед применением панель его проверяет. `parse_proxy(url)` проверяет адрес заранее.
- `XuiPanel(host).create_inbound(remark='main', target='')` - подключение vless + xhttp + reality на 443, uTLS firefox, сброс трафика ежемесячно. Цель reality подбирает сканер панели (TLS 1.3, h2, X25519, валидный сертификат, минимальная задержка) из `target` или из своих кандидатов. Ключи x25519 генерирует панель, shortId и путь xhttp - serverus. Существующее подключение с тем же `remark` не трогает. Возвращает `data`: `inbound_id`, `target`, `server_name`, `public_key`, `short_ids`, `path`.
- `RedirectProxy(host).redirect(target_ip)` - разрешает транзитный трафик и запускает `playbooks/files/redirect.sh <target_ip>`, который перенаправляет весь трафик, кроме ssh, на target_ip. **Сейчас скрипт - заглушка.**
@@ -94,8 +96,10 @@ api.delete_client('user-1')
## Ручная проверка на живом сервере
```
make serverus_xui ip=<ip> password=<root_password> # bootstrap + 3x-ui + подключение reality
make serverus_xui ip=<ip> password=<root_password> [domain=<domain>] [ssl=no] # bootstrap + 3x-ui + подключение reality
make serverus_key ip=<ip> action=add|del|stats name=<name> # ключ на панели
make serverus_cert ip=<ip> [domain=<domain>] # сертификат на домен или на ip
make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] # исходящий прокси, без proxy - напрямую
make serverus_release ip=<ip> # вернуть сервер
```
+3 -1
View File
@@ -5,10 +5,11 @@ from .host import Host
from .proxy import RedirectProxy
from .runner import Result, run_playbook
from .server import Server
from .xui import XuiPanel
from .xui import CF_WARP, XuiPanel, parse_proxy
from .xui_api import XuiApi, XuiApiError
__all__ = [
'CF_WARP',
'Host',
'KeyPair',
'RedirectProxy',
@@ -22,5 +23,6 @@ __all__ = [
'generate_port',
'generate_ssh_port',
'generate_username',
'parse_proxy',
'run_playbook',
]
+52 -5
View File
@@ -1,7 +1,9 @@
"""Ручная проверка сценариев на живом сервере.
python -m serverus xui <ip> <root_password> --out <dir>
python -m serverus xui <ip> <root_password> --out <dir> [--domain <domain>] [--no-ssl]
python -m serverus key add|del|stats <name> --out <dir>
python -m serverus cert [<domain>] --out <dir>
python -m serverus outbound [<proxy>] --out <dir>
python -m serverus release --out <dir>
Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/access.json до начала настройки:
@@ -29,24 +31,38 @@ def main(argv=None) -> int:
xui.add_argument('address')
xui.add_argument('password', help='текущий пароль root')
xui.add_argument('--out', type=Path, required=True, help='папка для доступов к серверу')
xui.add_argument('--domain', default='', help='домен панели, без него сертификат на ip')
xui.add_argument('--no-ssl', action='store_true', help='панель на http: для серверов без публичного адреса')
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')
cert = commands.add_parser('cert', help='сертификат панели на домен или, без домена, на ip')
cert.add_argument('domain', nargs='?', default='')
cert.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui')
outbound = commands.add_parser('outbound', help='исходящий прокси: socks5://..., http(s)://..., cf_warp')
outbound.add_argument('proxy', nargs='?', default='', help='без него трафик идет напрямую')
outbound.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)
if args.command == 'xui':
return install_xui(args.address, args.password, args.out)
return install_xui(args.address, args.password, args.out, args.domain, not args.no_ssl)
if args.command == 'key':
return manage_key(args.action, args.name, args.out)
if args.command == 'cert':
return issue_certificate(args.domain, args.out)
if args.command == 'outbound':
return set_outbound(args.proxy, args.out)
return release_server(args.out)
def install_xui(address: str, password: str, out: Path) -> int:
def install_xui(address: str, password: str, out: Path, domain: str, ssl: bool) -> int:
keys = generate_keypair()
panel_port = generate_port()
access = {
@@ -62,6 +78,8 @@ def install_xui(address: str, password: str, out: Path) -> int:
'web_base_path': generate_password(18),
'sub_port': generate_port(exclude={panel_port}),
'sub_path': generate_password(12),
'domain': domain,
'ssl': ssl,
},
}
save(out, access)
@@ -81,6 +99,7 @@ def install_xui(address: str, password: str, out: Path) -> int:
if not result.ok:
return 1
access['api_token'] = result.data['api_token']
access['panel_host'] = result.data['panel_host']
save(out, access)
result = XuiPanel(host).create_inbound()
@@ -106,7 +125,9 @@ def manage_key(action: str, name: str, out: Path) -> int:
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']}")
scheme = 'https' if panel.get('ssl', True) else 'http'
host = access.get('panel_host') or access['address']
print(f"subscription: {scheme}://{host}:{panel['sub_port']}/{panel['sub_path']}/{client['subId']}")
print(*api.get_links(name), sep='\n')
elif action == 'del':
api.delete_client(name)
@@ -119,6 +140,30 @@ def manage_key(action: str, name: str, out: Path) -> int:
return 0
def issue_certificate(domain: str, out: Path) -> int:
access = json.loads((out / ACCESS_FILE).read_text())
result = XuiPanel(load_host(access)).issue_certificate(domain)
report('cert', result)
if not result.ok:
return 1
access['panel'].update(domain=domain, ssl=True)
access['panel_host'] = result.data['panel_host']
save(out, access)
print(f"panel: {panel_url(access)}/")
return 0
def set_outbound(proxy: str, out: Path) -> int:
access = json.loads((out / ACCESS_FILE).read_text())
try:
result = XuiPanel(load_host(access)).set_outbound(proxy)
except ValueError as error:
print(error, file=sys.stderr)
return 1
report('outbound', result)
return 0 if result.ok else 1
def release_server(out: Path) -> int:
access = json.loads((out / ACCESS_FILE).read_text())
new_password = generate_password()
@@ -134,7 +179,9 @@ def release_server(out: Path) -> int:
def panel_url(access: dict) -> str:
panel = access['panel']
return f"https://{access['address']}:{panel['panel_port']}/{panel['web_base_path']}"
scheme = 'https' if panel.get('ssl', True) else 'http'
host = access.get('panel_host') or access['address']
return f"{scheme}://{host}:{panel['panel_port']}/{panel['web_base_path']}"
def load_host(access: dict) -> Host:
+64
View File
@@ -0,0 +1,64 @@
# vars: cert_host - домен или ip, на который выпускается сертификат
#
# Сертификат Let's Encrypt через acme.sh в режиме standalone: проверка HTTP-01 на 80 порту, он должен быть открыт.
# На ip выдается только короткий сертификат (~6 дней), acme.sh продлевает его по cron и перезапускает панель.
# Ставит сертификат панели и подписке. Если выпустить не удалось, падает, а не оставляет панель на http.
- name: Set certificate paths
ansible.builtin.set_fact:
cert_is_ip: "{{ cert_host is match('^[0-9.]+$') or ':' in cert_host }}"
cert_dir: "/root/cert/{{ cert_host }}"
acme: /root/.acme.sh/acme.sh
- name: Install acme.sh dependencies
ansible.builtin.apt:
name: [ curl, socat, cron ]
state: present
- name: Download acme.sh
ansible.builtin.get_url:
url: https://get.acme.sh
dest: /root/acme-install.sh
mode: "0700"
when: not acme is file
- name: Install acme.sh
ansible.builtin.shell: sh /root/acme-install.sh
args:
chdir: /root
creates: "{{ acme }}"
- name: Use Let's Encrypt
ansible.builtin.command: "{{ acme }} --set-default-ca --server letsencrypt"
changed_when: false
# код 2 - действующий сертификат уже есть, продлевать рано
- name: Issue certificate
ansible.builtin.command: >-
{{ acme }} --issue -d {{ cert_host }} --standalone --httpport 80 --server letsencrypt
{{ '--certificate-profile shortlived --days 6' if cert_is_ip else '' }}
register: cert_issue
changed_when: cert_issue.rc == 0
failed_when: cert_issue.rc not in [0, 2]
- name: Create certificate directory
ansible.builtin.file:
path: "{{ cert_dir }}"
state: directory
mode: "0700"
- name: Install certificate
ansible.builtin.command: >-
{{ acme }} --installcert -d {{ cert_host }}
--key-file {{ cert_dir }}/privkey.pem --fullchain-file {{ cert_dir }}/fullchain.pem
--reloadcmd "systemctl restart x-ui"
changed_when: true
- name: Check certificate
ansible.builtin.command: "openssl x509 -in {{ cert_dir }}/fullchain.pem -noout -checkend 3600 -ext subjectAltName"
register: cert_check
changed_when: false
failed_when: cert_check.rc != 0 or cert_host not in cert_check.stdout
- name: Use certificate for panel
ansible.builtin.command: "{{ xui_bin }} cert -webCert {{ cert_dir }}/fullchain.pem -webCertKey {{ cert_dir }}/privkey.pem"
changed_when: true
@@ -0,0 +1,24 @@
# vars: xui_settings_patch - поля настроек панели, которые нужно поменять. Нужен 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: xui_settings
failed_when: not xui_settings.json.success | default(false)
no_log: true
- name: Update panel settings
ansible.builtin.uri:
url: "{{ xui_api_url }}/setting/update"
method: POST
headers: "{{ xui_api_headers }}"
body_format: json
body: "{{ xui_settings.json.obj | combine(xui_settings_patch) }}"
validate_certs: false
register: xui_settings_update
failed_when: not xui_settings_update.json.success | default(false)
no_log: true
+20 -35
View File
@@ -1,7 +1,9 @@
# extravars: username, password, panel_port, web_base_path, sub_port, sub_path, domain (optional), version (optional)
# extravars: username, password, panel_port, web_base_path, sub_port, sub_path, domain (optional), ssl (optional),
# version (optional)
#
# Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме.
# Сертификат: на домен, если он задан, иначе на ip. Оба варианта требуют открытый 80 порт (acme).
# Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме, SSL скрипта выключен: он при ошибке молча оставляет
# панель на http. Сертификат выпускает tasks/xui_cert.yml на домен, если он задан, иначе на ip. Нужен открытый 80 порт.
# ssl=false - без сертификата, для серверов без публичного адреса.
# Системный DNS - DNS-over-TLS к Cloudflare, Xray резолвит через него. Подписка на sub_port под тем же сертификатом.
- name: Install 3x-ui panel
hosts: target
@@ -9,6 +11,8 @@
vars:
xui_bin: /usr/local/x-ui/x-ui
xui_domain: "{{ domain | default('') }}"
xui_ssl: "{{ ssl | default(true) | bool }}"
cert_host: "{{ xui_domain or ansible_host }}"
tasks:
- ansible.builtin.import_tasks: tasks/firewall.yml
vars:
@@ -33,8 +37,7 @@
XUI_PASSWORD: "{{ password }}"
XUI_PANEL_PORT: "{{ panel_port }}"
XUI_WEB_BASE_PATH: "{{ web_base_path }}"
XUI_SSL_MODE: "{{ 'domain' if xui_domain else 'ip' }}"
XUI_DOMAIN: "{{ xui_domain }}"
XUI_SSL_MODE: none
XUI_SERVER_IP: "{{ ansible_host }}"
no_log: true
@@ -45,6 +48,9 @@
-port {{ panel_port }} -webBasePath {{ web_base_path | quote }}
no_log: true
- ansible.builtin.import_tasks: tasks/xui_cert.yml
when: xui_ssl
- name: Restart panel
ansible.builtin.systemd_service:
name: x-ui
@@ -59,37 +65,14 @@
- 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
- ansible.builtin.import_tasks: tasks/xui_settings.yml
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
xui_settings_patch:
subEnable: true
subPort: "{{ sub_port | int }}"
subPath: "/{{ sub_path }}/"
subCertFile: "{{ cert_dir ~ '/fullchain.pem' if xui_ssl else '' }}"
subKeyFile: "{{ cert_dir ~ '/privkey.pem' if xui_ssl else '' }}"
- name: Read Xray config
ansible.builtin.uri:
@@ -131,4 +114,6 @@
aggregate: false
data:
api_token: "{{ api_token.stdout }}"
panel_host: "{{ cert_host if xui_ssl else ansible_host }}"
ssl: "{{ xui_ssl }}"
no_log: true
+37
View File
@@ -0,0 +1,37 @@
# extravars: domain (optional)
#
# Выпускает сертификат панели и подписки на domain, без него - на ip. Так можно поставить домен после установки на ip
# или вернуться с домена на ip. Домен должен указывать на этот сервер (или на сервер маскировки перед ним),
# 80 порт должен быть доступен снаружи.
- name: Issue panel certificate
hosts: target
gather_facts: false
vars:
xui_bin: /usr/local/x-ui/x-ui
cert_host: "{{ domain | default('', true) or ansible_host }}"
tasks:
- ansible.builtin.import_tasks: tasks/xui_cert.yml
- name: Restart panel
ansible.builtin.systemd_service:
name: x-ui
state: restarted
- ansible.builtin.import_tasks: tasks/xui_api.yml
- ansible.builtin.import_tasks: tasks/xui_settings.yml
vars:
xui_settings_patch:
subCertFile: "{{ cert_dir }}/fullchain.pem"
subKeyFile: "{{ cert_dir }}/privkey.pem"
- name: Restart panel with subscription certificate
ansible.builtin.systemd_service:
name: x-ui
state: restarted
- name: Return panel host
ansible.builtin.set_stats:
aggregate: false
data:
panel_host: "{{ cert_host }}"
+113
View File
@@ -0,0 +1,113 @@
# extravars: proxy_outbound (outbound Xray или null), warp_keys ({private_key, public_key} или null)
#
# Ставит исходящий прокси первым outbound Xray: он становится выходом по умолчанию для всего трафика клиентов.
# warp_keys - регистрирует устройство Cloudflare WARP через панель и выходит через него по wireguard.
# Оба null - прокси убирается, трафик идет напрямую. Перед применением прокси проверяется через панель.
- name: Set outbound proxy
hosts: target
gather_facts: false
vars:
xui_bin: /usr/local/x-ui/x-ui
proxy_tags: [ proxy, warp ]
tasks:
- ansible.builtin.import_tasks: tasks/xui_api.yml
- 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)
- name: Use proxy outbound
ansible.builtin.set_fact:
new_outbound: "{{ proxy_outbound }}"
no_log: true
- name: Register Cloudflare WARP
when: warp_keys is not none
block:
- name: Register WARP device
ansible.builtin.uri:
url: "{{ xui_api_url }}/xray/warp/reg"
method: POST
headers: "{{ xui_api_headers }}"
body_format: form-urlencoded
body:
privateKey: "{{ warp_keys.private_key }}"
publicKey: "{{ warp_keys.public_key }}"
validate_certs: false
register: warp_reg
failed_when: not warp_reg.json.success | default(false)
no_log: true
- name: Read WARP config
ansible.builtin.set_fact:
warp: "{{ warp_reg.json.obj | from_json }}"
no_log: true
# reserved - байты client_id, в Jinja нет ord
- name: Decode WARP client id
ansible.builtin.command:
argv: [ python3, -c, "import base64, json, sys; print(json.dumps(list(base64.b64decode(sys.argv[1]))))", "{{ warp.config.config.client_id | default(warp.data.client_id) }}" ]
register: warp_reserved
changed_when: false
- name: Use WARP outbound
vars:
config: "{{ warp.config.config }}"
addresses: "{{ config.interface.addresses }}"
ansible.builtin.set_fact:
new_outbound:
tag: warp
protocol: wireguard
settings:
mtu: 1420
secretKey: "{{ warp.data.private_key }}"
address: "{{ ([addresses.v4 ~ '/32'] if addresses.v4 | default('') else []) + ([addresses.v6 ~ '/128'] if addresses.v6 | default('') else []) }}"
reserved: "{{ warp_reserved.stdout | from_json }}"
domainStrategy: ForceIPv4v6
noKernelTun: true
peers:
- publicKey: "{{ config.peers[0].public_key }}"
endpoint: "{{ config.peers[0].endpoint.host }}"
no_log: true
- name: Test outbound
when: new_outbound is not none
ansible.builtin.uri:
url: "{{ xui_api_url }}/xray/testOutbound"
method: POST
headers: "{{ xui_api_headers }}"
body_format: form-urlencoded
body:
outbound: "{{ new_outbound | to_json }}"
validate_certs: false
timeout: 60
register: outbound_test
failed_when: not outbound_test.json.success | default(false)
no_log: true
- name: Require working outbound
when: new_outbound is not none
ansible.builtin.assert:
that: outbound_test.json.obj.success | default(false)
fail_msg: "Outbound test failed: {{ outbound_test.json.obj.error | default(outbound_test.json.msg) }}"
- name: Apply outbound
vars:
config: "{{ (xray_setting.json.obj | from_json).xraySetting }}"
outbounds: "{{ ([new_outbound] if new_outbound is not none else []) + (config.outbounds | rejectattr('tag', 'in', proxy_tags) | list) }}"
ansible.builtin.uri:
url: "{{ xui_api_url }}/xray/update"
method: POST
headers: "{{ xui_api_headers }}"
body_format: form-urlencoded
body:
xraySetting: "{{ config | combine({'outbounds': outbounds}) | to_json }}"
validate_certs: false
register: xray_update
failed_when: not xray_update.json.success | default(false)
no_log: true
+47 -2
View File
@@ -5,8 +5,8 @@ import urllib.error
from unittest import mock
from . import (
Host, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
generate_ssh_port, generate_username, run_playbook,
CF_WARP, Host, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
generate_ssh_port, generate_username, parse_proxy, run_playbook,
)
from .credentials import SERVICE_PORT_RANGE, SSH_PORT_RANGE
@@ -79,8 +79,53 @@ class XuiPanelTests(unittest.TestCase):
'sub_port': 12000,
'sub_path': 'sub',
'domain': 'example.com',
'ssl': True,
})
def test_issue_certificate(self):
runner = FakeRunner()
XuiPanel(Host('1.2.3.4'), runner=runner).issue_certificate('example.com')
self.assertEqual(runner.calls[0][0::2], ('xui_cert', {'domain': 'example.com'}))
def test_outbound_proxy(self):
runner = FakeRunner()
XuiPanel(Host('1.2.3.4'), runner=runner).set_outbound('socks5://user:p%40ss@proxy.example:1080')
playbook, _, extravars = runner.calls[0]
self.assertEqual(playbook, 'xui_outbound')
self.assertIsNone(extravars['warp_keys'])
self.assertEqual(extravars['proxy_outbound'], {
'tag': 'proxy',
'protocol': 'socks',
'settings': {'servers': [{
'address': 'proxy.example', 'port': 1080, 'users': [{'user': 'user', 'pass': 'p@ss'}],
}]},
})
def test_outbound_warp(self):
runner = FakeRunner()
XuiPanel(Host('1.2.3.4'), runner=runner).set_outbound(CF_WARP)
extravars = runner.calls[0][2]
self.assertIsNone(extravars['proxy_outbound'])
keys = extravars['warp_keys']
self.assertEqual({len(keys['private_key']), len(keys['public_key'])}, {44})
def test_outbound_direct(self):
runner = FakeRunner()
XuiPanel(Host('1.2.3.4'), runner=runner).set_outbound('')
self.assertEqual(runner.calls[0][2], {'proxy_outbound': None, 'warp_keys': None})
def test_https_proxy_uses_tls(self):
outbound = parse_proxy('https://proxy.example:443')
self.assertEqual(outbound['protocol'], 'http')
self.assertNotIn('users', outbound['settings']['servers'][0])
self.assertEqual(outbound['streamSettings']['tlsSettings']['serverName'], 'proxy.example')
def test_bad_proxy_hides_credentials(self):
for url in ('ftp://user:secret@host:21', 'socks5://user:secret@host', 'garbage'):
with self.assertRaises(ValueError) as error:
parse_proxy(url)
self.assertNotIn('secret', str(error.exception))
def test_create_inbound_generates_reality_ids(self):
runner = FakeRunner()
XuiPanel(Host('1.2.3.4'), runner=runner).create_inbound()
+56 -2
View File
@@ -1,9 +1,17 @@
import base64
import secrets
import urllib.parse
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from .runner import Result
from .server import Server
MAIN_INBOUND = 'main'
CF_WARP = 'cf_warp'
"""Исходящий прокси через Cloudflare WARP."""
PROXY_SCHEMES = {'socks': 'socks', 'socks5': 'socks', 'socks5h': 'socks', 'http': 'http', 'https': 'http'}
class XuiPanel(Server):
@@ -11,11 +19,13 @@ class XuiPanel(Server):
def install(
self, username: str, password: str, panel_port: int, web_base_path: str, sub_port: int, sub_path: str,
domain: str = '',
domain: str = '', ssl: bool = True,
) -> Result:
"""Ставит панель, включает подписку на sub_port по пути /sub_path/ и DNS-over-TLS к Cloudflare.
Открывает порты 80 (acme), 443, panel_port и sub_port. Возвращает data: {'api_token': ...}.
Сертификат Let's Encrypt на domain, без него - на ip; не удалось выпустить - ошибка. ssl=False - панель на http,
для серверов без публичного адреса. Открывает порты 80 (acme), 443, panel_port и sub_port.
Возвращает data: {'api_token', 'panel_host' (домен или ip из сертификата), 'ssl'}.
"""
return self.run(
'xui',
@@ -26,8 +36,25 @@ class XuiPanel(Server):
sub_port=sub_port,
sub_path=sub_path,
domain=domain,
ssl=ssl,
)
def issue_certificate(self, domain: str = '') -> Result:
"""Выпускает сертификат панели и подписки на domain, без него - на ip. Возвращает data: {'panel_host'}.
Так домен ставится или меняется после установки. Домен должен указывать на сервер или его маскировку.
"""
return self.run('xui_cert', domain=domain)
def set_outbound(self, proxy: str = '') -> Result:
"""Весь исходящий трафик клиентов через прокси. Перед применением прокси проверяется панелью.
proxy - socks5://user:password@host:port, http(s)://..., CF_WARP или пустая строка, чтобы выходить напрямую.
"""
warp_keys = generate_wireguard_keys() if proxy == CF_WARP else None
proxy_outbound = parse_proxy(proxy) if proxy and proxy != CF_WARP else None
return self.run('xui_outbound', proxy_outbound=proxy_outbound, warp_keys=warp_keys)
def create_inbound(self, remark: str = MAIN_INBOUND, target: str = '') -> Result:
"""Создает подключение vless + xhttp + reality на 443 порту, uTLS firefox, сброс трафика ежемесячно.
@@ -47,3 +74,30 @@ class XuiPanel(Server):
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)]
def parse_proxy(url: str) -> dict:
"""outbound Xray для прокси вида socks5://user:password@host:port или http(s)://... ValueError, если не разобрать."""
parts = urllib.parse.urlsplit(url)
protocol = PROXY_SCHEMES.get(parts.scheme)
if not protocol or not parts.hostname or not parts.port:
raise ValueError(f'expected socks5://user:password@host:port or http(s)://..., got {parts.scheme}://...')
server = {'address': parts.hostname, 'port': parts.port}
if parts.username:
server['users'] = [{
'user': urllib.parse.unquote(parts.username),
'pass': urllib.parse.unquote(parts.password or ''),
}]
outbound = {'tag': 'proxy', 'protocol': protocol, 'settings': {'servers': [server]}}
if parts.scheme == 'https':
outbound['streamSettings'] = {'security': 'tls', 'tlsSettings': {'serverName': parts.hostname}}
return outbound
def generate_wireguard_keys() -> dict:
key = X25519PrivateKey.generate()
private = key.private_bytes(
serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption(),
)
public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
return {'private_key': base64.b64encode(private).decode(), 'public_key': base64.b64encode(public).decode()}