chore: сохраняем пароль которым ставили сервер
This commit is contained in:
1 parent
6d322f36a9
commit
c4300066f5
9 files changed
+133
-38
No files matched your search
+4
-4
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]])
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user