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 WHEELS_DIR := wheels
REQUIREMENTS := requirements.txt REQUIREMENTS := requirements.txt
MANAGE := cd src/ && ../$(PYTHON) manage.py 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 := cd src/ && ../$(PYTHON) -m serverus
# доступы к серверам, настроенным вручную через serverus (папка в .gitignore) # доступы к серверам, настроенным вручную через serverus (папка в .gitignore)
SERVERUS_ACCESS_DIR := $(abspath extra/serverus) 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 MAKECMDGOALS_TARGETS := install freeze wheels sync sync-offline clean help
PKG := $(filter-out $(MAKECMDGOALS_TARGETS),$(MAKECMDGOALS)) 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: help:
@echo "make install <pkg> [<pkg> ...] - установить пакет(ы) в $(VENV), обновить $(REQUIREMENTS), собрать wheel в $(WHEELS_DIR)/" @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_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_cert ip=<ip> [domain=<domain>] [ca=yes] - сертификат панели на домен или на ip"
@echo "make serverus_outbound ip=<ip> [proxy=socks5://...|cf_warp] - исходящий прокси, без proxy - напрямую" @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: venv:
@test -d $(VENV) || python3 -m venv $(VENV) @test -d $(VENV) || python3 -m venv $(VENV)
@@ -87,6 +92,11 @@ dj_startapp:
tg_run: tg_run:
$(MANAGE) telegram_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: serverus_xui:
@test -n "$(ip)" -a -n "$(password)" || { echo "Укажите сервер: make serverus_xui ip=<ip> password=<root_password>"; exit 1; } @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) $(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: serverus_release:
@test -n "$(ip)" || { echo "Укажите сервер: make serverus_release ip=<ip>"; exit 1; } @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 или свои)
- домен (можно спрятать за 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: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 http://127.0.0.1:9050
<--- <---
Сервер: 127.0.0.1 - $(uname -a) Сервер: 127.0.0.1
Домен: - Домен: -
Cloudflare: Да Прокси: http://127.0.0.1:9050
Прокси: 127.0.0.1:9050 [домен][прокси][маскировка]
[домен][cloudflare][прокси] [отмена][установить][сервер][помощь]
[отмена][установить][сервер]
``` ```
``` ```
@@ -146,8 +125,9 @@ Cloudflare: Да
---> --->
/start /start
<--- <---
server_name 0Mb/0Mb 0/0 point-1a2b3c активен
[ФАК][добавить сервер][помощь][управление сервером] [ФАК][добавить сервер][помощь]
[управление сервером]
``` ```
Чтобы перейти к управлению сервером нужно вот такой квест пройти, надо все эти id в копируемый pre сунуть. Чтобы перейти к управлению сервером нужно вот такой квест пройти, надо все эти id в копируемый pre сунуть.
@@ -168,16 +148,17 @@ server_name 0Mb/0Mb 0/0
<--- <---
Сервер: NAME Сервер: NAME
Состояние: активен Состояние: активен
Ключи: 0/0 Панель: 1.2.3.4
Маскировка: -
Ключи: 2
Домен: - Домен: -
Прокси: - Прокси: -
[Вернуть][Переустановить] [Вернуть][Переустановить]
[Домен][Прокси][Cloudflare] [Домен][Прокси]
[Клиенты] [Клиенты]
[главное меню]
``` ```
Ключи отображаются онлайн/оффлайн.
Возврат сервера связан с отключением его от платформы. Доступы к серверу изымаются, ставится новый пароль рута, а данные остаются как есть. Просто отдаем сервер назад. Возврат сервера связан с отключением его от платформы. Доступы к серверу изымаются, ставится новый пароль рута, а данные остаются как есть. Просто отдаем сервер назад.
``` ```
@@ -225,10 +206,9 @@ server_name 0Mb/0Mb 0/0
Позволяют маскировать ip адрес вашей точки доступа от сайтов, Позволяют маскировать 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][отмена] [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>
``` ```
``` "выпустить ключ" просит имя (латиница, цифры, `-` и `_`), "отозвать ключ" - имя существующего. Клиенту выдается URL подписки, а не vless-ссылка: он переживает переустановку точки, ключи восстанавливаются с прежними uuid и subId. Ограничений на число ключей нет, выпускает их владелец точки.
--->
добавить
<---
если хотите, можете сами установить id
[сгенерируй]
```
Далее пользователь либо отправляет текст, который будет использован как id для нового клиента, либо сгенерирует новый уникальный id и вернет ключ.
### Настройка сервера ### Настройка сервера
@@ -331,7 +297,17 @@ id 0/0Mb On
Пакет также занимается и настройкой серверов базово. И позволяет сделать из сервера прокси или панель 3x-ui. Для наших целей требуется оба и в интерфейсе добавления сервера есть пункт "Сервер маскировки" - это сервер из которого нужно сделать прозрачный прокси. В App заводим модель в которой объединяем эти 2 сервера в единую систему. Так как кнопка "Вернуть" вернет оба сервера и они связаны настройками маршрутизации. Пакет также занимается и настройкой серверов базово. И позволяет сделать из сервера прокси или панель 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 - обязательно должен быть, ради него все и затевается - сервер 3x-ui - обязательно должен быть, ради него все и затевается
- сервер маскировки - если пользователь хочет особый режим, то он может добавить еще один сервер купленный в своей стране - сервер маскировки - если пользователь хочет особый режим, то он может добавить еще один сервер купленный в своей стране
- домен - если сервер маскировки указан, то домен обязательно нужно указать - домен - если сервер маскировки указан, то домен обязательно нужно указать
- ssl сертификаты - если домен не указан, то сертификаты выпускаются на IP сервера 3x-ui, а сервера маскировки нет; а если домен есть то он привязывается к серверу маскировки и на него выпускаются ssl ключи, который хранятся на сервере 3x-ui, потому что сервер маскировки лишь прозрачно проксирует - ssl сертификаты - если домен не указан, то сертификаты выпускаются на IP сервера 3x-ui, а сервера маскировки нет; а если домен есть то он привязывается к серверу маскировки и на него выпускаются ssl ключи, который хранятся на сервере 3x-ui, потому что сервер маскировки лишь прозрачно проксирует. Храним и домен, и ip, по ssh ходим только по ip
- ssh ключи - доступ к серверам, находящимся под нашей ведомостью, всегда осуществляется асимметричным способом (ключ-файл) - ssh ключи - доступ к серверам, находящимся под нашей ведомостью, всегда осуществляется асимметричным способом (ключ-файл)
- прокси - можно задать свои и с авторизацией, а можно воспользоваться cf_warp прокси, которые можно регистрировать автоматически через x-ui - прокси - можно задать свои и с авторизацией, а можно воспользоваться cf_warp прокси, которые можно регистрировать автоматически через x-ui
- ключи - клиенты панели: имя, uuid и subId, чтобы восстановить их после переустановки
Сервер - реальные сервера с ip и доступом к root. Настраиваем безопасное окружение по всем канонам. Сервер - реальные сервера с ip и доступом к root. Настраиваем безопасное окружение по всем канонам.
Сервис - На серверах у нас живут сервисы. Поднимается или настраивается какой-то сервис, чтобы решить какую-то полезную задачу. И реализуют api для управления сервисом на сервере. Сейчас эту функцию выполняет Роль сервера, потому что подразумевается что сервис может быть 1. Сервис - На серверах у нас живут сервисы. Поднимается или настраивается какой-то сервис, чтобы решить какую-то полезную задачу. И реализуют 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_NAME=
DB_USER= DB_USER=
DB_PASSWD= 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 и ERLANG_COOKIE не менять после первого запуска, иначе данные не прочитаются
RABBITMQ_HOST=rabbitmq RABBITMQ_HOST=rabbitmq
@@ -19,6 +22,11 @@ RABBITMQ_PASSWORD=
# Адрес для Django и воркера dramatiq # Адрес для Django и воркера dramatiq
RABBITMQ_ADDRESS=127.0.0.1:5672 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 для локального стенда) # Сертификаты панелей: letsencrypt или local (свой CA для локального стенда)
SERVERUS_CERTIFICATES=letsencrypt SERVERUS_CERTIFICATES=letsencrypt
# Папка своего CA, по умолчанию extra/serverus/ca # Папка своего CA, по умолчанию extra/serverus/ca
+1
View File
@@ -10,6 +10,7 @@ cffi==2.1.1
charset-normalizer==3.5.1 charset-normalizer==3.5.1
cryptography==50.0.1 cryptography==50.0.1
Django==6.1.1 Django==6.1.1
django-fernet-fields==0.6
django_dramatiq==0.15.0 django_dramatiq==0.15.0
dramatiq==2.2.1 dramatiq==2.2.1
frozenlist==1.8.0 frozenlist==1.8.0
+17 -3
View File
@@ -5,7 +5,7 @@ Django приложение связывающее все части сайта
-[x] Реализует кастомный менеджер модели и кастомную модель пользователя с отключенным email. -[x] Реализует кастомный менеджер модели и кастомную модель пользователя с отключенным email.
-[x] Модель данных для поддержки интерфейса и функционала -[x] Модель данных для поддержки интерфейса и функционала
-[ ] Настройки домена и прокси у готовой точки -[ ] Настройки домена и прокси у готовой точки
-[ ] Клиенты и статистика через API панели 3x-ui -[x] Клиенты и статистика через API панели 3x-ui
Зависимости: `Telegram` (интерфейс бота, пользователи, уведомления) и `serverus` (настройка серверов). Зависимости: `Telegram` (интерфейс бота, пользователи, уведомления) и `serverus` (настройка серверов).
@@ -13,6 +13,7 @@ Django приложение связывающее все части сайта
- `Point` - точка доступа: сервер с панелью 3x-ui и необязательный сервер маскировки. Владелец - `Telegram.User`. Хранит домен, прокси на выходе и доступ к панели. - `Point` - точка доступа: сервер с панелью 3x-ui и необязательный сервер маскировки. Владелец - `Telegram.User`. Хранит домен, прокси на выходе и доступ к панели.
- `Server` - сервер точки с ролью `panel` или `mask`. Хранит текущие порт ssh, пароль root, ключ и отпечаток ssh, а также пароль root, присланный пользователем при добавлении (`default_root_password`). - `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.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.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()`, серверы удаляются вместе с точкой - удаление: `point.delete()`, серверы удаляются вместе с точкой
Соглашение для поиска: `get_*` и `point.server(role)` бросают `DoesNotExist`, если записи нет, а `find_*` возвращают `None`, и это видно по типу. `point.panel` всегда есть, потому что панель создается вместе с точкой, а `point.mask` может быть `None`. Соглашение для поиска: `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`. - `tasks.py` - задачи dramatiq: запускают сценарий и присылают результат в чат. Воркер: `make dj_worker`.
- `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке). - `bot/interface.py` - интерфейс бота `MyPointInterface` (выбирается у бота в админке).
- `bot/texts.py` - кнопки и тексты. - `bot/texts.py` - кнопки и тексты.
@@ -38,6 +42,7 @@ Django приложение связывающее все части сайта
2. панель: 3x-ui с логином, паролем, портом и путем, которые сгенерировал App, сертификат на домен или ip, подписка, подключение reality `main`, исходящий прокси точки 2. панель: 3x-ui с логином, паролем, портом и путем, которые сгенерировал App, сертификат на домен или ip, подписка, подключение reality `main`, исходящий прокси точки
3. маскировка: перенаправление трафика на адрес панели 3. маскировка: перенаправление трафика на адрес панели
4. facts: `uname -a` для карточки сервера 4. facts: `uname -a` для карточки сервера
5. ключи точки восстанавливаются на панели (`restore_keys`), при первой установке их нет
"Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке. "Переустановить" сбрасывает отпечаток ssh сервера и повторяет установку с новым паролем от провайдера или, по кнопке "Использовать старый", с паролем, присланным при добавлении сервера. Отпечаток можно сбросить и в админке.
@@ -52,3 +57,12 @@ Django приложение связывающее все части сайта
Решение: за Cloudflare не работаем. Reality через проксирование Cloudflare невозможен: Cloudflare сам завершает TLS. Решение: за Cloudflare не работаем. Reality через проксирование Cloudflare невозможен: Cloudflare сам завершает TLS.
- [x] нужна функция установки прокси в исходящие (outbound) - [x] нужна функция установки прокси в исходящие (outbound)
При установке ставится `point.outbound_proxy`, у готовой точки - `services.set_outbound_proxy(point, proxy)`. Поддерживаются socks5, http(s) и cf_warp. При установке ставится `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 import serverus
from Telegram.interfaces import Interface, command, register, state, text 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 ..models import Credentials, Point, Server
from . import texts from . import texts
@@ -18,6 +18,11 @@ SELECT = 'select'
POINT = 'point' POINT = 'point'
POINT_RELEASE = 'point.release' POINT_RELEASE = 'point.release'
POINT_REINSTALL = 'point.reinstall' 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'} 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}$') 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) @state(DRAFT_PROXY)
def set_proxy(self, ctx): def set_proxy(self, ctx):
# в прокси может быть пароль
ctx.delete_message()
proxy = ctx.text.strip() proxy = ctx.text.strip()
if not is_proxy_url(proxy): if not is_proxy_url(proxy):
return ctx.reply(texts.PROXY_INVALID, parse_mode=HTML) return ctx.reply(texts.PROXY_INVALID, parse_mode=HTML)
@@ -274,15 +281,144 @@ class MyPointInterface(Interface):
@text(texts.NO, state=POINT_RELEASE) @text(texts.NO, state=POINT_RELEASE)
@text(texts.CANCEL, state=POINT_RELEASE) @text(texts.CANCEL, state=POINT_RELEASE)
@text(texts.CANCEL, state=POINT_REINSTALL) @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): def back_to_point(self, ctx):
point = self.current_point(ctx) point = self.current_point(ctx)
if point is None: if point is None:
return self.main_menu(ctx) return self.main_menu(ctx)
self.show_point(ctx, point) self.show_point(ctx, point)
# домен и прокси готовой точки: применяются в фоне, панель тем временем работает с прежними настройками
@text(texts.POINT_DOMAIN, state=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) @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) @text(texts.CLIENTS, state=POINT)
def not_implemented(self, ctx): @text(texts.CLIENTS, state=POINT_KEYS)
# TODO: настройки домена, прокси и клиенты через API панели 3x-ui @text(texts.CANCEL, state=POINT_KEY_ADD)
ctx.reply(texts.NOT_IMPLEMENTED) @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, данные пользователя экранируются.""" """Кнопки и тексты бота. Сообщения размечены HTML, данные пользователя экранируются."""
import datetime
from html import escape from html import escape
from urllib.parse import urlsplit, urlunsplit
from ..models import Point, Server from ..models import Key, Point, Server
# главное меню # главное меню
MAIN_MENU = 'главное меню' MAIN_MENU = 'главное меню'
@@ -33,10 +35,15 @@ POINT_PROXY = 'Прокси'
CLIENTS = 'Клиенты' CLIENTS = 'Клиенты'
USE_OLD = 'Использовать старый' USE_OLD = 'Использовать старый'
# ключи
ADD_KEY = 'выпустить ключ'
REVOKE_KEY = 'отозвать ключ'
MAIN_KEYBOARD = [[FAQ, ADD_SERVER, HELP]] MAIN_KEYBOARD = [[FAQ, ADD_SERVER, HELP]]
MAIN_KEYBOARD_WITH_POINTS = [[FAQ, ADD_SERVER, HELP], [MANAGE]] MAIN_KEYBOARD_WITH_POINTS = [[FAQ, ADD_SERVER, HELP], [MANAGE]]
DRAFT_KEYBOARD = [[DOMAIN, PROXY, MASK], [CANCEL, INSTALL, SERVER, HELP]] DRAFT_KEYBOARD = [[DOMAIN, PROXY, MASK], [CANCEL, INSTALL, SERVER, HELP]]
POINT_KEYBOARD = [[RELEASE, REINSTALL], [POINT_DOMAIN, POINT_PROXY], [CLIENTS], [MAIN_MENU]] POINT_KEYBOARD = [[RELEASE, REINSTALL], [POINT_DOMAIN, POINT_PROXY], [CLIENTS], [MAIN_MENU]]
KEYS_KEYBOARD = [[ADD_KEY, REVOKE_KEY], [SERVER_MENU]]
CANCEL_KEYBOARD = [[CANCEL]] CANCEL_KEYBOARD = [[CANCEL]]
EMPTY = '-' EMPTY = '-'
@@ -100,7 +107,14 @@ REINSTALL_PROMPT = '''Будет произведена повторная ус
"Использовать старый" переустановит все серверы точки с паролями, которые вы прислали при добавлении.''' "Использовать старый" переустановит все серверы точки с паролями, которые вы прислали при добавлении.'''
REINSTALL_UNKNOWN_ADDRESS = 'Сервер {address} не входит в эту точку.' REINSTALL_UNKNOWN_ADDRESS = 'Сервер {address} не входит в эту точку.'
REINSTALL_STARTED = 'Запускаю переустановку. Отправлю уведомление когда все будет готово.' 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 = 'Не понял. Воспользуйся кнопками меню.' FALLBACK = 'Не понял. Воспользуйся кнопками меню.'
@@ -113,7 +127,7 @@ def draft_card(draft: dict) -> str:
f"Сервер: {value(draft.get('server', {}).get('address', ''))}", f"Сервер: {value(draft.get('server', {}).get('address', ''))}",
f"Сервер маскировки: {value(draft.get('mask', {}).get('address', ''))}", f"Сервер маскировки: {value(draft.get('mask', {}).get('address', ''))}",
f"Домен: {value(draft.get('domain', ''))}", 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) server = servers.get(role)
lines.append(f'{role.label.capitalize()}: {escape(server.address) if server else EMPTY}') lines.append(f'{role.label.capitalize()}: {escape(server.address) if server else EMPTY}')
lines += [ lines += [
# TODO: онлайн/всего ключей из API панели f'Ключи: {point.keys.count() or EMPTY}',
f'Ключи: {EMPTY}',
f'Домен: {value(point.domain)}', f'Домен: {value(point.domain)}',
f'Прокси: {value(point.outbound_proxy)}', f'Прокси: {value(proxy_label(point.outbound_proxy))}',
] ]
if point.error: if point.error:
lines.append(f'Ошибка: {escape(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)} - пароль root <code>{escape(password)}</code>' for address, password in passwords.items()]
lines += [f'{escape(address)} - не удалось вернуть: {escape(error)}' for address, error in errors.items()] lines += [f'{escape(address)} - не удалось вернуть: {escape(error)}' for address, error in errors.items()]
return '\n'.join(lines) 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 import secrets
from dataclasses import dataclass from dataclasses import dataclass
@@ -6,6 +7,8 @@ from django.contrib.auth.models import AbstractUser
from django.db import models, transaction from django.db import models, transaction
from serverus import CF_WARP, Host, generate_password, generate_port, generate_username from serverus import CF_WARP, Host, generate_password, generate_port, generate_username
from .fields import EncryptedTextField
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
"""Менеджер пользователей без email: только username и password.""" """Менеджер пользователей без email: только username и password."""
@@ -129,6 +132,7 @@ class Point(models.Model):
class Status(models.TextChoices): class Status(models.TextChoices):
INSTALLING = 'installing', 'устанавливается' INSTALLING = 'installing', 'устанавливается'
UPDATING = 'updating', 'настраивается'
READY = 'ready', 'активен' READY = 'ready', 'активен'
ERROR = 'error', 'ошибка' ERROR = 'error', 'ошибка'
RELEASING = 'releasing', 'возвращается' RELEASING = 'releasing', 'возвращается'
@@ -139,15 +143,13 @@ class Point(models.Model):
error = models.TextField(blank=True) error = models.TextField(blank=True)
domain = models.CharField(max_length=253, blank=True) domain = models.CharField(max_length=253, blank=True)
outbound_proxy = models.CharField( outbound_proxy = EncryptedTextField(blank=True, help_text=f'protocol://user:password@host:port или {CF_WARP}')
max_length=512, blank=True, help_text=f'protocol://user:password@host:port или {CF_WARP}',
)
panel_username = models.CharField(max_length=64) panel_username = models.CharField(max_length=64)
panel_password = models.CharField(max_length=128) panel_password = EncryptedTextField()
panel_port = models.PositiveIntegerField() panel_port = models.PositiveIntegerField()
panel_path = models.CharField(max_length=64) 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 в панели') panel_inbound_id = models.PositiveIntegerField(null=True, blank=True, help_text='Подключение reality в панели')
sub_port = models.PositiveIntegerField(help_text='Порт подписки панели') sub_port = models.PositiveIntegerField(help_text='Порт подписки панели')
sub_path = models.CharField(max_length=64, help_text='Путь подписки без слешей') sub_path = models.CharField(max_length=64, help_text='Путь подписки без слешей')
@@ -165,7 +167,7 @@ class Point(models.Model):
@property @property
def is_busy(self) -> bool: 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 @property
def panel_host(self) -> str: def panel_host(self) -> str:
@@ -241,13 +243,12 @@ class Server(models.Model):
role = models.CharField(max_length=8, choices=Role.choices) role = models.CharField(max_length=8, choices=Role.choices)
address = models.GenericIPAddressField(unique=True) address = models.GenericIPAddressField(unique=True)
ssh_port = models.PositiveIntegerField(default=22) ssh_port = models.PositiveIntegerField(default=22)
default_root_password = models.CharField( default_root_password = EncryptedTextField(
max_length=128,
help_text='Пароль root, который пользователь прислал при добавлении сервера. ' help_text='Пароль root, который пользователь прислал при добавлении сервера. '
'После сброса сервера у провайдера он снова может быть актуальным', 'После сброса сервера у провайдера он снова может быть актуальным',
) )
root_password = models.CharField(max_length=128, help_text='Текущий пароль root') root_password = EncryptedTextField(help_text='Текущий пароль root')
private_key = models.TextField(blank=True) private_key = EncryptedTextField(blank=True)
public_key = models.TextField(blank=True) public_key = models.TextField(blank=True)
known_hosts = models.TextField(blank=True, help_text='Отпечаток ssh сервера. Сбрасывается после переустановки ОС') known_hosts = models.TextField(blank=True, help_text='Отпечаток ssh сервера. Сбрасывается после переустановки ОС')
uname = models.CharField(max_length=512, blank=True) uname = models.CharField(max_length=512, blank=True)
@@ -268,3 +269,45 @@ class Server(models.Model):
def reset_fingerprint(self): def reset_fingerprint(self):
self.known_hosts = '' self.known_hosts = ''
self.save(update_fields=['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 import serverus
from django.conf import settings from django.conf import settings
from .models import Point, Server from .models import KEY_NAME_RE, Key, Point, Server
class ProvisionError(Exception): class ProvisionError(Exception):
@@ -16,6 +16,7 @@ def install_point(point: Point):
with _installing(point): with _installing(point):
for server in point.servers.all(): for server in point.servers.all():
provision(server) provision(server)
restore_keys(point)
def reinstall(point: Point, address: str | None = None, password: str | None = None): 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.root_password = password or server.default_root_password
server.save() server.save()
provision(server) provision(server)
# панель доступна по домену только через маскировку, поэтому после всех серверов
restore_keys(point)
def release_point(point: Point) -> tuple[dict[str, str], dict[str, str]]: 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: if not domain and point.mask:
raise ValueError('маскировка работает только с доменом') raise ValueError('маскировка работает только с доменом')
with _installing(point): with _updating(point):
_check(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain, local_ca())) _check(point.panel, serverus.XuiPanel(point.panel.host()).issue_certificate(domain, local_ca()))
point.domain = domain point.domain = domain
point.save(update_fields=['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 или '' - напрямую.""" """Меняет исходящий прокси готовой точки: protocol://user:password@host:port, Point.CF_WARP или '' - напрямую."""
if proxy and proxy != Point.CF_WARP: if proxy and proxy != Point.CF_WARP:
serverus.parse_proxy(proxy) serverus.parse_proxy(proxy)
with _installing(point): with _updating(point):
_check(point.panel, serverus.XuiPanel(point.panel.host()).set_outbound(proxy)) _check(point.panel, serverus.XuiPanel(point.panel.host()).set_outbound(proxy))
point.outbound_proxy = proxy point.outbound_proxy = proxy
point.save(update_fields=['outbound_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: def local_ca() -> serverus.LocalCA | None:
"""Свой CA, если панели работают не с Let's Encrypt (SERVERUS_CERTIFICATES=local).""" """Свой CA, если панели работают не с Let's Encrypt (SERVERUS_CERTIFICATES=local)."""
if settings.SERVERUS_CERTIFICATES == 'local': if settings.SERVERUS_CERTIFICATES == 'local':
@@ -166,3 +209,18 @@ def _installing(point: Point):
raise raise
point.set_status(Point.Status.READY) 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.messages import send
from Telegram.models import Chat from Telegram.models import Chat
from . import services from . import fields, services
from .bot import texts from .bot import texts
from .models import Point from .models import Point
@@ -46,3 +46,27 @@ def release_point(point_id: int, chat_id: int):
name = point.name name = point.name
passwords, errors = services.release_point(point) passwords, errors = services.release_point(point)
_notify(chat_id, texts.released(name, passwords, errors)) _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.models import Bot, Chat, User as TelegramUser
from Telegram.testing import Conversation, FakeApi from Telegram.testing import Conversation, FakeApi
from . import tasks from . import fields, tasks
from .bot import texts from .bot import texts
from .bot.interface import MyPointInterface, is_proxy_url, parse_credentials from .bot.interface import MyPointInterface, is_proxy_url, parse_credentials
from .models import Point from .models import Credentials, Point, Server
from .tests_services import FakeRunner, create_point, patch_runner from .tests_services import FakeRunner, FakeXuiApi, create_point, patch_runner, patch_xui_api
class ParsersTests(TestCase): class ParsersTests(TestCase):
@@ -189,6 +189,132 @@ class PointMenuTests(BotTestCase):
self.assertIn(self.point.name, self.say(texts.SERVER_MENU)) 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): class TasksTests(TestCase):
def setUp(self): def setUp(self):
bot = Bot.objects.create(token='1:test') 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) text = self.run_task(tasks.release_point, self.point.pk, self.chat.pk)
self.assertIn('10.0.0.1 - пароль root', text) self.assertIn('10.0.0.1 - пароль root', text)
self.assertFalse(Point.objects.exists()) 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) 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') 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)) self.assertEqual((self.point.domain, self.point.status), ('example.com', Point.Status.READY))
def test_failed_certificate_keeps_old_domain(self): def test_failed_certificate_keeps_old_domain(self):
# сертификат меняется только после выпуска: точка остается рабочей, ошибка видна в карточке
with patch_runner(FakeRunner(fail='xui_cert')), self.assertRaises(services.ProvisionError): with patch_runner(FakeRunner(fail='xui_cert')), self.assertRaises(services.ProvisionError):
services.set_domain(self.point, 'example.com') services.set_domain(self.point, 'example.com')
self.point.refresh_from_db() 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): def test_mask_requires_domain(self):
Server.objects.create_server(self.point, Server.Role.MASK, Credentials('10.0.0.3', 'pw')) 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, []) 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): class ReinstallTests(TestCase):
def setUp(self): def setUp(self):
self.point = create_point() self.point = create_point()
+34
View File
@@ -3,6 +3,20 @@ from urllib.parse import quote
from .base import * 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 DEBUG = True
STATICFILES_DIRS = [ STATICFILES_DIRS = [
@@ -25,6 +39,26 @@ INSTALLED_APPS += [
# Свой Bot API сервер (сервис telegram в docker-compose.yaml). Пусто - api.telegram.org # Свой Bot API сервер (сервис telegram в docker-compose.yaml). Пусто - api.telegram.org
TELEGRAM_API_URL = os.environ.get('TELEGRAM_API_URL', '') 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, для серверов без публичного адреса (локальный стенд). # Сертификаты панелей: letsencrypt или local - свой CA, для серверов без публичного адреса (локальный стенд).
# SERVERUS_CA_DIR - папка CA, он создается при первом выпуске. ca.pem нужно добавить в доверенные на клиентах. # SERVERUS_CA_DIR - папка CA, он создается при первом выпуске. ca.pem нужно добавить в доверенные на клиентах.
SERVERUS_CERTIFICATES = os.environ.get('SERVERUS_CERTIFICATES', 'letsencrypt') SERVERUS_CERTIFICATES = os.environ.get('SERVERUS_CERTIFICATES', 'letsencrypt')
+6
View File
@@ -1,5 +1,11 @@
from .project import * from .project import *
# фиксированный тестовый ключ: тестам не нужен .env
FIELD_ENCRYPTION_KEYS = ['mCWd2vrKpmM4e7AvnqBO2Kv5Yx5JmR1c-9pGVuVbQq0=']
# тесты не зависят от локального .env и docker
DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}}
SERVERUS_CERTIFICATES = 'letsencrypt'
# задачи не уходят в rabbitmq # задачи не уходят в rabbitmq
DRAMATIQ_BROKER = { DRAMATIQ_BROKER = {
**DRAMATIQ_BROKER, **DRAMATIQ_BROKER,
+1
View File
@@ -44,6 +44,7 @@ class BotWorker(threading.Thread):
def _poll(self, api): def _poll(self, api):
offset = Bot.objects.values_list('update_offset', flat=True).get(pk=self.bot_id) offset = Bot.objects.values_list('update_offset', flat=True).get(pk=self.bot_id)
# TODO: переписать на setWebhook
updates = api.get_updates( updates = api.get_updates(
offset=offset, timeout=POLL_TIMEOUT, long_polling_timeout=POLL_TIMEOUT, allowed_updates=ALLOWED_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] нужен интерфейс выпуска, отзыва и получения статистики ключей
- [x] проверить `xui` и `xui_inbound` на живом сервере (Debian 13, свой CA): установка, DoT и запасной DoH, ключи, подписка, socks5 и WARP, отказ нерабочего прокси - [x] проверить `xui` и `xui_inbound` на живом сервере (Debian 13, свой CA): установка, DoT и запасной DoH, ключи, подписка, socks5 и WARP, отказ нерабочего прокси
- [ ] проверить `release` и сертификат Let's Encrypt на сервере с публичным адресом - [x] `release` на локальном стенде
- [ ] выпуск и отзыв ключей в боте поверх `XuiApi` - [ ] проверить на сервере с публичным адресом: сертификат 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 key add|del|stats <name> --out <dir>
python -m serverus cert [<domain>] --out <dir> [--ca <dir>] python -m serverus cert [<domain>] --out <dir> [--ca <dir>]
python -m serverus outbound [<proxy>] --out <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 до начала настройки: Доступы (ключ, пароль root, порт ssh, отпечаток, панель) пишутся в <dir>/access.json до начала настройки:
bootstrap меняет пароль и порт ssh, без этого файла доступ к серверу можно потерять. bootstrap меняет пароль и порт ssh, без этого файла доступ к серверу можно потерять.
@@ -51,6 +51,7 @@ def main(argv=None) -> int:
release = commands.add_parser('release', help='вернуть сервер: вход по паролю на 22 порт, новый пароль root') release = commands.add_parser('release', help='вернуть сервер: вход по паролю на 22 порт, новый пароль root')
release.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui') release.add_argument('--out', type=Path, required=True, help='папка с доступами от команды xui')
release.add_argument('--password', default='', help='новый пароль root, без него случайный')
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.command == 'xui': if args.command == 'xui':
@@ -61,7 +62,7 @@ def main(argv=None) -> int:
return issue_certificate(args.domain, args.out, args.ca) return issue_certificate(args.domain, args.out, args.ca)
if args.command == 'outbound': if args.command == 'outbound':
return set_outbound(args.proxy, args.out) 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: 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 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()) 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) result = Server(load_host(access)).release(new_password)
report('release', result) report('release', result)
if not result.ok: if not result.ok:
return 1 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) save(out, access)
print(f"ssh: ssh -p 22 root@{access['address']}, root password: {new_password}") print(f"ssh: ssh -p 22 root@{access['address']}, root password: {new_password}")
return 0 return 0