tunnelmate-cli 1.0.2__tar.gz → 1.0.4__tar.gz

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.
Files changed (44) hide show
  1. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/PKG-INFO +1 -1
  2. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/pyproject.toml +1 -1
  3. tunnelmate_cli-1.0.4/tests/test_cross_platform.py +84 -0
  4. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/__init__.py +1 -1
  5. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/bot/telegram_bot.py +11 -1
  6. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/cli/formatting.py +5 -0
  7. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/cli/interactive.py +69 -0
  8. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/cli/main.py +58 -2
  9. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/config.py +1 -0
  10. tunnelmate_cli-1.0.4/tunnelmate/core/autostart.py +283 -0
  11. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/daemon/service.py +31 -3
  12. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate_cli.egg-info/PKG-INFO +1 -1
  13. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate_cli.egg-info/SOURCES.txt +1 -0
  14. tunnelmate_cli-1.0.2/tests/test_cross_platform.py +0 -36
  15. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/README.md +0 -0
  16. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/setup.cfg +0 -0
  17. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tests/test_cli.py +0 -0
  18. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tests/test_cloudflared.py +0 -0
  19. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tests/test_config.py +0 -0
  20. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tests/test_db.py +0 -0
  21. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tests/test_health.py +0 -0
  22. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tests/test_ipc.py +0 -0
  23. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/bot/__init__.py +0 -0
  24. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/cli/__init__.py +0 -0
  25. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/cli/ssh_client.py +0 -0
  26. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/client/__init__.py +0 -0
  27. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/client/ipc_client.py +0 -0
  28. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/core/__init__.py +0 -0
  29. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/core/cloudflared.py +0 -0
  30. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/core/health.py +0 -0
  31. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/core/models.py +0 -0
  32. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/core/process.py +0 -0
  33. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/core/supervisor.py +0 -0
  34. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/daemon/__init__.py +0 -0
  35. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/daemon/protocol.py +0 -0
  36. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/daemon/server.py +0 -0
  37. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/db/__init__.py +0 -0
  38. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/db/repository.py +0 -0
  39. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/db/schema.py +0 -0
  40. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate/exceptions.py +0 -0
  41. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate_cli.egg-info/dependency_links.txt +0 -0
  42. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate_cli.egg-info/entry_points.txt +0 -0
  43. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate_cli.egg-info/requires.txt +0 -0
  44. {tunnelmate_cli-1.0.2 → tunnelmate_cli-1.0.4}/tunnelmate_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tunnelmate-cli
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Cloudflare Quick Tunnel manager with CLI, Interactive TUI, Daemon IPC, and Telegram Bot
5
5
  Author-email: Shashan Lumbhani <lumbhanishashan1510@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tunnelmate-cli"
7
- version = "1.0.2"
7
+ version = "1.0.4"
8
8
  description = "Cloudflare Quick Tunnel manager with CLI, Interactive TUI, Daemon IPC, and Telegram Bot"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,84 @@
