Files

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,
}