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

This commit is contained in:
protokey committed 2026-09-25 15:14:17 +04:00
1 parent 6d322f36a9
commit c4300066f5
9 files changed
+133 -38

No files matched your search

+4 -4
View File
@@ -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 сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке.
+3 -1
View File
@@ -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
+10 -3
View File
@@ -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', ''),
+10 -9
View File
@@ -48,13 +48,13 @@ EMPTY = '-'
GREETING = 'привет. выбери действие'
FAQ_TEXT = '''<b>Что это за бот?</b>
Бот настраивает ваш собственный VPN на ваших серверах: панель 3x-ui с подключениями vless + tls + reality и сервер маскировки перед ней.
Бот настраивает ваш собственный VPN на вашем сервере: панель 3x-ui с подключениями vless + tls + reality. По желанию перед ней ставится сервер маскировки.
<b>Что нужно от меня?</b>
Два арендованных сервера с Debian 13 (от 2 ядер, 2 Гб памяти, 20 Гб диска): ip и пароль root каждого. По желанию - домен и прокси на выходе.
Арендованный сервер с Debian 13 (от 2 ядер, 2 Гб памяти, 20 Гб диска): ip и пароль root. По желанию - второй такой же сервер для маскировки, домен и прокси на выходе.
<b>Зачем два сервера?</b>
Клиенты подключаются к серверу маскировки, он пересылает трафик на панель. Адрес панели никто не видит.
<b>Зачем сервер маскировки?</b>
Клиенты подключаются к серверу маскировки, он пересылает трафик на панель. Адрес панели никто не видит. Для маскировки нужен домен.
<b>Что бот делает с сервером?</b>
Прописывает свой ssh ключ, меняет пароль root, обновляет систему, переносит ssh на случайный порт с входом только по ключу, включает фаервол и ставит нужные сервисы.
@@ -63,12 +63,12 @@ FAQ_TEXT = '''<b>Что это за бот?</b>
Меню сервера - "Вернуть". Бот уберет свои доступы, откатит ssh и фаервол, поставит новый пароль root и пришлет его. Данные на сервере останутся.
<b>Сервер переустановили у провайдера</b>
Меню сервера - "Переустановить" и новый пароль root.'''
Меню сервера - "Переустановить" и новый пароль root. Если провайдер вернул пароль, который вы присылали боту при добавлении сервера, нажмите "Использовать старый".'''
HELP_TEXT = '''<b>Добавить сервер</b>
1. "добавить сервер"
2. "сервер" - пришлите <code>ip:пароль</code> сервера для панели
3. "маскировка" - пришлите <code>ip:пароль</code> сервера маскировки
3. по желанию "маскировка" - пришлите <code>ip:пароль</code> сервера маскировки, для нее нужен "домен"
4. по желанию "домен", "cloudflare", "прокси"
5. "установить" - установка идет в фоне, по окончании придет уведомление
@@ -86,7 +86,8 @@ DOMAIN_INVALID = 'Не похоже на доменное имя. Попробу
CLOUDFLARE_PROMPT = 'Если вы спрятали домен за клаудом, то нужно включить опцию тут.'
PROXY_PROMPT = 'Можно указать свои в формате <code>protocol://username:password@host:port</code> или включить cf_warp.'
PROXY_INVALID = 'Не похоже на <code>protocol://username:password@host:port</code>. Попробуй еще раз.'
DRAFT_INCOMPLETE = 'Нужны оба сервера: "сервер" и "маскировка".'
DRAFT_INCOMPLETE = 'Нужен сервер для панели: "сервер".'
MASK_WITHOUT_DOMAIN = 'Сервер маскировки работает только с доменом. Укажи домен или убери маскировку.'
CLOUDFLARE_WITHOUT_DOMAIN = 'Cloudflare работает только с доменом. Укажи домен или выключи cloudflare.'
INSTALL_STARTED = 'Запускаю установку сервера. Отправлю уведомление когда все будет готово.'
@@ -102,7 +103,7 @@ REINSTALL_PROMPT = '''Будет произведена повторная ус
Вызывайте эту функцию, только после того, как в панели управления
сервером в аккаунте хостинг провайдера вызовите переустановку сервера.
Если уверены, то пришлите <code>ip:новый_пароль</code> переустановленного сервера.
"Использовать старый" переустановит оба сервера с последними известными паролями.'''
"Использовать старый" переустановит все серверы точки с паролями, которые вы прислали при добавлении.'''
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)} - проименован как <code>{escape(point.name)}</code>, готов к работе.'
return f'сервер {escape(point.entry.address)} - проименован как <code>{escape(point.name)}</code>, готов к работе.'
def install_failed(point: Point, error: Exception) -> str:
@@ -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),
]
+15 -5
View File
@@ -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)
+12 -6
View File
@@ -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)
+19 -1
View File
@@ -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]])
+31 -9
View File
@@ -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):