1
+ """Tests for cross-platform process management and Windows/Linux compatibility."""
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from tunnelmate.core.process import is_pid_alive, spawn_background_process, terminate_process_by_pid
7
+ from tunnelmate.core.cloudflared import find_cloudflared
8
+ from tunnelmate.cli.ssh_client import clean_ssh_target
9
+
10
+
11
+ def test_is_pid_alive():
12
+ # Current process must be alive
13
+ assert is_pid_alive(os.getpid()) is True
14
+ # Non-existent PID must not be alive
15
+ assert is_pid_alive(99999999) is False
16
+ assert is_pid_alive(-1) is False
17
+
18
+
19
+ def test_spawn_and_terminate_process():
20
+ # Launch a short-lived python background sleep process
21
+ cmd = [sys.executable, "-c", "import time; time.sleep(10)"]
22
+ proc = spawn_background_process(cmd)
23
+ pid = proc.pid
24
+ assert pid is not None
25
+ assert is_pid_alive(pid) is True
26
+
27
+ # Terminate process safely using cross-platform helper
28
+ terminated = terminate_process_by_pid(pid, timeout=3.0)
29
+ assert terminated is True
30
+ assert is_pid_alive(pid) is False
31
+
32
+
33
+ def test_clean_ssh_target_windows_formats():
34
+ assert clean_ssh_target("shashan@subjective.trycloudflare.com") == "shashan@subjective.trycloudflare.com"
35
+ assert clean_ssh_target("shashan@https://subjective.trycloudflare.com/") == "shashan@subjective.trycloudflare.com"
36
+ assert clean_ssh_target("https://subjective.trycloudflare.com:22") == "subjective.trycloudflare.com"
37
+
38
+
39
+ def test_bot_auto_start_persistence(tmp_path: Path):
40
+ from tunnelmate.config import Settings, load_settings, save_settings
41
+
42
+ st = Settings(home_dir=tmp_path, bot_auto_start=False)
43
+ assert st.bot_auto_start is False
44
+
45
+ st.bot_auto_start = True
46
+ save_settings(st)
47
+
48
+ loaded = load_settings(custom_home=tmp_path)
49
+ assert loaded.bot_auto_start is True
50
+
51
+
52
+ def test_autostart_status_query():
53
+ from tunnelmate.core.autostart import is_autostart_enabled
54
+
55
+ # Should return a tuple of (bool, str) without throwing exceptions
56
+ enabled, details = is_autostart_enabled()
57
+ assert isinstance(enabled, bool)
58
+ assert isinstance(details, str)
59
+
60
+
61
+ def test_daemon_startup_auto_restoration(tmp_path: Path):
62
+ from tunnelmate.config import Settings
63
+ from tunnelmate.db.repository import Repository
64
+ from tunnelmate.core.models import Tunnel, TunnelStatus, TunnelProtocol
65
+
66
+ st = Settings(home_dir=tmp_path, bot_auto_start=True, telegram_bot_token="test_token")
67
+ repo = Repository(st.resolved_db_path)
68
+
69
+ # Create one running tunnel and one stopped tunnel
70
+ t1 = Tunnel(id="t1", name="running-web", target_port=8080, protocol=TunnelProtocol.HTTP, status=TunnelStatus.RUNNING)
71
+ t2 = Tunnel(id="t2", name="stopped-ssh", target_port=22, protocol=TunnelProtocol.TCP, status=TunnelStatus.STOPPED)
72
+ repo.create_tunnel(t1)
73
+ repo.create_tunnel(t2)
74
+
75
+ # Check repository tunnels
76
+ tunnels = repo.list_tunnels()
77
+ to_restore = [t for t in tunnels if t.status.value in ("running", "starting", "restarting")]
78
+ assert len(to_restore) == 1
79
+ assert to_restore[0].name == "running-web"
80
+
81
+ # Verify bot_auto_start setting is preserved
82
+ assert st.bot_auto_start is True
83
+
84
+
@@ -5,5 +5,5 @@ Provides CLI, interactive TUI, background daemon, Unix socket IPC, SQLite persis
5
5
  and Telegram Bot management.
