mirror of
https://github.com/browser-use/browser-use
synced 2026-04-22 17:45:09 +02:00
- 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()
346 lines
9.3 KiB
Python
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)}
|