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):