6
6
  """
7
7
 
8
- __version__ = "1.0.2"
8
+ __version__ = "1.0.4"
9
9
  __all__ = ["__version__"]
@@ -26,7 +26,7 @@ from telegram.ext import (
26
26
  )
27
27
 
28
28
  from tunnelmate.client.ipc_client import TunnelMateClient
29
- from tunnelmate.config import Settings, load_settings
29
+ from tunnelmate.config import Settings, load_settings, save_settings
30
30
  from tunnelmate.core.models import HealthStatus, Tunnel, TunnelProtocol, TunnelStatus
31
31
  from tunnelmate.core.process import is_pid_alive
32
32
  from tunnelmate.daemon.protocol import IPCRequest, IPCResponse
@@ -704,6 +704,16 @@ class TunnelMateBot:
704
704
 
705
705
  self._write_pid()
706
706
 
707
+ # Mark bot last status as started so after system reboot it automatically starts
708
+ try:
709
+ curr = load_settings()
710
+ if not curr.bot_auto_start:
711
+ curr.bot_auto_start = True
712
+ save_settings(curr)
713
+ self.settings = curr
714
+ except Exception as e:
715
+ logger.debug("Failed to persist bot_auto_start state: %s", e)
716
+
707
717
  request_config = HTTPXRequest(
708
718
  connection_pool_size=8,
709
719
  read_timeout=30.0,
@@ -172,6 +172,7 @@ def print_bot_status_panel(
172
172
  has_token: bool,
173
173
  has_pwd: bool,
174
174
  admin_chat_ids: List[int],
175
+ bot_auto_start: bool = False,
175
176
  ) -> None:
176
177
  """Display Telegram bot operational status panel."""
177
178
  status_text = f"🟢 RUNNING (PID: {pid})" if is_running else "🔴 STOPPED"
@@ -189,6 +190,10 @@ def print_bot_status_panel(
189
190
  if admin_chat_ids:
190
191
  admin_info += f" ({', '.join(str(cid) for cid in admin_chat_ids)})"
191
192
  table.add_row("Admin Whitelist:", admin_info)
193
+ table.add_row(
194
+ "Auto-Restore on Boot:",
195
+ "✅ Enabled (Starts automatically after restart)" if bot_auto_start else "❌ Disabled",
196
+ )
192
197
 
193
198
  console.print(Panel(table, title="🤖 Telegram Bot Controller", border_style="cyan"))
194
199
 
@@ -87,6 +87,7 @@ def run_interactive_menu(client: Optional[TunnelMateClient] = None) -> None:
87
87
  "🗑️ Delete a Tunnel",
88
88
  "📊 Daemon Statistics",
89
89
  "🤖 Telegram Bot Management",
90
+ "⚙️ Auto-Start on Boot Settings",
90
91
  "🚪 Exit",
91
92
  ],
92
93
  ).ask()
@@ -99,6 +100,10 @@ def run_interactive_menu(client: Optional[TunnelMateClient] = None) -> None:
99
100
  _handle_telegram_bot_menu()
100
101
  continue
101
102
 
103
+ if choice == "⚙️ Auto-Start on Boot Settings":
104
+ _handle_autostart_menu()
105
+ continue
106
+
102
107
  try:
103
108
  if choice == "📋 List All Tunnels & URLs":
104
109
  tunnels = client.list_tunnels()
@@ -248,6 +253,7 @@ def _handle_telegram_bot_menu() -> None:
248
253
  has_token=has_token,
249
254
  has_pwd=has_pwd,
250
255
  admin_chat_ids=settings.admin_chat_ids,
256
+ bot_auto_start=settings.bot_auto_start,
251
257
  )
252
258
 
253
259
  choices = []
@@ -285,20 +291,27 @@ def _handle_telegram_bot_menu() -> None:
285
291
 
286
292
  cmd = [sys.executable, "-m", "tunnelmate.bot.telegram_bot"]
287
293
  spawn_background_process(cmd)
294
+ settings.bot_auto_start = True
295
+ save_settings(settings)
296
+
288
297
  with console.status("[bold cyan]Starting Telegram bot in background...[/bold cyan]"):
289
298
  time.sleep(1.0)
290
299
  new_pid = TunnelMateBot.get_running_pid(settings)
291
300
  if new_pid:
292
301
  console.print(f"[bold green]✔ Telegram Bot started in background (PID: {new_pid}).[/bold green]")
302
+ console.print("[dim]Auto-restore on reboot: Enabled[/dim]")
293
303
  else:
294
304
  console.print("[bold red]Failed to start bot in background. Verify your token or logs.[/bold red]")
295
305
 
296
306
  elif bot_action == "🛑 Stop Telegram Bot":
307
+ settings.bot_auto_start = False
308
+ save_settings(settings)
297
309
  if pid:
298
310
  with console.status(f"[bold yellow]Stopping Telegram Bot (PID: {pid})...[/bold yellow]"):
299
311
  terminated = terminate_process_by_pid(pid)
300
312
  if terminated:
301
313
  console.print("[bold green]✔ Telegram Bot stopped successfully.[/bold green]")
314
+ console.print("[dim]Auto-restore on reboot: Disabled[/dim]")
302
315
  else:
303
316
  console.print("[bold red]Failed to stop Telegram Bot process.[/bold red]")
304
317
  else:
@@ -447,3 +460,59 @@ def _handle_telegram_bot_menu() -> None:
447
460
  console.print()
448
461
  questionary.press_any_key_to_continue().ask()
449
462
 
463
+
464
+ def _handle_autostart_menu() -> None:
465
+ """Dedicated interactive submenu for managing system boot autostart."""
466
+ from rich.panel import Panel
467
+ from tunnelmate.core.autostart import disable_autostart, enable_autostart, is_autostart_enabled
468
+
469
+ while True:
470
+ console.clear()
471
+ print_banner()
472
+
473
+ enabled, details = is_autostart_enabled()
474
+ status_text = "[bold green]🟢 ENABLED[/bold green]" if enabled else "[bold red]🔴 DISABLED[/bold red]"
475
+
476
+ console.print(
477
+ Panel(
478
+ f"Auto-Start on Boot: {status_text}\n"
479
+ f"Configuration: [white]{details}[/white]\n\n"
480
+ "[dim]When enabled, the TunnelMate daemon launches automatically on system restart.\n"
481
+ "All tunnels and Telegram bot active before reboot will automatically resume.[/dim]",
482
+ title="⚙️ [bold cyan]System Boot Auto-Start[/bold cyan]",
483
+ border_style="cyan",
484
+ )
485
+ )
486
+
487
+ choices = []
488
+ if enabled:
489
+ choices.append("❌ Disable Auto-Start on Boot")
490
+ else:
491
+ choices.append("✔ Enable Auto-Start on Boot")
492
+ choices.append("🔙 Back to Main Menu")
493
+
494
+ action = questionary.select("Select action:", choices=choices).ask()
495
+ if not action or action == "🔙 Back to Main Menu":
496
+ break
497
+
498
+ if action == "✔ Enable Auto-Start on Boot":
499
+ with console.status("[bold cyan]Configuring system auto-start for TunnelMate daemon...[/bold cyan]"):
500
+ ok, msg = enable_autostart()
501
+ if ok:
502
+ console.print(f"[bold green]✔ {msg}[/bold green]")
503
+ else:
504
+ console.print(f"[bold red]❌ {msg}[/bold red]")
505
+ console.print()
506
+ questionary.press_any_key_to_continue().ask()
507
+
508
+ elif action == "❌ Disable Auto-Start on Boot":
509
+ with console.status("[bold yellow]Removing auto-start configuration...[/bold yellow]"):
510
+ ok, msg = disable_autostart()
511
+ if ok:
512
+ console.print(f"[bold yellow]✔ {msg}[/bold yellow]")
513
+ else:
514
+ console.print(f"[bold red]❌ {msg}[/bold red]")
515
+ console.print()
516
+ questionary.press_any_key_to_continue().ask()
517
+
518
+
@@ -42,9 +42,11 @@ app = typer.Typer(
42
42
  )
43
43
  daemon_app = typer.Typer(name="daemon", help="Manage background daemon service.")
44
44
  bot_app = typer.Typer(name="bot", help="Manage Telegram remote management bot.")
45
+ autostart_app = typer.Typer(name="autostart", help="Configure system boot / restart autostart for TunnelMate.")
45
46
 
46
47
  app.add_typer(daemon_app, name="daemon")
47
48
  app.add_typer(bot_app, name="bot")
49
+ app.add_typer(autostart_app, name="autostart")
48
50
 
49
51
 
50
52
  def _get_client() -> TunnelMateClient:
@@ -382,6 +384,46 @@ def cmd_daemon_status():
382
384
  console.print(f"[bold yellow]Daemon process exists (PID: {pid}) but IPC is unresponsive.[/bold yellow]")
383
385
 
384
386
 
387
+ @autostart_app.command(name="enable")
388
+ @daemon_app.command(name="enable")
389
+ def cmd_autostart_enable():
390
+ """Enable TunnelMate daemon to start automatically on system reboot / boot."""
391
+ from tunnelmate.core.autostart import enable_autostart
392
+
393
+ with console.status("[bold cyan]Configuring system auto-start for TunnelMate daemon...[/bold cyan]"):
394
+ success, msg = enable_autostart()
395
+ if success:
396
+ console.print(f"[bold green]✔ {msg}[/bold green]")
397
+ else:
398
+ console.print(f"[bold red]❌ {msg}[/bold red]")
399
+
400
+
401
+ @autostart_app.command(name="disable")
402
+ @daemon_app.command(name="disable")
403
+ def cmd_autostart_disable():
404
+ """Disable TunnelMate daemon from starting automatically on system boot."""
405
+ from tunnelmate.core.autostart import disable_autostart
406
+
407
+ with console.status("[bold yellow]Removing auto-start configuration...[/bold yellow]"):
408
+ success, msg = disable_autostart()
409
+ if success:
410
+ console.print(f"[bold yellow]✔ {msg}[/bold yellow]")
411
+ else:
412
+ console.print(f"[bold red]❌ {msg}[/bold red]")
413
+
414
+
415
+ @autostart_app.command(name="status")
416
+ def cmd_autostart_status():
417
+ """Check if TunnelMate daemon is configured to start on boot / restart."""
418
+ from tunnelmate.core.autostart import is_autostart_enabled
419
+
420
+ enabled, msg = is_autostart_enabled()
421
+ if enabled:
422
+ console.print(f"[bold green]✔ Auto-Start is ENABLED on boot:[/bold green] {msg}")
423
+ else:
424
+ console.print(f"[bold red]❌ Auto-Start is DISABLED on boot:[/bold red] {msg}")
425
+
426
+
385
427
  # --- Telegram Bot Subcommands ---
386
428
 
387
429
 
@@ -540,11 +582,12 @@ def cmd_bot_status():
540
582
  console.print(f"Telegram Token Configured: {'[bold green]Yes[/bold green]' if has_token else '[bold red]No[/bold red]'}")
541
583
  console.print(f"Admin Password Configured: {'[bold green]Yes[/bold green]' if has_pwd else '[bold yellow]No[/bold yellow]'}")
542
584
  console.print(f"Admin Whitelisted Chat IDs: {admins_count}")
585
+ console.print(f"Auto-Restore on Reboot: {'[bold green]Enabled[/bold green]' if settings.bot_auto_start else '[dim]Disabled[/dim]'}")
543
586
 
544
587
 
545
588
  @bot_app.command(name="start")
546
589
  def cmd_bot_start():
547
- """Launch Telegram Bot in background."""
590
+ """Launch Telegram Bot in background and enable auto-restore."""
548
591
  from tunnelmate.bot.telegram_bot import TunnelMateBot
549
592
  settings = load_settings()
550
593
  if not settings.telegram_bot_token:
@@ -555,26 +598,39 @@ def cmd_bot_start():
555
598
  existing_pid = TunnelMateBot.get_running_pid(settings)
556
599
  if existing_pid:
557
600
  console.print(f"[bold yellow]Telegram Bot is already running in background (PID: {existing_pid}).[/bold yellow]")
601
+ if not settings.bot_auto_start:
602
+ settings.bot_auto_start = True
603
+ save_settings(settings)
558
604
  return
559
605
 
560
606
  # Spawn bot process in background
561
607
  cmd = [sys.executable, "-m", "tunnelmate.bot.telegram_bot"]
562
608
  spawn_background_process(cmd)
563
609
 
610
+ # Persist autostart state
611
+ settings.bot_auto_start = True
612
+ save_settings(settings)
613
+
564
614
  time.sleep(1.0)
565
615
  new_pid = TunnelMateBot.get_running_pid(settings)
566
616
  if new_pid:
567
617
  console.print(f"[bold green]✔ TunnelMate Telegram bot started in background (PID: {new_pid}).[/bold green]")
618
+ console.print("[dim]Auto-restore on system restart: Enabled[/dim]")
568
619
  else:
569
620
  console.print("[bold red]Failed to start bot in background. Try 'tunnelmate bot run' to view logs.[/bold red]")
570
621
 
571
622
 
572
623
  @bot_app.command(name="stop")
573
624
  def cmd_bot_stop():
574
- """Stop the background Telegram Bot process."""
625
+ """Stop the background Telegram Bot process and disable auto-restore."""
575
626
  from tunnelmate.bot.telegram_bot import TunnelMateBot
576
627
  settings = load_settings()
577
628
  pid = TunnelMateBot.get_running_pid(settings)
629
+
630
+ # Set bot_auto_start to False
631
+ settings.bot_auto_start = False
632
+ save_settings(settings)
633
+
578
634
  if not pid:
579
635
  console.print("[dim]Telegram Bot is not currently running.[/dim]")
580
636
  return
@@ -34,6 +34,7 @@ class Settings(BaseModel):
34
34
  admin_password_salt: Optional[str] = None
35
35
  admin_chat_ids: List[int] = Field(default_factory=list)
36
36
  allow_public_subscribers: bool = True
37
+ bot_auto_start: bool = False
37
38
 
38
39
  # Supervision & Automation
39
40
  health_check_interval: int = 60 # seconds
@@ -0,0 +1,283 @@
1
+ """Cross-platform system boot and reboot autostart management for TunnelMate daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Tuple
12
+
13
+
14
+ def _get_daemon_exec_cmd() -> str:
15
+ """Return the optimal command string to execute the daemon."""
16
+ which_tm = shutil.which("tunnelmate")
17
+ if which_tm:
18
+ return f"{which_tm} daemon run"
19
+ return f"{sys.executable} -m tunnelmate.daemon.service"
20
+
21
+
22
+ def is_autostart_enabled() -> Tuple[bool, str]:
23
+ """Check if TunnelMate daemon is configured to start on system boot."""
24
+ if sys.platform == "win32":
25
+ try:
26
+ import winreg
27
+
28
+ key = winreg.OpenKey(
29
+ winreg.HKEY_CURRENT_USER,
30
+ r"Software\Microsoft\Windows\CurrentVersion\Run",
31
+ 0,
32
+ winreg.KEY_READ,
33
+ )
34
+ try:
35
+ val, _ = winreg.QueryValueEx(key, "TunnelMateDaemon")
36
+ return True, f"Windows Registry Run key configured: {val}"
37
+ finally:
38
+ winreg.CloseKey(key)
39
+ except OSError:
40
+ pass
41
+
42
+ # Check schtasks
43
+ try:
44
+ res = subprocess.run(
45
+ ["schtasks", "/Query", "/TN", "TunnelMateDaemon"],
46
+ capture_output=True,
47
+ text=True,
48
+ )
49
+ if res.returncode == 0:
50
+ return True, "Windows Scheduled Task 'TunnelMateDaemon' active"
51
+ except Exception:
52
+ pass
53
+
54
+ return False, "Autostart is not configured on Windows."
55
+
56
+ else:
57
+ # Linux / Unix
58
+ if shutil.which("systemctl"):
59
+ try:
60
+ res = subprocess.run(
61
+ ["systemctl", "--user", "is-enabled", "tunnelmate-daemon.service"],
62
+ capture_output=True,
63
+ text=True,
64
+ )
65
+ if res.returncode == 0 and "enabled" in res.stdout.lower():
66
+ return True, "systemd user service 'tunnelmate-daemon' is enabled"
67
+ except Exception:
68
+ pass
69
+
70
+ # Check crontab
71
+ if shutil.which("crontab"):
72
+ try:
73
+ res = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
74
+ if res.returncode == 0 and "@reboot" in res.stdout and "tunnelmate" in res.stdout:
75
+ return True, "Cron @reboot entry configured"
76
+ except Exception:
77
+ pass
78
+
79
+ return False, "Autostart is not enabled on Linux."
80
+
81
+
82
+ def enable_autostart() -> Tuple[bool, str]:
83
+ """Configure system to start TunnelMate daemon automatically on boot/restart."""
84
+ exec_cmd = _get_daemon_exec_cmd()
85
+
86
+ if sys.platform == "win32":
87
+ details = []
88
+ # 1. Register in HKCU Run Key
89
+ try:
90
+ import winreg
91
+
92
+ key = winreg.OpenKey(
93
+ winreg.HKEY_CURRENT_USER,
94
+ r"Software\Microsoft\Windows\CurrentVersion\Run",
95
+ 0,
96
+ winreg.KEY_SET_VALUE,
97
+ )
98
+ try:
99
+ # Use pythonw if available for windowless execution on Windows
100
+ pythonw = Path(sys.executable).parent / "pythonw.exe"
101
+ run_target = (
102
+ f'"{pythonw}" -m tunnelmate.daemon.service'
103
+ if pythonw.exists()
104
+ else f'"{sys.executable}" -m tunnelmate.daemon.service'
105
+ )
106
+ winreg.SetValueEx(key, "TunnelMateDaemon", 0, winreg.REG_SZ, run_target)
107
+ details.append("Windows Registry Run key added")
108
+ finally:
109
+ winreg.CloseKey(key)
110
+ except Exception as e:
111
+ details.append(f"Registry error: {e}")
112
+
113
+ # 2. Add Task Scheduler task
114
+ try:
115
+ cmd = [
116
+ "schtasks",
117
+ "/Create",
118
+ "/TN",
119
+ "TunnelMateDaemon",
120
+ "/TR",
121
+ f'"{sys.executable}" -m tunnelmate.daemon.service',
122
+ "/SC",
123
+ "ONLOGON",
124
+ "/F",
125
+ ]
126
+ res = subprocess.run(cmd, capture_output=True, text=True)
127
+ if res.returncode == 0:
128
+ details.append("Scheduled Task 'TunnelMateDaemon' created")
129
+ except Exception:
130
+ pass
131
+
132
+ return True, f"Auto-start successfully enabled on Windows ({'; '.join(details)})."
133
+
134
+ else:
135
+ # Linux / Unix
136
+ systemd_configured = False
137
+ linger_configured = False
138
+ cron_configured = False
139
+
140
+ if shutil.which("systemctl"):
141
+ try:
142
+ user_unit_dir = Path.home() / ".config" / "systemd" / "user"
143
+ user_unit_dir.mkdir(parents=True, exist_ok=True)
144
+ service_file = user_unit_dir / "tunnelmate-daemon.service"
145
+
146
+ unit_content = f"""[Unit]
147
+ Description=TunnelMate Cloudflare Quick Tunnel Daemon
148
+ Documentation=https://github.com/tunnelmate/tunnelmate
149
+ After=network-online.target
150
+ Wants=network-online.target
151
+
152
+ [Service]
153
+ Type=simple
154
+ ExecStart={exec_cmd}
155
+ Restart=always
156
+ RestartSec=5s
157
+ Environment="PYTHONUNBUFFERED=1"
158
+
159
+ [Install]
160
+ WantedBy=default.target
161
+ """
162
+ service_file.write_text(unit_content, encoding="utf-8")
163
+
164
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
165
+ res_enable = subprocess.run(
166
+ ["systemctl", "--user", "enable", "tunnelmate-daemon.service"],
167
+ capture_output=True,
168
+ text=True,
169
+ )
170
+ if res_enable.returncode == 0:
171
+ systemd_configured = True
172
+ # Also start now if stopped
173
+ subprocess.run(
174
+ ["systemctl", "--user", "start", "tunnelmate-daemon.service"],
175
+ capture_output=True,
176
+ )
177
+ except Exception:
178
+ pass
179
+
180
+ # Configure user lingering so user service boots even before manual login
181
+ if shutil.which("loginctl"):
182
+ try:
183
+ username = getpass.getuser()
184
+ res_linger = subprocess.run(
185
+ ["loginctl", "enable-linger", username],
186
+ capture_output=True,
187
+ text=True,
188
+ )
189
+ if res_linger.returncode == 0:
190
+ linger_configured = True
191
+ except Exception:
192
+ pass
193
+
194
+ # Fallback / supplementary crontab @reboot entry
195
+ if shutil.which("crontab"):
196
+ try:
197
+ res_cron = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
198
+ existing = res_cron.stdout if res_cron.returncode == 0 else ""
199
+ cron_line = f"@reboot {exec_cmd} >/dev/null 2>&1"
200
+ if cron_line not in existing:
201
+ new_cron = (existing.strip() + f"\n{cron_line}\n").strip() + "\n"
202
+ proc = subprocess.Popen(["crontab", "-"], stdin=subprocess.PIPE)
203
+ proc.communicate(new_cron.encode("utf-8"))
204
+ if proc.returncode == 0:
205
+ cron_configured = True
206
+ except Exception:
207
+ pass
208
+
209
+ msgs = []
210
+ if systemd_configured:
211
+ msgs.append("systemd user service enabled")
212
+ if linger_configured:
213
+ msgs.append("loginctl lingering enabled for boot persistence")
214
+ if cron_configured:
215
+ msgs.append("@reboot cron schedule registered")
216
+
217
+ if msgs:
218
+ return True, f"Auto-start successfully enabled: {', '.join(msgs)}."
219
+ return False, "Failed to register autostart via systemd or crontab."
220
+
221
+
222
+ def disable_autostart() -> Tuple[bool, str]:
223
+ """Remove TunnelMate daemon from system boot / restart autostart."""
224
+ if sys.platform == "win32":
225
+ details = []
226
+ try:
227
+ import winreg
228
+
229
+ key = winreg.OpenKey(
230
+ winreg.HKEY_CURRENT_USER,
231
+ r"Software\Microsoft\Windows\CurrentVersion\Run",
232
+ 0,
233
+ winreg.KEY_SET_VALUE,
234
+ )
235
+ try:
236
+ winreg.DeleteValue(key, "TunnelMateDaemon")
237
+ details.append("Registry Run key removed")
238
+ finally:
239
+ winreg.CloseKey(key)
240
+ except OSError:
241
+ pass
242
+
243
+ try:
244
+ subprocess.run(
245
+ ["schtasks", "/Delete", "/TN", "TunnelMateDaemon", "/F"],
246
+ capture_output=True,
247
+ )
248
+ details.append("Scheduled task removed")
249
+ except Exception:
250
+ pass
251
+
252
+ return True, "Auto-start disabled on Windows."
253
+
254
+ else:
255
+ # Linux
256
+ details = []
257
+ if shutil.which("systemctl"):
258
+ try:
259
+ subprocess.run(
260
+ ["systemctl", "--user", "disable", "tunnelmate-daemon.service"],
261
+ capture_output=True,
262
+ )
263
+ subprocess.run(
264
+ ["systemctl", "--user", "stop", "tunnelmate-daemon.service"],
265
+ capture_output=True,
266
+ )
267
+ details.append("systemd user service disabled")
268
+ except Exception:
269
+ pass
270
+
271
+ if shutil.which("crontab"):
272
+ try:
273
+ res_cron = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
274
+ if res_cron.returncode == 0 and "tunnelmate" in res_cron.stdout:
275
+ lines = [l for l in res_cron.stdout.splitlines() if "tunnelmate" not in l]
276
+ new_cron = "\n".join(lines).strip() + "\n" if lines else ""
277
+ proc = subprocess.Popen(["crontab", "-"], stdin=subprocess.PIPE)
278
+ proc.communicate(new_cron.encode("utf-8"))
279
+ details.append("crontab entry removed")
280
+ except Exception:
281
+ pass
282
+
283
+ return True, f"Auto-start disabled ({', '.join(details) if details else 'None'})."
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from typing import Optional
13
13
 
