489 lines
16 KiB
Python
489 lines
16 KiB
Python
"""Покупка и управление AmneziaWG 2.0 конфигами через Amnezia Web Panel."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import secrets
|
|
import string
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from remnawave import RemnawaveSDK
|
|
|
|
from services.awg_client import DEFAULT_PROTOCOL, AwgClient, AwgError
|
|
from services.db import Database
|
|
from services.xui_wg import subscription_is_active, _subscription_limits
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_AWG_CONFIGS = 10
|
|
SETTING_ENABLED = "awg_purchase_enabled"
|
|
SETTING_PRICE = "awg_config_price"
|
|
DEFAULT_PRICE = 0.0
|
|
|
|
|
|
def _truthy(value: str) -> bool:
|
|
return (value or "").strip().lower() in ("1", "true", "yes", "on", "да")
|
|
|
|
|
|
def _format_price(price: float) -> str:
|
|
if price <= 0:
|
|
return "бесплатно"
|
|
if price == int(price):
|
|
return f"{int(price)} ₽"
|
|
return f"{price:.2f} ₽"
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AwgPurchaseSettings:
|
|
enabled: bool = False
|
|
price: float = DEFAULT_PRICE
|
|
max_configs: int = MAX_AWG_CONFIGS
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"enabled": self.enabled,
|
|
"price": self.price,
|
|
"price_label": _format_price(self.price),
|
|
"max_configs": self.max_configs,
|
|
}
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class AwgActionResult:
|
|
ok: bool
|
|
message: str
|
|
balance: float = 0.0
|
|
config: dict[str, Any] | None = None
|
|
configs: list[dict[str, Any]] | None = None
|
|
|
|
|
|
def load_awg_purchase_settings(db: Database) -> AwgPurchaseSettings:
|
|
raw = db.get_settings_map([SETTING_ENABLED, SETTING_PRICE])
|
|
price_raw = (raw.get(SETTING_PRICE) or "").strip()
|
|
try:
|
|
price = float(price_raw) if price_raw else DEFAULT_PRICE
|
|
except ValueError:
|
|
price = DEFAULT_PRICE
|
|
return AwgPurchaseSettings(
|
|
enabled=_truthy(raw.get(SETTING_ENABLED, "")),
|
|
price=max(0.0, price),
|
|
max_configs=MAX_AWG_CONFIGS,
|
|
)
|
|
|
|
|
|
def save_awg_purchase_settings(
|
|
db: Database,
|
|
*,
|
|
enabled: bool | None = None,
|
|
price: float | None = None,
|
|
) -> AwgPurchaseSettings:
|
|
data: dict[str, str] = {}
|
|
if enabled is not None:
|
|
data[SETTING_ENABLED] = "1" if enabled else "0"
|
|
if price is not None:
|
|
data[SETTING_PRICE] = str(max(0.0, float(price)))
|
|
if data:
|
|
db.set_settings_map(data)
|
|
return load_awg_purchase_settings(db)
|
|
|
|
|
|
def _client_from_server(row: dict[str, Any]) -> AwgClient:
|
|
return AwgClient(
|
|
str(row["api_url"]),
|
|
api_token=str(row.get("api_token") or ""),
|
|
verify_ssl=bool(row.get("verify_ssl", True)),
|
|
)
|
|
|
|
|
|
def _new_client_name(telegram_id: int, remark: str = "") -> str:
|
|
if remark.strip():
|
|
return remark.strip()[:80]
|
|
suffix = "".join(
|
|
secrets.choice(string.ascii_lowercase + string.digits) for _ in range(4)
|
|
)
|
|
return f"awg_{telegram_id}_{suffix}"
|
|
|
|
|
|
def serialize_awg_config(
|
|
row: dict[str, Any], server: dict[str, Any] | None = None
|
|
) -> dict[str, Any]:
|
|
expire = row.get("expire_at")
|
|
return {
|
|
"id": int(row["id"]),
|
|
"server_id": int(row["server_id"]),
|
|
"server_name": (server or {}).get("name") or row.get("server_name") or "",
|
|
"client_id": row.get("client_id") or "",
|
|
"client_name": row.get("client_name") or "",
|
|
"remark": row.get("remark") or "",
|
|
"vpn_link": row.get("vpn_link") or "",
|
|
"config": row.get("config_text") or "",
|
|
"enabled": bool(row.get("enabled", True)),
|
|
"protocol": row.get("protocol") or DEFAULT_PROTOCOL,
|
|
"expire_at": expire.isoformat() if isinstance(expire, datetime) else expire,
|
|
"qr_url": f"/api/awg/configs/{int(row['id'])}/qr",
|
|
"created_at": row["created_at"].isoformat()
|
|
if isinstance(row.get("created_at"), datetime)
|
|
else row.get("created_at"),
|
|
}
|
|
|
|
|
|
def list_available_awg_servers(db: Database) -> list[dict[str, Any]]:
|
|
rows = db.list_awg_servers(enabled_only=True)
|
|
out: list[dict[str, Any]] = []
|
|
for r in rows:
|
|
if not str(r.get("api_token") or "").strip():
|
|
continue
|
|
out.append(
|
|
{
|
|
"id": int(r["id"]),
|
|
"name": r.get("name") or f"AWG #{r['id']}",
|
|
"protocol": r.get("protocol") or DEFAULT_PROTOCOL,
|
|
"panel_server_id": int(r.get("panel_server_id") or 0),
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def list_user_awg_configs(db: Database, telegram_id: int) -> list[dict[str, Any]]:
|
|
rows = db.list_awg_configs(telegram_id)
|
|
return [serialize_awg_config(r) for r in rows]
|
|
|
|
|
|
async def purchase_awg_config(
|
|
*,
|
|
db: Database,
|
|
sdk: RemnawaveSDK,
|
|
telegram_id: int,
|
|
server_id: int,
|
|
remark: str = "",
|
|
login: str | None = None,
|
|
) -> AwgActionResult:
|
|
settings = load_awg_purchase_settings(db)
|
|
bot_user = db.ensure_user(telegram_id)
|
|
balance = bot_user.balance
|
|
login = login or bot_user.login
|
|
|
|
if not settings.enabled:
|
|
return AwgActionResult(
|
|
ok=False,
|
|
message="Покупка AmneziaWG временно отключена.",
|
|
balance=balance,
|
|
)
|
|
|
|
active, reason = await subscription_is_active(
|
|
sdk, telegram_id, login=login, db=db
|
|
)
|
|
if not active:
|
|
return AwgActionResult(ok=False, message=reason, balance=balance)
|
|
|
|
count = db.count_awg_configs(telegram_id)
|
|
if count >= settings.max_configs:
|
|
return AwgActionResult(
|
|
ok=False,
|
|
message=f"Лимит: максимум {settings.max_configs} AWG-конфигов на аккаунт.",
|
|
balance=balance,
|
|
)
|
|
|
|
server = db.get_awg_server(server_id)
|
|
if not server or not server.get("enabled"):
|
|
return AwgActionResult(ok=False, message="Сервер недоступен.", balance=balance)
|
|
|
|
price = float(settings.price)
|
|
if price > 0:
|
|
ok, balance = db.try_charge(
|
|
telegram_id,
|
|
price,
|
|
kind="awg_config",
|
|
meta=f"awg:{server_id}",
|
|
)
|
|
if not ok:
|
|
return AwgActionResult(
|
|
ok=False,
|
|
message=(
|
|
f"Недостаточно средств.\n"
|
|
f"Нужно: <b>{_format_price(price)}</b>\n"
|
|
f"Баланс: <b>{balance:.0f} ₽</b>"
|
|
),
|
|
balance=balance,
|
|
)
|
|
|
|
client_name = _new_client_name(telegram_id, remark)
|
|
remark = (remark or "").strip()[:80] or client_name
|
|
protocol = str(server.get("protocol") or DEFAULT_PROTOCOL)
|
|
panel_sid = int(server.get("panel_server_id") or 0)
|
|
|
|
try:
|
|
expire_at, _traffic = await _subscription_limits(
|
|
sdk, telegram_id, login=login, db=db
|
|
)
|
|
async with _client_from_server(server) as client:
|
|
created = await client.add_connection(
|
|
panel_sid, name=client_name, protocol=protocol
|
|
)
|
|
client_id = str(created.get("client_id") or "")
|
|
config_text = str(created.get("config") or "")
|
|
vpn_link = str(created.get("vpn_link") or "")
|
|
if not vpn_link and config_text:
|
|
import base64
|
|
|
|
vpn_link = "vpn://" + base64.b64encode(
|
|
config_text.strip().encode("utf-8")
|
|
).decode("utf-8")
|
|
row = db.create_awg_config(
|
|
telegram_id=telegram_id,
|
|
server_id=server_id,
|
|
client_id=client_id,
|
|
client_name=client_name,
|
|
remark=remark,
|
|
config_text=config_text,
|
|
vpn_link=vpn_link,
|
|
protocol=protocol,
|
|
enabled=True,
|
|
expire_at=expire_at,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("AWG config create failed")
|
|
if price > 0:
|
|
balance = db.add_balance(
|
|
telegram_id,
|
|
price,
|
|
kind="refund",
|
|
meta=f"awg_refund:{server_id}",
|
|
)
|
|
return AwgActionResult(
|
|
ok=False,
|
|
message=f"Не удалось создать конфиг: {exc}",
|
|
balance=balance,
|
|
)
|
|
|
|
return AwgActionResult(
|
|
ok=True,
|
|
message="AmneziaWG-конфиг создан",
|
|
balance=balance,
|
|
config=serialize_awg_config(row, server),
|
|
)
|
|
|
|
|
|
async def set_awg_config_enabled(
|
|
*,
|
|
db: Database,
|
|
telegram_id: int,
|
|
config_id: int,
|
|
enabled: bool,
|
|
sdk: RemnawaveSDK | None = None,
|
|
login: str | None = None,
|
|
) -> AwgActionResult:
|
|
bot_user = db.ensure_user(telegram_id)
|
|
login = login or bot_user.login
|
|
row = db.get_awg_config(config_id, telegram_id=telegram_id)
|
|
if not row:
|
|
return AwgActionResult(ok=False, message="Конфиг не найден", balance=bot_user.balance)
|
|
|
|
if enabled:
|
|
if sdk is None:
|
|
return AwgActionResult(
|
|
ok=False,
|
|
message="Не удалось проверить подписку.",
|
|
balance=bot_user.balance,
|
|
)
|
|
active, reason = await subscription_is_active(
|
|
sdk, telegram_id, login=login, db=db
|
|
)
|
|
if not active:
|
|
return AwgActionResult(ok=False, message=reason, balance=bot_user.balance)
|
|
|
|
server = db.get_awg_server(int(row["server_id"]))
|
|
if not server:
|
|
return AwgActionResult(ok=False, message="Сервер не найден", balance=bot_user.balance)
|
|
try:
|
|
async with _client_from_server(server) as client:
|
|
await client.toggle_connection(
|
|
int(server["panel_server_id"]),
|
|
client_id=str(row["client_id"]),
|
|
enable=enabled,
|
|
protocol=str(row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL),
|
|
)
|
|
db.update_awg_config(config_id, enabled=enabled)
|
|
except AwgError as exc:
|
|
return AwgActionResult(ok=False, message=str(exc), balance=bot_user.balance)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("AWG toggle failed")
|
|
return AwgActionResult(ok=False, message=f"Ошибка: {exc}", balance=bot_user.balance)
|
|
refreshed = db.get_awg_config(config_id, telegram_id=telegram_id)
|
|
return AwgActionResult(
|
|
ok=True,
|
|
message="Включено" if enabled else "Выключено",
|
|
balance=bot_user.balance,
|
|
config=serialize_awg_config(refreshed, server) if refreshed else None,
|
|
)
|
|
|
|
|
|
async def delete_awg_config(
|
|
*,
|
|
db: Database,
|
|
telegram_id: int,
|
|
config_id: int,
|
|
) -> AwgActionResult:
|
|
row = db.get_awg_config(config_id, telegram_id=telegram_id)
|
|
if not row:
|
|
return AwgActionResult(ok=False, message="Конфиг не найден")
|
|
server = db.get_awg_server(int(row["server_id"]))
|
|
if server:
|
|
try:
|
|
async with _client_from_server(server) as client:
|
|
await client.remove_connection(
|
|
int(server["panel_server_id"]),
|
|
client_id=str(row["client_id"]),
|
|
protocol=str(
|
|
row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL
|
|
),
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
logger.exception("AWG remove on panel failed (deleting local anyway)")
|
|
db.delete_awg_config(config_id, telegram_id=telegram_id)
|
|
return AwgActionResult(ok=True, message="Удалено")
|
|
|
|
|
|
async def refresh_awg_config_payload(
|
|
*,
|
|
db: Database,
|
|
telegram_id: int,
|
|
config_id: int,
|
|
) -> AwgActionResult:
|
|
row = db.get_awg_config(config_id, telegram_id=telegram_id)
|
|
if not row:
|
|
return AwgActionResult(ok=False, message="Конфиг не найден")
|
|
server = db.get_awg_server(int(row["server_id"]))
|
|
if not server:
|
|
return AwgActionResult(ok=False, message="Сервер не найден")
|
|
try:
|
|
async with _client_from_server(server) as client:
|
|
data = await client.get_connection_config(
|
|
int(server["panel_server_id"]),
|
|
client_id=str(row["client_id"]),
|
|
protocol=str(
|
|
row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL
|
|
),
|
|
)
|
|
config_text = str(data.get("config") or row.get("config_text") or "")
|
|
vpn_link = str(data.get("vpn_link") or "")
|
|
if not vpn_link and config_text:
|
|
import base64
|
|
|
|
vpn_link = "vpn://" + base64.b64encode(
|
|
config_text.strip().encode("utf-8")
|
|
).decode("utf-8")
|
|
db.update_awg_config(
|
|
config_id,
|
|
config_text=config_text,
|
|
vpn_link=vpn_link or row.get("vpn_link") or "",
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.exception("AWG refresh failed")
|
|
return AwgActionResult(ok=False, message=f"Не удалось обновить: {exc}")
|
|
refreshed = db.get_awg_config(config_id, telegram_id=telegram_id)
|
|
return AwgActionResult(
|
|
ok=True,
|
|
message="Обновлено",
|
|
config=serialize_awg_config(refreshed, server) if refreshed else None,
|
|
)
|
|
|
|
|
|
async def sync_awg_configs_expiry(
|
|
*,
|
|
db: Database,
|
|
sdk: RemnawaveSDK,
|
|
telegram_id: int,
|
|
login: str | None = None,
|
|
restore_enabled: bool = False,
|
|
) -> None:
|
|
"""Обновляет expire_at у локальных AWG-конфигов по подписке Remnawave."""
|
|
rows = db.list_awg_configs(telegram_id)
|
|
if not rows:
|
|
return
|
|
bot_user = db.ensure_user(telegram_id)
|
|
login = login or bot_user.login
|
|
try:
|
|
expire_at, _ = await _subscription_limits(
|
|
sdk, telegram_id, login=login, db=db
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
return
|
|
for row in rows:
|
|
db.update_awg_config(int(row["id"]), expire_at=expire_at)
|
|
if restore_enabled and not row.get("enabled"):
|
|
server = db.get_awg_server(int(row["server_id"]))
|
|
if not server:
|
|
continue
|
|
try:
|
|
async with _client_from_server(server) as client:
|
|
await client.toggle_connection(
|
|
int(server["panel_server_id"]),
|
|
client_id=str(row["client_id"]),
|
|
enable=True,
|
|
protocol=str(
|
|
row.get("protocol")
|
|
or server.get("protocol")
|
|
or DEFAULT_PROTOCOL
|
|
),
|
|
)
|
|
db.update_awg_config(int(row["id"]), enabled=True)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning(
|
|
"AWG restore enable failed config=%s: %s",
|
|
row.get("id"),
|
|
exc,
|
|
)
|
|
|
|
|
|
async def _disable_awg_row(db: Database, row: dict[str, Any]) -> bool:
|
|
if not row.get("enabled"):
|
|
return False
|
|
server = db.get_awg_server(int(row["server_id"]))
|
|
if server:
|
|
try:
|
|
async with _client_from_server(server) as client:
|
|
await client.toggle_connection(
|
|
int(server["panel_server_id"]),
|
|
client_id=str(row["client_id"]),
|
|
enable=False,
|
|
protocol=str(
|
|
row.get("protocol") or server.get("protocol") or DEFAULT_PROTOCOL
|
|
),
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning(
|
|
"AWG auto-disable panel failed config=%s: %s",
|
|
row.get("id"),
|
|
exc,
|
|
)
|
|
db.update_awg_config(int(row["id"]), enabled=False)
|
|
return True
|
|
|
|
|
|
async def enforce_awg_configs_subscription(
|
|
*,
|
|
db: Database,
|
|
sdk: RemnawaveSDK,
|
|
telegram_id: int,
|
|
login: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Нет активной подписки → выключить все AWG-конфиги на панели и в БД."""
|
|
bot_user = db.ensure_user(telegram_id)
|
|
login = login or bot_user.login
|
|
active, reason = await subscription_is_active(
|
|
sdk, telegram_id, login=login, db=db
|
|
)
|
|
disabled = 0
|
|
if not active:
|
|
for row in db.list_awg_configs(telegram_id):
|
|
if await _disable_awg_row(db, row):
|
|
disabled += 1
|
|
return {
|
|
"subscription_active": active,
|
|
"reason": "" if active else reason,
|
|
"disabled": disabled,
|
|
}
|