chore: настройка панелей
This commit is contained in:
1 parent
8532557ef6
commit
96213e182e
16 files changed
+529
-64
No files matched your search
@@ -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_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:
|
help:
|
||||||
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
|
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
|
||||||
@@ -20,8 +20,10 @@ help:
|
|||||||
@echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения"
|
@echo "make freeze - перезаписать $(REQUIREMENTS) текущим состоянием окружения"
|
||||||
@echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)"
|
@echo "make wheels - собрать wheel для всех пакетов из $(REQUIREMENTS)"
|
||||||
@echo "make clean - удалить $(WHEELS_DIR)/"
|
@echo "make clean - удалить $(WHEELS_DIR)/"
|
||||||
@echo "make serverus_xui ip=<ip> password=<root_password> - настроить свежий сервер и поставить 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_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 порт"
|
@echo "make serverus_release ip=<ip> - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт"
|
||||||
|
|
||||||
venv:
|
venv:
|
||||||
@@ -84,12 +86,20 @@ tg_run:
|
|||||||
|
|
||||||
serverus_xui:
|
serverus_xui:
|
||||||
@test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip=<ip> password=<root_password>"; exit 1; }
|
@test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip=<ip> password=<root_password>"; exit 1; }
|
||||||
$(SERVERUS) xui $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip)
|
$(SERVERUS) xui $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(domain),--domain $(domain)) $(if $(filter no,$(ssl)),--no-ssl)
|
||||||
|
|
||||||
serverus_key:
|
serverus_key:
|
||||||
@test -n "$(ip)" -a -n "$(action)" -a -n "$(name)" || { echo "Укажите ключ: make serverus_key ip=<ip> action=add|del|stats name=<name>"; exit 1; }
|
@test -n "$(ip)" -a -n "$(action)" -a -n "$(name)" || { echo "Укажите ключ: make serverus_key ip=<ip> action=add|del|stats name=<name>"; exit 1; }
|
||||||
$(SERVERUS) key $(action) '$(name)' --out $(SERVERUS_ACCESS_DIR)/$(ip)
|
$(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:
|
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)
|
||||||
+12
-8
@@ -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`.
|
- `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`.
|
||||||
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
|
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
|
||||||
- `bot/texts.py` - кнопки и тексты.
|
- `bot/texts.py` - кнопки и тексты.
|
||||||
@@ -35,16 +35,20 @@ Django приложение связывающее все части сайта
|
|||||||
## Установка точки
|
## Установка точки
|
||||||
|
|
||||||
1. bootstrap обоих серверов (ключ, пароль root, обновление, ssh на случайном порту, nftables)
|
1. bootstrap обоих серверов (ключ, пароль root, обновление, ssh на случайном порту, nftables)
|
||||||
2. панель: 3x-ui с логином, паролем, портом и путем, которые сгенерировал App
|
2. панель: 3x-ui с логином, паролем, портом и путем, которые сгенерировал App, сертификат на домен или ip, подписка, подключение reality `main`, исходящий прокси точки
|
||||||
3. маскировка: перенаправление трафика на адрес панели
|
3. маскировка: перенаправление трафика на адрес панели
|
||||||
4. facts: `uname -a` для карточки сервера
|
4. facts: `uname -a` для карточки сервера
|
||||||
|
|
||||||
"Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке.
|
"Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке.
|
||||||
|
|
||||||
# TODO:
|
## TODO
|
||||||
|
|
||||||
-[ ] для панели 3x-ui нужно активировать ssl, панель умеет сама создавать сертификаты для домена или ip адреса.
|
- [x] для панели 3x-ui нужно активировать ssl, панель умеет сама создавать сертификаты для домена или ip адреса.
|
||||||
-[ ] можно ли поставить домен после того как настроили сервер на ip? я имею ввиду поставить домен и выпустить ssl сертификат от letsencrypt.
|
Скрипт 3x-ui при ошибке молча оставлял панель на http и при повторной установке SSL не трогал. Теперь сертификат выпускает serverus и падает, если не вышло. На ip без публичного адреса сертификат не выпустить: `ssl=no`.
|
||||||
-[ ] что нужно сделать чтобы сервис мог нормально работать и раздавать прокси интернет через cloudflare? когда домен за прокси клауда.
|
- [x] можно ли поставить домен после того как настроили сервер на ip? я имею ввиду поставить домен и выпустить ssl сертификат от letsencrypt.
|
||||||
-[ ] если используется и прокси сервер и домен за claudflare, то будет работать?
|
Да: `services.set_domain(point, domain)`, в serverus - `XuiPanel.issue_certificate(domain)`. В боте кнопки пока нет.
|
||||||
-[ ] нужна функция установки прокси в исходящие (outbound)
|
- [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.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
import re
|
import re
|
||||||
from urllib.parse import urlsplit
|
|
||||||
|
|
||||||
|
import serverus
|
||||||
from Telegram.interfaces import Interface, command, register, state, text
|
from Telegram.interfaces import Interface, command, register, state, text
|
||||||
|
|
||||||
from .. import services, tasks
|
from .. import services, tasks
|
||||||
@@ -22,7 +22,6 @@ POINT_REINSTALL = 'point.reinstall'
|
|||||||
|
|
||||||
DRAFT_CREDENTIALS = {DRAFT_SERVER: 'server', DRAFT_MASK: 'mask'}
|
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}$')
|
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'
|
HTML = 'HTML'
|
||||||
|
|
||||||
|
|
||||||
@@ -42,10 +41,10 @@ def is_domain(value: str) -> bool:
|
|||||||
|
|
||||||
def is_proxy_url(value: str) -> bool:
|
def is_proxy_url(value: str) -> bool:
|
||||||
try:
|
try:
|
||||||
url = urlsplit(value)
|
serverus.parse_proxy(value)
|
||||||
return url.scheme in PROXY_SCHEMES and bool(url.hostname) and bool(url.port)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@register
|
@register
|
||||||
|
|||||||
+2
-2
@@ -4,7 +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, generate_port, generate_username
|
from serverus import CF_WARP, Host, generate_password, generate_port, generate_username
|
||||||
|
|
||||||
|
|
||||||
class UserManager(BaseUserManager):
|
class UserManager(BaseUserManager):
|
||||||
@@ -127,7 +127,7 @@ class Point(models.Model):
|
|||||||
Создание: Point.objects.create_point. Поиск: методы PointQuerySet. Удаление: point.delete() удаляет и серверы.
|
Создание: Point.objects.create_point. Поиск: методы PointQuerySet. Удаление: point.delete() удаляет и серверы.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CF_WARP = 'cf_warp'
|
CF_WARP = CF_WARP
|
||||||
|
|
||||||
class Status(models.TextChoices):
|
class Status(models.TextChoices):
|
||||||
INSTALLING = 'installing', 'устанавливается'
|
INSTALLING = 'installing', 'устанавливается'
|
||||||
|
|||||||
@@ -55,6 +55,29 @@ def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]:
|
|||||||
return passwords, errors
|
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):
|
def provision(server: Server):
|
||||||
"""Базовая настройка сервера и специализация по его роли."""
|
"""Базовая настройка сервера и специализация по его роли."""
|
||||||
_bootstrap(server)
|
_bootstrap(server)
|
||||||
@@ -97,6 +120,8 @@ def _install_panel(server: Server):
|
|||||||
result = _check(server, panel.create_inbound())
|
result = _check(server, panel.create_inbound())
|
||||||
point.panel_inbound_id = result.data.get('inbound_id')
|
point.panel_inbound_id = result.data.get('inbound_id')
|
||||||
point.save(update_fields=['panel_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):
|
def _redirect_to_panel(server: Server):
|
||||||
|
|||||||
@@ -160,6 +160,58 @@ class InstallTests(TestCase):
|
|||||||
self.assertEqual(Server.objects.get(pk=panel.pk).private_key, private_key)
|
self.assertEqual(Server.objects.get(pk=panel.pk).private_key, private_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_install_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):
|
class ReinstallTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.point = create_point()
|
self.point = create_point()
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ server.release(generate_password())
|
|||||||
|
|
||||||
Специализации - наследники `Server`:
|
Специализации - наследники `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`.
|
- `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. **Сейчас скрипт - заглушка.**
|
||||||
|
|
||||||
@@ -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_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> # вернуть сервер
|
make serverus_release ip=<ip> # вернуть сервер
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ 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 CF_WARP, XuiPanel, parse_proxy
|
||||||
from .xui_api import XuiApi, XuiApiError
|
from .xui_api import XuiApi, XuiApiError
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
'CF_WARP',
|
||||||
'Host',
|
'Host',
|
||||||
'KeyPair',
|
'KeyPair',
|
||||||
'RedirectProxy',
|
'RedirectProxy',
|
||||||
@@ -22,5 +23,6 @@ __all__ = [
|
|||||||
'generate_port',
|
'generate_port',
|
||||||
'generate_ssh_port',
|
'generate_ssh_port',
|
||||||
'generate_username',
|
'generate_username',
|
||||||
|
'parse_proxy',
|
||||||
'run_playbook',
|
'run_playbook',
|
||||||
]
|
]
|
||||||
@@ -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 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>
|
python -m serverus release --out <dir>
|
||||||
|
|
||||||
Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/access.json до начала настройки:
|
Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/access.json до начала настройки:
|
||||||
@@ -29,24 +31,38 @@ def main(argv=None) -> int:
|
|||||||
xui.add_argument('address')
|
xui.add_argument('address')
|
||||||
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='папка для доступов к серверу')
|
||||||
|
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 = commands.add_parser('key', help='выпуск, отзыв и статистика ключа на панели от команды xui')
|
||||||
key.add_argument('action', choices=['add', 'del', 'stats'])
|
key.add_argument('action', choices=['add', 'del', 'stats'])
|
||||||
key.add_argument('name', help='имя ключа (email клиента в панели)')
|
key.add_argument('name', help='имя ключа (email клиента в панели)')
|
||||||
key.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui')
|
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 = 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)
|
||||||
if args.command == 'xui':
|
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':
|
if args.command == 'key':
|
||||||
return manage_key(args.action, args.name, args.out)
|
return manage_key(args.action, args.name, args.out)
|
||||||
|
if args.command == 'cert':
|
||||||
|
return issue_certificate(args.domain, args.out)
|
||||||
|
if args.command == 'outbound':
|
||||||
|
return set_outbound(args.proxy, args.out)
|
||||||
return release_server(args.out)
|
return release_server(args.out)
|
||||||
|
|
||||||
|
|
||||||
def install_xui(address: str, password: str, out: Path) -> int:
|
def install_xui(address: str, password: str, out: Path, domain: str, ssl: bool) -> int:
|
||||||
keys = generate_keypair()
|
keys = generate_keypair()
|
||||||
panel_port = generate_port()
|
panel_port = generate_port()
|
||||||
access = {
|
access = {
|
||||||
@@ -62,6 +78,8 @@ def install_xui(address: str, password: str, out: Path) -> int:
|
|||||||
'web_base_path': generate_password(18),
|
'web_base_path': generate_password(18),
|
||||||
'sub_port': generate_port(exclude={panel_port}),
|
'sub_port': generate_port(exclude={panel_port}),
|
||||||
'sub_path': generate_password(12),
|
'sub_path': generate_password(12),
|
||||||
|
'domain': domain,
|
||||||
|
'ssl': ssl,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
save(out, access)
|
save(out, access)
|
||||||
@@ -81,6 +99,7 @@ def install_xui(address: str, password: str, out: Path) -> int:
|
|||||||
if not result.ok:
|
if not result.ok:
|
||||||
return 1
|
return 1
|
||||||
access['api_token'] = result.data['api_token']
|
access['api_token'] = result.data['api_token']
|
||||||
|
access['panel_host'] = result.data['panel_host']
|
||||||
save(out, access)
|
save(out, access)
|
||||||
|
|
||||||
result = XuiPanel(host).create_inbound()
|
result = XuiPanel(host).create_inbound()
|
||||||
@@ -106,7 +125,9 @@ def manage_key(action: str, name: str, out: Path) -> int:
|
|||||||
if action == 'add':
|
if action == 'add':
|
||||||
client = api.add_client(access['inbound']['inbound_id'], name)
|
client = api.add_client(access['inbound']['inbound_id'], name)
|
||||||
panel = access['panel']
|
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')
|
print(*api.get_links(name), sep='\n')
|
||||||
elif action == 'del':
|
elif action == 'del':
|
||||||
api.delete_client(name)
|
api.delete_client(name)
|
||||||
@@ -119,6 +140,30 @@ def manage_key(action: str, name: str, out: Path) -> int:
|
|||||||
return 0
|
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:
|
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()
|
||||||
@@ -134,7 +179,9 @@ def release_server(out: Path) -> int:
|
|||||||
|
|
||||||
def panel_url(access: dict) -> str:
|
def panel_url(access: dict) -> str:
|
||||||
panel = access['panel']
|
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:
|
def load_host(access: dict) -> Host:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 проекта в неинтерактивном режиме.
|
# Ставит 3x-ui скриптом из README проекта в неинтерактивном режиме, SSL скрипта выключен: он при ошибке молча оставляет
|
||||||
# Сертификат: на домен, если он задан, иначе на ip. Оба варианта требуют открытый 80 порт (acme).
|
# панель на http. Сертификат выпускает tasks/xui_cert.yml на домен, если он задан, иначе на ip. Нужен открытый 80 порт.
|
||||||
|
# ssl=false - без сертификата, для серверов без публичного адреса.
|
||||||
# Системный DNS - DNS-over-TLS к Cloudflare, Xray резолвит через него. Подписка на sub_port под тем же сертификатом.
|
# Системный DNS - DNS-over-TLS к Cloudflare, Xray резолвит через него. Подписка на sub_port под тем же сертификатом.
|
||||||
- name: Install 3x-ui panel
|
- name: Install 3x-ui panel
|
||||||
hosts: target
|
hosts: target
|
||||||
@@ -9,6 +11,8 @@
|
|||||||
vars:
|
vars:
|
||||||
xui_bin: /usr/local/x-ui/x-ui
|
xui_bin: /usr/local/x-ui/x-ui
|
||||||
xui_domain: "{{ domain | default('') }}"
|
xui_domain: "{{ domain | default('') }}"
|
||||||
|
xui_ssl: "{{ ssl | default(true) | bool }}"
|
||||||
|
cert_host: "{{ xui_domain or ansible_host }}"
|
||||||
tasks:
|
tasks:
|
||||||
- ansible.builtin.import_tasks: tasks/firewall.yml
|
- ansible.builtin.import_tasks: tasks/firewall.yml
|
||||||
vars:
|
vars:
|
||||||
@@ -33,8 +37,7 @@
|
|||||||
XUI_PASSWORD: "{{ password }}"
|
XUI_PASSWORD: "{{ password }}"
|
||||||
XUI_PANEL_PORT: "{{ panel_port }}"
|
XUI_PANEL_PORT: "{{ panel_port }}"
|
||||||
XUI_WEB_BASE_PATH: "{{ web_base_path }}"
|
XUI_WEB_BASE_PATH: "{{ web_base_path }}"
|
||||||
XUI_SSL_MODE: "{{ 'domain' if xui_domain else 'ip' }}"
|
XUI_SSL_MODE: none
|
||||||
XUI_DOMAIN: "{{ xui_domain }}"
|
|
||||||
XUI_SERVER_IP: "{{ ansible_host }}"
|
XUI_SERVER_IP: "{{ ansible_host }}"
|
||||||
no_log: true
|
no_log: true
|
||||||
|
|
||||||
@@ -45,6 +48,9 @@
|
|||||||
-port {{ panel_port }} -webBasePath {{ web_base_path | quote }}
|
-port {{ panel_port }} -webBasePath {{ web_base_path | quote }}
|
||||||
no_log: true
|
no_log: true
|
||||||
|
|
||||||
|
- ansible.builtin.import_tasks: tasks/xui_cert.yml
|
||||||
|
when: xui_ssl
|
||||||
|
|
||||||
- name: Restart panel
|
- name: Restart panel
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: x-ui
|
name: x-ui
|
||||||
@@ -59,37 +65,14 @@
|
|||||||
|
|
||||||
- ansible.builtin.import_tasks: tasks/xui_api.yml
|
- ansible.builtin.import_tasks: tasks/xui_api.yml
|
||||||
|
|
||||||
# настройки меняются только целиком: читаем все, меняем подписку, отправляем обратно
|
- ansible.builtin.import_tasks: tasks/xui_settings.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:
|
vars:
|
||||||
settings: "{{ panel_settings.json.obj }}"
|
xui_settings_patch:
|
||||||
ansible.builtin.uri:
|
subEnable: true
|
||||||
url: "{{ xui_api_url }}/setting/update"
|
subPort: "{{ sub_port | int }}"
|
||||||
method: POST
|
subPath: "/{{ sub_path }}/"
|
||||||
headers: "{{ xui_api_headers }}"
|
subCertFile: "{{ cert_dir ~ '/fullchain.pem' if xui_ssl else '' }}"
|
||||||
body_format: json
|
subKeyFile: "{{ cert_dir ~ '/privkey.pem' if xui_ssl else '' }}"
|
||||||
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
|
- name: Read Xray config
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
@@ -131,4 +114,6 @@
|
|||||||
aggregate: false
|
aggregate: false
|
||||||
data:
|
data:
|
||||||
api_token: "{{ api_token.stdout }}"
|
api_token: "{{ api_token.stdout }}"
|
||||||
|
panel_host: "{{ cert_host if xui_ssl else ansible_host }}"
|
||||||
|
ssl: "{{ xui_ssl }}"
|
||||||
no_log: true
|
no_log: true
|
||||||
@@ -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 }}"
|
||||||
@@ -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
@@ -5,8 +5,8 @@ import urllib.error
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
Host, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
|
CF_WARP, Host, Result, Server, XuiApi, XuiApiError, XuiPanel, generate_keypair, generate_password, generate_port,
|
||||||
generate_ssh_port, generate_username, run_playbook,
|
generate_ssh_port, generate_username, parse_proxy, run_playbook,
|
||||||
)
|
)
|
||||||
from .credentials import SERVICE_PORT_RANGE, SSH_PORT_RANGE
|
from .credentials import SERVICE_PORT_RANGE, SSH_PORT_RANGE
|
||||||
|
|
||||||
@@ -79,8 +79,53 @@ class XuiPanelTests(unittest.TestCase):
|
|||||||
'sub_port': 12000,
|
'sub_port': 12000,
|
||||||
'sub_path': 'sub',
|
'sub_path': 'sub',
|
||||||
'domain': 'example.com',
|
'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):
|
def test_create_inbound_generates_reality_ids(self):
|
||||||
runner = FakeRunner()
|
runner = FakeRunner()
|
||||||
XuiPanel(Host('1.2.3.4'), runner=runner).create_inbound()
|
XuiPanel(Host('1.2.3.4'), runner=runner).create_inbound()
|
||||||
|
|||||||
+56
-2
@@ -1,9 +1,17 @@
|
|||||||
|
import base64
|
||||||
import secrets
|
import secrets
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||||
|
|
||||||
from .runner import Result
|
from .runner import Result
|
||||||
from .server import Server
|
from .server import Server
|
||||||
|
|
||||||
MAIN_INBOUND = 'main'
|
MAIN_INBOUND = 'main'
|
||||||
|
CF_WARP = 'cf_warp'
|
||||||
|
"""Исходящий прокси через Cloudflare WARP."""
|
||||||
|
PROXY_SCHEMES = {'socks': 'socks', 'socks5': 'socks', 'socks5h': 'socks', 'http': 'http', 'https': 'http'}
|
||||||
|
|
||||||
|
|
||||||
class XuiPanel(Server):
|
class XuiPanel(Server):
|
||||||
@@ -11,11 +19,13 @@ class XuiPanel(Server):
|
|||||||
|
|
||||||
def install(
|
def install(
|
||||||
self, username: str, password: str, panel_port: int, web_base_path: str, sub_port: int, sub_path: str,
|
self, username: str, password: str, panel_port: int, web_base_path: str, sub_port: int, sub_path: str,
|
||||||
domain: str = '',
|
domain: str = '', ssl: bool = True,
|
||||||
) -> Result:
|
) -> Result:
|
||||||
"""Ставит панель, включает подписку на sub_port по пути /sub_path/ и DNS-over-TLS к Cloudflare.
|
"""Ставит панель, включает подписку на 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(
|
return self.run(
|
||||||
'xui',
|
'xui',
|
||||||
@@ -26,8 +36,25 @@ class XuiPanel(Server):
|
|||||||
sub_port=sub_port,
|
sub_port=sub_port,
|
||||||
sub_path=sub_path,
|
sub_path=sub_path,
|
||||||
domain=domain,
|
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:
|
def create_inbound(self, remark: str = MAIN_INBOUND, target: str = '') -> Result:
|
||||||
"""Создает подключение vless + xhttp + reality на 443 порту, uTLS firefox, сброс трафика ежемесячно.
|
"""Создает подключение vless + xhttp + reality на 443 порту, uTLS firefox, сброс трафика ежемесячно.
|
||||||
|
|
||||||
@@ -47,3 +74,30 @@ class XuiPanel(Server):
|
|||||||
def generate_short_ids(count: int = 4) -> list[str]:
|
def generate_short_ids(count: int = 4) -> list[str]:
|
||||||
"""shortId reality: hex длиной от 2 до 16 символов."""
|
"""shortId reality: hex длиной от 2 до 16 символов."""
|
||||||
return [secrets.token_hex(secrets.randbelow(8) + 1) for _ in range(count)]
|
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()}
|
||||||
Reference in new issue
Block a user