14
14
  from tunnelmate.config import Settings, load_settings
15
- from tunnelmate.core.process import is_pid_alive
15
+ from tunnelmate.core.process import is_pid_alive, spawn_background_process
16
16
  from tunnelmate.core.supervisor import TunnelSupervisor
17
17
  from tunnelmate.daemon.server import DaemonIPCServer
18
18
  from tunnelmate.db.repository import Repository
@@ -66,6 +66,11 @@ class DaemonService:
66
66
  while not self._stop_event.is_set():
67
67
  try:
68
68
  await asyncio.sleep(interval)
69
+ # Keep settings synced with disk (e.g., bot_auto_start changes)
70
+ try:
71
+ self.settings = load_settings()
72
+ except Exception:
73
+ pass
69
74
  tunnels = await asyncio.to_thread(self.supervisor.list_tunnels)
70
75
  for t in tunnels:
71
76
  if t.status.value == "running":
@@ -73,6 +78,17 @@ class DaemonService:
73
78
  await asyncio.to_thread(self.supervisor.check_health, t.id)
74
79
  except Exception as e:
75
80
  logger.debug("Health check failed for %s: %s", t.name, e)
81
+
82
+ # Auto-recover Telegram Bot if bot_auto_start is True and bot process exited
83
+ if self.settings.bot_auto_start and self.settings.telegram_bot_token:
84
+ from tunnelmate.bot.telegram_bot import TunnelMateBot
85
+ if not TunnelMateBot.get_running_pid(self.settings):
86
+ logger.info("Telegram Bot was active but process exited. Auto-recovering...")
87
+ try:
88
+ cmd = [sys.executable, "-m", "tunnelmate.bot.telegram_bot"]
89
+ spawn_background_process(cmd)
90
+ except Exception as e:
91
+ logger.debug("Could not recover Telegram Bot: %s", e)
76
92
  except asyncio.CancelledError:
