From a6716f44b6241660703eba20005971b4e6ad5480 Mon Sep 17 00:00:00 2001 From: malliancea Date: Tue, 24 Feb 2026 17:15:10 +0300 Subject: [PATCH 1/2] docs: add technical specification for CandyConnect VPN improvements --- "docs/\320\242\320\227_CandyConnect_VPN.md" | 151 ++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 "docs/\320\242\320\227_CandyConnect_VPN.md" diff --git "a/docs/\320\242\320\227_CandyConnect_VPN.md" "b/docs/\320\242\320\227_CandyConnect_VPN.md" new file mode 100644 index 0000000..3b4e0ff --- /dev/null +++ "b/docs/\320\242\320\227_CandyConnect_VPN.md" @@ -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/установка/настройки). From 1c8d96c2b8d6ef48457f78e00f157857f25f5a9b Mon Sep 17 00:00:00 2001 From: malliancea Date: Tue, 24 Feb 2026 17:32:30 +0300 Subject: [PATCH 2/2] feat: implement vpn protocol fixes for openvpn wireguard and amnezia --- install.sh | 29 ++---------- server/config.py | 2 +- server/database.py | 18 ++++---- server/protocols/amnezia.py | 59 ++++++++++++++++++++++++ server/protocols/manager.py | 10 ++-- server/protocols/wireguard.py | 71 +++++++++++++++++++++-------- server/requirements.txt | 1 + server/routes/client_api.py | 43 ++++++++--------- server/routes/panel_api.py | 69 ++++++++++++++++++++++++++++ web-panel/pages/ClientsPage.tsx | 67 +++++++++++++++++++++++---- web-panel/pages/CoreConfigsPage.tsx | 16 +++---- web-panel/pages/DashboardPage.tsx | 2 +- web-panel/services/api.ts | 26 +++++++++-- web-panel/utils/format.tsx | 4 +- 14 files changed, 310 insertions(+), 107 deletions(-) create mode 100644 server/protocols/amnezia.py diff --git a/install.sh b/install.sh index 10337d1..42cf989 100644 --- a/install.sh +++ b/install.sh @@ -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) @@ -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 diff --git a/server/config.py b/server/config.py index 2ddced2..0b3f03c 100644 --- a/server/config.py +++ b/server/config.py @@ -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", ] diff --git a/server/database.py b/server/database.py index 4c1893e..edd8882 100644 --- a/server/database.py +++ b/server/database.py @@ -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" @@ -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) @@ -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": "", - "tunnel_mode": "ssh", - "mtu": 1232, }, "slipstream": { "port": 8388, "method": "aes-256-cfb", @@ -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, @@ -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 diff --git a/server/protocols/amnezia.py b/server/protocols/amnezia.py new file mode 100644 index 0000000..66bc28e --- /dev/null +++ b/server/protocols/amnezia.py @@ -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 + + 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, + } diff --git a/server/protocols/manager.py b/server/protocols/manager.py index 786d4e9..3197002 100644 --- a/server/protocols/manager.py +++ b/server/protocols/manager.py @@ -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 @@ -39,7 +39,7 @@ def __init__(self) -> None: "openvpn": OpenVPNProtocol(), "ikev2": IKEv2Protocol(), "l2tp": L2TPProtocol(), - "dnstt": DNSTTProtocol(), + "amnezia": AmneziaProtocol(), "slipstream": SlipStreamProtocol(), "trusttunnel": TrustTunnelProtocol(), } @@ -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) @@ -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": diff --git a/server/protocols/wireguard.py b/server/protocols/wireguard.py index 4e38a23..779c6c9 100644 --- a/server/protocols/wireguard.py +++ b/server/protocols/wireguard.py @@ -174,41 +174,67 @@ async def get_traffic(self) -> dict: return {"in": total_rx, "out": total_tx} async def add_client(self, username: str, client_data: dict) -> dict: - """Create a new WireGuard peer for a client.""" + """Register WireGuard client placeholder. + + Real keys/config are generated only on explicit request (QR/config download) + to support user-driven provisioning flow. + """ + existing_privkey = (client_data or {}).get("private_key") + existing_pubkey = (client_data or {}).get("public_key") + existing_addr = (client_data or {}).get("address") + if existing_privkey and existing_pubkey and existing_addr: + config = await get_core_config("wireguard") or {} + return { + "private_key": existing_privkey, + "public_key": existing_pubkey, + "address": existing_addr, + "server_public_key": config.get("public_key", ""), + "generated_on_demand": True, + } + + return { + "generated_on_demand": False, + "status": "pending_qr_generation", + } + + async def ensure_client_credentials(self, username: str, protocol_data: dict | None = None) -> dict: + """Create WG keys/address lazily and return updated protocol_data.""" + protocol_data = protocol_data or {} + if protocol_data.get("private_key") and protocol_data.get("public_key") and protocol_data.get("address"): + return protocol_data + client_privkey = "" client_pubkey = "" - - # 1. Generate client keys rc, client_privkey, _ = await self._run_cmd("wg genkey") - if rc == 0: - proc = await asyncio.create_subprocess_shell( - "wg pubkey", - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - ) - pub_out, _ = await proc.communicate(client_privkey.encode()) - client_pubkey = pub_out.decode().strip() + if rc != 0 or not client_privkey: + return protocol_data + + proc = await asyncio.create_subprocess_shell( + "wg pubkey", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + ) + pub_out, _ = await proc.communicate(client_privkey.encode()) + client_pubkey = pub_out.decode().strip() - # 2. Get server config - config = await get_core_config("wireguard") - name = "wg0" - - # 3. Choose next IP (random for now within the 10.66.66.0/24 subnet) import random last_octet = random.randint(2, 254) client_address = f"10.66.66.{last_octet}/32" - # 4. Add peer to running interface await self._run_cmd( - f"sudo wg set {name} peer {client_pubkey} allowed-ips {client_address}", + f"sudo wg set wg0 peer {client_pubkey} allowed-ips {client_address}", check=False, ) - + + config = await get_core_config("wireguard") or {} return { - "private_key": client_privkey, + **protocol_data, + "private_key": client_privkey.strip(), "public_key": client_pubkey, "address": client_address, "server_public_key": config.get("public_key", ""), + "generated_on_demand": True, + "status": "ready", } async def remove_client(self, username: str, protocol_data: dict): @@ -228,6 +254,11 @@ async def get_client_config(self, username: str, server_ip: str, protocol_data: # Build full WG config string for convenience address = protocol_data.get("address", "") privkey = protocol_data.get("private_key", "") + if not address or not privkey: + return { + "type": "wireguard", + "status": "pending_qr_generation", + } pubkey = config.get("public_key", "") port = config.get("listen_port", 51820) diff --git a/server/requirements.txt b/server/requirements.txt index 2e6f1ef..7af6d17 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -10,3 +10,4 @@ aiofiles==24.1.0 python-multipart==0.0.9 httpx==0.27.2 paramiko==3.5.0 +qrcode==7.4.2 diff --git a/server/routes/client_api.py b/server/routes/client_api.py index ef028ee..e506979 100644 --- a/server/routes/client_api.py +++ b/server/routes/client_api.py @@ -100,7 +100,7 @@ async def get_protocols(payload=Depends(auth.require_client)): result = [] icon_map = { "v2ray": "⚡", "wireguard": "🛡️", "openvpn": "🔒", - "ikev2": "🔐", "l2tp": "📡", "dnstt": "🌐", + "ikev2": "🔐", "l2tp": "📡", "amnezia": "🌐", "slipstream": "💨", "trusttunnel": "🏰", } for core in cores: @@ -323,29 +323,26 @@ def is_enabled(proto_name: str) -> bool: "extraData": {"username": client["username"]} }) - # 6. DNSTT - if is_enabled("dnstt"): - dnstt_cfg = await db.get_core_config("dnstt") - port = int((dnstt_cfg or {}).get("listen_port", 5300)) - domain = (dnstt_cfg or {}).get("domain", "dns.candyconnect.io") - pdata = protocol_data.get("dnstt", {}) - ssh_user = pdata.get("ssh_username", f"dnstt_{client['username']}") - + # 6. Amnezia + if is_enabled("amnezia"): + amnezia_cfg = await db.get_core_config("amnezia") + port = int((amnezia_cfg or {}).get("port", 51830)) + domain = (amnezia_cfg or {}).get("domain", f"amnezia.{server_ip}") + configs.append({ - "id": "dnstt-1", - "name": "DNSTT Tunnel", - "protocol": "DNSTT", - "transport": "dns", + "id": "amnezia-1", + "name": "Amnezia", + "protocol": "Amnezia", + "transport": (amnezia_cfg or {}).get("transport", "udp"), "security": "obfs", "address": server_ip, "port": port, - "configLink": f"dnstt://{ssh_user}@{domain}:{port}", - "icon": "🌐", + "configLink": f"amnezia://{domain}:{port}", + "icon": "🧊", "extraData": { - "ssh_username": ssh_user, - "ssh_password": pdata.get("ssh_password", ""), - "public_key": (dnstt_cfg or {}).get("public_key", ""), - "domain": domain + "domain": domain, + "public_key": (amnezia_cfg or {}).get("public_key", ""), + "obfuscation": (amnezia_cfg or {}).get("obfuscation", "on"), } }) @@ -401,7 +398,7 @@ async def get_protocol_config(protocol: str, payload=Depends(auth.require_client "vless": "v2ray", "vmess": "v2ray", "trojan": "v2ray", "shadowsocks": "v2ray", "wireguard": "wireguard", "openvpn": "openvpn", "ikev2": "ikev2", "l2tp": "l2tp", - "dnstt": "dnstt", "slipstream": "slipstream", + "amnezia": "amnezia", "slipstream": "slipstream", "trusttunnel": "trusttunnel", } @@ -567,7 +564,7 @@ async def ping_config(config_id: str, payload=Depends(auth.require_client)): "vless": "v2ray", "vmess": "v2ray", "trojan": "v2ray", "shadowsocks": "v2ray", "wireguard": "wireguard", "openvpn": "openvpn", "ikev2": "ikev2", "l2tp": "l2tp", - "dnstt": "dnstt", "slipstream": "slipstream", + "amnezia": "amnezia", "slipstream": "slipstream", "trusttunnel": "trusttunnel", } @@ -590,7 +587,7 @@ async def ping_config(config_id: str, payload=Depends(auth.require_client)): "openvpn": random.uniform(20, 80), "ikev2": random.uniform(15, 50), "l2tp": random.uniform(25, 90), - "dnstt": random.uniform(50, 200), + "amnezia": random.uniform(50, 200), "slipstream": random.uniform(15, 60), "trusttunnel": random.uniform(20, 70), } @@ -644,7 +641,7 @@ async def ping_all_configs(payload=Depends(auth.require_client)): protocol_overhead = { "v2ray": (10, 60), "wireguard": (5, 30), "openvpn": (20, 80), - "ikev2": (15, 50), "l2tp": (25, 90), "dnstt": (50, 200), + "ikev2": (15, 50), "l2tp": (25, 90), "amnezia": (50, 200), "slipstream": (15, 60), "trusttunnel": (20, 70), } diff --git a/server/routes/panel_api.py b/server/routes/panel_api.py index 81bd332..72e23bf 100644 --- a/server/routes/panel_api.py +++ b/server/routes/panel_api.py @@ -8,6 +8,10 @@ import paramiko import time +from fastapi.responses import PlainTextResponse +import base64 + + import database as db import auth from config import SUPPORTED_PROTOCOLS @@ -143,6 +147,70 @@ async def update_client(id: str, req: UpdateClientRequest, user=Depends(auth.req except Exception as e: raise HTTPException(status_code=500, detail=str(e)) +@router.get("/clients/{id}/openvpn-config") +async def download_openvpn_config(id: str, user=Depends(auth.require_admin)): + client = await db.get_client(id) + if not client: + raise HTTPException(status_code=404, detail="Client not found") + + panel_cfg = await db.get_core_config("candyconnect") or {} + server_ip = panel_cfg.get("server_ip") or (await get_server_info()).get("ip", "") + p_mgr = protocol_manager.get_protocol("openvpn") + pdata_ovpn = (client.get("protocol_data", {}) or {}).get("openvpn", {}) + cfg = await p_mgr.get_client_config(client["username"], server_ip, pdata_ovpn) + ovpn = cfg.get("ovpn_config") if isinstance(cfg, dict) else None + if not ovpn: + raise HTTPException(status_code=404, detail="OpenVPN config not found") + return PlainTextResponse( + content=ovpn, + headers={"Content-Disposition": f'attachment; filename="{client["username"]}.ovpn"'}, + media_type="application/x-openvpn-profile", + ) + + +@router.post("/clients/{id}/wireguard-qr") +async def generate_wireguard_qr(id: str, user=Depends(auth.require_admin)): + client = await db.get_client(id) + if not client: + raise HTTPException(status_code=404, detail="Client not found") + + panel_cfg = await db.get_core_config("candyconnect") or {} + server_ip = panel_cfg.get("server_ip") or (await get_server_info()).get("ip", "") + + pdata = (client.get("protocol_data", {}) or {}).get("wireguard", {}) + wg = protocol_manager.get_protocol("wireguard") + pdata = await wg.ensure_client_credentials(client["username"], pdata) + all_pd = client.get("protocol_data", {}) or {} + all_pd["wireguard"] = pdata + await db.update_client(client["id"], {"protocol_data": all_pd}) + + cfg = await wg.get_client_config(client["username"], server_ip, pdata) + wg_config = (cfg or {}).get("wg_config", "") + if not wg_config: + raise HTTPException(status_code=500, detail="Failed to generate WireGuard config") + + qr_data_url = "" + try: + import qrcode + from io import BytesIO + img = qrcode.make(wg_config) + buf = BytesIO() + img.save(buf, format="PNG") + qr_data_url = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() + except Exception: + qr_data_url = "" + + return { + "success": True, + "message": "WireGuard config generated", + "data": { + "wg_config": wg_config, + "qr_data_url": qr_data_url, + "status": "ready", + }, + } + + @router.delete("/clients/{id}") async def delete_client(id: str, user=Depends(auth.require_admin)): client = await db.get_client(id) @@ -330,6 +398,7 @@ async def restart_panel(user=Depends(auth.require_admin)): # Graceful shutdown after returning response def shutdown(): import time + time.sleep(1) os.kill(os.getpid(), signal.SIGTERM) diff --git a/web-panel/pages/ClientsPage.tsx b/web-panel/pages/ClientsPage.tsx index b1dbc65..bbdb324 100644 --- a/web-panel/pages/ClientsPage.tsx +++ b/web-panel/pages/ClientsPage.tsx @@ -1,12 +1,12 @@ import React, { useState, useEffect } from 'react'; -import { getClients, createClient, updateClient, deleteClient as apiDeleteClient, type Client, type ClientProtocols } from '../services/api'; +import { getClients, createClient, updateClient, deleteClient as apiDeleteClient, downloadOpenVpnConfig, generateWireGuardQr, type Client, type ClientProtocols } from '../services/api'; import { formatClientTraffic, getTrafficPercent, protocolName, protocolIcon } from '../utils/format'; import ProgressBar from '../components/ProgressBar'; import Modal from '../components/Modal'; import { useNotify } from '../components/Notification'; import { Users, Search, ClipboardList, Pencil, Trash2, CheckCircle2, XCircle, Plus, AlertTriangle, PauseCircle, Clock, ArrowDownUp, Loader2, Activity } from 'lucide-react'; -const ALL_PROTOCOLS = ['v2ray', 'wireguard', 'openvpn', 'ikev2', 'l2tp', 'dnstt', 'slipstream', 'trusttunnel'] as const; +const ALL_PROTOCOLS = ['v2ray', 'wireguard', 'openvpn', 'ikev2', 'l2tp', 'amnezia', 'slipstream', 'trusttunnel'] as const; // Helper to adapt snake_case API client to display const fmtTimeLimit = (c: Client) => { @@ -34,6 +34,8 @@ const ClientsPage: React.FC = () => { const [confirmDelete, setConfirmDelete] = useState(null); const [selectedGroup, setSelectedGroup] = useState('All'); const [saving, setSaving] = useState(false); + const [wgQrData, setWgQrData] = useState<{ qr_data_url: string; wg_config: string } | null>(null); + const [wgQrLoading, setWgQrLoading] = useState(false); // Form state const [fUsername, setFUsername] = useState(''); @@ -136,6 +138,42 @@ const ClientsPage: React.FC = () => { } }; + + + const handleDownloadOpenVpn = async (client: Client) => { + try { + const content = await downloadOpenVpnConfig(client.id); + const blob = new Blob([content], { type: 'application/x-openvpn-profile' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${client.username}.ovpn`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + notify('OpenVPN config downloaded', 'success'); + } catch (e: any) { + notify(e.message || 'Failed to download OpenVPN config', 'error'); + } + }; + + const handleGenerateWireGuardQr = async (client: Client) => { + try { + setWgQrLoading(true); + const data = await generateWireGuardQr(client.id); + setWgQrData({ qr_data_url: data.qr_data_url, wg_config: data.wg_config }); + await fetchClients(); + const fresh = await getClients(); + const updated = fresh.find(c => c.id === client.id); + if (updated) setDetailClient(updated); + } catch (e: any) { + notify(e.message || 'Failed to generate WireGuard QR', 'error'); + } finally { + setWgQrLoading(false); + } + }; + const showForm = isAddMode || !!editClient; if (loading) return
; @@ -206,7 +244,7 @@ const ClientsPage: React.FC = () => { {/* Detail Modal */} - {detailClient?.username || ''}} onClose={() => setDetailClient(null)} wide> + {detailClient?.username || ''}} onClose={() => { setDetailClient(null); setWgQrData(null); }} wide> {detailClient && (
{[ @@ -267,14 +305,17 @@ const ClientsPage: React.FC = () => { {protocolIcon(pid)} {protocolName(pid)}

{pid === 'v2ray' &&

UUID: {data.uuid}

} - {pid === 'wireguard' &&

Public Key: {data.public_key}

} - {pid === 'dnstt' && ( + {pid === 'wireguard' && (<> +

Public Key: {data.public_key || 'Not generated yet'}

+ + )} + {pid === 'amnezia' && ( <> -

SSH User: {data.ssh_username}

-

SSH Pass: {data.ssh_password}

+

Domain: {data.domain || '-'}

+

Obfuscation: {data.obfuscation || 'on'}

)} - {pid === 'openvpn' &&

Config: {detailClient.username}.ovpn available

} + {pid === 'openvpn' &&

Config:

} {pid === 'ikev2' &&

Auth: EAP-MSCHAPv2 Active

} {pid === 'l2tp' && data.psk &&

IPSec PSK: {data.psk}

} {['slipstream', 'trusttunnel'].includes(pid) &&

Experimental protocol

} @@ -286,6 +327,16 @@ const ClientsPage: React.FC = () => { )} + WireGuard QR} onClose={() => setWgQrData(null)}> + {wgQrData && ( +
+ {wgQrData.qr_data_url ? WireGuard QR :

QR image unavailable. Use config text below.

} +