diff --git a/src/App/README.md b/src/App/README.md
index a919b10..da88577 100644
--- a/src/App/README.md
+++ b/src/App/README.md
@@ -11,10 +11,10 @@ Django приложение связывающее все части сайта
## Модель данных
-- `Point` - точка доступа: кластер из сервера с панелью 3x-ui и сервера маскировки. Владелец - `Telegram.User`. Хранит домен, cloudflare, прокси на выходе и доступ к панели.
-- `Server` - сервер точки с ролью `panel` или `mask`. Хранит текущие порт ssh, пароль root, ключ и отпечаток ssh.
+- `Point` - точка доступа: сервер с панелью 3x-ui и необязательный сервер маскировки. Владелец - `Telegram.User`. Хранит домен, cloudflare, прокси на выходе и доступ к панели.
+- `Server` - сервер точки с ролью `panel` или `mask`. Хранит текущие порт ssh, пароль root, ключ и отпечаток ssh, а также пароль root, присланный пользователем при добавлении (`default_root_password`).
-Клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес.
+С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес. Маскировка требует домен.
## Модули
@@ -30,4 +30,4 @@ Django приложение связывающее все части сайта
3. маскировка: перенаправление трафика на адрес панели
4. facts: `uname -a` для карточки сервера
-"Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку. Отпечаток можно сбросить и в админке.
+"Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке.
diff --git a/src/App/admin.py b/src/App/admin.py
index 38c9f50..62506d3 100644
--- a/src/App/admin.py
+++ b/src/App/admin.py
@@ -33,7 +33,9 @@ class UserAdmin(BaseUserAdmin):
class ServerInline(admin.StackedInline):
model = Server
extra = 0
- fields = ('role', 'address', 'ssh_port', 'root_password', 'public_key', 'known_hosts', 'uname')
+ fields = (
+ 'role', 'address', 'ssh_port', 'root_password', 'default_root_password', 'public_key', 'known_hosts', 'uname',
+ )
readonly_fields = fields
can_delete = False
diff --git a/src/App/bot/interface.py b/src/App/bot/interface.py
index c336102..2ed3763 100644
--- a/src/App/bot/interface.py
+++ b/src/App/bot/interface.py
@@ -93,7 +93,12 @@ class MyPointInterface(Interface):
@text(texts.MASK, state=DRAFT)
def ask_mask(self, ctx):
- self.ask(ctx, DRAFT_MASK, texts.CREDENTIALS_PROMPT)
+ self.ask(ctx, DRAFT_MASK, texts.CREDENTIALS_PROMPT, [[texts.CANCEL, texts.DELETE]])
+
+ @text(texts.DELETE, state=DRAFT_MASK)
+ def delete_mask(self, ctx):
+ ctx.data['draft'].pop('mask', None)
+ self.show_draft(ctx)
@text(texts.DOMAIN, state=DRAFT)
def ask_domain(self, ctx):
@@ -184,14 +189,16 @@ class MyPointInterface(Interface):
@text(texts.INSTALL, state=DRAFT)
def install(self, ctx):
draft = ctx.data['draft']
- if not (draft.get('server') and draft.get('mask')):
+ if not draft.get('server'):
return ctx.reply(texts.DRAFT_INCOMPLETE)
+ if draft.get('mask') and not draft.get('domain'):
+ return ctx.reply(texts.MASK_WITHOUT_DOMAIN)
if draft.get('cloudflare') and not draft.get('domain'):
return ctx.reply(texts.CLOUDFLARE_WITHOUT_DOMAIN)
point = services.create_point(
ctx.user,
panel=services.Credentials(**draft['server']),
- mask=services.Credentials(**draft['mask']),
+ mask=services.Credentials(**draft['mask']) if draft.get('mask') else None,
domain=draft.get('domain', ''),
cloudflare=draft.get('cloudflare', False),
outbound_proxy=draft.get('proxy', ''),
diff --git a/src/App/bot/texts.py b/src/App/bot/texts.py
index 8f790c5..5c3a3a9 100644
--- a/src/App/bot/texts.py
+++ b/src/App/bot/texts.py
@@ -48,13 +48,13 @@ EMPTY = '-'
GREETING = 'привет. выбери действие'
FAQ_TEXT = '''Что это за бот?
-Бот настраивает ваш собственный VPN на ваших серверах: панель 3x-ui с подключениями vless + tls + reality и сервер маскировки перед ней.
+Бот настраивает ваш собственный VPN на вашем сервере: панель 3x-ui с подключениями vless + tls + reality. По желанию перед ней ставится сервер маскировки.
Что нужно от меня?
-Два арендованных сервера с Debian 13 (от 2 ядер, 2 Гб памяти, 20 Гб диска): ip и пароль root каждого. По желанию - домен и прокси на выходе.
+Арендованный сервер с Debian 13 (от 2 ядер, 2 Гб памяти, 20 Гб диска): ip и пароль root. По желанию - второй такой же сервер для маскировки, домен и прокси на выходе.
-Зачем два сервера?
-Клиенты подключаются к серверу маскировки, он пересылает трафик на панель. Адрес панели никто не видит.
+Зачем сервер маскировки?
+Клиенты подключаются к серверу маскировки, он пересылает трафик на панель. Адрес панели никто не видит. Для маскировки нужен домен.
Что бот делает с сервером?
Прописывает свой ssh ключ, меняет пароль root, обновляет систему, переносит ssh на случайный порт с входом только по ключу, включает фаервол и ставит нужные сервисы.
@@ -63,12 +63,12 @@ FAQ_TEXT = '''Что это за бот?
Меню сервера - "Вернуть". Бот уберет свои доступы, откатит ssh и фаервол, поставит новый пароль root и пришлет его. Данные на сервере останутся.
Сервер переустановили у провайдера
-Меню сервера - "Переустановить" и новый пароль root.'''
+Меню сервера - "Переустановить" и новый пароль root. Если провайдер вернул пароль, который вы присылали боту при добавлении сервера, нажмите "Использовать старый".'''
HELP_TEXT = '''Добавить сервер
1. "добавить сервер"
2. "сервер" - пришлите ip:пароль сервера для панели
-3. "маскировка" - пришлите ip:пароль сервера маскировки
+3. по желанию "маскировка" - пришлите ip:пароль сервера маскировки, для нее нужен "домен"
4. по желанию "домен", "cloudflare", "прокси"
5. "установить" - установка идет в фоне, по окончании придет уведомление
@@ -86,7 +86,8 @@ DOMAIN_INVALID = 'Не похоже на доменное имя. Попробу
CLOUDFLARE_PROMPT = 'Если вы спрятали домен за клаудом, то нужно включить опцию тут.'
PROXY_PROMPT = 'Можно указать свои в формате protocol://username:password@host:port или включить cf_warp.'
PROXY_INVALID = 'Не похоже на protocol://username:password@host:port. Попробуй еще раз.'
-DRAFT_INCOMPLETE = 'Нужны оба сервера: "сервер" и "маскировка".'
+DRAFT_INCOMPLETE = 'Нужен сервер для панели: "сервер".'
+MASK_WITHOUT_DOMAIN = 'Сервер маскировки работает только с доменом. Укажи домен или убери маскировку.'
CLOUDFLARE_WITHOUT_DOMAIN = 'Cloudflare работает только с доменом. Укажи домен или выключи cloudflare.'
INSTALL_STARTED = 'Запускаю установку сервера. Отправлю уведомление когда все будет готово.'
@@ -102,7 +103,7 @@ REINSTALL_PROMPT = '''Будет произведена повторная ус
Вызывайте эту функцию, только после того, как в панели управления
сервером в аккаунте хостинг провайдера вызовите переустановку сервера.
Если уверены, то пришлите ip:новый_пароль переустановленного сервера.
-"Использовать старый" переустановит оба сервера с последними известными паролями.'''
+"Использовать старый" переустановит все серверы точки с паролями, которые вы прислали при добавлении.'''
REINSTALL_UNKNOWN_ADDRESS = 'Сервер {address} не входит в эту точку.'
REINSTALL_STARTED = 'Запускаю переустановку. Отправлю уведомление когда все будет готово.'
NOT_IMPLEMENTED = 'Пока не реализовано.'
@@ -149,7 +150,7 @@ def point_card(point: Point) -> str:
def installed(point: Point) -> str:
- return f'сервер {escape(point.mask.address)} - проименован как {escape(point.name)}, готов к работе.'
+ return f'сервер {escape(point.entry.address)} - проименован как {escape(point.name)}, готов к работе.'
def install_failed(point: Point, error: Exception) -> str:
diff --git a/src/App/migrations/0003_server_default_root_password.py b/src/App/migrations/0003_server_default_root_password.py
new file mode 100644
index 0000000..0debb2b
--- /dev/null
+++ b/src/App/migrations/0003_server_default_root_password.py
@@ -0,0 +1,29 @@
+from django.db import migrations, models
+
+
+def copy_root_password(apps, schema_editor):
+ # пароль от пользователя для уже добавленных серверов не сохранился, лучшее что есть - текущий
+ Server = apps.get_model('App', 'Server')
+ Server.objects.update(default_root_password=models.F('root_password'))
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('App', '0002_point_server'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='server',
+ name='default_root_password',
+ field=models.CharField(
+ default='',
+ help_text='Пароль root, который пользователь прислал при добавлении сервера. '
+ 'После сброса сервера у провайдера он снова может быть актуальным',
+ max_length=128,
+ ),
+ preserve_default=False,
+ ),
+ migrations.RunPython(copy_root_password, migrations.RunPython.noop),
+ ]
diff --git a/src/App/models.py b/src/App/models.py
index 9346990..2fe2b04 100644
--- a/src/App/models.py
+++ b/src/App/models.py
@@ -51,9 +51,9 @@ class User(AbstractUser):
class Point(models.Model):
- """Точка доступа: кластер из сервера с панелью 3x-ui и сервера маскировки перед ним.
+ """Точка доступа: сервер с панелью 3x-ui и, по желанию, сервер маскировки перед ним.
- Клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес.
+ С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес.
"""
CF_WARP = 'cf_warp'
@@ -94,17 +94,22 @@ class Point(models.Model):
def is_busy(self) -> bool:
return self.status in (self.Status.INSTALLING, self.Status.RELEASING)
- def server(self, role: str) -> 'Server':
- return self.servers.get(role=role)
+ def server(self, role: str) -> 'Server | None':
+ return self.servers.filter(role=role).first()
@property
def panel(self) -> 'Server':
return self.server(Server.Role.PANEL)
@property
- def mask(self) -> 'Server':
+ def mask(self) -> 'Server | None':
return self.server(Server.Role.MASK)
+ @property
+ def entry(self) -> 'Server':
+ """Сервер, к которому подключаются клиенты."""
+ return self.mask or self.panel
+
class Server(models.Model):
"""Сервер под управлением системы. Поля ssh отражают текущее состояние сервера."""
@@ -117,6 +122,11 @@ class Server(models.Model):
role = models.CharField(max_length=8, choices=Role.choices)
address = models.GenericIPAddressField(unique=True)
ssh_port = models.PositiveIntegerField(default=22)
+ default_root_password = models.CharField(
+ max_length=128,
+ help_text='Пароль root, который пользователь прислал при добавлении сервера. '
+ 'После сброса сервера у провайдера он снова может быть актуальным',
+ )
root_password = models.CharField(max_length=128, help_text='Текущий пароль root')
private_key = models.TextField(blank=True)
public_key = models.TextField(blank=True)
diff --git a/src/App/services.py b/src/App/services.py
index 85b46f2..de81fb4 100644
--- a/src/App/services.py
+++ b/src/App/services.py
@@ -23,9 +23,10 @@ class Credentials:
def create_point(
- owner, panel: Credentials, mask: Credentials, domain: str = '', cloudflare: bool = False,
+ owner, panel: Credentials, mask: Credentials | None = None, domain: str = '', cloudflare: bool = False,
outbound_proxy: str = '',
) -> Point:
+ """Создает точку. mask - необязательный сервер маскировки."""
low, high = PANEL_PORT_RANGE
with transaction.atomic():
point = Point.objects.create(
@@ -40,12 +41,18 @@ def create_point(
panel_path=serverus.generate_password(18),
)
for role, credentials in ((Server.Role.PANEL, panel), (Server.Role.MASK, mask)):
- point.servers.create(role=role, address=credentials.address, root_password=credentials.password)
+ if credentials:
+ point.servers.create(
+ role=role,
+ address=credentials.address,
+ root_password=credentials.password,
+ default_root_password=credentials.password,
+ )
return point
def install_point(point: Point):
- """Настраивает оба сервера точки. Порядок не важен: маскировке нужен только адрес панели."""
+ """Настраивает серверы точки. Порядок не важен: маскировке нужен только адрес панели."""
with _installing(point):
for server in point.servers.all():
provision(server)
@@ -55,15 +62,14 @@ def reinstall(point: Point, address: str | None = None, password: str | None = N
"""Повторная установка после переустановки ОС у провайдера.
address - переустановленный сервер точки, без него переустанавливаются все.
- password - новый пароль root от провайдера, без него используется последний известный.
+ password - новый пароль root от провайдера, без него используется пароль, присланный при добавлении сервера.
"""
servers = point.servers.filter(address=address) if address else point.servers.all()
with _installing(point):
for server in servers:
server.known_hosts = ''
server.ssh_port = 22
- if password:
- server.root_password = password
+ server.root_password = password or server.default_root_password
server.save()
provision(server)
diff --git a/src/App/tests_bot.py b/src/App/tests_bot.py
index 47e1863..0fbc2f1 100644
--- a/src/App/tests_bot.py
+++ b/src/App/tests_bot.py
@@ -105,10 +105,27 @@ class AddServerTests(BotTestCase):
self.assertEqual(self.fill(texts.PROXY, 'http://nope'), texts.PROXY_INVALID)
send.assert_not_called()
- def test_cloudflare_requires_domain(self, send):
+ def test_install_without_mask(self, send):
+ self.say(texts.ADD_SERVER)
+ self.fill(texts.SERVER, '10.0.0.1:pw')
+ self.assertEqual(self.say(texts.INSTALL), texts.INSTALL_STARTED)
+ point = Point.objects.get()
+ self.assertIsNone(point.mask)
+ send.assert_called_once_with(point.pk, mock.ANY)
+
+ def test_mask_requires_domain(self, send):
self.say(texts.ADD_SERVER)
self.fill(texts.SERVER, '10.0.0.1:pw')
self.fill(texts.MASK, '10.0.0.2:pw')
+ self.assertEqual(self.say(texts.INSTALL), texts.MASK_WITHOUT_DOMAIN)
+ self.say(texts.MASK)
+ self.assertIn('Сервер маскировки: -', self.say(texts.DELETE))
+ self.assertEqual(self.say(texts.INSTALL), texts.INSTALL_STARTED)
+ self.assertIsNone(Point.objects.get().mask)
+
+ def test_cloudflare_requires_domain(self, send):
+ self.say(texts.ADD_SERVER)
+ self.fill(texts.SERVER, '10.0.0.1:pw')
self.fill(texts.CLOUDFLARE, texts.ENABLE)
self.assertEqual(self.say(texts.INSTALL), texts.CLOUDFLARE_WITHOUT_DOMAIN)
send.assert_not_called()
@@ -200,6 +217,7 @@ class TasksTests(TestCase):
def test_install_notifies_success(self):
text = self.run_task(tasks.install_point, self.point.pk, self.chat.pk)
self.assertIn(self.point.name, text)
+ self.assertIn('10.0.0.2', text)
self.assertIn('готов к работе', text)
self.assertEqual(self.api.last_keyboard, [[texts.SERVER_MENU]])
diff --git a/src/App/tests_services.py b/src/App/tests_services.py
index cf21bf6..58b49a9 100644
--- a/src/App/tests_services.py
+++ b/src/App/tests_services.py
@@ -34,11 +34,12 @@ def patch_runner(runner):
return mock.patch('serverus.runner.run_playbook', runner)
-def create_point(owner=None, **kwargs):
+MASK = services.Credentials('10.0.0.2', 'mask-pw')
+
+
+def create_point(owner=None, mask=MASK, **kwargs):
owner = owner or TelegramUser.objects.create(tg_id=1)
- return services.create_point(
- owner, services.Credentials('10.0.0.1', 'panel-pw'), services.Credentials('10.0.0.2', 'mask-pw'), **kwargs,
- )
+ return services.create_point(owner, services.Credentials('10.0.0.1', 'panel-pw'), mask, **kwargs)
class CreatePointTests(TestCase):
@@ -50,6 +51,14 @@ class CreatePointTests(TestCase):
low, high = services.PANEL_PORT_RANGE
self.assertTrue(low <= point.panel_port < high)
self.assertTrue(point.panel_username and point.panel_password and point.panel_path)
+ self.assertEqual(point.panel.default_root_password, 'panel-pw')
+ self.assertEqual(point.entry, point.mask)
+
+ def test_without_mask(self):
+ point = create_point(mask=None)
+ self.assertIsNone(point.mask)
+ self.assertEqual(list(point.servers.all()), [point.panel])
+ self.assertEqual(point.entry, point.panel)
class InstallTests(TestCase):
@@ -83,6 +92,15 @@ class InstallTests(TestCase):
_, extravars = runner.call('proxy', '10.0.0.2')
self.assertEqual(extravars['target_ip'], '10.0.0.1')
+ def test_install_point_without_mask(self):
+ point = create_point(mask=None)
+ runner = FakeRunner()
+ with patch_runner(runner):
+ services.install_point(point)
+ self.assertEqual([playbook for playbook, _, _ in runner.calls], ['bootstrap', 'xui', 'facts'])
+ point.refresh_from_db()
+ self.assertEqual(point.status, Point.Status.READY)
+
def test_install_failure_marks_point(self):
point = create_point()
runner = FakeRunner(fail='xui')
@@ -125,14 +143,18 @@ class ReinstallTests(TestCase):
self.point.refresh_from_db()
self.assertEqual(self.point.status, Point.Status.READY)
- def test_reinstall_all_with_old_passwords(self):
- old_password = self.point.panel.root_password
+ def test_reinstall_all_with_passwords_from_user(self):
+ self.assertNotEqual(self.point.panel.root_password, 'panel-pw')
runner = FakeRunner()
with patch_runner(runner):
services.reinstall(self.point)
- _, extravars = runner.call('bootstrap', '10.0.0.1')
- self.assertEqual(extravars['login_password'], old_password)
- self.assertTrue(runner.playbooks('10.0.0.2'))
+ self.assertEqual(runner.call('bootstrap', '10.0.0.1')[1]['login_password'], 'panel-pw')
+ self.assertEqual(runner.call('bootstrap', '10.0.0.2')[1]['login_password'], 'mask-pw')
+
+ def test_new_password_does_not_replace_password_from_user(self):
+ with patch_runner(FakeRunner()):
+ services.reinstall(self.point, '10.0.0.1', 'new-provider-pw')
+ self.assertEqual(self.point.panel.default_root_password, 'panel-pw')
class ReleaseTests(TestCase):