77
93
  break
78
94
  except Exception as e:
@@ -102,16 +118,28 @@ class DaemonService:
102
118
  # Start periodic health task
103
119
  self._health_task = asyncio.create_task(self._health_loop())
104
120
 
105
- # Auto-restart tunnels marked as running previously if needed
121
+ # Auto-restart tunnels marked as running previously
106
122
  try:
107
123
  tunnels = await asyncio.to_thread(self.supervisor.list_tunnels)
108
124
  for t in tunnels:
109
- if t.auto_restart and t.status.value == "running":
125
+ if t.status.value in ("running", "starting", "restarting"):
110
126
  logger.info("Restoring previously active tunnel: %s", t.name)
111
127
  asyncio.create_task(asyncio.to_thread(self.supervisor.start_tunnel, t.id))
112
128
  except Exception as e:
113
129
  logger.warning("Could not restore previous tunnels: %s", e)
114
130
 
131
+ # Auto-restore Telegram Bot if it was previously active
132
+ if self.settings.bot_auto_start and self.settings.telegram_bot_token:
133
+ from tunnelmate.bot.telegram_bot import TunnelMateBot
134
+ existing_bot_pid = TunnelMateBot.get_running_pid(self.settings)
135
+ if not existing_bot_pid:
136
+ logger.info("Restoring previously active Telegram Bot in background...")
137
+ try:
138
+ cmd = [sys.executable, "-m", "tunnelmate.bot.telegram_bot"]
139
+ spawn_background_process(cmd)
140
+ except Exception as e:
141
+ logger.warning("Could not auto-restore Telegram Bot: %s", e)
142
+
115
143
  # Wait until stopped
