chore: сделал

This commit is contained in:
protokey committed 2026-09-26 17:40:47 +04:00
1 parent 602199d510
commit 45e7a1d22f
25 files changed
+1164 -113

No files matched your search

+13 -3
View File
@@ -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 <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/"
@@ -27,7 +32,7 @@ help:
@echo "make serverus_key ip=<ip> action=add|del|stats name=<name> - ключ на панели, настроенной через serverus_xui"
@echo "make serverus_cert ip=<ip> [domain=<domain>] [ca=yes] - сертификат панели на домен или на ip"
@echo "make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] - исходящий прокси, без proxy - напрямую"
@echo "make serverus_release ip=<ip> - вернуть сервер, настроенный через serverus_xui: вход по паролю на 22 порт"
@echo "make serverus_release ip=<ip> [password=<new_root_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=<ip> [cmd='uptime']
serve_ssh:
@test -n "$(ip)" || { echo "Укажите сервер: make serve_ssh ip=<ip> [cmd='<команда>']"; exit 1; }
$(MANAGE) serve_ssh $(ip) $(if $(cmd),-- $(cmd))
serverus_xui:
@test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip=<ip> password=<root_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=<ip>"; exit 1; }
$(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip)
$(SERVERUS) release --out $(SERVERUS_ACCESS_DIR)/$(ip) $(if $(password),--password '$(password)')
+86 -72
View File
@@ -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/<subId>
```
```
--->
добавить
<---
если хотите, можете сами установить 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 лежат на диске): шифровать, как прокси.
+8
View File
@@ -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
+1
View File
@@ -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
+17 -3
View File
@@ -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=<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=<ip>` (или `manage.py serve_ssh <ip> [команда]`). Ключ и отпечаток берутся из базы, ключ живет во временной папке только на время сессии. В админке и боте приватный ключ не показывается.
+140 -4
View File
@@ -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)
+116 -6
View File
@@ -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 = 'Пришли имя ключа: латиница, цифры, - и _. Например <code>mom-phone</code>.'
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 <code>{escape(password)}</code>' 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'<b>Ключи {escape(point.name)}</b>']
if not keys:
lines.append('Ключей пока нет.')
for key, traffic in keys:
lines.append(f'<code>{escape(key.name)}</code> {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'Ключ <b>{escape(key.name)}</b>: {traffic_line(traffic)}',
'',
'Подписка:',
f'<code>{escape(key.subscription_url)}</code>',
'',
'Добавь ссылку как подписку в клиенте (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-запись уже должна указывать на <code>{escape(point.entry.address)}</code>, '
'иначе сертификат не выпустится.',
]
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'<code>{escape(point.name)}</code>: домен убран, сертификат выпущен на ip.'
text = f'<code>{escape(point.name)}</code>: домен {escape(domain)} установлен.'
if point.keys.exists():
text += '\nСсылки подписок сменились: открой "Клиенты" и раздай ключи заново.'
return text
def proxy_changed(point: Point, proxy: str) -> str:
return f'<code>{escape(point.name)}</code>: прокси на выходе {value(proxy_label(proxy)) if proxy else "отключен"}.'
def change_failed(point: Point, error: Exception) -> str:
return (
f'Не удалось применить настройку <code>{escape(point.name)}</code>:\n{escape(str(error))}\n'
'Точка работает с прежними настройками.'
)
+79
View File
@@ -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])
View File
Whitespace-only changes.
Whitespace-only changes.
+54
View File
@@ -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)
+31
View File
@@ -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')],
},
),
]
@@ -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')),
]
@@ -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),
),
]
+53 -10
View File
@@ -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)
+61 -3
View File
@@ -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)
+25 -1
View File
@@ -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))
+142 -3
View File
@@ -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, ''))
+104
View File
@@ -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])
+107 -1
View File
@@ -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()
+34
View File
@@ -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')
+6
View File
@@ -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,
+1
View File
@@ -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,
)
+3 -2
View File
@@ -131,5 +131,6 @@ make serverus_release ip=<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`
+6 -5
View File
@@ -4,7 +4,7 @@
python -m serverus key add|del|stats <name> --out <dir>
python -m serverus cert [<domain>] --out <dir> [--ca <dir>]
python -m serverus outbound [<proxy>] --out <dir>
python -m serverus release --out <dir>
python -m serverus release --out <dir> [--password <new_root_password>]
Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/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