Add reverse proxy masking and Cloudflare panel SSL via DNS-01.
Introduce Caddy-based reverse proxy with decoy site and DPI masking, plus automatic Let's Encrypt certificate issuance for the panel through Cloudflare API without binding port 443. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+404
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
Telegram bot for Amnezia Web Panel.
|
||||
Uses raw Telegram Bot API via httpx — no library version conflicts.
|
||||
Runs as a background asyncio task alongside the FastAPI app.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Global state
|
||||
# ----------------------------------------------------------------------- #
|
||||
_bot_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
def is_running() -> bool:
|
||||
return _bot_task is not None and not _bot_task.done()
|
||||
|
||||
|
||||
def launch_bot(token: str, load_data_fn: Callable, generate_vpn_link_fn: Callable):
|
||||
global _bot_task
|
||||
_bot_task = asyncio.create_task(
|
||||
_run_bot(token, load_data_fn, generate_vpn_link_fn),
|
||||
name="telegram_bot",
|
||||
)
|
||||
return _bot_task
|
||||
|
||||
|
||||
async def stop_bot():
|
||||
global _bot_task
|
||||
if _bot_task and not _bot_task.done():
|
||||
_bot_task.cancel()
|
||||
try:
|
||||
await _bot_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_bot_task = None
|
||||
logger.info("Telegram bot stopped.")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Low-level Telegram API helpers
|
||||
# ----------------------------------------------------------------------- #
|
||||
class TelegramAPI:
|
||||
def __init__(self, token: str, client: httpx.AsyncClient):
|
||||
self.base = f"https://api.telegram.org/bot{token}"
|
||||
self.client = client
|
||||
|
||||
async def call(self, method: str, **params) -> dict:
|
||||
r = await self.client.post(f"{self.base}/{method}", json=params, timeout=30)
|
||||
return r.json()
|
||||
|
||||
async def get_updates(self, offset: int = 0, timeout: int = 25) -> list:
|
||||
r = await self.client.post(
|
||||
f"{self.base}/getUpdates",
|
||||
json={"offset": offset, "timeout": timeout, "allowed_updates": ["message", "callback_query"]},
|
||||
timeout=timeout + 10,
|
||||
)
|
||||
data = r.json()
|
||||
if data.get("ok"):
|
||||
return data["result"]
|
||||
return []
|
||||
|
||||
async def send_message(self, chat_id, text: str, reply_markup=None, parse_mode="HTML") -> dict:
|
||||
import json
|
||||
params = {"chat_id": chat_id, "text": text, "parse_mode": parse_mode}
|
||||
if reply_markup:
|
||||
params["reply_markup"] = json.dumps(reply_markup)
|
||||
return (await self.call("sendMessage", **params))
|
||||
|
||||
async def edit_message(self, chat_id, message_id, text: str, reply_markup=None, parse_mode="HTML"):
|
||||
import json
|
||||
params = {"chat_id": chat_id, "message_id": message_id, "text": text, "parse_mode": parse_mode}
|
||||
if reply_markup:
|
||||
params["reply_markup"] = json.dumps(reply_markup)
|
||||
await self.call("editMessageText", **params)
|
||||
|
||||
async def answer_callback(self, callback_query_id: str, text: str = ""):
|
||||
await self.call("answerCallbackQuery", callback_query_id=callback_query_id, text=text)
|
||||
|
||||
async def send_document(self, chat_id, filename: str, content: bytes, caption: str = ""):
|
||||
files = {"document": (filename, content, "text/plain")}
|
||||
data = {"chat_id": str(chat_id), "caption": caption}
|
||||
r = await self.client.post(f"{self.base}/sendDocument", data=data, files=files, timeout=30)
|
||||
return r.json()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# ----------------------------------------------------------------------- #
|
||||
def _find_user(load_data_fn: Callable, tg_id: str):
|
||||
data = load_data_fn()
|
||||
tg_id_clean = str(tg_id).lstrip("@")
|
||||
for u in data.get("users", []):
|
||||
stored = str(u.get("telegramId", "") or "").lstrip("@")
|
||||
if stored and stored == tg_id_clean:
|
||||
return u
|
||||
return None
|
||||
|
||||
|
||||
def _build_connections_keyboard(conns: list, data: dict) -> dict:
|
||||
"""Build inline keyboard where each button = one connection."""
|
||||
rows = []
|
||||
servers = data.get("servers", [])
|
||||
for c in conns:
|
||||
sid = c.get("server_id", 0)
|
||||
server_name = "Unknown"
|
||||
if sid < len(servers):
|
||||
srv = servers[sid]
|
||||
server_name = srv.get("name") or srv.get("host", "Unknown")[:20]
|
||||
proto = c.get("protocol", "").upper()
|
||||
name = c.get("name", "Connection")
|
||||
label = f"🔐 {name} · {proto} · {server_name}"
|
||||
# callback_data must be ≤ 64 bytes — use short prefix
|
||||
rows.append([{"text": label, "callback_data": f"cfg:{c['id']}"}])
|
||||
rows.append([{"text": "🔄 Обновить список", "callback_data": "refresh"}])
|
||||
return {"inline_keyboard": rows}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# /start handler — shows connections list immediately
|
||||
# ----------------------------------------------------------------------- #
|
||||
async def _handle_start(api: TelegramAPI, msg: dict, load_data_fn: Callable):
|
||||
chat_id = msg["chat"]["id"]
|
||||
tg_id = str(msg["from"]["id"])
|
||||
first_name = msg["from"].get("first_name", "")
|
||||
|
||||
panel_user = _find_user(load_data_fn, tg_id)
|
||||
|
||||
if not panel_user:
|
||||
await api.send_message(
|
||||
chat_id,
|
||||
f"👋 Hi, <b>{first_name}</b>!\n\n"
|
||||
"Your Telegram account is not linked to any panel user.\n"
|
||||
"Please contact your administrator — they need to add your Telegram ID to your profile.\n\n"
|
||||
f"Your Telegram ID: <code>{tg_id}</code>",
|
||||
)
|
||||
return
|
||||
|
||||
data = load_data_fn()
|
||||
conns = [c for c in data.get("user_connections", []) if c["user_id"] == panel_user["id"]]
|
||||
|
||||
if not conns:
|
||||
await api.send_message(
|
||||
chat_id,
|
||||
f"👋 Hi, <b>{first_name}</b>!\n\n"
|
||||
f"You are registered as <b>{panel_user['username']}</b>.\n\n"
|
||||
"You have no connections yet. Please contact your administrator.",
|
||||
)
|
||||
return
|
||||
|
||||
kb = _build_connections_keyboard(conns, data)
|
||||
await api.send_message(
|
||||
chat_id,
|
||||
f"👋 Hi, <b>{first_name}</b>!\n\n"
|
||||
f"You are registered as <b>{panel_user['username']}</b>.\n\n"
|
||||
f"<b>Your connections</b> ({len(conns)}) — tap to get config:",
|
||||
reply_markup=kb,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Refresh — edit existing message with updated list
|
||||
# ----------------------------------------------------------------------- #
|
||||
async def _handle_refresh(
|
||||
api: TelegramAPI, chat_id: int, message_id: int, callback_id: str, tg_id: str, load_data_fn: Callable
|
||||
):
|
||||
await api.answer_callback(callback_id, "Updated!")
|
||||
panel_user = _find_user(load_data_fn, tg_id)
|
||||
if not panel_user:
|
||||
await api.edit_message(chat_id, message_id, "❌ Access denied.")
|
||||
return
|
||||
data = load_data_fn()
|
||||
conns = [c for c in data.get("user_connections", []) if c["user_id"] == panel_user["id"]]
|
||||
if not conns:
|
||||
await api.edit_message(chat_id, message_id, "You have no connections.")
|
||||
return
|
||||
kb = _build_connections_keyboard(conns, data)
|
||||
await api.edit_message(
|
||||
chat_id, message_id,
|
||||
f"<b>Your connections</b> ({len(conns)}) — tap to get config:",
|
||||
reply_markup=kb,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Get config — send multiple messages with different formats
|
||||
# ----------------------------------------------------------------------- #
|
||||
async def _handle_get_config(
|
||||
api: TelegramAPI,
|
||||
chat_id: int,
|
||||
message_id: int,
|
||||
callback_id: str,
|
||||
conn_id: str,
|
||||
tg_id: str,
|
||||
load_data_fn: Callable,
|
||||
generate_vpn_link_fn: Callable,
|
||||
):
|
||||
await api.answer_callback(callback_id, "Fetching config...")
|
||||
|
||||
panel_user = _find_user(load_data_fn, tg_id)
|
||||
if not panel_user:
|
||||
await api.send_message(chat_id, "❌ Access denied.")
|
||||
return
|
||||
|
||||
data = load_data_fn()
|
||||
conn = next(
|
||||
(c for c in data.get("user_connections", [])
|
||||
if c["id"] == conn_id and c["user_id"] == panel_user["id"]),
|
||||
None,
|
||||
)
|
||||
if not conn:
|
||||
await api.send_message(chat_id, "❌ Connection not found.")
|
||||
return
|
||||
|
||||
sid = conn["server_id"]
|
||||
servers = data.get("servers", [])
|
||||
if sid >= len(servers):
|
||||
await api.send_message(chat_id, "❌ Server not found.")
|
||||
return
|
||||
|
||||
server = servers[sid]
|
||||
proto = conn.get("protocol", "awg")
|
||||
conn_name = conn.get("name", "Connection")
|
||||
|
||||
# Send "Loading..." as new message
|
||||
loading_result = await api.send_message(chat_id, f"⏳ Fetching config for <b>{conn_name}</b>...")
|
||||
loading_msg_id = loading_result.get("result", {}).get("message_id")
|
||||
|
||||
try:
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from managers.ssh_manager import SSHManager
|
||||
from managers.awg_manager import AWGManager
|
||||
from managers.xray_manager import XrayManager
|
||||
|
||||
ssh = SSHManager(
|
||||
server["host"],
|
||||
server.get("ssh_port", 22),
|
||||
server["username"],
|
||||
server.get("password", ""),
|
||||
server.get("private_key", ""),
|
||||
)
|
||||
|
||||
proto_info = server.get("protocols", {}).get(proto, {})
|
||||
port = proto_info.get("port", "55424")
|
||||
|
||||
def _get_cfg():
|
||||
ssh.connect()
|
||||
from managers.wireguard_manager import WireGuardManager
|
||||
from managers.telemt_manager import TelemtManager
|
||||
|
||||
if proto == "xray":
|
||||
mgr = XrayManager(ssh)
|
||||
cfg = mgr.get_client_config(proto, conn["client_id"], server["host"], port)
|
||||
elif proto == "wireguard":
|
||||
mgr = WireGuardManager(ssh)
|
||||
cfg = mgr.get_client_config(conn["client_id"], server["host"])
|
||||
elif proto == "telemt":
|
||||
mgr = TelemtManager(ssh)
|
||||
cfg = mgr.get_client_config(proto, conn["client_id"], server["host"], port)
|
||||
else:
|
||||
# awg, awg2, awg_legacy
|
||||
mgr = AWGManager(ssh)
|
||||
cfg = mgr.get_client_config(proto, conn["client_id"], server["host"], port)
|
||||
ssh.disconnect()
|
||||
return cfg
|
||||
|
||||
config = await asyncio.to_thread(_get_cfg)
|
||||
|
||||
if not config:
|
||||
if loading_msg_id:
|
||||
await api.edit_message(chat_id, loading_msg_id, "❌ Failed to retrieve configuration.")
|
||||
return
|
||||
|
||||
vpn_link = generate_vpn_link_fn(config) if config else ""
|
||||
|
||||
# Delete loading message
|
||||
if loading_msg_id:
|
||||
await api.call("deleteMessage", chat_id=chat_id, message_id=loading_msg_id)
|
||||
|
||||
# ------- 1. Header -------
|
||||
server_name = server.get("name") or server.get("host", "Unknown")
|
||||
await api.send_message(
|
||||
chat_id,
|
||||
f"✅ <b>{conn_name}</b>\n"
|
||||
f"🌐 Server: <b>{server_name}</b>\n"
|
||||
f"🔌 Protocol: <b>{proto.upper()}</b>",
|
||||
)
|
||||
|
||||
# ------- 2. Send config (format depends on protocol) -------
|
||||
# Protocols that return a link/URI rather than an INI-style config file
|
||||
is_link_proto = proto in ("xray", "telemt")
|
||||
|
||||
if is_link_proto:
|
||||
# Show as a tappable link — no .conf file needed
|
||||
await api.send_message(
|
||||
chat_id,
|
||||
f"🔗 <b>Connection link</b> (tap to copy):\n<code>{config}</code>",
|
||||
)
|
||||
else:
|
||||
# AWG / WireGuard — INI config text
|
||||
MAX_LEN = 4000
|
||||
if len(config) <= MAX_LEN:
|
||||
await api.send_message(chat_id, f"<b>📄 Configuration:</b>\n<pre>{config}</pre>")
|
||||
else:
|
||||
chunks = [config[i:i+MAX_LEN] for i in range(0, len(config), MAX_LEN)]
|
||||
for i, chunk in enumerate(chunks, 1):
|
||||
await api.send_message(chat_id, f"<b>📄 Configuration (part {i}/{len(chunks)}):</b>\n<pre>{chunk}</pre>")
|
||||
|
||||
# VPN deep-link (vpn:// base64 URI for the Amnezia app)
|
||||
vpn_link = generate_vpn_link_fn(config)
|
||||
if vpn_link:
|
||||
await api.send_message(
|
||||
chat_id,
|
||||
f"🔗 <b>VPN Link</b> (tap to copy):\n<code>{vpn_link}</code>",
|
||||
)
|
||||
|
||||
# .conf file attachment
|
||||
filename = f"{conn_name.replace(' ', '_')}.conf"
|
||||
await api.send_document(
|
||||
chat_id,
|
||||
filename=filename,
|
||||
content=config.encode("utf-8"),
|
||||
caption=f"📁 Config file: {conn_name}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Bot: error getting config")
|
||||
if loading_msg_id:
|
||||
await api.edit_message(chat_id, loading_msg_id, f"❌ Error: {e}")
|
||||
else:
|
||||
await api.send_message(chat_id, f"❌ Error: {e}")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- #
|
||||
# Main polling loop
|
||||
# ----------------------------------------------------------------------- #
|
||||
async def _run_bot(token: str, load_data_fn: Callable, generate_vpn_link_fn: Callable):
|
||||
offset = 0
|
||||
logger.info("Telegram bot started (raw httpx polling).")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
api = TelegramAPI(token, client)
|
||||
|
||||
me = await api.call("getMe")
|
||||
if not me.get("ok"):
|
||||
logger.error(f"Telegram bot: invalid token or API error: {me}")
|
||||
return
|
||||
logger.info(f"Telegram bot logged in as @{me['result']['username']}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
updates = await api.get_updates(offset=offset, timeout=25)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Telegram bot polling cancelled.")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Telegram bot polling error: {e}")
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
|
||||
for update in updates:
|
||||
offset = update["update_id"] + 1
|
||||
try:
|
||||
await _dispatch(api, update, load_data_fn, generate_vpn_link_fn)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception as e:
|
||||
logger.exception(f"Telegram bot: error handling update {update['update_id']}: {e}")
|
||||
|
||||
|
||||
async def _dispatch(api: TelegramAPI, update: dict, load_data_fn: Callable, generate_vpn_link_fn: Callable):
|
||||
# --- Text messages ---
|
||||
if "message" in update:
|
||||
msg = update["message"]
|
||||
text = msg.get("text", "")
|
||||
if text.startswith("/start"):
|
||||
await _handle_start(api, msg, load_data_fn)
|
||||
elif text.startswith("/connections"):
|
||||
# Alias for /start
|
||||
await _handle_start(api, msg, load_data_fn)
|
||||
|
||||
# --- Inline button callbacks ---
|
||||
elif "callback_query" in update:
|
||||
cq = update["callback_query"]
|
||||
callback_id = cq["id"]
|
||||
data_str = cq.get("data", "")
|
||||
chat_id = cq["message"]["chat"]["id"]
|
||||
message_id = cq["message"]["message_id"]
|
||||
tg_id = str(cq["from"]["id"])
|
||||
|
||||
if data_str == "refresh":
|
||||
await _handle_refresh(api, chat_id, message_id, callback_id, tg_id, load_data_fn)
|
||||
elif data_str.startswith("cfg:"):
|
||||
conn_id = data_str[4:]
|
||||
await _handle_get_config(
|
||||
api, chat_id, message_id, callback_id,
|
||||
conn_id, tg_id, load_data_fn, generate_vpn_link_fn
|
||||
)
|
||||
Reference in New Issue
Block a user