Files
browser-use/browser_use/skill_cli/tunnel.py
ShawnPana e09f55eedc chore: remove more dead code (second codex pass)
- Removed utils.py is_daemon_alive() (stale duplicate of main.py version)
- Removed TunnelManager.is_available() (unused, get_status() used instead)
- Removed dead json_output param from setup.handle()
2026-04-01 17:56:39 -07:00

346 lines
9.3 KiB
Python

"""Cloudflared tunnel binary management.
This module manages the cloudflared binary for tunnel support.
Cloudflared must be installed via install.sh or manually by the user.
Tunnels are managed independently of browser sessions - they are purely
a network utility for exposing local ports via Cloudflare quick tunnels.
Tunnels survive CLI process exit by:
1. Spawning cloudflared as a daemon (start_new_session on Unix, CREATE_NEW_PROCESS_GROUP on Windows)
2. Tracking tunnel info via PID files in ~/.browser-use/tunnels/
"""
import asyncio
import json
import logging
import os
import re
import shutil
import signal
import subprocess
import sys
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Pattern to extract tunnel URL from cloudflared output
_URL_PATTERN = re.compile(r'(https://\S+\.trycloudflare\.com)')
def _tunnels_dir() -> Path:
"""Get tunnel metadata directory (lazy to respect BROWSER_USE_HOME)."""
from browser_use.skill_cli.utils import get_tunnel_dir
return get_tunnel_dir()
class TunnelManager:
"""Manages cloudflared binary location."""
def __init__(self) -> None:
self._binary_path: str | None = None
def get_binary_path(self) -> str:
"""Get cloudflared binary path.
Returns:
Absolute path to cloudflared binary
Raises:
RuntimeError: If cloudflared is not installed
"""
# Cached result from previous call
if self._binary_path:
return self._binary_path
# Check system installation
system_binary = shutil.which('cloudflared')
if system_binary:
logger.info('Using cloudflared: %s', system_binary)
self._binary_path = system_binary
return system_binary
# Not found
raise RuntimeError(
'cloudflared not installed.\n\n'
'Install cloudflared:\n'
' macOS: brew install cloudflared\n'
' Linux: curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o ~/.local/bin/cloudflared && chmod +x ~/.local/bin/cloudflared\n'
' Windows: winget install Cloudflare.cloudflared\n\n'
'Or re-run install.sh which installs cloudflared automatically.\n\n'
'Then retry: browser-use tunnel <port>'
)
def get_status(self) -> dict[str, Any]:
"""Get tunnel capability status for doctor command."""
system_binary = shutil.which('cloudflared')
if system_binary:
return {
'available': True,
'source': 'system',
'path': system_binary,
'note': 'cloudflared installed',
}
return {
'available': False,
'source': None,
'path': None,
'note': 'cloudflared not installed - run install.sh or install manually',
}
# Global singleton instance
_tunnel_manager: TunnelManager | None = None
def get_tunnel_manager() -> TunnelManager:
"""Get the global TunnelManager instance (singleton pattern)."""
global _tunnel_manager
if _tunnel_manager is None:
_tunnel_manager = TunnelManager()
return _tunnel_manager
# =============================================================================
# PID File Management
# =============================================================================
def _get_tunnel_file(port: int) -> Path:
"""Get the path to a tunnel's info file."""
return _tunnels_dir() / f'{port}.json'
def _save_tunnel_info(port: int, pid: int, url: str) -> None:
"""Save tunnel info to disk."""
_tunnels_dir().mkdir(parents=True, exist_ok=True)
_get_tunnel_file(port).write_text(json.dumps({'port': port, 'pid': pid, 'url': url}))
def _load_tunnel_info(port: int) -> dict[str, Any] | None:
"""Load tunnel info from disk, returning None if not found or process dead."""
tunnel_file = _get_tunnel_file(port)
if not tunnel_file.exists():
return None
try:
info = json.loads(tunnel_file.read_text())
pid = info.get('pid')
if pid and _is_process_alive(pid):
return info
# Process dead, clean up stale file
tunnel_file.unlink(missing_ok=True)
return None
except (json.JSONDecodeError, OSError):
tunnel_file.unlink(missing_ok=True)
return None
def _delete_tunnel_info(port: int) -> None:
"""Delete tunnel info file."""
_get_tunnel_file(port).unlink(missing_ok=True)
def _is_process_alive(pid: int) -> bool:
"""Check if a process is still running."""
from browser_use.skill_cli.utils import is_process_alive
return is_process_alive(pid)
def _kill_process(pid: int) -> bool:
"""Kill a process by PID. Returns True if killed, False if already dead."""
import time
if sys.platform == 'win32':
import ctypes
PROCESS_TERMINATE = 0x0001
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
if not handle:
return False
try:
ctypes.windll.kernel32.TerminateProcess(handle, 1)
for _ in range(10):
if not _is_process_alive(pid):
return True
time.sleep(0.1)
return not _is_process_alive(pid)
finally:
ctypes.windll.kernel32.CloseHandle(handle)
else:
try:
os.kill(pid, signal.SIGTERM)
for _ in range(10):
if not _is_process_alive(pid):
return True
time.sleep(0.1)
# Force kill if still alive
os.kill(pid, signal.SIGKILL)
return True
except (OSError, ProcessLookupError):
return False
# =============================================================================
# Standalone Tunnel Functions (no browser session required)
# =============================================================================
async def start_tunnel(port: int) -> dict[str, Any]:
"""Start a cloudflare quick tunnel for a local port.
The tunnel runs as a daemon process that survives CLI exit.
Args:
port: Local port to tunnel
Returns:
Dict with 'url' and 'port' on success, or 'error' on failure
"""
# Check if tunnel already exists for this port
existing = _load_tunnel_info(port)
if existing:
return {'url': existing['url'], 'port': port, 'existing': True}
# Get cloudflared binary
try:
tunnel_manager = get_tunnel_manager()
cloudflared_binary = tunnel_manager.get_binary_path()
except RuntimeError as e:
return {'error': str(e)}
# Create log file for cloudflared stderr (avoids SIGPIPE when parent exits)
_tunnels_dir().mkdir(parents=True, exist_ok=True)
log_file_path = _tunnels_dir() / f'{port}.log'
log_file = open(log_file_path, 'w') # noqa: ASYNC230
# Spawn cloudflared as a daemon
# - start_new_session / creationflags: survives parent exit
# - stderr to file: avoids SIGPIPE when parent's pipe closes
spawn_kwargs: dict[str, Any] = {}
if sys.platform == 'win32':
spawn_kwargs['creationflags'] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
else:
spawn_kwargs['start_new_session'] = True
process = await asyncio.create_subprocess_exec(
cloudflared_binary,
'tunnel',
'--url',
f'http://localhost:{port}',
stdout=asyncio.subprocess.DEVNULL,
stderr=log_file,
**spawn_kwargs,
)
# Poll the log file until we find the tunnel URL
url: str | None = None
try:
import time
deadline = time.time() + 15
while time.time() < deadline:
# Check if process died
if process.returncode is not None:
log_file.close()
content = log_file_path.read_text() if log_file_path.exists() else ''
return {'error': f'cloudflared exited unexpectedly: {content[:500]}'}
# Read log file content
try:
content = log_file_path.read_text()
match = _URL_PATTERN.search(content)
if match:
url = match.group(1)
break
except OSError:
pass
await asyncio.sleep(0.2)
except Exception as e:
process.terminate()
log_file.close()
return {'error': f'Failed to start tunnel: {e}'}
if url is None:
process.terminate()
log_file.close()
return {'error': 'Timed out waiting for cloudflare tunnel URL (15s)'}
# Close log file handle to avoid leaking file descriptors
log_file.close()
# Save tunnel info to disk so it persists across CLI invocations
_save_tunnel_info(port, process.pid, url)
logger.info(f'Tunnel started: localhost:{port} -> {url} (pid={process.pid})')
return {'url': url, 'port': port}
def list_tunnels() -> dict[str, Any]:
"""List active tunnels.
Returns:
Dict with 'tunnels' list and 'count'
"""
tunnels = []
if _tunnels_dir().exists():
for tunnel_file in _tunnels_dir().glob('*.json'):
try:
port = int(tunnel_file.stem)
info = _load_tunnel_info(port)
if info:
tunnels.append({'port': info['port'], 'url': info['url']})
except (ValueError, json.JSONDecodeError):
continue
return {'tunnels': tunnels, 'count': len(tunnels)}
async def stop_tunnel(port: int) -> dict[str, Any]:
"""Stop a tunnel for a specific port.
Args:
port: Port number to stop tunnel for
Returns:
Dict with 'stopped' port and 'url' on success, or 'error'
"""
info = _load_tunnel_info(port)
if not info:
return {'error': f'No tunnel running on port {port}'}
url = info['url']
pid = info['pid']
_kill_process(pid)
_delete_tunnel_info(port)
# Clean up log file
log_file = _tunnels_dir() / f'{port}.log'
log_file.unlink(missing_ok=True)
logger.info(f'Tunnel stopped: localhost:{port}')
return {'stopped': port, 'url': url}
async def stop_all_tunnels() -> dict[str, Any]:
"""Stop all active tunnels.
Returns:
Dict with 'stopped' list of ports
"""
stopped = []
if _tunnels_dir().exists():
for tunnel_file in _tunnels_dir().glob('*.json'):
try:
port = int(tunnel_file.stem)
result = await stop_tunnel(port)
if 'stopped' in result:
stopped.append(port)
except ValueError:
continue
return {'stopped': stopped, 'count': len(stopped)}