116
144
  await self._stop_event.wait()
117
145
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tunnelmate-cli
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Cloudflare Quick Tunnel manager with CLI, Interactive TUI, Daemon IPC, and Telegram Bot
5
5
  Author-email: Shashan Lumbhani <lumbhanishashan1510@gmail.com>
6
6
  License: MIT
@@ -20,6 +20,7 @@ tunnelmate/cli/ssh_client.py
20
20
  tunnelmate/client/__init__.py
21
21
  tunnelmate/client/ipc_client.py
22
22
  tunnelmate/core/__init__.py
23
+ tunnelmate/core/autostart.py
23
24
  tunnelmate/core/cloudflared.py
24
25
  tunnelmate/core/health.py
25
26
  tunnelmate/core/models.py
@@ -1,36 +0,0 @@
1
- """Tests for cross-platform process management and Windows/Linux compatibility."""
2
-
3
- import os
4
- import sys
5
- from pathlib import Path
6
- from tunnelmate.core.process import is_pid_alive, spawn_background_process, terminate_process_by_pid
7
- from tunnelmate.core.cloudflared import find_cloudflared
8
- from tunnelmate.cli.ssh_client import clean_ssh_target
9
-
10
-
11
- def test_is_pid_alive():
12
- # Current process must be alive
13
- assert is_pid_alive(os.getpid()) is True
14
- # Non-existent PID must not be alive
15
- assert is_pid_alive(99999999) is False
16
- assert is_pid_alive(-1) is False
17
-
18
-
19
- def test_spawn_and_terminate_process():
20
- # Launch a short-lived python background sleep process
21
- cmd = [sys.executable, "-c", "import time; time.sleep(10)"]
22
- proc = spawn_background_process(cmd)
23
- pid = proc.pid
24
- assert pid is not None
25
- assert is_pid_alive(pid) is True
26
-
27
- # Terminate process safely using cross-platform helper
28
- terminated = terminate_process_by_pid(pid, timeout=3.0)
29
- assert terminated is True
30
- assert is_pid_alive(pid) is False
31
-
32
-
33
- def test_clean_ssh_target_windows_formats():
34
- assert clean_ssh_target("shashan@subjective.trycloudflare.com") == "shashan@subjective.trycloudflare.com"
35
- assert clean_ssh_target("shashan@https://subjective.trycloudflare.com/") == "shashan@subjective.trycloudflare.com"
36
- assert clean_ssh_target("https://subjective.trycloudflare.com:22") == "subjective.trycloudflare.com"
File without changes
File without changes