chore: сделал
This commit is contained in:
1 parent
602199d510
commit
45e7a1d22f
25 files changed
+1164
-113
No files matched your search
@@ -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)')
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||||
|
'Точка работает с прежними настройками.'
|
||||||
|
)
|
||||||
@@ -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])
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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)
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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, ''))
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user