diff --git a/Makefile b/Makefile index fce6065..5bf25db 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,11 @@ PIP := $(VENV)/bin/pip WHEELS_DIR := wheels REQUIREMENTS := requirements.txt MANAGE := cd src/ && ../$(PYTHON) manage.py +# .env для Django и воркера: те же переменные, что читает docker compose +ifneq (,$(wildcard .env)) +include .env +export $(shell sed -n 's/^\([A-Z_][A-Z0-9_]*\)=.*/\1/p' .env) +endif SERVERUS := cd src/ && ../$(PYTHON) -m serverus # доступы к серверам, настроенным вручную через serverus (папка в .gitignore) SERVERUS_ACCESS_DIR := $(abspath extra/serverus) @@ -14,7 +19,7 @@ SERVERUS_CA = $(if $(filter yes,$(ca)),--ca $(SERVERUS_CA_DIR)) MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help 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_cert serverus_outbound 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 serve_ssh serverus_xui serverus_key serverus_cert serverus_outbound serverus_release help: @echo "make install [ ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/" @@ -27,7 +32,7 @@ help: @echo "make serverus_key ip= action=add|del|stats name= - ключ на панели, настроенной через serverus_xui" @echo "make serverus_cert ip= [domain=] [ca=yes] - сертификат панели на домен или на ip" @echo "make serverus_outbound ip= [proxy=socks5://...|cf_warp] - исходящий прокси, без proxy - напрямую" - @echo "make serverus_release ip= - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт" + @echo "make serverus_release ip= [password=] - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт" venv: @test -d $(VENV) || python3 -m venv $(VENV) @@ -87,6 +92,11 @@ dj_startapp: tg_run: $(MANAGE) telegram_run +# ручной вход на сервер точки с ключом из базы: make serve_ssh ip= [cmd='uptime'] +serve_ssh: + @test -n "$(ip)" || { echo "Укажите сервер: make serve_ssh ip= [cmd='<команда>']"; exit 1; } + $(MANAGE) serve_ssh $(ip) $(if $(cmd),-- $(cmd)) + serverus_xui: @test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip= password="; exit 1; } $(SERVERUS) xui $(ip) '$(password)' --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(domain),--domain $(domain)) $(if $(filter no,$(ssl)),--no-ssl) $(SERVERUS_CA) @@ -105,4 +115,4 @@ serverus_outbound: serverus_release: @test -n "$(ip)" || { echo "Укажите сервер: make serverus_release ip="; exit 1; } - $(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip) + $(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(password),--password '$(password)') diff --git a/README.md b/README.md index effd207..70d3b41 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ Серверу можно настроить: - прокси на выходе (cf или свои) -- домен (можно спрятать за cf, сертификат получим у certbot) +- домен (сертификат Let's Encrypt; без домена сертификат выпускается на ip) + +За Cloudflare точки не работают: reality через проксирование Cloudflare невозможен, Cloudflare сам завершает TLS. Скрыть адрес панели от клиентов можно сервером маскировки. ### Окружение @@ -53,9 +55,8 @@ Сервер: - Сервер маскировки: - Домен: - -Cloudflare: - Прокси: - -[домен][cloudflare][прокси][маскировка] +[домен][прокси][маскировка] [отмена][установить][сервер][помощь] ``` @@ -74,32 +75,11 @@ Cloudflare: - ---> 127.0.0.1:toor <--- -Сервер: 127.0.0.1 - $(uname -a) +Сервер: 127.0.0.1 Домен: - -Cloudflare: - Прокси: - -[домен][cloudflare][прокси] -[отмена][установить][сервер] -``` - -``` ----> -cloudflare -<--- -Если вы спрятали домен за клаудом, то нужно включить опцию тут. -[включить][отмена] -``` - -``` ----> -включить -<--- -Сервер: 127.0.0.1 - $(uname -a) -Домен: - -Cloudflare: Да -Прокси: - -[домен][cloudflare][прокси] -[отмена][установить][сервер] +[домен][прокси][маскировка] +[отмена][установить][сервер][помощь] ``` ``` @@ -114,12 +94,11 @@ Cloudflare: Да ---> http://127.0.0.1:9050 <--- -Сервер: 127.0.0.1 - $(uname -a) +Сервер: 127.0.0.1 Домен: - -Cloudflare: Да -Прокси: 127.0.0.1:9050 -[домен][cloudflare][прокси] -[отмена][установить][сервер] +Прокси: http://127.0.0.1:9050 +[домен][прокси][маскировка] +[отмена][установить][сервер][помощь] ``` ``` @@ -146,8 +125,9 @@ Cloudflare: Да ---> /start <--- -server_name 0Mb/0Mb 0/0 -[ФАК][добавить сервер][помощь][управление сервером] +point-1a2b3c активен +[ФАК][добавить сервер][помощь] +[управление сервером] ``` Чтобы перейти к управлению сервером нужно вот такой квест пройти, надо все эти id в копируемый pre сунуть. @@ -168,16 +148,17 @@ server_name 0Mb/0Mb 0/0 <--- Сервер: NAME Состояние: активен -Ключи: 0/0 +Панель: 1.2.3.4 +Маскировка: - +Ключи: 2 Домен: - Прокси: - [Вернуть][Переустановить] -[Домен][Прокси][Cloudflare] +[Домен][Прокси] [Клиенты] +[главное меню] ``` -Ключи отображаются онлайн/оффлайн. - Возврат сервера связан с отключением его от платформы. Доступы к серверу изымаются, ставится новый пароль рута, а данные остаются как есть. Просто отдаем сервер назад. ``` @@ -225,10 +206,9 @@ server_name 0Mb/0Mb 0/0 Позволяют маскировать ip адрес вашей точки доступа от сайтов, которые посещаются. -X -> Cloudflare <> MyPoint <> PROXY <- Web +X <> MyPoint <> PROXY <- Web -X через домен видит ip от cloudflare, а web сайты видят ip прокси, -это позволяет вашей точке доступа оставаться инкогнито. +Сайты видят ip прокси, а не ip вашей точки доступа. [установить][отключить] ``` @@ -241,48 +221,34 @@ X через домен видит ip от cloudflare, а web сайты вид [cloudflare warp][отмена] ``` -Еще одна полезная возможность это скрытие ip за cloudflare. И для этого тоже будет меню. - -``` ----> -Cloudflare -<--- -Сейчас активировано: Нет -Позволяет защитить ip адрес вашей точки доступа от клиентов, -которые к ней подключаются. - -X -> Cloudflare <> MyPoint <> PROXY <- Web - -[установить][отключить] -``` - ##### Меню управления клиентами +Домен и прокси меняются на горячую, в фоне: точка на это время "настраивается", по окончании приходит уведомление. Новый прокси сначала проверяется, а сертификат на новый домен сначала выпускается. Если не вышло, точка остается активной с прежними настройками, а ошибка видна в карточке. У точки с маскировкой домен можно сменить, но не убрать. Смена домена меняет ссылки подписок: ключи нужно раздать заново. Сообщение с прокси бот удаляет из чата, пароль прокси в карточке скрыт. + ``` ---> Клиенты <--- -id 0/0Mb On -[добавить][удалить][получить ключ] +Ключи point-1a2b3c +mom-phone ↓ 1.2 ГБ ↑ 80.0 МБ, в сети 26.09 14:05 +laptop ↓ 0 Б ↑ 0 Б, не подключался + +Пришли имя ключа, чтобы получить ссылку подписки. +[выпустить ключ][отозвать ключ] +[меню сервера] ``` ``` ---> -удалить +mom-phone <--- -Чтобы удалить клиента пришлите его id -[отмена] +Ключ mom-phone: ↓ 1.2 ГБ ↑ 80.0 МБ, в сети 26.09 14:05 + +Подписка: +https://1.2.3.4:12839/9g72PmSGAQ3V/ ``` -``` ----> -добавить -<--- -если хотите, можете сами установить id -[сгенерируй] -``` - -Далее пользователь либо отправляет текст, который будет использован как id для нового клиента, либо сгенерирует новый уникальный id и вернет ключ. +"выпустить ключ" просит имя (латиница, цифры, `-` и `_`), "отозвать ключ" - имя существующего. Клиенту выдается URL подписки, а не vless-ссылка: он переживает переустановку точки, ключи восстанавливаются с прежними uuid и subId. Ограничений на число ключей нет, выпускает их владелец точки. ### Настройка сервера @@ -331,7 +297,17 @@ id 0/0Mb On Пакет также занимается и настройкой серверов базово. И позволяет сделать из сервера прокси или панель 3x-ui. Для наших целей требуется оба и в интерфейсе добавления сервера есть пункт "Сервер маскировки" - это сервер из которого нужно сделать прозрачный прокси. В App заводим модель в которой объединяем эти 2 сервера в единую систему. Так как кнопка "Вернуть" вернет оба сервера и они связаны настройками маршрутизации. -\# TODO: дописать документацию +### Запуск + +1. `cp env_example .env` и заполнить: база, RabbitMQ, `FIELD_ENCRYPTION_KEYS`, `SERVERUS_CERTIFICATES`. +2. `docker compose up -d` - Postgres, RabbitMQ, свой Bot API сервер. +3. `make sync`, `make dj_migrate`, `make dj_superuser`. +4. Три процесса: `make dj_run` (сайт и админка), `make dj_worker` (установка и возврат серверов), `make tg_run` (бот, запускать строго один экземпляр). +5. В админке добавить бота с токеном, выбрать интерфейс MyPoint и опубликовать. + +Подробности: `src/App/README.md` (сценарии, секреты, ручной вход `make serve_ssh`), `src/serverus/README.md` (плейбуки, ручная проверка на сервере), `src/Telegram/README.md` (боты). + +Локальный стенд: `SERVERUS_CERTIFICATES=local`, сертификаты выпускает свой CA из `extra/serverus/ca`, `ca.pem` нужно добавить в доверенные на машине с клиентом. Let's Encrypt на серых адресах не работает. ### Модель данных @@ -344,10 +320,48 @@ id 0/0Mb On - сервер 3x-ui - обязательно должен быть, ради него все и затевается - сервер маскировки - если пользователь хочет особый режим, то он может добавить еще один сервер купленный в своей стране - домен - если сервер маскировки указан, то домен обязательно нужно указать -- ssl сертификаты - если домен не указан, то сертификаты выпускаются на IP сервера 3x-ui, а сервера маскировки нет; а если домен есть то он привязывается к серверу маскировки и на него выпускаются ssl ключи, который хранятся на сервере 3x-ui, потому что сервер маскировки лишь прозрачно проксирует +- ssl сертификаты - если домен не указан, то сертификаты выпускаются на IP сервера 3x-ui, а сервера маскировки нет; а если домен есть то он привязывается к серверу маскировки и на него выпускаются ssl ключи, который хранятся на сервере 3x-ui, потому что сервер маскировки лишь прозрачно проксирует. Храним и домен, и ip, по ssh ходим только по ip - ssh ключи - доступ к серверам, находящимся под нашей ведомостью, всегда осуществляется асимметричным способом (ключ-файл) - прокси - можно задать свои и с авторизацией, а можно воспользоваться cf_warp прокси, которые можно регистрировать автоматически через x-ui +- ключи - клиенты панели: имя, uuid и subId, чтобы восстановить их после переустановки Сервер - реальные сервера с ip и доступом к root. Настраиваем безопасное окружение по всем канонам. Сервис - На серверах у нас живут сервисы. Поднимается или настраивается какой-то сервис, чтобы решить какую-то полезную задачу. И реализуют api для управления сервисом на сервере. Сейчас эту функцию выполняет Роль сервера, потому что подразумевается что сервис может быть 1. + +### Что осталось + +Проверено на локальном стенде (Debian 13, свой CA): установка точки из бота с маскировкой, DoT и запасной DoH, ключи и подписка, socks5 и WARP на выходе, отказ нерабочего прокси, переустановка с восстановлением ключей, возврат серверов, `serve_ssh`, шифрование секретов в Postgres. + +**Функции** + +- [x] Кнопки "Домен" и "Прокси" у готовой точки, смена на горячую. +- [ ] Скрипт маскировки (сейчас маскировка - перенаправление портов nftables). +- [ ] Статистика точки в главном меню и карточке: трафик и число ключей онлайн (сейчас статистика только у ключей). +- [ ] Кто может пользоваться ботом: сейчас любой, кто написал боту, может добавить сервер. Решить: белый список, инвайты или открыто. + +**Надежность** + +- [ ] Точка, зависшая в "устанавливается" или "возвращается", если воркер упал посреди задачи: в боте с ней ничего не сделать, а в админке статус только для чтения. Нужно действие в админке (сбросить в "ошибка") или проверка по времени. +- [ ] Периодическая проверка точек: панель отвечает, сертификат не истекает (короткий сертификат на ip продлевается каждые несколько дней). + +**Проверка на настоящих серверах** + +- [ ] Let's Encrypt на домен и на ip, продление по cron. +- [ ] Возврат серверов и Debian 12. +- [ ] Точка с маскировкой и настоящим доменом. + +**Продакшен** + +- [ ] Настройки: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS` из `.env` (сейчас в `base.py` небезопасные значения для разработки). +- [ ] Запуск на сервере: systemd юниты или контейнеры для сайта (gunicorn за nginx), воркера и `telegram_run` (строго один экземпляр), логи. +- [ ] Бэкапы Postgres. `FIELD_ENCRYPTION_KEYS` хранить отдельно от бэкапов: без него база бесполезна. + +**Уборка** + +- [ ] Удалить `django-fernet-fields` (не работает на Django 6.1, заменен своим полем) и обновить `requirements.txt`. +- [ ] Удалить `src/db.sqlite3` и `extra/db-before-encryption-*.sqlite3`: в бэкапе секреты открытым текстом. +- [ ] Контейнер Postgres называется `shop-postgres`: переименовать. +- [ ] `Key` в админке (только для чтения). +- [x] Карточка точки показывает прокси вместе с логином и паролем: маскировать пароль. +- [ ] Пароль root при переустановке уходит в задачу dramatiq открытым текстом (сообщения RabbitMQ лежат на диске): шифровать, как прокси. diff --git a/env_example b/env_example index 19b0d57..cf3b009 100644 --- a/env_example +++ b/env_example @@ -8,6 +8,9 @@ TELEGRAM_API_URL=http://127.0.0.1:8081 DB_NAME= DB_USER= DB_PASSWD= +# Адрес базы для Django, по умолчанию 127.0.0.1:5432 (порт из docker-compose.yaml) +DB_HOST=127.0.0.1 +DB_PORT=5432 # RabbitMQ: HOST и ERLANG_COOKIE не менять после первого запуска, иначе данные не прочитаются RABBITMQ_HOST=rabbitmq @@ -19,6 +22,11 @@ RABBITMQ_PASSWORD= # Адрес для Django и воркера dramatiq RABBITMQ_ADDRESS=127.0.0.1:5672 +# Ключи шифрования секретов в базе через запятую, первый - текущий. Сгенерировать: +# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# Потеряете ключ - потеряете доступ ко всем серверам. Храните отдельно от бэкапов базы. +FIELD_ENCRYPTION_KEYS= + # Сертификаты панелей: letsencrypt или local (свой CA для локального стенда) SERVERUS_CERTIFICATES=letsencrypt # Папка своего CA, по умолчанию extra/serverus/ca diff --git a/requirements.txt b/requirements.txt index f4a4246..8c7a0a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ cffi==2.1.1 charset-normalizer==3.5.1 cryptography==50.0.1 Django==6.1.1 +django-fernet-fields==0.6 django_dramatiq==0.15.0 dramatiq==2.2.1 frozenlist==1.8.0 diff --git a/src/App/README.md b/src/App/README.md index 711cd37..1ebe0a1 100644 --- a/src/App/README.md +++ b/src/App/README.md @@ -5,7 +5,7 @@ Django приложение связывающее все части сайта -[x] Реализует кастомный менеджер модели и кастомную модель пользователя с отключенным email. -[x] Модель данных для поддержки интерфейса и функционала -[ ] Настройки домена и прокси у готовой точки --[ ] Клиенты и статистика через API панели 3x-ui +-[x] Клиенты и статистика через API панели 3x-ui Зависимости: `Telegram` (интерфейс бота, пользователи, уведомления) и `serverus` (настройка серверов). @@ -13,6 +13,7 @@ Django приложение связывающее все части сайта - `Point` - точка доступа: сервер с панелью 3x-ui и необязательный сервер маскировки. Владелец - `Telegram.User`. Хранит домен, прокси на выходе и доступ к панели. - `Server` - сервер точки с ролью `panel` или `mask`. Хранит текущие порт ssh, пароль root, ключ и отпечаток ssh, а также пароль root, присланный пользователем при добавлении (`default_root_password`). +- `Key` - ключ клиента на панели точки: имя (оно же email клиента в панели), `uuid` и `sub_id`. Клиенту выдается `key.subscription_url`, после переустановки ключ восстанавливается с теми же ссылками. С маскировкой клиенты подключаются к серверу маскировки, он перенаправляет трафик на панель и скрывает ее адрес. Маскировка требует домен. @@ -20,14 +21,17 @@ Django приложение связывающее все части сайта - создание: `Point.objects.create_point(owner, panel, mask=None, *, domain, outbound_proxy)` создает точку вместе с серверами, имя и доступ к панели генерирует сама. `Server.objects.create_server(point, role, credentials)`. `Credentials` - адрес и пароль root, которые прислал пользователь - поиск: `Point.objects.owned_by(owner)`, дальше `find_by_name`, `find_by_pk`, `find_last`. У серверов `find_by_address` и `is_address_taken`, в том числе в пределах точки через `point.servers` -- изменение: `point.set_status(status, error)`, `server.reset_fingerprint()` +- изменение: `point.set_status(status, error)`, `server.reset_fingerprint()`. Статусы: `installing`, `updating` (меняется домен или прокси готовой точки), `ready`, `error`, `releasing`. Пока точка занята, с ней ничего не сделать. Неудачная смена домена или прокси оставляет точку `ready` с текстом ошибки: serverus применяет их только после выпуска сертификата и проверки прокси +- ключи: `services.issue_key(point, name)`, `services.revoke_key(key)`, поиск `point.keys.find_by_name(name)` - удаление: `point.delete()`, серверы удаляются вместе с точкой Соглашение для поиска: `get_*` и `point.server(role)` бросают `DoesNotExist`, если записи нет, а `find_*` возвращают `None`, и это видно по типу. `point.panel` всегда есть, потому что панель создается вместе с точкой, а `point.mask` может быть `None`. ## Модули -- `services.py` - сценарии поверх serverus: `install_point`, `reinstall`, `release_point`, у готовой точки `set_domain` (новый сертификат) и `set_outbound_proxy`. +- `services.py` - сценарии поверх serverus: `install_point`, `reinstall`, `release_point`, у готовой точки `set_domain` (новый сертификат) и `set_outbound_proxy`. Ключи через API панели: `issue_key`, `revoke_key`, `key_traffic`, `restore_keys`. +- `fields.py` - `EncryptedTextField`: секреты в базе зашифрованы Fernet ключами `FIELD_ENCRYPTION_KEYS` из `.env`. +- `management/commands/serve_ssh.py` - ручной вход на сервер: `make serve_ssh ip=`. - `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`. - `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке). - `bot/texts.py` - кнопки и тексты. @@ -38,6 +42,7 @@ Django приложение связывающее все части сайта 2. панель: 3x-ui с логином, паролем, портом и путем, которые сгенерировал App, сертификат на домен или ip, подписка, подключение reality `main`, исходящий прокси точки 3. маскировка: перенаправление трафика на адрес панели 4. facts: `uname -a` для карточки сервера +5. ключи точки восстанавливаются на панели (`restore_keys`), при первой установке их нет "Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке. @@ -52,3 +57,12 @@ Django приложение связывающее все части сайта Решение: за Cloudflare не работаем. Reality через проксирование Cloudflare невозможен: Cloudflare сам завершает TLS. - [x] нужна функция установки прокси в исходящие (outbound) При установке ставится `point.outbound_proxy`, у готовой точки - `services.set_outbound_proxy(point, proxy)`. Поддерживаются socks5, http(s) и cf_warp. +- [x] кнопки "Домен" и "Прокси" у готовой точки: задачи `tasks.set_domain` и `tasks.set_outbound_proxy`, прокси в брокер уходит зашифрованным + +## Секреты + +Ключи и пароли серверов (`private_key`, `root_password`, `default_root_password`), доступ к панели (`panel_password`, `panel_api_token`), исходящий прокси и ключи клиентов (`uuid`, `sub_id`) хранятся зашифрованными (`App.fields.EncryptedTextField`, Fernet). Дамп базы без ключа не дает доступа к серверам. Искать по этим полям нельзя. + +- Ключ - `FIELD_ENCRYPTION_KEYS` в `.env` (настройки сами читают `.env`, как бы ни был запущен сайт, воркер или бот). Потеря ключа - потеря доступа ко всем серверам: храните его отдельно от бэкапов базы. +- Смена ключа: новый ключ первым через запятую, старый следом, пересохранить записи, убрать старый. +- Ручной вход на сервер: `make serve_ssh ip=` (или `manage.py serve_ssh [команда]`). Ключ и отпечаток берутся из базы, ключ живет во временной папке только на время сессии. В админке и боте приватный ключ не показывается. diff --git a/src/App/bot/interface.py b/src/App/bot/interface.py index 1cd02fd..526305a 100644 --- a/src/App/bot/interface.py +++ b/src/App/bot/interface.py @@ -4,7 +4,7 @@ import re import serverus from Telegram.interfaces import Interface, command, register, state, text -from .. import services, tasks +from .. import fields, services, tasks from ..models import Credentials, Point, Server from . import texts @@ -18,6 +18,11 @@ SELECT = 'select' POINT = 'point' POINT_RELEASE = 'point.release' POINT_REINSTALL = 'point.reinstall' +POINT_DOMAIN_EDIT = 'point.domain' +POINT_PROXY_EDIT = 'point.proxy' +POINT_KEYS = 'point.keys' +POINT_KEY_ADD = 'point.keys.add' +POINT_KEY_REVOKE = 'point.keys.revoke' 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}$') @@ -149,6 +154,8 @@ class MyPointInterface(Interface): @state(DRAFT_PROXY) def set_proxy(self, ctx): + # в прокси может быть пароль + ctx.delete_message() proxy = ctx.text.strip() if not is_proxy_url(proxy): return ctx.reply(texts.PROXY_INVALID, parse_mode=HTML) @@ -274,15 +281,144 @@ class MyPointInterface(Interface): @text(texts.NO, state=POINT_RELEASE) @text(texts.CANCEL, state=POINT_RELEASE) @text(texts.CANCEL, state=POINT_REINSTALL) + @text(texts.CANCEL, state=POINT_DOMAIN_EDIT) + @text(texts.CANCEL, state=POINT_PROXY_EDIT) def back_to_point(self, ctx): point = self.current_point(ctx) if point is None: return self.main_menu(ctx) self.show_point(ctx, point) + # домен и прокси готовой точки: применяются в фоне, панель тем временем работает с прежними настройками + @text(texts.POINT_DOMAIN, state=POINT) + def ask_point_domain(self, ctx): + point = self.ready_point(ctx) + if point: + keyboard = texts.CANCEL_KEYBOARD if point.mask else [[texts.CANCEL, texts.DELETE]] + self.ask(ctx, POINT_DOMAIN_EDIT, texts.domain_prompt(point), keyboard) + + @state(POINT_DOMAIN_EDIT) + def change_domain(self, ctx): + domain = ctx.text.strip().lower().rstrip('.') + if not is_domain(domain): + return ctx.reply(texts.DOMAIN_INVALID) + point = self.ready_point(ctx) + if point: + self.start_change(ctx, point, tasks.set_domain, domain) + + @text(texts.DELETE, state=POINT_DOMAIN_EDIT) + def delete_point_domain(self, ctx): + point = self.ready_point(ctx) + if point is None: + return + if point.mask: + return ctx.reply(texts.DOMAIN_REQUIRED_BY_MASK) + if not point.domain: + return self.show_point(ctx, point) + self.start_change(ctx, point, tasks.set_domain, '') + @text(texts.POINT_PROXY, state=POINT) + def ask_point_proxy(self, ctx): + point = self.ready_point(ctx) + if point: + self.ask(ctx, POINT_PROXY_EDIT, texts.proxy_prompt(point), [[texts.CF_WARP, texts.CANCEL, texts.DELETE]]) + + @state(POINT_PROXY_EDIT) + def change_proxy(self, ctx): + # в прокси может быть пароль + ctx.delete_message() + proxy = ctx.text.strip() + if not is_proxy_url(proxy): + return ctx.reply(texts.PROXY_INVALID, parse_mode=HTML) + self.start_proxy_change(ctx, proxy) + + @text(texts.CF_WARP, state=POINT_PROXY_EDIT) + def change_proxy_to_warp(self, ctx): + self.start_proxy_change(ctx, Point.CF_WARP) + + @text(texts.DELETE, state=POINT_PROXY_EDIT) + def delete_point_proxy(self, ctx): + self.start_proxy_change(ctx, '') + + def start_proxy_change(self, ctx, proxy: str): + point = self.ready_point(ctx) + if point: + self.start_change(ctx, point, tasks.set_outbound_proxy, fields.encrypt(proxy) if proxy else '') + + def start_change(self, ctx, point: Point, actor, value: str): + point.set_status(Point.Status.UPDATING) + actor.send(point.pk, ctx.chat.pk, value) + ctx.reset() + ctx.reply(texts.CHANGE_STARTED, keyboard=texts.MAIN_KEYBOARD_WITH_POINTS) + + # ключи + + def ready_point(self, ctx) -> Point | None: + """Текущая точка, если на ее панели можно выпускать ключи. Иначе отвечает пользователю и возвращает None.""" + point = self.idle_point(ctx) + if point is not None and (point.status != Point.Status.READY or not point.panel_inbound_id): + ctx.reply(texts.POINT_NOT_READY) + return None + return point + @text(texts.CLIENTS, state=POINT) - def not_implemented(self, ctx): - # TODO: настройки домена, прокси и клиенты через API панели 3x-ui - ctx.reply(texts.NOT_IMPLEMENTED) + @text(texts.CLIENTS, state=POINT_KEYS) + @text(texts.CANCEL, state=POINT_KEY_ADD) + @text(texts.CANCEL, state=POINT_KEY_REVOKE) + def show_keys(self, ctx): + point = self.ready_point(ctx) + if point is None: + return + ctx.state = POINT_KEYS + keys = [(key, services.key_traffic(key)) for key in point.keys.all()] + ctx.reply(texts.keys_list(point, keys), keyboard=texts.KEYS_KEYBOARD, parse_mode=HTML) + + @state(POINT_KEYS) + def show_key(self, ctx): + point = self.ready_point(ctx) + if point is None: + return + key = point.keys.find_by_name(ctx.text.strip().lower()) + if key is None: + return ctx.reply(texts.KEY_NOT_FOUND) + ctx.reply(texts.key_card(key, services.key_traffic(key)), parse_mode=HTML) + + @text(texts.ADD_KEY, state=POINT_KEYS) + def ask_key_name(self, ctx): + if self.ready_point(ctx): + self.ask(ctx, POINT_KEY_ADD, texts.KEY_NAME_PROMPT) + + @state(POINT_KEY_ADD) + def add_key(self, ctx): + point = self.ready_point(ctx) + if point is None: + return + try: + key = services.issue_key(point, ctx.text.strip().lower()) + except ValueError as error: + return ctx.reply(str(error)) + except serverus.XuiApiError as error: + return ctx.reply(texts.PANEL_ERROR.format(error=error)) + ctx.state = POINT_KEYS + ctx.reply(texts.key_card(key, None), keyboard=texts.KEYS_KEYBOARD, parse_mode=HTML) + + @text(texts.REVOKE_KEY, state=POINT_KEYS) + def ask_revoke_key(self, ctx): + if self.ready_point(ctx): + self.ask(ctx, POINT_KEY_REVOKE, texts.KEY_REVOKE_PROMPT) + + @state(POINT_KEY_REVOKE) + def revoke_key(self, ctx): + point = self.ready_point(ctx) + if point is None: + return + key = point.keys.find_by_name(ctx.text.strip().lower()) + if key is None: + return ctx.reply(texts.KEY_NOT_FOUND) + try: + services.revoke_key(key) + except serverus.XuiApiError as error: + return ctx.reply(texts.PANEL_ERROR.format(error=error)) + ctx.reply(texts.KEY_REVOKED.format(name=key.name)) + self.show_keys(ctx) diff --git a/src/App/bot/texts.py b/src/App/bot/texts.py index 4da4e45..66bea20 100644 --- a/src/App/bot/texts.py +++ b/src/App/bot/texts.py @@ -1,7 +1,9 @@ """Кнопки и тексты бота. Сообщения размечены HTML, данные пользователя экранируются.""" +import datetime from html import escape +from urllib.parse import urlsplit, urlunsplit -from ..models import Point, Server +from ..models import Key, Point, Server # главное меню MAIN_MENU = 'главное меню' @@ -33,10 +35,15 @@ POINT_PROXY = 'Прокси' CLIENTS = 'Клиенты' USE_OLD = 'Использовать старый' +# ключи +ADD_KEY = 'выпустить ключ' +REVOKE_KEY = 'отозвать ключ' + MAIN_KEYBOARD = [[FAQ, ADD_SERVER, HELP]] MAIN_KEYBOARD_WITH_POINTS = [[FAQ, ADD_SERVER, HELP], [MANAGE]] DRAFT_KEYBOARD = [[DOMAIN, PROXY, MASK], [CANCEL, INSTALL, SERVER, HELP]] POINT_KEYBOARD = [[RELEASE, REINSTALL], [POINT_DOMAIN, POINT_PROXY], [CLIENTS], [MAIN_MENU]] +KEYS_KEYBOARD = [[ADD_KEY, REVOKE_KEY], [SERVER_MENU]] CANCEL_KEYBOARD = [[CANCEL]] EMPTY = '-' @@ -100,7 +107,14 @@ REINSTALL_PROMPT = '''Будет произведена повторная ус "Использовать старый" переустановит все серверы точки с паролями, которые вы прислали при добавлении.''' REINSTALL_UNKNOWN_ADDRESS = 'Сервер {address} не входит в эту точку.' REINSTALL_STARTED = 'Запускаю переустановку. Отправлю уведомление когда все будет готово.' -NOT_IMPLEMENTED = 'Пока не реализовано.' +POINT_NOT_READY = 'Сервер еще не установлен. Ключи, домен и прокси настраиваются после установки.' +DOMAIN_REQUIRED_BY_MASK = 'Сервер маскировки работает только с доменом, убрать его нельзя. Можно сменить на другой.' +CHANGE_STARTED = 'Применяю. Отправлю уведомление когда все будет готово.' +KEY_NAME_PROMPT = 'Пришли имя ключа: латиница, цифры, - и _. Например mom-phone.' +KEY_REVOKE_PROMPT = 'Пришли имя ключа, который нужно отозвать. Клиент с ним сразу потеряет доступ.' +KEY_NOT_FOUND = 'Ключа с таким именем нет.' +KEY_REVOKED = 'Ключ {name} отозван.' +PANEL_ERROR = 'Панель не ответила: {error}' FALLBACK = 'Не понял. Воспользуйся кнопками меню.' @@ -113,7 +127,7 @@ def draft_card(draft: dict) -> str: f"Сервер: {value(draft.get('server', {}).get('address', ''))}", f"Сервер маскировки: {value(draft.get('mask', {}).get('address', ''))}", f"Домен: {value(draft.get('domain', ''))}", - f"Прокси: {value(draft.get('proxy', ''))}", + f"Прокси: {value(proxy_label(draft.get('proxy', '')))}", ]) @@ -131,10 +145,9 @@ def point_card(point: Point) -> str: server = servers.get(role) lines.append(f'{role.label.capitalize()}: {escape(server.address) if server else EMPTY}') lines += [ - # TODO: онлайн/всего ключей из API панели - f'Ключи: {EMPTY}', + f'Ключи: {point.keys.count() or EMPTY}', f'Домен: {value(point.domain)}', - f'Прокси: {value(point.outbound_proxy)}', + f'Прокси: {value(proxy_label(point.outbound_proxy))}', ] if point.error: lines.append(f'Ошибка: {escape(point.error)}') @@ -154,3 +167,100 @@ def released(name: str, passwords: dict[str, str], errors: dict[str, str]) -> st lines += [f'{escape(address)} - пароль root {escape(password)}' for address, password in passwords.items()] lines += [f'{escape(address)} - не удалось вернуть: {escape(error)}' for address, error in errors.items()] return '\n'.join(lines) + + +def size(value: int) -> str: + for unit in ('Б', 'КБ', 'МБ', 'ГБ'): + if value < 1024: + return f'{value:.0f} {unit}' if unit == 'Б' else f'{value:.1f} {unit}' + value /= 1024 + return f'{value:.1f} ТБ' + + +def last_online(milliseconds: int) -> str: + if not milliseconds: + return 'не подключался' + moment = datetime.datetime.fromtimestamp(milliseconds / 1000) + return f'в сети {moment:%d.%m %H:%M}' + + +def traffic_line(traffic: dict | None) -> str: + if traffic is None: + return 'нет данных' + return f"↓ {size(traffic.get('down', 0))} ↑ {size(traffic.get('up', 0))}, {last_online(traffic.get('lastOnline', 0))}" + + +def keys_list(point: Point, keys: list[tuple[Key, dict | None]]) -> str: + lines = [f'Ключи {escape(point.name)}'] + if not keys: + lines.append('Ключей пока нет.') + for key, traffic in keys: + lines.append(f'{escape(key.name)} {traffic_line(traffic)}') + if keys: + lines.append('\nПришли имя ключа, чтобы получить ссылку подписки.') + return '\n'.join(lines) + + +def key_card(key: Key, traffic: dict | None) -> str: + return '\n'.join([ + f'Ключ {escape(key.name)}: {traffic_line(traffic)}', + '', + 'Подписка:', + f'{escape(key.subscription_url)}', + '', + 'Добавь ссылку как подписку в клиенте (v2rayN, Hiddify, Happ, v2rayNG). ' + 'В браузере по ней открываются vless-ссылки.', + ]) + + +def proxy_label(proxy: str) -> str: + """Прокси для показа в чате: без пароля.""" + parts = urlsplit(proxy) + if not parts.password: + return proxy + host = parts.netloc.rpartition('@')[2] + return urlunsplit(parts._replace(netloc=f'{parts.username}:***@{host}')) + + +def domain_prompt(point: Point) -> str: + lines = [ + f'Сейчас установлен: {value(point.domain)}', + '', + f'Пришли доменное имя. Его A-запись уже должна указывать на {escape(point.entry.address)}, ' + 'иначе сертификат не выпустится.', + ] + if not point.mask: + lines.append('"удалить" вернет сертификат на ip сервера.') + if point.keys.exists(): + lines.append('\nСсылки подписок всех ключей сменятся: их придется раздать заново.') + return '\n'.join(lines) + + +def proxy_prompt(point: Point) -> str: + return '\n'.join([ + f'Сейчас установлен: {value(proxy_label(point.outbound_proxy))}', + '', + 'Сайты будут видеть ip прокси, а не ip вашей точки доступа.', + PROXY_PROMPT, + '"удалить" - выход напрямую. Перед применением прокси проверяется, нерабочий не встанет.', + ]) + + +def domain_changed(point: Point, domain: str) -> str: + if not domain: + return f'{escape(point.name)}: домен убран, сертификат выпущен на ip.' + text = f'{escape(point.name)}: домен {escape(domain)} установлен.' + if point.keys.exists(): + text += '\nСсылки подписок сменились: открой "Клиенты" и раздай ключи заново.' + return text + + +def proxy_changed(point: Point, proxy: str) -> str: + return f'{escape(point.name)}: прокси на выходе {value(proxy_label(proxy)) if proxy else "отключен"}.' + + +def change_failed(point: Point, error: Exception) -> str: + return ( + f'Не удалось применить настройку {escape(point.name)}:\n{escape(str(error))}\n' + 'Точка работает с прежними настройками.' + ) diff --git a/src/App/fields.py b/src/App/fields.py new file mode 100644 index 0000000..b826b7b --- /dev/null +++ b/src/App/fields.py @@ -0,0 +1,79 @@ +"""Шифрование секретов в базе: ключи и пароли серверов, доступ к панели, прокси, ключи клиентов. + +Значение шифруется Fernet (AES-128-CBC + HMAC) и хранится в колонке TEXT токеном Fernet. Ключи - список +FIELD_ENCRYPTION_KEYS: шифруется первым, расшифровывается любым, так ключ можно сменить без простоя +(новый ключ первым, старые следом, пересохранить записи, убрать старые). + +Утечка дампа базы без ключа не дает доступа к серверам. Искать по зашифрованным полям нельзя: токен каждый раз разный. +""" +from functools import cache + +from cryptography.fernet import Fernet, InvalidToken, MultiFernet +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.db import models + +TOKEN_PREFIX = 'gAAAAA' +"""Начало любого токена Fernet (версия и время), по нему миграция отличает уже зашифрованное.""" + + +@cache +def _fernet(keys: tuple[str, ...]) -> MultiFernet: + if not keys: + raise ImproperlyConfigured('FIELD_ENCRYPTION_KEYS is empty: generate one with Fernet.generate_key()') + return MultiFernet([Fernet(key) for key in keys]) + + +def fernet() -> MultiFernet: + return _fernet(tuple(settings.FIELD_ENCRYPTION_KEYS)) + + +def encrypt(value: str) -> str: + return fernet().encrypt(value.encode()).decode() + + +def decrypt(token: str) -> str: + try: + return fernet().decrypt(token.encode()).decode() + except InvalidToken: + raise ImproperlyConfigured('cannot decrypt field: wrong FIELD_ENCRYPTION_KEYS or plaintext in the database') + + +class EncryptedTextField(models.TextField): + """Текст, зашифрованный в базе. Пустая строка хранится как есть: пустых секретов не бывает.""" + + def get_prep_value(self, value): + value = super().get_prep_value(value) + return encrypt(value) if value else value + + def from_db_value(self, value, expression, connection): + return decrypt(value) if value else value + + +def encrypt_columns(model, *field_names): + """RunPython для миграции: шифрует уже сохраненные открытые значения полей model. Повторный запуск безопасен.""" + + def forward(apps, schema_editor): + _rewrite(apps.get_model(*model.split('.')), field_names, schema_editor, encrypt, lambda v: not _is_token(v)) + + def backward(apps, schema_editor): + _rewrite(apps.get_model(*model.split('.')), field_names, schema_editor, decrypt, _is_token) + + return forward, backward + + +def _is_token(value: str) -> bool: + return value.startswith(TOKEN_PREFIX) + + +def _rewrite(model, field_names, schema_editor, convert, needs): + # мимо ORM: поле уже зашифрованное и попыталось бы расшифровать открытый текст + quote = schema_editor.quote_name + table, pk = quote(model._meta.db_table), quote(model._meta.pk.column) + with schema_editor.connection.cursor() as cursor: + for name in field_names: + column = quote(model._meta.get_field(name).column) + cursor.execute(f'SELECT {pk}, {column} FROM {table}') + for row_id, value in cursor.fetchall(): + if value and needs(value): + cursor.execute(f'UPDATE {table} SET {column} = %s WHERE {pk} = %s', [convert(value), row_id]) diff --git a/src/App/management/__init__.py b/src/App/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/App/management/commands/__init__.py b/src/App/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/App/management/commands/serve_ssh.py b/src/App/management/commands/serve_ssh.py new file mode 100644 index 0000000..bd2b6ce --- /dev/null +++ b/src/App/management/commands/serve_ssh.py @@ -0,0 +1,54 @@ +import os +import subprocess +import tempfile +from pathlib import Path + +from django.core.management.base import BaseCommand, CommandError + +from App.models import Server + + +def ssh_command(server: Server, directory: Path, remote: list[str]) -> list[str]: + """ssh на сервер с ключом и отпечатком из базы. Ключ и known_hosts кладутся в directory.""" + key_file = directory / 'id_server' + key_file.touch(mode=0o600) + key_file.write_text(server.private_key.rstrip('\n') + '\n') + known_hosts = directory / 'known_hosts' + known_hosts.write_text(server.known_hosts) + return [ + 'ssh', + '-i', str(key_file), + '-p', str(server.ssh_port), + '-o', 'IdentitiesOnly=yes', + # как в serverus: один отпечаток на адрес, смена порта ssh доверие не сбрасывает + '-o', f'HostKeyAlias={server.address}', + '-o', f'UserKnownHostsFile={known_hosts}', + '-o', f"StrictHostKeyChecking={'yes' if server.known_hosts else 'accept-new'}", + f'root@{server.address}', + *remote, + ] + + +class Command(BaseCommand): + help = ( + 'Ручной вход на сервер по ssh с ключом из базы: ' + 'ключ живет во временной папке только на время сессии, отпечаток сервера проверяется' + ) + + def add_arguments(self, parser): + parser.add_argument('address', help='ip сервера') + parser.add_argument('remote', nargs='*', help='команда на сервере, без нее - интерактивная сессия') + + def handle(self, address, remote, **options): + server = Server.objects.find_by_address(address) + if server is None: + raise CommandError(f'server {address} not found') + if not server.private_key: + raise CommandError(f'server {address} has no ssh key yet: it was not bootstrapped') + if not server.known_hosts: + self.stderr.write('warning: no saved fingerprint, trusting the key on first connection') + with tempfile.TemporaryDirectory(prefix='serve-ssh-') as directory: + os.chmod(directory, 0o700) + code = subprocess.call(ssh_command(server, Path(directory), remote)) + if code: + raise SystemExit(code) diff --git a/src/App/migrations/0006_key.py b/src/App/migrations/0006_key.py new file mode 100644 index 0000000..c894e8e --- /dev/null +++ b/src/App/migrations/0006_key.py @@ -0,0 +1,31 @@ +# Generated by Django 6.1.1 on 2026-09-26 10:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('App', '0005_point_remove_cloudflare'), + ] + + operations = [ + migrations.CreateModel( + name='Key', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=32)), + ('uuid', models.CharField(max_length=36)), + ('sub_id', models.CharField(max_length=64)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('point', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='keys', to='App.point')), + ], + options={ + 'verbose_name': 'ключ', + 'verbose_name_plural': 'ключи', + 'ordering': ['created_at'], + 'constraints': [models.UniqueConstraint(fields=('point', 'name'), name='app_key_name_unique')], + }, + ), + ] diff --git a/src/App/migrations/0007_encrypt_secrets.py b/src/App/migrations/0007_encrypt_secrets.py new file mode 100644 index 0000000..840d654 --- /dev/null +++ b/src/App/migrations/0007_encrypt_secrets.py @@ -0,0 +1,59 @@ +# Generated by Django 6.1.1 on 2026-09-26 12:48 + +import App.fields +from App.fields import encrypt_columns +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('App', '0006_key'), + ] + + operations = [ + migrations.AlterField( + model_name='key', + name='sub_id', + field=App.fields.EncryptedTextField(), + ), + migrations.AlterField( + model_name='key', + name='uuid', + field=App.fields.EncryptedTextField(), + ), + migrations.AlterField( + model_name='point', + name='outbound_proxy', + field=App.fields.EncryptedTextField(blank=True, help_text='protocol://user:password@host:port или cf_warp'), + ), + migrations.AlterField( + model_name='point', + name='panel_api_token', + field=App.fields.EncryptedTextField(blank=True), + ), + migrations.AlterField( + model_name='point', + name='panel_password', + field=App.fields.EncryptedTextField(), + ), + migrations.AlterField( + model_name='server', + name='default_root_password', + field=App.fields.EncryptedTextField(help_text='Пароль root, который пользователь прислал при добавлении сервера. После сброса сервера у провайдера он снова может быть актуальным'), + ), + migrations.AlterField( + model_name='server', + name='private_key', + field=App.fields.EncryptedTextField(blank=True), + ), + migrations.AlterField( + model_name='server', + name='root_password', + field=App.fields.EncryptedTextField(help_text='Текущий пароль root'), + ), + # открытые значения, сохраненные до шифрования + migrations.RunPython(*encrypt_columns('App.Server', 'root_password', 'default_root_password', 'private_key')), + migrations.RunPython(*encrypt_columns('App.Point', 'panel_password', 'panel_api_token', 'outbound_proxy')), + migrations.RunPython(*encrypt_columns('App.Key', 'uuid', 'sub_id')), + ] diff --git a/src/App/migrations/0008_point_status_updating.py b/src/App/migrations/0008_point_status_updating.py new file mode 100644 index 0000000..6f8e262 --- /dev/null +++ b/src/App/migrations/0008_point_status_updating.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1.1 on 2026-09-26 13:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('App', '0007_encrypt_secrets'), + ] + + operations = [ + migrations.AlterField( + model_name='point', + name='status', + field=models.CharField(choices=[('installing', 'устанавливается'), ('updating', 'настраивается'), ('ready', 'активен'), ('error', 'ошибка'), ('releasing', 'возвращается')], default='installing', max_length=16), + ), + ] diff --git a/src/App/models.py b/src/App/models.py index 938ad5e..effd6b5 100644 --- a/src/App/models.py +++ b/src/App/models.py @@ -1,3 +1,4 @@ +import re import secrets from dataclasses import dataclass @@ -6,6 +7,8 @@ from django.contrib.auth.models import AbstractUser from django.db import models, transaction from serverus import CF_WARP, Host, generate_password, generate_port, generate_username +from .fields import EncryptedTextField + class UserManager(BaseUserManager): """Менеджер пользователей без email: только username и password.""" @@ -129,6 +132,7 @@ class Point(models.Model): class Status(models.TextChoices): INSTALLING = 'installing', 'устанавливается' + UPDATING = 'updating', 'настраивается' READY = 'ready', 'активен' ERROR = 'error', 'ошибка' RELEASING = 'releasing', 'возвращается' @@ -139,15 +143,13 @@ class Point(models.Model): error = models.TextField(blank=True) domain = models.CharField(max_length=253, blank=True) - outbound_proxy = models.CharField( - max_length=512, blank=True, help_text=f'protocol://user:password@host:port или {CF_WARP}', - ) + outbound_proxy = EncryptedTextField(blank=True, help_text=f'protocol://user:password@host:port или {CF_WARP}') panel_username = models.CharField(max_length=64) - panel_password = models.CharField(max_length=128) + panel_password = EncryptedTextField() panel_port = models.PositiveIntegerField() panel_path = models.CharField(max_length=64) - panel_api_token = models.CharField(max_length=255, blank=True) + panel_api_token = EncryptedTextField(blank=True) panel_inbound_id = models.PositiveIntegerField(null=True, blank=True, help_text='Подключение reality в панели') sub_port = models.PositiveIntegerField(help_text='Порт подписки панели') sub_path = models.CharField(max_length=64, help_text='Путь подписки без слешей') @@ -165,7 +167,7 @@ class Point(models.Model): @property def is_busy(self) -> bool: - return self.status in (self.Status.INSTALLING, self.Status.RELEASING) + return self.status in (self.Status.INSTALLING, self.Status.UPDATING, self.Status.RELEASING) @property def panel_host(self) -> str: @@ -241,13 +243,12 @@ 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, + default_root_password = EncryptedTextField( help_text='Пароль root, который пользователь прислал при добавлении сервера. ' 'После сброса сервера у провайдера он снова может быть актуальным', ) - root_password = models.CharField(max_length=128, help_text='Текущий пароль root') - private_key = models.TextField(blank=True) + root_password = EncryptedTextField(help_text='Текущий пароль root') + private_key = EncryptedTextField(blank=True) public_key = models.TextField(blank=True) known_hosts = models.TextField(blank=True, help_text='Отпечаток ssh сервера. Сбрасывается после переустановки ОС') uname = models.CharField(max_length=512, blank=True) @@ -268,3 +269,45 @@ class Server(models.Model): def reset_fingerprint(self): self.known_hosts = '' self.save(update_fields=['known_hosts']) + + +KEY_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,31}$') +"""Имя ключа: оно же email клиента в панели, поэтому только латиница, цифры, - и _.""" + + +class KeyQuerySet(models.QuerySet): + def find_by_name(self, name: str) -> 'Key | None': + return self.filter(name=name).first() + + def is_name_taken(self, name: str) -> bool: + return self.filter(name=name).exists() + + +class Key(models.Model): + """Ключ клиента на панели точки. Выпускает и отзывает владелец точки, ограничений нет. + + uuid и sub_id хранятся, чтобы после переустановки восстановить ключ с прежними ссылками: клиенту выдается + URL подписки point.subscription_url(key.sub_id), он переживает переустановку. + Создание и удаление: services.issue_key, services.revoke_key. Поиск: point.keys.find_by_name(name). + """ + + point = models.ForeignKey(Point, on_delete=models.CASCADE, related_name='keys') + name = models.CharField(max_length=32) + uuid = EncryptedTextField() + sub_id = EncryptedTextField() + created_at = models.DateTimeField(auto_now_add=True) + + objects = KeyQuerySet.as_manager() + + class Meta: + verbose_name = 'ключ' + verbose_name_plural = 'ключи' + ordering = ['created_at'] + constraints = [models.UniqueConstraint(fields=['point', 'name'], name='app_key_name_unique')] + + def __str__(self): + return f'{self.name} ({self.point})' + + @property + def subscription_url(self) -> str: + return self.point.subscription_url(self.sub_id) diff --git a/src/App/services.py b/src/App/services.py index cdc1d8c..ccf7dab 100644 --- a/src/App/services.py +++ b/src/App/services.py @@ -4,7 +4,7 @@ from contextlib import contextmanager import serverus from django.conf import settings -from .models import Point, Server +from .models import KEY_NAME_RE, Key, Point, Server class ProvisionError(Exception): @@ -16,6 +16,7 @@ def install_point(point: Point): with _installing(point): for server in point.servers.all(): provision(server) + restore_keys(point) def reinstall(point: Point, address: str | None = None, password: str | None = None): @@ -32,6 +33,8 @@ def reinstall(point: Point, address: str | None = None, password: str | None = N server.root_password = password or server.default_root_password server.save() provision(server) + # панель доступна по домену только через маскировку, поэтому после всех серверов + restore_keys(point) def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]: @@ -63,7 +66,7 @@ def set_domain(point: Point, domain: str): """ if not domain and point.mask: raise ValueError('маскировка работает только с доменом') - with _installing(point): + with _updating(point): _check(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain, local_ca())) point.domain = domain point.save(update_fields=['domain']) @@ -73,12 +76,52 @@ 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): + with _updating(point): _check(point.panel, serverus.XuiPanel(point.panel.host()).set_outbound(proxy)) point.outbound_proxy = proxy point.save(update_fields=['outbound_proxy']) +def issue_key(point: Point, name: str) -> Key: + """Выпускает ключ на панели точки. ValueError - имя не подходит или занято, XuiApiError - ошибка панели.""" + if not KEY_NAME_RE.match(name): + raise ValueError('имя ключа: латиница в нижнем регистре, цифры, - и _, до 32 символов') + if point.keys.is_name_taken(name): + raise ValueError(f'ключ {name} уже есть') + client = xui_api(point).add_client(point.panel_inbound_id, name) + return Key.objects.create(point=point, name=name, uuid=client['uuid'], sub_id=client['subId']) + + +def revoke_key(key: Key): + """Отзывает ключ: удаляет клиента на панели и ключ. Если на панели его уже нет, просто удаляет ключ.""" + api = xui_api(key.point) + try: + api.get_client(key.name) + except serverus.XuiApiError: + pass + else: + api.delete_client(key.name) + key.delete() + + +def key_traffic(key: Key) -> dict | None: + """Статистика ключа с панели: up, down, lastOnline. None, если панель недоступна или ключа на ней нет.""" + try: + return xui_api(key.point).get_traffic(key.name) + except serverus.XuiApiError: + return None + + +def restore_keys(point: Point): + """Прописывает ключи точки на панели с прежними uuid и subId: после переустановки ссылки подписок не меняются.""" + api = xui_api(point) + for key in point.keys.all(): + try: + api.get_client(key.name) + except serverus.XuiApiError: + api.add_client(point.panel_inbound_id, key.name, uuid=key.uuid, sub_id=key.sub_id) + + def local_ca() -> serverus.LocalCA | None: """Свой CA, если панели работают не с Let's Encrypt (SERVERUS_CERTIFICATES=local).""" if settings.SERVERUS_CERTIFICATES == 'local': @@ -166,3 +209,18 @@ def _installing(point: Point): raise point.set_status(Point.Status.READY) + +@contextmanager +def _updating(point: Point): + """Статус UPDATING на время смены настройки готовой точки, затем READY. + + При ошибке точка тоже остается READY с текстом ошибки: serverus меняет сертификат и прокси только после + успешного выпуска и проверки, так что панель работает с прежними настройками. + """ + point.set_status(Point.Status.UPDATING) + try: + yield + except Exception as error: + point.set_status(Point.Status.READY, str(error)) + raise + point.set_status(Point.Status.READY) diff --git a/src/App/tasks.py b/src/App/tasks.py index 5b15e28..ea53156 100644 --- a/src/App/tasks.py +++ b/src/App/tasks.py @@ -5,7 +5,7 @@ import dramatiq from Telegram.messages import send from Telegram.models import Chat -from . import services +from . import fields, services from .bot import texts from .models import Point @@ -46,3 +46,27 @@ def release_point(point_id: int, chat_id: int): name = point.name passwords, errors = services.release_point(point) _notify(chat_id, texts.released(name, passwords, errors)) + + +def _run_change(point: Point, chat_id: int, operation, value: str, done: str): + try: + operation(point, value) + except Exception as error: + logger.exception('Point %s change failed', point) + _notify(chat_id, texts.change_failed(point, error), keyboard=[[texts.SERVER_MENU]]) + return + _notify(chat_id, done, keyboard=[[texts.SERVER_MENU]]) + + +@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS) +def set_domain(point_id: int, chat_id: int, domain: str): + point = Point.objects.get(pk=point_id) + _run_change(point, chat_id, services.set_domain, domain, texts.domain_changed(point, domain)) + + +@dramatiq.actor(max_retries=0, time_limit=TIME_LIMIT_MS) +def set_outbound_proxy(point_id: int, chat_id: int, encrypted_proxy: str): + """encrypted_proxy - fields.encrypt(proxy): в прокси логин и пароль, а сообщения брокера лежат на диске.""" + point = Point.objects.get(pk=point_id) + proxy = fields.decrypt(encrypted_proxy) if encrypted_proxy else '' + _run_change(point, chat_id, services.set_outbound_proxy, proxy, texts.proxy_changed(point, proxy)) diff --git a/src/App/tests_bot.py b/src/App/tests_bot.py index ee500dd..e7277d0 100644 --- a/src/App/tests_bot.py +++ b/src/App/tests_bot.py @@ -5,11 +5,11 @@ from Telegram import interfaces from Telegram.models import Bot, Chat, User as TelegramUser from Telegram.testing import Conversation, FakeApi -from . import tasks +from . import fields, tasks from .bot import texts from .bot.interface import MyPointInterface, is_proxy_url, parse_credentials -from .models import Point -from .tests_services import FakeRunner, create_point, patch_runner +from .models import Credentials, Point, Server +from .tests_services import FakeRunner, FakeXuiApi, create_point, patch_runner, patch_xui_api class ParsersTests(TestCase): @@ -189,6 +189,132 @@ class PointMenuTests(BotTestCase): self.assertIn(self.point.name, self.say(texts.SERVER_MENU)) +class KeysMenuTests(BotTestCase): + def setUp(self): + super().setUp() + self.say('/start') + self.point = create_point(TelegramUser.objects.get(), mask=None) + self.point.panel_inbound_id = 1 + self.point.status = Point.Status.READY + self.point.save() + self.panel_api = FakeXuiApi() + patcher = patch_xui_api(self.panel_api) + patcher.start() + self.addCleanup(patcher.stop) + self.say(texts.MANAGE) + self.say(self.point.name) + + def test_issue_show_and_revoke_key(self): + self.assertIn('Ключей пока нет', self.say(texts.CLIENTS)) + self.assertEqual(self.keyboard, texts.KEYS_KEYBOARD) + self.assertEqual(self.say(texts.ADD_KEY), texts.KEY_NAME_PROMPT) + self.assertIn('латиница', self.say('Мама')) + card = self.say('Mom-Phone') + self.assertIn(self.point.subscription_url('sub-mom-phone'), card) + self.assertIn('mom-phone', self.say(texts.CLIENTS)) + self.assertIn('↓ 3.0 МБ', self.say('mom-phone')) + self.assertEqual(self.say('ghost'), texts.KEY_NOT_FOUND) + self.say(texts.REVOKE_KEY) + self.assertIn('Ключей пока нет', self.say('mom-phone')) + self.assertEqual(self.panel_api.clients, {}) + + def test_panel_error(self): + self.say(texts.CLIENTS) + self.say(texts.ADD_KEY) + self.panel_api.down = True + self.assertEqual(self.say('mom'), texts.PANEL_ERROR.format(error='connection refused')) + + def test_not_ready_point(self): + self.point.set_status(Point.Status.ERROR) + self.assertEqual(self.say(texts.CLIENTS), texts.POINT_NOT_READY) + + def test_card_counts_keys(self): + self.say(texts.CLIENTS) + self.say(texts.ADD_KEY) + self.say('mom') + self.assertIn('Ключи: 1', self.say(texts.SERVER_MENU)) + + +class PointSettingsTests(BotTestCase): + def setUp(self): + super().setUp() + self.say('/start') + self.point = create_point(TelegramUser.objects.get(), mask=None) + self.point.panel_inbound_id = 1 + self.point.status = Point.Status.READY + self.point.save() + self.say(texts.MANAGE) + self.say(self.point.name) + + @mock.patch.object(tasks.set_domain, 'send') + def test_change_domain(self, send): + self.assertIn('10.0.0.1', self.say(texts.POINT_DOMAIN)) + self.assertEqual(self.keyboard, [[texts.CANCEL, texts.DELETE]]) + self.assertEqual(self.say('not a domain'), texts.DOMAIN_INVALID) + self.assertEqual(self.say('VPN.Example.com.'), texts.CHANGE_STARTED) + send.assert_called_once_with(self.point.pk, mock.ANY, 'vpn.example.com') + self.point.refresh_from_db() + self.assertEqual(self.point.status, Point.Status.UPDATING) + self.assertEqual(self.say(texts.SERVER_MENU).count('настраивается'), 1) + self.assertEqual(self.say(texts.POINT_DOMAIN), texts.POINT_BUSY.format(status='настраивается')) + + @mock.patch.object(tasks.set_domain, 'send') + def test_mask_point_cannot_drop_domain(self, send): + Server.objects.create_server(self.point, Server.Role.MASK, Credentials('10.0.0.3', 'pw')) + self.point.domain = 'vpn.example.com' + self.point.save() + self.assertIn('10.0.0.3', self.say(texts.POINT_DOMAIN)) + self.assertEqual(self.say(texts.DELETE), texts.DOMAIN_REQUIRED_BY_MASK) + send.assert_not_called() + + @mock.patch.object(tasks.set_domain, 'send') + def test_drop_domain(self, send): + self.point.domain = 'vpn.example.com' + self.point.save() + self.say(texts.POINT_DOMAIN) + self.say(texts.DELETE) + send.assert_called_once_with(self.point.pk, mock.ANY, '') + + @mock.patch.object(tasks.set_outbound_proxy, 'send') + def test_change_proxy(self, send): + self.say(texts.POINT_PROXY) + self.assertEqual(self.keyboard, [[texts.CF_WARP, texts.CANCEL, texts.DELETE]]) + self.assertEqual(self.say('ftp://x:21'), texts.PROXY_INVALID) + self.assertEqual(self.say('socks5://user:secret@proxy.example:1080'), texts.CHANGE_STARTED) + # сообщение с паролем удалено, в брокер прокси уходит зашифрованным + self.assertEqual(len(self.api.deleted), 2) + encrypted = send.call_args.args[2] + self.assertNotIn('secret', encrypted) + self.assertEqual(fields.decrypt(encrypted), 'socks5://user:secret@proxy.example:1080') + + @mock.patch.object(tasks.set_outbound_proxy, 'send') + def test_warp_and_direct(self, send): + self.say(texts.POINT_PROXY) + self.say(texts.CF_WARP) + self.assertEqual(fields.decrypt(send.call_args.args[2]), Point.CF_WARP) + self.point.set_status(Point.Status.READY) + self.say(texts.SERVER_MENU) + self.say(texts.POINT_PROXY) + self.say(texts.DELETE) + self.assertEqual(send.call_args.args[2], '') + + def test_cancel_returns_to_point(self): + self.say(texts.POINT_PROXY) + self.assertIn('Состояние: активен', self.say(texts.CANCEL)) + self.assertEqual(self.keyboard, texts.POINT_KEYBOARD) + + def test_not_ready_point(self): + self.point.set_status(Point.Status.ERROR) + self.assertEqual(self.say(texts.POINT_PROXY), texts.POINT_NOT_READY) + + def test_card_hides_proxy_password(self): + self.point.outbound_proxy = 'socks5://user:secret@proxy.example:1080' + self.point.save() + card = self.say(texts.SERVER_MENU) + self.assertIn('socks5://user:***@proxy.example:1080', card) + self.assertNotIn('secret', card) + + class TasksTests(TestCase): def setUp(self): bot = Bot.objects.create(token='1:test') @@ -219,3 +345,16 @@ class TasksTests(TestCase): text = self.run_task(tasks.release_point, self.point.pk, self.chat.pk) self.assertIn('10.0.0.1 - пароль root', text) self.assertFalse(Point.objects.exists()) + + def test_change_notifies_result(self): + self.point.panel_inbound_id = 1 + self.point.status = Point.Status.READY + self.point.save() + proxy = fields.encrypt('socks5://user:secret@proxy.example:1080') + text = self.run_task(tasks.set_outbound_proxy, self.point.pk, self.chat.pk, proxy) + self.assertIn('user:***@proxy.example', text) + with self.assertLogs('App.tasks', 'ERROR'): + text = self.run_task(tasks.set_domain, self.point.pk, self.chat.pk, 'vpn.example.com', runner=FakeRunner(fail='xui_cert')) + self.assertIn('прежними настройками', text) + self.point.refresh_from_db() + self.assertEqual((self.point.status, self.point.domain), (Point.Status.READY, '')) diff --git a/src/App/tests_security.py b/src/App/tests_security.py new file mode 100644 index 0000000..3c21c54 --- /dev/null +++ b/src/App/tests_security.py @@ -0,0 +1,104 @@ +from io import StringIO +from pathlib import Path +from unittest import mock + +from cryptography.fernet import Fernet +from django.core.exceptions import ImproperlyConfigured +from django.core.management import CommandError, call_command +from django.db import connection +from django.test import TestCase, override_settings + +from . import fields +from .models import Server +from .tests_services import create_point + + +def raw(model, pk, column): + with connection.cursor() as cursor: + cursor.execute(f'SELECT {column} FROM {model._meta.db_table} WHERE id = %s', [pk]) + return cursor.fetchone()[0] + + +class EncryptedFieldTests(TestCase): + def setUp(self): + self.point = create_point(mask=None, outbound_proxy='socks5://user:secret@proxy:1080') + self.server = self.point.panel + self.server.private_key = 'PRIVATE KEY' + self.server.save() + + def test_secrets_are_encrypted_in_database(self): + stored = raw(Server, self.server.pk, 'root_password') + self.assertTrue(stored.startswith(fields.TOKEN_PREFIX)) + self.assertNotIn('panel-pw', stored) + self.assertNotIn('secret', raw(type(self.point), self.point.pk, 'outbound_proxy')) + server = Server.objects.get(pk=self.server.pk) + self.assertEqual((server.root_password, server.private_key), ('panel-pw', 'PRIVATE KEY')) + + def test_empty_value_is_not_encrypted(self): + self.assertEqual(raw(type(self.point), self.point.pk, 'panel_api_token'), '') + + def test_key_rotation(self): + new_key = Fernet.generate_key().decode() + with override_settings(FIELD_ENCRYPTION_KEYS=[new_key, *self._keys()]): + self.assertEqual(Server.objects.get(pk=self.server.pk).root_password, 'panel-pw') + self.server.save() + with override_settings(FIELD_ENCRYPTION_KEYS=[new_key]): + self.assertEqual(Server.objects.get(pk=self.server.pk).root_password, 'panel-pw') + + def test_wrong_key_fails_loudly(self): + with override_settings(FIELD_ENCRYPTION_KEYS=[Fernet.generate_key().decode()]): + with self.assertRaises(ImproperlyConfigured): + Server.objects.get(pk=self.server.pk) + + def test_migration_encrypts_plaintext_once(self): + with connection.cursor() as cursor: + cursor.execute(f'UPDATE {Server._meta.db_table} SET root_password = %s WHERE id = %s', ['plain', self.server.pk]) + forward, backward = fields.encrypt_columns('App.Server', 'root_password') + apps = mock.Mock(get_model=lambda *_: Server) + editor = mock.Mock(connection=connection, quote_name=connection.ops.quote_name) + forward(apps, editor) + forward(apps, editor) + self.assertEqual(Server.objects.get(pk=self.server.pk).root_password, 'plain') + backward(apps, editor) + self.assertEqual(raw(Server, self.server.pk, 'root_password'), 'plain') + + @staticmethod + def _keys(): + from django.conf import settings + return list(settings.FIELD_ENCRYPTION_KEYS) + + +class ServeSshTests(TestCase): + def setUp(self): + self.server = create_point(mask=None).panel + self.server.private_key = 'PRIVATE KEY' + self.server.ssh_port = 40000 + self.server.known_hosts = '1.2.3.4 ssh-ed25519 AAAA' + self.server.save() + + def test_runs_ssh_with_key_from_database_and_removes_it(self): + seen = {} + + def call(argv): + key_file = Path(argv[argv.index('-i') + 1]) + seen.update(argv=argv, key=key_file.read_text(), mode=key_file.stat().st_mode & 0o777, path=key_file) + return 0 + + with mock.patch('subprocess.call', call): + call_command('serve_ssh', '10.0.0.1', 'uptime') + argv = seen['argv'] + self.assertEqual(argv[-2:], ['root@10.0.0.1', 'uptime']) + self.assertEqual(argv[argv.index('-p') + 1], '40000') + self.assertIn('StrictHostKeyChecking=yes', argv) + self.assertEqual((seen['key'], seen['mode']), ('PRIVATE KEY\n', 0o600)) + self.assertFalse(seen['path'].exists()) + + def test_unknown_server(self): + with self.assertRaises(CommandError): + call_command('serve_ssh', '10.9.9.9') + + def test_without_fingerprint_trusts_first_key(self): + self.server.reset_fingerprint() + with mock.patch('subprocess.call', return_value=0) as call: + call_command('serve_ssh', '10.0.0.1', stderr=StringIO()) + self.assertIn('StrictHostKeyChecking=accept-new', call.call_args.args[0]) diff --git a/src/App/tests_services.py b/src/App/tests_services.py index 4b3825a..e08723d 100644 --- a/src/App/tests_services.py +++ b/src/App/tests_services.py @@ -41,6 +41,46 @@ def patch_runner(runner): return mock.patch('serverus.runner.run_playbook', runner) +class FakeXuiApi: + """Подменяет API панели: клиенты живут в словаре. down=True - панель недоступна.""" + + def __init__(self): + self.clients = {} + self.down = False + self.added = [] + + def _check(self): + if self.down: + raise serverus.XuiApiError('connection refused') + + def add_client(self, inbound_id, email, uuid='', sub_id=''): + self._check() + if email in self.clients: + raise serverus.XuiApiError('email already in use') + self.added.append((inbound_id, email, uuid, sub_id)) + self.clients[email] = {'email': email, 'uuid': uuid or f'uuid-{email}', 'subId': sub_id or f'sub-{email}'} + return self.clients[email] + + def get_client(self, email): + self._check() + if email not in self.clients: + raise serverus.XuiApiError('record not found') + return self.clients[email] + + def delete_client(self, email): + self._check() + del self.clients[email] + + def get_traffic(self, email): + self._check() + self.get_client(email) + return {'up': 1024, 'down': 3 * 1024 * 1024, 'lastOnline': 0} + + +def patch_xui_api(api): + return mock.patch('App.services.xui_api', lambda point: api) + + MASK = Credentials('10.0.0.2', 'mask-pw') @@ -211,10 +251,22 @@ class ReadyPointTests(TestCase): 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)) + self.assertEqual((self.point.domain, self.point.status), ('', Point.Status.READY)) + self.assertIn('boom', self.point.error) + with patch_runner(FakeRunner()): + services.set_domain(self.point, 'example.com') + self.point.refresh_from_db() + self.assertEqual(self.point.error, '') + + def test_failed_proxy_keeps_old_proxy(self): + with patch_runner(FakeRunner(fail='xui_outbound')), self.assertRaises(services.ProvisionError): + services.set_outbound_proxy(self.point, Point.CF_WARP) + self.point.refresh_from_db() + self.assertEqual((self.point.outbound_proxy, self.point.status), ('', Point.Status.READY)) def test_mask_requires_domain(self): Server.objects.create_server(self.point, Server.Role.MASK, Credentials('10.0.0.3', 'pw')) @@ -237,6 +289,60 @@ class ReadyPointTests(TestCase): self.assertEqual(runner.calls, []) +class KeyTests(TestCase): + def setUp(self): + self.point = create_point(mask=None) + self.point.panel_inbound_id = 3 + self.point.save() + self.api = FakeXuiApi() + patcher = patch_xui_api(self.api) + patcher.start() + self.addCleanup(patcher.stop) + + def test_issue_key(self): + key = services.issue_key(self.point, 'mom-phone') + self.assertEqual(self.api.added, [(3, 'mom-phone', '', '')]) + self.assertEqual((key.uuid, key.sub_id), ('uuid-mom-phone', 'sub-mom-phone')) + self.assertEqual(key.subscription_url, self.point.subscription_url('sub-mom-phone')) + + def test_bad_or_taken_name(self): + services.issue_key(self.point, 'mom') + for name in ('mom', 'Мама', 'has space', ''): + with self.assertRaises(ValueError): + services.issue_key(self.point, name) + + def test_panel_error_keeps_no_key(self): + self.api.down = True + with self.assertRaises(serverus.XuiApiError): + services.issue_key(self.point, 'mom') + self.assertFalse(self.point.keys.exists()) + + def test_revoke_key(self): + key = services.issue_key(self.point, 'mom') + services.revoke_key(key) + self.assertEqual(self.api.clients, {}) + self.assertFalse(self.point.keys.exists()) + + def test_revoke_key_missing_on_panel(self): + key = services.issue_key(self.point, 'mom') + self.api.clients.clear() + services.revoke_key(key) + self.assertFalse(self.point.keys.exists()) + + def test_traffic(self): + key = services.issue_key(self.point, 'mom') + self.assertEqual(services.key_traffic(key)['up'], 1024) + self.api.down = True + self.assertIsNone(services.key_traffic(key)) + + def test_reinstall_restores_keys_with_same_ids(self): + key = services.issue_key(self.point, 'mom') + self.api.clients.clear() + with patch_runner(FakeRunner()): + services.reinstall(self.point) + self.assertEqual(self.api.added[-1], (7, 'mom', key.uuid, key.sub_id)) + + class ReinstallTests(TestCase): def setUp(self): self.point = create_point() diff --git a/src/MyPointVPN/settings/project.py b/src/MyPointVPN/settings/project.py index c1fa5e0..60c8c1e 100644 --- a/src/MyPointVPN/settings/project.py +++ b/src/MyPointVPN/settings/project.py @@ -3,6 +3,20 @@ from urllib.parse import quote from .base import * + +def _load_env(path): + """Переменные из .env в окружение, если их там еще нет: сайт, воркер и бот видят одно и то же, + как бы их ни запустили (make, PyCharm, systemd). Формат docker compose: KEY=VALUE, # - комментарий.""" + if not path.exists(): + return + for line in path.read_text().splitlines(): + key, sep, value = line.strip().partition('=') + if sep and key and not key.startswith('#'): + os.environ.setdefault(key.strip(), value.strip()) + + +_load_env(BASE_DIR.parent / '.env') + DEBUG = True STATICFILES_DIRS = [ @@ -25,6 +39,26 @@ INSTALLED_APPS += [ # Свой Bot API сервер (сервис telegram в docker-compose.yaml). Пусто - api.telegram.org TELEGRAM_API_URL = os.environ.get('TELEGRAM_API_URL', '') +# PostgreSQL из docker-compose.yaml. Без DB_NAME - SQLite из base.py (разработка без docker) +if os.environ.get('DB_NAME'): + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ['DB_NAME'], + 'USER': os.environ.get('DB_USER', ''), + 'PASSWORD': os.environ.get('DB_PASSWD', ''), + 'HOST': os.environ.get('DB_HOST', '127.0.0.1'), + 'PORT': os.environ.get('DB_PORT', '5432'), + 'CONN_MAX_AGE': 60, + 'CONN_HEALTH_CHECKS': True, + } + } + +# Ключи шифрования секретов в базе (App.fields), через запятую: шифрует первый, расшифровывает любой. +# Новый ключ: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +# Потеря ключа = потеря доступа ко всем серверам, храните его отдельно от бэкапов базы. +FIELD_ENCRYPTION_KEYS = [key for key in os.environ.get('FIELD_ENCRYPTION_KEYS', '').split(',') if key] + # Сертификаты панелей: letsencrypt или local - свой CA, для серверов без публичного адреса (локальный стенд). # SERVERUS_CA_DIR - папка CA, он создается при первом выпуске. ca.pem нужно добавить в доверенные на клиентах. SERVERUS_CERTIFICATES = os.environ.get('SERVERUS_CERTIFICATES', 'letsencrypt') diff --git a/src/MyPointVPN/settings/test.py b/src/MyPointVPN/settings/test.py index 1185af3..e9544d0 100644 --- a/src/MyPointVPN/settings/test.py +++ b/src/MyPointVPN/settings/test.py @@ -1,5 +1,11 @@ from .project import * +# фиксированный тестовый ключ: тестам не нужен .env +FIELD_ENCRYPTION_KEYS = ['mCWd2vrKpmM4e7AvnqBO2Kv5Yx5JmR1c-9pGVuVbQq0='] +# тесты не зависят от локального .env и docker +DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}} +SERVERUS_CERTIFICATES = 'letsencrypt' + # задачи не уходят в rabbitmq DRAMATIQ_BROKER = { **DRAMATIQ_BROKER, diff --git a/src/Telegram/runner.py b/src/Telegram/runner.py index 9ab795c..5423819 100644 --- a/src/Telegram/runner.py +++ b/src/Telegram/runner.py @@ -44,6 +44,7 @@ class BotWorker(threading.Thread): def _poll(self, api): offset = Bot.objects.values_list('update_offset', flat=True).get(pk=self.bot_id) + # TODO: переписать на setWebhook updates = api.get_updates( offset=offset, timeout=POLL_TIMEOUT, long_polling_timeout=POLL_TIMEOUT, allowed_updates=ALLOWED_UPDATES, ) diff --git a/src/serverus/README.md b/src/serverus/README.md index e0310e3..f01543a 100644 --- a/src/serverus/README.md +++ b/src/serverus/README.md @@ -131,5 +131,6 @@ make serverus_release ip= # вернуть се - сгенерировать ключи и сертификаты - [x] нужен интерфейс выпуска, отзыва и получения статистики ключей - [x] проверить `xui` и `xui_inbound` на живом сервере (Debian 13, свой CA): установка, DoT и запасной DoH, ключи, подписка, socks5 и WARP, отказ нерабочего прокси -- [ ] проверить `release` и сертификат Let's Encrypt на сервере с публичным адресом -- [ ] выпуск и отзыв ключей в боте поверх `XuiApi` +- [x] `release` на локальном стенде +- [ ] проверить на сервере с публичным адресом: сертификат Let's Encrypt на домен и на ip (продление короткого сертификата по cron), `release`, Debian 12 +- [x] выпуск и отзыв ключей в боте поверх `XuiApi` diff --git a/src/serverus/__main__.py b/src/serverus/__main__.py index 746c165..26ac411 100644 --- a/src/serverus/__main__.py +++ b/src/serverus/__main__.py @@ -4,7 +4,7 @@ python -m serverus key add|del|stats --out python -m serverus cert [] --out [--ca ] python -m serverus outbound [] --out - python -m serverus release --out + python -m serverus release --out [--password ] Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в /access.json до начала настройки: bootstrap меняет пароль и порт ssh, без этого файла доступ к серверу можно потерять. @@ -51,6 +51,7 @@ def main(argv=None) -> int: release = commands.add_parser('release', help='вернуть сервер: вход по паролю на 22 порт, новый пароль root') release.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui') + release.add_argument('--password', default='', help='новый пароль root, без него случайный') args = parser.parse_args(argv) if args.command == 'xui': @@ -61,7 +62,7 @@ def main(argv=None) -> int: return issue_certificate(args.domain, args.out, args.ca) if args.command == 'outbound': return set_outbound(args.proxy, args.out) - return release_server(args.out) + return release_server(args.out, args.password) def install_xui(address: str, password: str, out: Path, domain: str, ssl: bool, ca_dir: Path | None) -> int: @@ -179,14 +180,14 @@ def set_outbound(proxy: str, out: Path) -> int: return 0 if result.ok else 1 -def release_server(out: Path) -> int: +def release_server(out: Path, password: str = '') -> int: access = json.loads((out / ACCESS_FILE).read_text()) - new_password = generate_password() + new_password = password or generate_password() result = Server(load_host(access)).release(new_password) report('release', result) if not result.ok: return 1 - access.update(ssh_port=22, root_password=new_password, known_hosts='') + access.update(ssh_port=22, root_password=new_password, known_hosts='', bootstrapped=False) save(out, access) print(f"ssh: ssh -p 22 root@{access['address']}, root password: {new_password}") return 0