tunnelmate-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tunnelmate/__init__.py +9 -0
- tunnelmate/bot/__init__.py +5 -0
- tunnelmate/bot/telegram_bot.py +767 -0
- tunnelmate/cli/__init__.py +5 -0
- tunnelmate/cli/formatting.py +253 -0
- tunnelmate/cli/interactive.py +220 -0
- tunnelmate/cli/main.py +616 -0
- tunnelmate/cli/ssh_client.py +111 -0
- tunnelmate/client/__init__.py +5 -0
- tunnelmate/client/ipc_client.py +165 -0
- tunnelmate/config.py +162 -0
- tunnelmate/core/__init__.py +35 -0
- tunnelmate/core/cloudflared.py +125 -0
- tunnelmate/core/health.py +102 -0
- tunnelmate/core/models.py +84 -0
- tunnelmate/core/process.py +224 -0
- tunnelmate/core/supervisor.py +388 -0
- tunnelmate/daemon/__init__.py +14 -0
- tunnelmate/daemon/protocol.py +47 -0
- tunnelmate/daemon/server.py +295 -0
- tunnelmate/daemon/service.py +148 -0
- tunnelmate/db/__init__.py +6 -0
- tunnelmate/db/repository.py +435 -0
- tunnelmate/db/schema.py +57 -0
- tunnelmate/exceptions.py +40 -0
- tunnelmate_cli-1.0.0.dist-info/METADATA +352 -0
- tunnelmate_cli-1.0.0.dist-info/RECORD +30 -0
- tunnelmate_cli-1.0.0.dist-info/WHEEL +5 -0
- tunnelmate_cli-1.0.0.dist-info/entry_points.txt +3 -0
- tunnelmate_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
"""Telegram Bot management interface for TunnelMate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Optional
|
|
10
|
+
import telegram.error
|
|
11
|
+
from telegram.request import HTTPXRequest
|
|
12
|
+
from telegram import (
|
|
13
|
+
InlineKeyboardButton,
|
|
14
|
+
InlineKeyboardMarkup,
|
|
15
|
+
Update,
|
|
16
|
+
)
|
|
17
|
+
from telegram.constants import ParseMode
|
|
18
|
+
from telegram.ext import (
|
|
19
|
+
Application,
|
|
20
|
+
ApplicationBuilder,
|
|
21
|
+
CallbackQueryHandler,
|
|
22
|
+
CommandHandler,
|
|
23
|
+
ContextTypes,
|
|
24
|
+
MessageHandler,
|
|
25
|
+
filters,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from tunnelmate.client.ipc_client import TunnelMateClient
|
|
29
|
+
from tunnelmate.config import Settings, load_settings
|
|
30
|
+
from tunnelmate.core.models import HealthStatus, Tunnel, TunnelProtocol, TunnelStatus
|
|
31
|
+
from tunnelmate.daemon.protocol import IPCRequest, IPCResponse
|
|
32
|
+
from tunnelmate.db.repository import Repository
|
|
33
|
+
from tunnelmate.exceptions import DaemonConnectionError, TunnelMateError
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("tunnelmate.bot")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TunnelMateBot:
|
|
39
|
+
"""Telegram Bot Controller providing menu-based remote tunnel management."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, settings: Optional[Settings] = None):
|
|
42
|
+
self.settings = settings or load_settings()
|
|
43
|
+
self.repo = Repository(self.settings.resolved_db_path)
|
|
44
|
+
self.client = TunnelMateClient(self.settings)
|
|
45
|
+
self.app: Optional[Application] = None
|
|
46
|
+
self._background_tasks: list[asyncio.Task] = []
|
|
47
|
+
|
|
48
|
+
def is_admin(self, user_id: int) -> bool:
|
|
49
|
+
"""Check if user has administrative rights."""
|
|
50
|
+
if user_id in self.settings.admin_chat_ids:
|
|
51
|
+
return True
|
|
52
|
+
sub = self.repo.get_subscriber(user_id)
|
|
53
|
+
return bool(sub and sub.is_admin and sub.is_authenticated)
|
|
54
|
+
|
|
55
|
+
# --- UI Keyboards ---
|
|
56
|
+
|
|
57
|
+
def _main_menu_keyboard(self, user_id: int) -> InlineKeyboardMarkup:
|
|
58
|
+
is_adm = self.is_admin(user_id)
|
|
59
|
+
keyboard = [
|
|
60
|
+
[
|
|
61
|
+
InlineKeyboardButton("📋 Tunnels List", callback_data="menu:list"),
|
|
62
|
+
InlineKeyboardButton("🔄 Refresh", callback_data="menu:refresh"),
|
|
63
|
+
]
|
|
64
|
+
]
|
|
65
|
+
if is_adm:
|
|
66
|
+
keyboard.append([
|
|
67
|
+
InlineKeyboardButton("➕ Create Tunnel", callback_data="menu:create_hint"),
|
|
68
|
+
InlineKeyboardButton("📊 Daemon Stats", callback_data="menu:stats"),
|
|
69
|
+
])
|
|
70
|
+
keyboard.append([
|
|
71
|
+
InlineKeyboardButton("🔔 Notifications", callback_data="menu:notify_settings"),
|
|
72
|
+
InlineKeyboardButton("❓ Help", callback_data="menu:help"),
|
|
73
|
+
])
|
|
74
|
+
return InlineKeyboardMarkup(keyboard)
|
|
75
|
+
|
|
76
|
+
def _tunnels_list_keyboard(self, tunnels: list[Tunnel]) -> InlineKeyboardMarkup:
|
|
77
|
+
keyboard = []
|
|
78
|
+
for t in tunnels:
|
|
79
|
+
icon = "🟢" if t.status == TunnelStatus.RUNNING else "🔴"
|
|
80
|
+
btn_text = f"{icon} {t.name} (:{t.target_port})"
|
|
81
|
+
keyboard.append([InlineKeyboardButton(btn_text, callback_data=f"tun:{t.id}")])
|
|
82
|
+
|
|
83
|
+
keyboard.append([
|
|
84
|
+
InlineKeyboardButton("🔄 Refresh", callback_data="menu:list"),
|
|
85
|
+
InlineKeyboardButton("🔙 Back to Main Menu", callback_data="menu:main"),
|
|
86
|
+
])
|
|
87
|
+
return InlineKeyboardMarkup(keyboard)
|
|
88
|
+
|
|
89
|
+
def _tunnel_action_keyboard(self, tunnel: Tunnel, is_adm: bool) -> InlineKeyboardMarkup:
|
|
90
|
+
keyboard = []
|
|
91
|
+
|
|
92
|
+
if is_adm:
|
|
93
|
+
if tunnel.status == TunnelStatus.RUNNING:
|
|
94
|
+
keyboard.append([
|
|
95
|
+
InlineKeyboardButton("⏹️ Stop", callback_data=f"act:stop:{tunnel.id}"),
|
|
96
|
+
InlineKeyboardButton("🔄 Restart", callback_data=f"act:restart:{tunnel.id}"),
|
|
97
|
+
])
|
|
98
|
+
else:
|
|
99
|
+
keyboard.append([
|
|
100
|
+
InlineKeyboardButton("▶️ Start", callback_data=f"act:start:{tunnel.id}"),
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
row_link = [InlineKeyboardButton("🔗 Latest Link", callback_data=f"act:link:{tunnel.id}")]
|
|
104
|
+
if tunnel.current_url and tunnel.status == TunnelStatus.RUNNING:
|
|
105
|
+
row_link.append(InlineKeyboardButton("🌐 Open", url=tunnel.current_url))
|
|
106
|
+
keyboard.append(row_link)
|
|
107
|
+
|
|
108
|
+
if is_adm:
|
|
109
|
+
keyboard.append([
|
|
110
|
+
InlineKeyboardButton("🩺 Health Check", callback_data=f"act:health:{tunnel.id}"),
|
|
111
|
+
InlineKeyboardButton("✏️ Change Port", callback_data=f"act:port_hint:{tunnel.id}"),
|
|
112
|
+
])
|
|
113
|
+
keyboard.append([
|
|
114
|
+
InlineKeyboardButton("🗑️ Delete", callback_data=f"act:delete:{tunnel.id}"),
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
keyboard.append([
|
|
118
|
+
InlineKeyboardButton("🔙 Back to Tunnels", callback_data="menu:list"),
|
|
119
|
+
])
|
|
120
|
+
return InlineKeyboardMarkup(keyboard)
|
|
121
|
+
|
|
122
|
+
# --- Command Handlers ---
|
|
123
|
+
|
|
124
|
+
async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
125
|
+
"""Handle /start command and render initial dashboard."""
|
|
126
|
+
if not update.effective_user or not update.effective_message:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
user = update.effective_user
|
|
130
|
+
sub = self.repo.get_or_create_subscriber(
|
|
131
|
+
chat_id=user.id,
|
|
132
|
+
username=user.username,
|
|
133
|
+
first_name=user.first_name,
|
|
134
|
+
is_admin=(user.id in self.settings.admin_chat_ids),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
is_adm = self.is_admin(user.id)
|
|
138
|
+
admin_badge = "👑 <b>Administrator</b>" if is_adm else "👤 <b>Subscriber</b>"
|
|
139
|
+
|
|
140
|
+
text = (
|
|
141
|
+
f"⚡ <b>TunnelMate Control Hub</b>\n\n"
|
|
142
|
+
f"Welcome, {user.first_name}!\n"
|
|
143
|
+
f"Access Level: {admin_badge}\n\n"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if not is_adm and self.settings.has_admin_password():
|
|
147
|
+
text += "🔒 <i>To unlock admin controls, authenticate using:</i>\n<code>/login <password></code>\n\n"
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
tunnels = await asyncio.to_thread(self.client.list_tunnels)
|
|
151
|
+
running = sum(1 for t in tunnels if t.status == TunnelStatus.RUNNING)
|
|
152
|
+
text += f"📊 <b>Active Tunnels:</b> {running} / {len(tunnels)}\n"
|
|
153
|
+
except Exception:
|
|
154
|
+
text += "⚠️ <i>(Daemon is offline or starting...)</i>\n"
|
|
155
|
+
|
|
156
|
+
text += "Select an option below to manage tunnels:"
|
|
157
|
+
|
|
158
|
+
await update.effective_message.reply_text(
|
|
159
|
+
text,
|
|
160
|
+
reply_markup=self._main_menu_keyboard(user.id),
|
|
161
|
+
parse_mode=ParseMode.HTML,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def cmd_login(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
165
|
+
"""Handle /login <password> authentication."""
|
|
166
|
+
if not update.effective_user or not update.effective_message:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
# Attempt to delete password message for privacy
|
|
170
|
+
try:
|
|
171
|
+
await update.effective_message.delete()
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
if not context.args:
|
|
176
|
+
await update.effective_chat.send_message("❌ Usage: <code>/login <password></code>", parse_mode=ParseMode.HTML)
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
entered = " ".join(context.args).strip()
|
|
180
|
+
if self.settings.verify_admin_password(entered):
|
|
181
|
+
sub = self.repo.get_or_create_subscriber(update.effective_user.id)
|
|
182
|
+
sub.is_admin = True
|
|
183
|
+
sub.is_authenticated = True
|
|
184
|
+
self.repo.update_subscriber(sub)
|
|
185
|
+
|
|
186
|
+
await update.effective_chat.send_message(
|
|
187
|
+
"✅ <b>Authentication successful!</b>\nYou now have full administrator access.",
|
|
188
|
+
reply_markup=self._main_menu_keyboard(update.effective_user.id),
|
|
189
|
+
parse_mode=ParseMode.HTML,
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
await update.effective_chat.send_message("❌ <b>Invalid password.</b> Access denied.", parse_mode=ParseMode.HTML)
|
|
193
|
+
|
|
194
|
+
async def cmd_logout(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
195
|
+
"""Handle /logout to clear admin session."""
|
|
196
|
+
if not update.effective_user:
|
|
197
|
+
return
|
|
198
|
+
sub = self.repo.get_subscriber(update.effective_user.id)
|
|
199
|
+
if sub and update.effective_user.id not in self.settings.admin_chat_ids:
|
|
200
|
+
sub.is_authenticated = False
|
|
201
|
+
self.repo.update_subscriber(sub)
|
|
202
|
+
await update.effective_chat.send_message("👋 Logged out of administrator session.")
|
|
203
|
+
|
|
204
|
+
async def cmd_create(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
205
|
+
"""Handle /create <name> <port> [protocol]."""
|
|
206
|
+
if not update.effective_user or not self.is_admin(update.effective_user.id):
|
|
207
|
+
await update.effective_chat.send_message("⛔ Administrator access required.")
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
if not context.args or len(context.args) < 2:
|
|
211
|
+
await update.effective_chat.send_message(
|
|
212
|
+
"❌ Usage: <code>/create <name> <port> [protocol]</code>\n"
|
|
213
|
+
"Example: <code>/create my-app 8080 http</code>",
|
|
214
|
+
parse_mode=ParseMode.HTML,
|
|
215
|
+
)
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
name = context.args[0]
|
|
219
|
+
try:
|
|
220
|
+
port = int(context.args[1])
|
|
221
|
+
except ValueError:
|
|
222
|
+
await update.effective_chat.send_message("❌ Port must be an integer between 1 and 65535.")
|
|
223
|
+
return
|
|
224
|
+
|
|
225
|
+
proto = context.args[2].lower() if len(context.args) > 2 else "http"
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
tun = await asyncio.to_thread(
|
|
229
|
+
self.client.create_tunnel,
|
|
230
|
+
name=name,
|
|
231
|
+
port=port,
|
|
232
|
+
protocol=proto,
|
|
233
|
+
)
|
|
234
|
+
await update.effective_chat.send_message(
|
|
235
|
+
f"✅ Tunnel <b>{tun.name}</b> created forwarding to <code>{tun.target_url}</code>!\n"
|
|
236
|
+
f"You can now start it from the menu.",
|
|
237
|
+
reply_markup=self._tunnel_action_keyboard(tun, True),
|
|
238
|
+
parse_mode=ParseMode.HTML,
|
|
239
|
+
)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
await update.effective_chat.send_message(f"❌ Error creating tunnel: {e}")
|
|
242
|
+
|
|
243
|
+
async def cmd_port(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
244
|
+
"""Handle /port <name> <new_port>."""
|
|
245
|
+
if not update.effective_user or not self.is_admin(update.effective_user.id):
|
|
246
|
+
await update.effective_chat.send_message("⛔ Administrator access required.")
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
if not context.args or len(context.args) < 2:
|
|
250
|
+
await update.effective_chat.send_message("❌ Usage: <code>/port <tunnel_name> <new_port></code>", parse_mode=ParseMode.HTML)
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
name = context.args[0]
|
|
254
|
+
try:
|
|
255
|
+
new_port = int(context.args[1])
|
|
256
|
+
except ValueError:
|
|
257
|
+
await update.effective_chat.send_message("❌ Invalid port number.")
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
tun = await asyncio.to_thread(self.client.change_port, name, new_port)
|
|
262
|
+
url_text = f"\n🔗 <b>New URL:</b> {tun.current_url}" if tun.current_url else ""
|
|
263
|
+
await update.effective_chat.send_message(
|
|
264
|
+
f"✅ Port for tunnel <b>{tun.name}</b> updated to <b>{new_port}</b>!{url_text}",
|
|
265
|
+
reply_markup=self._tunnel_action_keyboard(tun, True),
|
|
266
|
+
parse_mode=ParseMode.HTML,
|
|
267
|
+
)
|
|
268
|
+
except Exception as e:
|
|
269
|
+
await update.effective_chat.send_message(f"❌ Failed to update port: {e}")
|
|
270
|
+
|
|
271
|
+
# --- Callback Query Handlers ---
|
|
272
|
+
|
|
273
|
+
async def handle_callback_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
274
|
+
"""Process inline button clicks."""
|
|
275
|
+
query = update.callback_query
|
|
276
|
+
if not query or not query.data or not update.effective_user:
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
await query.answer()
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
data = query.data
|
|
285
|
+
user_id = update.effective_user.id
|
|
286
|
+
is_adm = self.is_admin(user_id)
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
if data == "menu:main":
|
|
290
|
+
await query.edit_message_text(
|
|
291
|
+
"⚡ <b>TunnelMate Control Hub</b>\nSelect an option below:",
|
|
292
|
+
reply_markup=self._main_menu_keyboard(user_id),
|
|
293
|
+
parse_mode=ParseMode.HTML,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
elif data == "menu:refresh":
|
|
297
|
+
tunnels = await asyncio.to_thread(self.client.list_tunnels)
|
|
298
|
+
running = sum(1 for t in tunnels if t.status == TunnelStatus.RUNNING)
|
|
299
|
+
await query.edit_message_text(
|
|
300
|
+
f"⚡ <b>TunnelMate Control Hub</b> (Refreshed)\n"
|
|
301
|
+
f"📊 <b>Active Tunnels:</b> {running} / {len(tunnels)}\n\n"
|
|
302
|
+
f"Select an option below:",
|
|
303
|
+
reply_markup=self._main_menu_keyboard(user_id),
|
|
304
|
+
parse_mode=ParseMode.HTML,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
elif data == "menu:list":
|
|
308
|
+
tunnels = await asyncio.to_thread(self.client.list_tunnels)
|
|
309
|
+
if not tunnels:
|
|
310
|
+
await query.edit_message_text(
|
|
311
|
+
"📋 <b>Tunnels List</b>\n\nNo tunnels created yet. Create one with /create or via CLI.",
|
|
312
|
+
reply_markup=self._main_menu_keyboard(user_id),
|
|
313
|
+
parse_mode=ParseMode.HTML,
|
|
314
|
+
)
|
|
315
|
+
else:
|
|
316
|
+
await query.edit_message_text(
|
|
317
|
+
f"📋 <b>Registered Tunnels ({len(tunnels)})</b>\nClick a tunnel to view controls:",
|
|
318
|
+
reply_markup=self._tunnels_list_keyboard(tunnels),
|
|
319
|
+
parse_mode=ParseMode.HTML,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
elif data == "menu:create_hint":
|
|
323
|
+
await query.edit_message_text(
|
|
324
|
+
"➕ <b>Create New Quick Tunnel</b>\n\n"
|
|
325
|
+
"Send command in chat:\n"
|
|
326
|
+
"<code>/create <name> <port> [protocol]</code>\n\n"
|
|
327
|
+
"Example:\n<code>/create dev-api 8080 http</code>",
|
|
328
|
+
reply_markup=InlineKeyboardMarkup([[
|
|
329
|
+
InlineKeyboardButton("🔙 Back", callback_data="menu:main")
|
|
330
|
+
]]),
|
|
331
|
+
parse_mode=ParseMode.HTML,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
elif data == "menu:stats":
|
|
335
|
+
stats = await asyncio.to_thread(self.client.get_stats)
|
|
336
|
+
msg = (
|
|
337
|
+
"📊 <b>Daemon Performance Stats</b>\n\n"
|
|
338
|
+
f"• <b>PID:</b> {stats.get('pid')}\n"
|
|
339
|
+
f"• <b>Uptime:</b> {stats.get('uptime_seconds')}s\n"
|
|
340
|
+
f"• <b>Total Tunnels:</b> {stats.get('total_tunnels')}\n"
|
|
341
|
+
f"• <b>Active Tunnels:</b> {stats.get('running_tunnels')}\n"
|
|
342
|
+
)
|
|
343
|
+
await query.edit_message_text(
|
|
344
|
+
msg,
|
|
345
|
+
reply_markup=InlineKeyboardMarkup([[
|
|
346
|
+
InlineKeyboardButton("🔙 Back", callback_data="menu:main")
|
|
347
|
+
]]),
|
|
348
|
+
parse_mode=ParseMode.HTML,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
elif data == "menu:notify_settings":
|
|
352
|
+
sub = self.repo.get_or_create_subscriber(user_id)
|
|
353
|
+
new_state = not sub.notify_on_url_change
|
|
354
|
+
sub.notify_on_url_change = new_state
|
|
355
|
+
sub.notify_on_status_change = new_state
|
|
356
|
+
self.repo.update_subscriber(sub)
|
|
357
|
+
|
|
358
|
+
state_str = "🔔 <b>ENABLED</b>" if new_state else "🔕 <b>DISABLED</b>"
|
|
359
|
+
await query.edit_message_text(
|
|
360
|
+
f"Notification preferences updated:\n"
|
|
361
|
+
f"Alerts are now: {state_str}\n\n"
|
|
362
|
+
f"You will receive instant alerts when tunnels start, change links, or recover.",
|
|
363
|
+
reply_markup=InlineKeyboardMarkup([[
|
|
364
|
+
InlineKeyboardButton("Toggle Again", callback_data="menu:notify_settings"),
|
|
365
|
+
InlineKeyboardButton("🔙 Back", callback_data="menu:main"),
|
|
366
|
+
]]),
|
|
367
|
+
parse_mode=ParseMode.HTML,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
elif data == "menu:help":
|
|
371
|
+
help_text = (
|
|
372
|
+
"❓ <b>TunnelMate Help & Commands</b>\n\n"
|
|
373
|
+
"• <code>/start</code> - Open main interactive menu\n"
|
|
374
|
+
"• <code>/login <password></code> - Authenticate as Admin\n"
|
|
375
|
+
"• <code>/create <name> <port></code> - Create new tunnel\n"
|
|
376
|
+
"• <code>/port <name> <new_port></code> - Change target port\n"
|
|
377
|
+
"• <code>/logout</code> - Exit admin session\n"
|
|
378
|
+
)
|
|
379
|
+
await query.edit_message_text(
|
|
380
|
+
help_text,
|
|
381
|
+
reply_markup=InlineKeyboardMarkup([[
|
|
382
|
+
InlineKeyboardButton("🔙 Back", callback_data="menu:main")
|
|
383
|
+
]]),
|
|
384
|
+
parse_mode=ParseMode.HTML,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
elif data.startswith("tun:"):
|
|
388
|
+
tunnel_id = data.split(":", 1)[1]
|
|
389
|
+
tunnel = await asyncio.to_thread(self.client.get_tunnel, tunnel_id)
|
|
390
|
+
if not tunnel:
|
|
391
|
+
await query.edit_message_text("❌ Tunnel not found.", reply_markup=self._main_menu_keyboard(user_id))
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
msg = (
|
|
395
|
+
f"⚡ <b>Tunnel:</b> <code>{tunnel.name}</code>\n"
|
|
396
|
+
f"• <b>Status:</b> {'🟢 RUNNING' if tunnel.status == TunnelStatus.RUNNING else '🔴 STOPPED'}\n"
|
|
397
|
+
f"• <b>Target:</b> <code>{tunnel.target_url}</code>\n"
|
|
398
|
+
f"• <b>Auto-Restart:</b> {'Enabled' if tunnel.auto_restart else 'Disabled'}\n"
|
|
399
|
+
f"• <b>PID:</b> {tunnel.pid or 'None'}\n\n"
|
|
400
|
+
)
|
|
401
|
+
if tunnel.current_url and tunnel.status == TunnelStatus.RUNNING:
|
|
402
|
+
msg += f"🔗 <b>Public URL:</b>\n{tunnel.current_url}\n"
|
|
403
|
+
else:
|
|
404
|
+
msg += "🔗 <i>Public URL not currently active.</i>\n"
|
|
405
|
+
|
|
406
|
+
await query.edit_message_text(
|
|
407
|
+
msg,
|
|
408
|
+
reply_markup=self._tunnel_action_keyboard(tunnel, is_adm),
|
|
409
|
+
parse_mode=ParseMode.HTML,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
elif data.startswith("act:"):
|
|
413
|
+
_, action, tunnel_id = data.split(":", 2)
|
|
414
|
+
tunnel = await asyncio.to_thread(self.client.get_tunnel, tunnel_id)
|
|
415
|
+
if not tunnel:
|
|
416
|
+
await query.answer("Tunnel not found!", show_alert=True)
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
if action in ("start", "stop", "restart", "delete") and not is_adm:
|
|
420
|
+
await query.answer("⛔ Admin access required!", show_alert=True)
|
|
421
|
+
return
|
|
422
|
+
|
|
423
|
+
if action == "link":
|
|
424
|
+
if tunnel.current_url and tunnel.status == TunnelStatus.RUNNING:
|
|
425
|
+
text = (
|
|
426
|
+
f"🌐 <b>Quick Tunnel URL for {tunnel.name}:</b>\n\n"
|
|
427
|
+
f"<code>{tunnel.current_url}</code>\n\n"
|
|
428
|
+
f"🎯 Forwarding to: <code>{tunnel.target_url}</code>"
|
|
429
|
+
)
|
|
430
|
+
if tunnel.protocol in (TunnelProtocol.TCP, TunnelProtocol.TLS) or tunnel.target_port == 22:
|
|
431
|
+
host = tunnel.current_url.replace("https://", "").replace("http://", "").strip("/")
|
|
432
|
+
text += (
|
|
433
|
+
f"\n\n💡 <b>Direct SSH Command:</b>\n"
|
|
434
|
+
f"<code>tunnelmate-ssh user@{host}</code>\n"
|
|
435
|
+
f"<i>(Or: <code>tunnelmate ssh user@{host}</code>)</i>\n"
|
|
436
|
+
)
|
|
437
|
+
btn = InlineKeyboardMarkup([
|
|
438
|
+
[InlineKeyboardButton("🌐 Open in Browser", url=tunnel.current_url)],
|
|
439
|
+
[InlineKeyboardButton("🔙 Back to Tunnel", callback_data=f"tun:{tunnel.id}")],
|
|
440
|
+
])
|
|
441
|
+
await query.edit_message_text(text, reply_markup=btn, parse_mode=ParseMode.HTML)
|
|
442
|
+
else:
|
|
443
|
+
await query.answer("Tunnel is not currently running!", show_alert=True)
|
|
444
|
+
|
|
445
|
+
elif action == "start":
|
|
446
|
+
await query.edit_message_text(f"⏳ Starting tunnel <b>{tunnel.name}</b>...", parse_mode=ParseMode.HTML)
|
|
447
|
+
started = await asyncio.to_thread(self.client.start_tunnel, tunnel.id)
|
|
448
|
+
await query.edit_message_text(
|
|
449
|
+
f"✅ Tunnel <b>{started.name}</b> is now RUNNING!\n\n"
|
|
450
|
+
f"🔗 <b>URL:</b> {started.current_url or 'Acquired'}",
|
|
451
|
+
reply_markup=self._tunnel_action_keyboard(started, is_adm),
|
|
452
|
+
parse_mode=ParseMode.HTML,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
elif action == "stop":
|
|
456
|
+
await query.edit_message_text(f"⏳ Stopping tunnel <b>{tunnel.name}</b>...", parse_mode=ParseMode.HTML)
|
|
457
|
+
stopped = await asyncio.to_thread(self.client.stop_tunnel, tunnel.id)
|
|
458
|
+
await query.edit_message_text(
|
|
459
|
+
f"🛑 Tunnel <b>{stopped.name}</b> has been stopped.",
|
|
460
|
+
reply_markup=self._tunnel_action_keyboard(stopped, is_adm),
|
|
461
|
+
parse_mode=ParseMode.HTML,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
elif action == "restart":
|
|
465
|
+
await query.edit_message_text(f"🔄 Restarting tunnel <b>{tunnel.name}</b>...", parse_mode=ParseMode.HTML)
|
|
466
|
+
restarted = await asyncio.to_thread(self.client.restart_tunnel, tunnel.id)
|
|
467
|
+
await query.edit_message_text(
|
|
468
|
+
f"✅ Tunnel <b>{restarted.name}</b> restarted!\n\n"
|
|
469
|
+
f"🔗 <b>New URL:</b> {restarted.current_url}",
|
|
470
|
+
reply_markup=self._tunnel_action_keyboard(restarted, is_adm),
|
|
471
|
+
parse_mode=ParseMode.HTML,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
elif action == "health":
|
|
475
|
+
health, details = await asyncio.to_thread(self.client.check_health, tunnel.id)
|
|
476
|
+
h_icon = "🟢" if health == HealthStatus.HEALTHY else "🔴"
|
|
477
|
+
msg = (
|
|
478
|
+
f"🩺 <b>Health Diagnostic: {tunnel.name}</b>\n\n"
|
|
479
|
+
f"• <b>Status:</b> {h_icon} {health.value.upper()}\n"
|
|
480
|
+
f"• <b>Diagnosis:</b> {details.get('message')}\n"
|
|
481
|
+
f"• <b>Local Port Open:</b> {'Yes' if details.get('local_port_open') else 'No'}\n"
|
|
482
|
+
)
|
|
483
|
+
await query.edit_message_text(
|
|
484
|
+
msg,
|
|
485
|
+
reply_markup=self._tunnel_action_keyboard(tunnel, is_adm),
|
|
486
|
+
parse_mode=ParseMode.HTML,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
elif action == "port_hint":
|
|
490
|
+
await query.edit_message_text(
|
|
491
|
+
f"✏️ <b>Change Port for {tunnel.name}</b>\n\n"
|
|
492
|
+
f"Current Port: <code>{tunnel.target_port}</code>\n\n"
|
|
493
|
+
f"Send command in chat:\n"
|
|
494
|
+
f"<code>/port {tunnel.name} <new_port></code>\n"
|
|
495
|
+
f"Example: <code>/port {tunnel.name} 9000</code>",
|
|
496
|
+
reply_markup=InlineKeyboardMarkup([[
|
|
497
|
+
InlineKeyboardButton("🔙 Back", callback_data=f"tun:{tunnel.id}")
|
|
498
|
+
]]),
|
|
499
|
+
parse_mode=ParseMode.HTML,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
elif action == "delete":
|
|
503
|
+
deleted = await asyncio.to_thread(self.client.delete_tunnel, tunnel.id)
|
|
504
|
+
if deleted:
|
|
505
|
+
await query.edit_message_text(
|
|
506
|
+
f"🗑️ Tunnel <b>{tunnel.name}</b> deleted successfully.",
|
|
507
|
+
reply_markup=self._main_menu_keyboard(user_id),
|
|
508
|
+
parse_mode=ParseMode.HTML,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
except telegram.error.BadRequest as e:
|
|
512
|
+
if "Message is not modified" in str(e):
|
|
513
|
+
pass
|
|
514
|
+
else:
|
|
515
|
+
logger.warning("Telegram BadRequest: %s", e)
|
|
516
|
+
except (telegram.error.NetworkError, telegram.error.TimedOut) as e:
|
|
517
|
+
logger.warning("Transient network error during callback query: %s", e)
|
|
518
|
+
except Exception as e:
|
|
519
|
+
logger.error("Error handling callback: %s", e, exc_info=True)
|
|
520
|
+
try:
|
|
521
|
+
await query.answer(f"Error: {e}", show_alert=True)
|
|
522
|
+
except Exception:
|
|
523
|
+
pass
|
|
524
|
+
|
|
525
|
+
# --- Live Subscriber Event Broadcast Loop ---
|
|
526
|
+
|
|
527
|
+
async def _event_listener_loop(self) -> None:
|
|
528
|
+
"""Connect to daemon IPC socket, subscribe to events, and broadcast to Telegram users."""
|
|
529
|
+
use_unix = (sys.platform != "win32") and (not self.settings.use_tcp_ipc)
|
|
530
|
+
|
|
531
|
+
while True:
|
|
532
|
+
try:
|
|
533
|
+
if use_unix:
|
|
534
|
+
reader, writer = await asyncio.open_unix_connection(
|
|
535
|
+
path=str(self.settings.resolved_socket_path)
|
|
536
|
+
)
|
|
537
|
+
else:
|
|
538
|
+
reader, writer = await asyncio.open_connection(
|
|
539
|
+
host=self.settings.tcp_ipc_host,
|
|
540
|
+
port=self.settings.tcp_ipc_port,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# Send subscribe request
|
|
544
|
+
req = IPCRequest(id="bot-sub", action="subscribe_events")
|
|
545
|
+
writer.write(req.to_line())
|
|
546
|
+
await writer.drain()
|
|
547
|
+
|
|
548
|
+
# Read ack
|
|
549
|
+
await reader.readline()
|
|
550
|
+
|
|
551
|
+
logger.info("Telegram Bot successfully subscribed to daemon events.")
|
|
552
|
+
|
|
553
|
+
while True:
|
|
554
|
+
line_bytes = await reader.readline()
|
|
555
|
+
if not line_bytes:
|
|
556
|
+
break
|
|
557
|
+
|
|
558
|
+
line = line_bytes.decode("utf-8").strip()
|
|
559
|
+
if not line:
|
|
560
|
+
continue
|
|
561
|
+
|
|
562
|
+
try:
|
|
563
|
+
import json
|
|
564
|
+
raw = json.loads(line)
|
|
565
|
+
if raw.get("type") == "event":
|
|
566
|
+
await self._broadcast_event(raw.get("event"), raw.get("tunnel_id"), raw.get("data", {}))
|
|
567
|
+
except Exception as e:
|
|
568
|
+
logger.error("Error processing event line: %s", e)
|
|
569
|
+
|
|
570
|
+
except (asyncio.CancelledError, KeyboardInterrupt):
|
|
571
|
+
break
|
|
572
|
+
except Exception as e:
|
|
573
|
+
logger.debug("Daemon IPC not yet available for event listener: %s. Retrying in 5s...", e)
|
|
574
|
+
await asyncio.sleep(5.0)
|
|
575
|
+
|
|
576
|
+
async def _broadcast_event(self, event_type: str, tunnel_id: str, data: dict) -> None:
|
|
577
|
+
"""Send notification to registered subscribers."""
|
|
578
|
+
if not self.app:
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
name = data.get("tunnel_name", tunnel_id)
|
|
582
|
+
url = data.get("url")
|
|
583
|
+
|
|
584
|
+
msg = ""
|
|
585
|
+
btn = None
|
|
586
|
+
|
|
587
|
+
if event_type == "url_assigned" and url:
|
|
588
|
+
msg = (
|
|
589
|
+
f"⚡ <b>Quick Tunnel Online!</b>\n\n"
|
|
590
|
+
f"Tunnel: <b>{name}</b>\n"
|
|
591
|
+
f"🔗 <b>New URL:</b>\n{url}\n"
|
|
592
|
+
)
|
|
593
|
+
btn = InlineKeyboardMarkup([[InlineKeyboardButton("🌐 Open in Browser", url=url)]])
|
|
594
|
+
|
|
595
|
+
elif event_type == "crashed":
|
|
596
|
+
msg = f"⚠️ <b>Tunnel Alert:</b> '{name}' crashed! Attempting automatic recovery..."
|
|
597
|
+
|
|
598
|
+
elif event_type == "recovered" and url:
|
|
599
|
+
msg = (
|
|
600
|
+
f"✅ <b>Tunnel Recovered!</b>\n\n"
|
|
601
|
+
f"Tunnel: <b>{name}</b>\n"
|
|
602
|
+
f"🔗 <b>Restored URL:</b>\n{url}\n"
|
|
603
|
+
)
|
|
604
|
+
btn = InlineKeyboardMarkup([[InlineKeyboardButton("🌐 Open in Browser", url=url)]])
|
|
605
|
+
|
|
606
|
+
elif event_type == "stopped":
|
|
607
|
+
msg = f"🛑 <b>Tunnel Stopped:</b> '{name}' was stopped."
|
|
608
|
+
|
|
609
|
+
if not msg:
|
|
610
|
+
return
|
|
611
|
+
|
|
612
|
+
# Fetch active subscribers from SQLite
|
|
613
|
+
subscribers = await asyncio.to_thread(self.repo.list_subscribers, only_active=True)
|
|
614
|
+
for sub in subscribers:
|
|
615
|
+
try:
|
|
616
|
+
await self.app.bot.send_message(
|
|
617
|
+
chat_id=sub.chat_id,
|
|
618
|
+
text=msg,
|
|
619
|
+
reply_markup=btn,
|
|
620
|
+
parse_mode=ParseMode.HTML,
|
|
621
|
+
)
|
|
622
|
+
except Exception as e:
|
|
623
|
+
logger.debug("Failed sending broadcast to chat_id %d: %s", sub.chat_id, e)
|
|
624
|
+
|
|
625
|
+
async def error_handler(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
626
|
+
"""Handle errors caused by Updates."""
|
|
627
|
+
err = context.error
|
|
628
|
+
if isinstance(err, (telegram.error.NetworkError, telegram.error.TimedOut)):
|
|
629
|
+
logger.warning("Transient network issue with Telegram API: %s", err)
|
|
630
|
+
return
|
|
631
|
+
if isinstance(err, telegram.error.BadRequest) and "Message is not modified" in str(err):
|
|
632
|
+
return
|
|
633
|
+
logger.error("Unhandled exception in Telegram bot: %s", err, exc_info=err)
|
|
634
|
+
|
|
635
|
+
def _write_pid(self) -> None:
|
|
636
|
+
"""Write current PID to bot.pid."""
|
|
637
|
+
pid_file = self.settings.bot_pid_path
|
|
638
|
+
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
|
639
|
+
with open(pid_file, "w", encoding="utf-8") as f:
|
|
640
|
+
f.write(str(os.getpid()))
|
|
641
|
+
|
|
642
|
+
def _remove_pid(self) -> None:
|
|
643
|
+
"""Clean up bot.pid on termination."""
|
|
644
|
+
pid_file = self.settings.bot_pid_path
|
|
645
|
+
if pid_file.exists():
|
|
646
|
+
try:
|
|
647
|
+
pid_file.unlink()
|
|
648
|
+
except OSError:
|
|
649
|
+
pass
|
|
650
|
+
|
|
651
|
+
@classmethod
|
|
652
|
+
def get_running_pid(cls, settings: Optional[Settings] = None) -> Optional[int]:
|
|
653
|
+
"""Check if bot process is currently running by PID."""
|
|
654
|
+
st = settings or load_settings()
|
|
655
|
+
pid_file = st.bot_pid_path
|
|
656
|
+
if not pid_file.exists():
|
|
657
|
+
return None
|
|
658
|
+
try:
|
|
659
|
+
pid = int(pid_file.read_text().strip())
|
|
660
|
+
os.kill(pid, 0)
|
|
661
|
+
return pid
|
|
662
|
+
except (ValueError, OSError, ProcessLookupError):
|
|
663
|
+
return None
|
|
664
|
+
|
|
665
|
+
@classmethod
|
|
666
|
+
def fetch_recent_chats(cls, token: Optional[str], exclude_chat_ids: Optional[set[int]] = None) -> list[dict]:
|
|
667
|
+
"""Fetch unrecorded recent users from Telegram getUpdates API."""
|
|
668
|
+
if not token:
|
|
669
|
+
return []
|
|
670
|
+
import httpx
|
|
671
|
+
exclude = exclude_chat_ids or set()
|
|
672
|
+
recent = []
|
|
673
|
+
seen = set(exclude)
|
|
674
|
+
try:
|
|
675
|
+
with httpx.Client(timeout=5.0) as http_client:
|
|
676
|
+
resp = http_client.get(f"https://api.telegram.org/bot{token}/getUpdates")
|
|
677
|
+
if resp.status_code == 200:
|
|
678
|
+
data = resp.json()
|
|
679
|
+
for upd in data.get("result", []):
|
|
680
|
+
msg = upd.get("message") or upd.get("callback_query", {}).get("message") or {}
|
|
681
|
+
chat = msg.get("chat") or upd.get("callback_query", {}).get("from") or {}
|
|
682
|
+
c_id = chat.get("id")
|
|
683
|
+
if c_id and c_id not in seen:
|
|
684
|
+
seen.add(c_id)
|
|
685
|
+
first_name = chat.get("first_name", "")
|
|
686
|
+
username = chat.get("username")
|
|
687
|
+
recent.append({
|
|
688
|
+
"chat_id": c_id,
|
|
689
|
+
"name": first_name or "Unknown",
|
|
690
|
+
"username": f"@{username}" if username else "—",
|
|
691
|
+
"notify": "—",
|
|
692
|
+
"created": "Telegram Updates",
|
|
693
|
+
})
|
|
694
|
+
except Exception:
|
|
695
|
+
pass
|
|
696
|
+
return recent
|
|
697
|
+
|
|
698
|
+
async def start(self) -> None:
|
|
699
|
+
"""Build and run Telegram bot application."""
|
|
700
|
+
token = self.settings.telegram_bot_token
|
|
701
|
+
if not token:
|
|
702
|
+
logger.error("Telegram bot token not configured! Set via 'tunnelmate bot set-token <token>'.")
|
|
703
|
+
return
|
|
704
|
+
|
|
705
|
+
self._write_pid()
|
|
706
|
+
|
|
707
|
+
request_config = HTTPXRequest(
|
|
708
|
+
connection_pool_size=8,
|
|
709
|
+
read_timeout=30.0,
|
|
710
|
+
write_timeout=20.0,
|
|
711
|
+
connect_timeout=20.0,
|
|
712
|
+
pool_timeout=15.0,
|
|
713
|
+
)
|
|
714
|
+
self.app = (
|
|
715
|
+
ApplicationBuilder()
|
|
716
|
+
.token(token)
|
|
717
|
+
.request(request_config)
|
|
718
|
+
.build()
|
|
719
|
+
)
|
|
720
|
+
self.app.add_error_handler(self.error_handler)
|
|
721
|
+
|
|
722
|
+
# Register handlers
|
|
723
|
+
self.app.add_handler(CommandHandler("start", self.cmd_start))
|
|
724
|
+
self.app.add_handler(CommandHandler("login", self.cmd_login))
|
|
725
|
+
self.app.add_handler(CommandHandler("logout", self.cmd_logout))
|
|
726
|
+
self.app.add_handler(CommandHandler("create", self.cmd_create))
|
|
727
|
+
self.app.add_handler(CommandHandler("port", self.cmd_port))
|
|
728
|
+
self.app.add_handler(CallbackQueryHandler(self.handle_callback_query))
|
|
729
|
+
|
|
730
|
+
# Launch event listener background task
|
|
731
|
+
task = asyncio.create_task(self._event_listener_loop())
|
|
732
|
+
self._background_tasks.append(task)
|
|
733
|
+
|
|
734
|
+
logger.info("TunnelMate Telegram Bot initialized (PID: %d). Starting polling...", os.getpid())
|
|
735
|
+
await self.app.initialize()
|
|
736
|
+
await self.app.start()
|
|
737
|
+
await self.app.updater.start_polling()
|
|
738
|
+
|
|
739
|
+
try:
|
|
740
|
+
while True:
|
|
741
|
+
await asyncio.sleep(1.0)
|
|
742
|
+
finally:
|
|
743
|
+
if self.app.updater:
|
|
744
|
+
await self.app.updater.stop()
|
|
745
|
+
await self.app.stop()
|
|
746
|
+
await self.app.shutdown()
|
|
747
|
+
for t in self._background_tasks:
|
|
748
|
+
t.cancel()
|
|
749
|
+
self._remove_pid()
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def run_telegram_bot() -> None:
|
|
753
|
+
"""Entry point to launch the Telegram bot process."""
|
|
754
|
+
logging.basicConfig(
|
|
755
|
+
level=logging.INFO,
|
|
756
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
757
|
+
)
|
|
758
|
+
bot = TunnelMateBot()
|
|
759
|
+
try:
|
|
760
|
+
asyncio.run(bot.start())
|
|
761
|
+
except KeyboardInterrupt:
|
|
762
|
+
pass
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
if __name__ == "__main__":
|
|
766
|
+
run_telegram_bot()
|
|
767
|
+
|