Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions docs/ТЗ_CandyConnect_VPN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Техническое задание (ТЗ)
## Проект: CandyConnect VPN

## 1. Цель доработки
Обновить функциональность панели управления VPN в части протоколов OpenVPN и WireGuard, а также заменить тип подключения DNSTT на Amnezia. Доработки должны повысить стабильность выдачи клиентских конфигов, улучшить UX подключения WireGuard и добавить поддержку нового типа подключения в backend, frontend и инфраструктуре установки.

---

## 2. Проблемы текущей версии
1. **OpenVPN**
- Для новых клиентов не формируется/не выдается конфиг корректно.
- В карточке нового клиента отображается `openvpn.config`, но элемент не кликабелен и не позволяет скачать файл.
2. **WireGuard**
- Приватный ключ назначается автоматически сразу при создании клиента.
- Нет явного действия пользователя для получения QR-кода/данных авторизации после создания клиента.
3. **DNSTT**
- Протокол присутствует в системе, но должен быть полностью удален.
- Вместо DNSTT требуется внедрить **Amnezia**.

---

## 3. Объем работ

### 3.1 OpenVPN: исправление генерации и выдачи конфигов
#### Функциональные требования
1. При создании нового OpenVPN-клиента система обязана:
- генерировать валидный клиентский конфиг;
- сохранять его в хранилище (файловое/БД — согласно текущей архитектуре);
- отдавать ссылку/кнопку на скачивание в карточке клиента.
2. В карточке клиента элемент `openvpn.config` (или кнопка «Скачать конфиг») должен быть кликабельным.
3. По клику должен выполняться один из сценариев:
- скачивание файла с корректным MIME/именем файла;
- открытие модального окна с кнопкой скачивания (если так реализована UX-логика).
4. Для клиента, у которого конфиг отсутствует, должен отображаться понятный статус (например: «Конфиг не сгенерирован») и кнопка «Сгенерировать повторно» (опционально, но желательно).

#### Нефункциональные требования
- Время ответа API на выдачу ссылки/файла: до 2 сек при нормальной нагрузке.
- Корректная обработка ошибок (404, 500) с человекочитаемыми сообщениями в UI.

---

### 3.2 WireGuard: генерация QR по запросу
#### Функциональные требования
1. При создании нового WireGuard-клиента **не выполнять автогенерацию приватного ключа в интерфейсе как финальный шаг подключения**.
2. В профиле WireGuard-клиента после создания должна отображаться кнопка **«QR-код»**.
3. При нажатии кнопки:
- формируется (или загружается, если уже есть) конфиг клиента;
- генерируется QR-код на основе клиентского конфига;
- QR отображается в модальном окне/отдельном блоке;
- доступна кнопка «Скачать конфиг».
4. Должна быть защита от повторной гонки генерации (идемпотентность): повторные нажатия не должны ломать состояние клиента.

#### UX-требования
- Кнопка «QR-код» видна только для WireGuard-профилей.
- Во время генерации показывать индикатор загрузки.
- При ошибке — понятное сообщение (например: «Не удалось сгенерировать QR, попробуйте позже»).

---

### 3.3 Удаление DNSTT и внедрение Amnezia
#### Функциональные требования
1. Полностью удалить DNSTT из системы:
- backend-модели/enum/роуты;
- frontend-опции выбора типа подключения;
- скрипты установки и конфигурации;
- документацию и подсказки UI.
2. Добавить новый тип подключения **Amnezia**:
- в основные настройки системы;
- в настройки профилей;
- в API (создание/чтение/обновление профилей);
- в UI формы создания/редактирования.
3. Добавить установку необходимых пакетов для Amnezia в установочные скрипты (install/deploy/docker-образ при необходимости).
4. Реализовать проверку доступности зависимостей Amnezia после установки (health-check/валидация).

#### Инфраструктурные требования
- Новые пакеты должны устанавливаться без интерактивного ввода.
- Ошибки установки логируются и возвращаются в понятном виде.

---

