208 lines
7.3 KiB
Python
208 lines
7.3 KiB
Python
"""Cloudflare tunnel lifecycle manager."""
|
|
|
|
import asyncio
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
|
|
from ..constants import TUNNEL_START_TIMEOUT
|
|
from ..logging import debug_log
|
|
from ..network import get_server_port, normalize_host
|
|
from ..process import is_process_alive, terminate_process
|
|
from .binary import ensure_binary
|
|
from .process_reader import ProcessReader
|
|
from .state import clear_tunnel_state, load_tunnel_state, persist_tunnel_state, resolve_restore_master_host
|
|
|
|
|
|
class CloudflareTunnelManager:
|
|
def __init__(self):
|
|
self.process = None
|
|
self.pid = None
|
|
self.public_url = None
|
|
self.last_error = None
|
|
self.log_file = None
|
|
self.status = "stopped"
|
|
self.previous_master_host = None
|
|
|
|
self._lock = asyncio.Lock()
|
|
self._reader = ProcessReader()
|
|
self.binary_path = None
|
|
|
|
self._restore_state()
|
|
|
|
@property
|
|
def base_dir(self):
|
|
return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
|
|
|
def _restore_state(self):
|
|
state = load_tunnel_state()
|
|
|
|
self.public_url = state.get("public_url") or None
|
|
self.previous_master_host = state.get("previous_master_host")
|
|
self.log_file = state.get("log_file")
|
|
pid = state.get("pid")
|
|
|
|
if pid and is_process_alive(pid):
|
|
self.pid = pid
|
|
self.status = state.get("status") or "running"
|
|
debug_log(f"Detected existing cloudflared process (pid={pid})")
|
|
else:
|
|
clear_tunnel_state(log_file=self.log_file, previous_host=self.previous_master_host)
|
|
self.status = "stopped"
|
|
self.pid = None
|
|
|
|
async def start_tunnel(self):
|
|
async with self._lock:
|
|
if self.process and self.process.poll() is None:
|
|
return {
|
|
"status": self.status,
|
|
"public_url": self.public_url,
|
|
"pid": self.process.pid,
|
|
"log_file": self.log_file,
|
|
}
|
|
|
|
if self.pid and is_process_alive(self.pid):
|
|
debug_log(f"Stopping stale cloudflared pid {self.pid} before starting a new one")
|
|
await self.stop_tunnel()
|
|
|
|
binary = await asyncio.to_thread(ensure_binary)
|
|
self.binary_path = binary
|
|
port = get_server_port()
|
|
self.status = "starting"
|
|
self.last_error = None
|
|
self.public_url = None
|
|
|
|
state = load_tunnel_state()
|
|
master_host = state.get("master_host") or ""
|
|
if state.get("previous_master_host"):
|
|
self.previous_master_host = state.get("previous_master_host")
|
|
else:
|
|
self.previous_master_host = master_host
|
|
|
|
os.makedirs(os.path.join(self.base_dir, "logs"), exist_ok=True)
|
|
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
|
self.log_file = os.path.join(self.base_dir, "logs", f"cloudflare-{timestamp}.log")
|
|
|
|
cmd = [
|
|
binary,
|
|
"tunnel",
|
|
"--no-autoupdate",
|
|
"--url",
|
|
f"http://127.0.0.1:{port}",
|
|
]
|
|
|
|
debug_log(f"Starting cloudflared: {' '.join(cmd)}")
|
|
try:
|
|
self.process = subprocess.Popen(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
)
|
|
except FileNotFoundError:
|
|
self.status = "error"
|
|
raise RuntimeError("cloudflared binary not found")
|
|
except Exception as exc:
|
|
self.status = "error"
|
|
raise RuntimeError(f"Failed to start cloudflared: {exc}") from exc
|
|
|
|
self.pid = self.process.pid
|
|
persist_tunnel_state(
|
|
status="starting",
|
|
pid=self.pid,
|
|
log_file=self.log_file,
|
|
previous_host=self.previous_master_host,
|
|
)
|
|
|
|
loop = asyncio.get_running_loop()
|
|
self._reader.set_log_file(self.log_file)
|
|
self._reader.start(self.process, loop)
|
|
|
|
try:
|
|
await self._reader.wait_for_url(timeout=TUNNEL_START_TIMEOUT)
|
|
except asyncio.TimeoutError:
|
|
self.last_error = "Timed out waiting for Cloudflare to assign a URL"
|
|
await self.stop_tunnel()
|
|
raise RuntimeError(self.last_error)
|
|
|
|
public_url = self._reader.get_url()
|
|
if not public_url:
|
|
self.last_error = self._reader.get_last_error() or "Cloudflare tunnel failed to start"
|
|
await self.stop_tunnel()
|
|
raise RuntimeError(self.last_error)
|
|
|
|
self.public_url = public_url
|
|
self.status = "running"
|
|
debug_log(f"Cloudflare tunnel ready at {self.public_url}")
|
|
|
|
persist_tunnel_state(
|
|
status="running",
|
|
public_url=self.public_url,
|
|
pid=self.pid,
|
|
log_file=self.log_file,
|
|
previous_host=self.previous_master_host or "",
|
|
master_host=normalize_host(self.public_url),
|
|
)
|
|
return {
|
|
"status": self.status,
|
|
"public_url": self.public_url,
|
|
"pid": self.pid,
|
|
"log_file": self.log_file,
|
|
}
|
|
|
|
async def stop_tunnel(self):
|
|
async with self._lock:
|
|
pid = self.process.pid if self.process else self.pid
|
|
if not pid:
|
|
clear_tunnel_state(log_file=self.log_file, previous_host=self.previous_master_host)
|
|
self.status = "stopped"
|
|
return {"status": "stopped"}
|
|
|
|
debug_log(f"Stopping cloudflared (pid={pid})")
|
|
if self.process:
|
|
terminate_process(self.process, timeout=5)
|
|
else:
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
time.sleep(0.5)
|
|
except Exception as exc: # pragma: no cover
|
|
debug_log(f"Error stopping cloudflared pid {pid}: {exc}")
|
|
|
|
restore_host = resolve_restore_master_host(self.previous_master_host)
|
|
|
|
self.status = "stopped"
|
|
self.public_url = None
|
|
self.pid = None
|
|
self.process = None
|
|
self.last_error = None
|
|
self._reader.stop()
|
|
|
|
clear_tunnel_state(
|
|
log_file=self.log_file,
|
|
previous_host=self.previous_master_host,
|
|
master_host=restore_host,
|
|
)
|
|
return {"status": "stopped"}
|
|
|
|
def get_status(self):
|
|
alive = False
|
|
pid = self.process.pid if self.process else self.pid
|
|
if pid:
|
|
alive = is_process_alive(pid)
|
|
if not alive and self.status == "running":
|
|
self.status = "stopped"
|
|
|
|
return {
|
|
"status": self.status,
|
|
"public_url": self.public_url,
|
|
"pid": pid,
|
|
"log_file": self.log_file,
|
|
"last_error": self.last_error or self._reader.get_last_error(),
|
|
"binary_path": self.binary_path or shutil.which("cloudflared"),
|
|
"recent_logs": self._reader.get_recent_logs()[-20:],
|
|
"previous_master_host": self.previous_master_host,
|
|
}
|