## 4. Изменяемые компоненты (минимум)
- **Backend**: API клиентов, генерация конфигов, бизнес-логика протоколов, enum типов подключений.
- **Frontend (web-panel/client UI)**: карточка клиента, кнопки скачивания/QR, формы и настройки протоколов.
- **DevOps/Install**: install-скрипты, Dockerfile/docker-compose (если влияет), зависимости Amnezia.
- **Docs**: актуализация README/инструкций по подключению.

---

## 5. API/контракты (ожидаемые изменения)
1. Endpoint для скачивания OpenVPN-конфига должен стабильно возвращать файл.
2. Endpoint для WireGuard QR (новый или существующий) должен:
- принимать `clientId`;
- возвращать изображение/BASE64/URL QR;
- возвращать статус генерации.
3. В типах протоколов удалить `DNSTT`, добавить `AMNEZIA`.

> Точные пути endpoint и форматы определяются текущей архитектурой проекта, но обратная совместимость для неизмененных методов должна быть сохранена.

---

## 6. Критерии приемки

### 6.1 OpenVPN
- [ ] Новый OpenVPN-клиент получает рабочий конфиг.
- [ ] В карточке клиента есть кликабельная кнопка/ссылка скачивания конфига.
- [ ] Конфиг скачивается и импортируется в OpenVPN-клиент без ошибок формата.

### 6.2 WireGuard
- [ ] После создания WireGuard-клиента отображается кнопка «QR-код».
- [ ] При нажатии отображается валидный QR для авторизации/импорта.
- [ ] Пользователь может скачать конфиг из того же интерфейса.

### 6.3 Amnezia
- [ ] DNSTT отсутствует в UI, API и установке.
- [ ] Amnezia доступна в основных настройках и настройках профиля.
- [ ] Установочные скрипты ставят необходимые пакеты Amnezia.
- [ ] Создание профиля с типом Amnezia проходит успешно.

---

## 7. Тестирование
1. **Unit tests**
- генерация OpenVPN-конфига;
- генерация WireGuard-QR;
- валидация enum протоколов (без DNSTT, с Amnezia).
2. **Integration tests**
- создание клиента + скачивание OpenVPN-конфига;
- создание WireGuard-клиента + получение QR;
- создание/редактирование Amnezia-профиля.
3. **UI/E2E tests**
- кликабельность `openvpn.config`;
- отображение и открытие кнопки «QR-код»;
- отсутствие DNSTT и наличие Amnezia в селекторах.
4. **Smoke tests после деплоя**
- проверка установки зависимостей Amnezia;
- создание тестовых клиентов по каждому поддерживаемому типу.

---

## 8. Ограничения и риски
- Возможна миграция существующих данных, если DNSTT уже использовался в профилях.
- Возможны различия в пакетах Amnezia в зависимости от ОС/дистрибутива.
- Генерация QR должна учитывать безопасность: не логировать приватные ключи в открытом виде.

---

## 9. Ожидаемый результат
После выполнения доработок система CandyConnect VPN должна:
1. Корректно выдавать OpenVPN-конфиги новым клиентам.
2. Предоставлять WireGuard QR-код по нажатию кнопки в профиле клиента.
3. Полностью заменить DNSTT на Amnezia во всех слоях системы (UI/API/установка/настройки).
29 changes: 4 additions & 25 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ install_dependencies() {
ca-certificates gnupg \
openvpn easy-rsa \
strongswan strongswan-pki libcharon-extra-plugins \
xl2tpd wireguard wireguard-tools dante-server \
xl2tpd wireguard wireguard-tools amneziawg-tools dante-server \
openssh-server openssh-client

# Install Node.js (proper version)
Expand All @@ -97,30 +97,9 @@ install_dependencies() {
bash -c 'curl -sL https://raw.githubusercontent.com/XTLS/Xray-install/main/install-release.sh | bash -s -- install' || warn "Xray installation failed"
fi

# Install DNSTT-server (Robust install with checksum verification)
info "Installing DNSTT..."
if [ ! -f "/usr/local/bin/dnstt-server" ]; then
DURL="https://dnstt.network"
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then DARCH="amd64"; elif [ "$ARCH" = "aarch64" ]; then DARCH="arm64"; else DARCH="386"; fi
DFILE="dnstt-server-linux-${DARCH}"

info "Downloading DNSTT from ${DURL}/${DFILE}..."
curl -L -o "/tmp/${DFILE}" "${DURL}/${DFILE}"
curl -L -s -o "/tmp/SHA256SUMS" "${DURL}/SHA256SUMS"

cd /tmp
if sha256sum -c <(grep "${DFILE}" SHA256SUMS) 2>/dev/null; then
mv "/tmp/${DFILE}" "/usr/local/bin/dnstt-server"
chmod +x "/usr/local/bin/dnstt-server"
info "DNSTT verified and installed"
else
warn "DNSTT checksum verification failed! Attempting insecure install..."
wget -qO /usr/local/bin/dnstt-server "${DURL}/${DFILE}" || warn "DNSTT download failed"
chmod +x /usr/local/bin/dnstt-server
fi
cd - >/dev/null
fi
info "Installing Amnezia tools..."
command -v amneziawg >/dev/null 2>&1 || warn "amneziawg binary not found; ensure repository package is available for your distro"


# Enable and start Redis
systemctl enable redis-server || true
Expand Down
2 changes: 1 addition & 1 deletion server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,6 @@ def _load_or_create_jwt_secret() -> str:

# ── Protocols ──
SUPPORTED_PROTOCOLS = [
"v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "dnstt",
"v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "amnezia",
"slipstream", "trusttunnel",
]
18 changes: 9 additions & 9 deletions server/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async def init_db():
await r.hset(K_CONFIGS, section, json.dumps(data))

# Default core statuses
for proto in ["v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "dnstt", "slipstream", "trusttunnel"]:
for proto in ["v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "amnezia", "slipstream", "trusttunnel"]:
if not await r.hexists(K_CORE_STATUS, proto):
# All protocols except slipstream and trusttunnel are running by default
status = "running" if proto not in ["slipstream", "trusttunnel"] else "stopped"
Expand All @@ -124,7 +124,7 @@ async def init_db():
"comment": "Default administrator client",
"enabled": True,
"protocols": {p: (p not in ["slipstream", "trusttunnel"]) for p in [
"v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "dnstt", "slipstream", "trusttunnel"
"v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "amnezia", "slipstream", "trusttunnel"
]}
}
await create_client(admin_client_data)
Expand Down Expand Up @@ -211,12 +211,12 @@ def _default_core_configs() -> dict:
"remote_range": "10.20.0.10-10.20.0.250",
"dns": "1.1.1.1", "mtu": 1400, "mru": 1400,
},
"dnstt": {
"listen_port": 5300,
"domain": f"dns.{PANEL_DOMAIN}",
"amnezia": {
"port": 51830,
"transport": "udp",
"obfuscation": "on",
"domain": f"amnezia.{PANEL_DOMAIN}",
"public_key": "",
Comment on lines +214 to 219
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

f"amnezia.{PANEL_DOMAIN}" produces an invalid domain when PANEL_DOMAIN is empty.

The default value of PANEL_DOMAIN is often an empty string (set via env-var). When that happens, the seed value becomes "amnezia." — a syntactically invalid hostname that will be persisted to Redis on first boot until the admin overrides it.

🐛 Proposed fix
-        "domain": f"amnezia.{PANEL_DOMAIN}",
+        "domain": f"amnezia.{PANEL_DOMAIN}" if PANEL_DOMAIN else "",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"amnezia": {
"port": 51830,
"transport": "udp",
"obfuscation": "on",
"domain": f"amnezia.{PANEL_DOMAIN}",
"public_key": "",
"amnezia": {
"port": 51830,
"transport": "udp",
"obfuscation": "on",
"domain": f"amnezia.{PANEL_DOMAIN}" if PANEL_DOMAIN else "",
"public_key": "",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/database.py` around lines 214 - 219, The domain value
f"amnezia.{PANEL_DOMAIN}" can produce "amnezia." when PANEL_DOMAIN is empty;
change the assignment for the "amnezia" service to produce a valid hostname by
checking PANEL_DOMAIN and using either "amnezia" when PANEL_DOMAIN is
falsy/empty or f"amnezia.{PANEL_DOMAIN}" when it is set (i.e., replace the
direct f-string with a conditional expression that uses PANEL_DOMAIN only when
non-empty), ensuring the sanitized value is what gets persisted to Redis.

"tunnel_mode": "ssh",
"mtu": 1232,
},
"slipstream": {
"port": 8388, "method": "aes-256-cfb",
Expand Down Expand Up @@ -365,7 +365,7 @@ async def create_client(data: dict) -> dict:
"created_at": now,
"expires_at": _calc_expiry(now, data.get("time_limit", {"mode": "days", "value": 30})),
"protocols": data.get("protocols", {p: True for p in [
"v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "dnstt", "slipstream", "trusttunnel"
"v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "amnezia", "slipstream", "trusttunnel"
]}),
"protocol_data": data.get("protocol_data", {}),
"last_connected_ip": None,
Expand Down Expand Up @@ -413,7 +413,7 @@ async def delete_client(client_id: str) -> bool:
await r.hdel(K_CLIENTS, client_id)
await r.hdel(K_CLIENT_IDX, client.get("username", ""))
# Clean up traffic keys
for proto in ["v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "dnstt", "slipstream", "trusttunnel"]:
for proto in ["v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "amnezia", "slipstream", "trusttunnel"]:
await r.hdel(K_TRAFFIC, f"{client_id}:{proto}")
await _add_log("INFO", "System", f"Client '{client.get('username', '')}' deleted")
return True
Expand Down
59 changes: 59 additions & 0 deletions server/protocols/amnezia.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""CandyConnect - Amnezia Protocol Manager."""
import time
from protocols.base import BaseProtocol
from database import get_core_status, set_core_status, add_log


class AmneziaProtocol(BaseProtocol):
PROTOCOL_ID = "amnezia"
PROTOCOL_NAME = "Amnezia"
DEFAULT_PORT = 51830

async def install(self) -> bool:
try:
await add_log("INFO", self.PROTOCOL_NAME, "Installing Amnezia dependencies...")
await self._apt_install("amneziawg-tools")
return True
except Exception as e:
await add_log("ERROR", self.PROTOCOL_NAME, f"Installation error: {e}")
return False
Comment on lines +12 to +19
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

install() ignores _apt_install() return value — always reports success on failure.

_apt_install() returns False (without raising) when apt fails after all retries. Because the return value is discarded, install() always returns True even when the package wasn't installed.

🐛 Proposed fix
 async def install(self) -> bool:
     try:
         await add_log("INFO", self.PROTOCOL_NAME, "Installing Amnezia dependencies...")
-        await self._apt_install("amneziawg-tools")
-        return True
+        ok = await self._apt_install("amneziawg-tools")
+        if not ok:
+            await add_log("ERROR", self.PROTOCOL_NAME, "Failed to install amneziawg-tools")
+        return ok
     except Exception as e:
         await add_log("ERROR", self.PROTOCOL_NAME, f"Installation error: {e}")
         return False
🧰 Tools
🪛 Ruff (0.15.2)

[warning] 16-16: Consider moving this statement to an else block

(TRY300)


[warning] 17-17: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/protocols/amnezia.py` around lines 12 - 19, install() currently
discards the boolean result of self._apt_install("amneziawg-tools") and always
returns True; update install() to capture the return value from
self._apt_install, and if it is False await add_log("ERROR", self.PROTOCOL_NAME,
"Installation failed for amneziawg-tools") (or include returned/failure context)
and return False; only return True when self._apt_install returns True, keeping
the existing exception handling that logs and returns False on exceptions.


async def start(self) -> bool:
status = await get_core_status(self.PROTOCOL_ID)
await set_core_status(self.PROTOCOL_ID, {
"status": "running",
"pid": status.get("pid"),
"started_at": int(time.time()),
"version": status.get("version", ""),
})
await add_log("INFO", self.PROTOCOL_NAME, "Amnezia marked as running")
return True

async def stop(self) -> bool:
status = await get_core_status(self.PROTOCOL_ID)
await set_core_status(self.PROTOCOL_ID, {
"status": "stopped",
"pid": None,
"started_at": None,
"version": status.get("version", ""),
})
await add_log("INFO", self.PROTOCOL_NAME, "Amnezia stopped")
return True

async def is_running(self) -> bool:
status = await get_core_status(self.PROTOCOL_ID)
return status.get("status") == "running"

async def add_client(self, username: str, client_data: dict) -> dict:
return {"username": username, "status": "ready"}

async def remove_client(self, username: str, protocol_data: dict):
return None

async def get_client_config(self, username: str, server_ip: str, protocol_data: dict, config_id: str = None) -> dict:
return {
"type": "amnezia",
"server": server_ip,
"port": self.DEFAULT_PORT,
"username": username,
Comment on lines +53 to +58
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

get_client_config() always returns the hardcoded DEFAULT_PORT instead of the configured port.

_default_core_configs() stores an amnezia.port in Redis, and the admin can change it. _get_protocol_port in manager.py (line 287) already reads from DB, but this method ignores DB config entirely and always returns 51830.

🐛 Proposed fix
+from database import get_core_config
+
 async def get_client_config(self, username: str, server_ip: str, protocol_data: dict, config_id: str | None = None) -> dict:
+    cfg = await get_core_config(self.PROTOCOL_ID) or {}
+    port = int(cfg.get("port", self.DEFAULT_PORT))
     return {
         "type": "amnezia",
         "server": server_ip,
-        "port": self.DEFAULT_PORT,
+        "port": port,
         "username": username,
     }
🧰 Tools
🪛 Ruff (0.15.2)

[warning] 53-53: Unused method argument: protocol_data

(ARG002)


[warning] 53-53: Unused method argument: config_id

(ARG002)


[warning] 53-53: PEP 484 prohibits implicit Optional

Convert to T | None

(RUF013)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/protocols/amnezia.py` around lines 53 - 58, get_client_config
currently always returns self.DEFAULT_PORT (DEFAULT_PORT) instead of using the
configured port; update get_client_config in amnezia.py to use the port from the
configuration (prefer protocol_data["port"] or protocol_data.get("amnezia.port")
/ protocol_data.get("port")) and fall back to self.DEFAULT_PORT if not present,
or alternatively call the central reader _get_protocol_port from manager.py if
available; ensure you reference get_client_config and DEFAULT_PORT in the change
so the returned dict uses the resolved port value rather than the hardcoded
51830.

}
10 changes: 5 additions & 5 deletions server/protocols/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from protocols.openvpn import OpenVPNProtocol
from protocols.ikev2 import IKEv2Protocol
from protocols.l2tp import L2TPProtocol
from protocols.dnstt import DNSTTProtocol
from protocols.amnezia import AmneziaProtocol
from protocols.slipstream import SlipStreamProtocol
from protocols.trusttunnel import TrustTunnelProtocol

Expand All @@ -39,7 +39,7 @@ def __init__(self) -> None:
"openvpn": OpenVPNProtocol(),
"ikev2": IKEv2Protocol(),
"l2tp": L2TPProtocol(),
"dnstt": DNSTTProtocol(),
"amnezia": AmneziaProtocol(),
"slipstream": SlipStreamProtocol(),
"trusttunnel": TrustTunnelProtocol(),
}
Expand All @@ -53,7 +53,7 @@ async def auto_start_protocols(self) -> None:
logger = logging.getLogger("candyconnect")

# 1. Start Protocols
for pid in ["v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "dnstt"]:
for pid in ["v2ray", "wireguard", "openvpn", "ikev2", "l2tp", "amnezia"]:
try:
installed = await self.install_protocol(pid)
started = await self.start_protocol(pid)
Expand Down Expand Up @@ -283,8 +283,8 @@ async def _get_protocol_port(self, pid: str) -> int:
return int(cfg.get("port", default_port))
elif pid == "l2tp":
return int(cfg.get("port", default_port))
elif pid == "dnstt":
return int(cfg.get("listen_port", default_port))
elif pid == "amnezia":
return int(cfg.get("port", default_port))
elif pid in ("slipstream", "trusttunnel"):
return int(cfg.get("port", default_port))
elif pid == "v2ray":
Expand Down
Loading