diff --git a/capabilities/web-security/agents/web-security.md b/capabilities/web-security/agents/web-security.md index 0065228..c6a8164 100644 --- a/capabilities/web-security/agents/web-security.md +++ b/capabilities/web-security/agents/web-security.md @@ -86,6 +86,7 @@ Use tools proactively when they reduce uncertainty or verify a finding. Match th - Use `get_callback_url` and `check_callbacks` for out-of-band testing (blind SSRF, blind XSS, DNS exfiltration). - Use `list_free_phone_numbers` and `read_phone_inbox` when signup or MFA flows require SMS verification, unless prompted by the user. Free public numbers first — fall back to `request_private_number`/`poll_private_number` (paid API, needs key via `store_credential`) only when the target blocks public numbers. - Use `generate_rebinding_hostname` and `list_rebinding_presets` for DNS rebinding SSRF bypass when IP filters validate resolved addresses before fetching. +- Use `flareprox_*` tools for IP rotation only when `IPROTATE_ENABLED` is set and the target is rate-limiting, IP-banning, or WAF-blocking normal requests. Start with `flareprox_status`, deploy workers with `flareprox_create`, send requests with `flareprox_request`, and always run `flareprox_cleanup` when finished. - Use the local `pacu` CLI when an authorized test yields AWS credentials, cloud metadata access, or another AWS-impact lead that needs validation. Load the `pacu-aws-exploitation` skill first, confirm AWS scope, and start with identity/read-only enumeration before any mutating module. - Use `log_image_output`, `log_audio_output`, and `log_video_output` when another tool has already written useful PoC media to disk and you need it attached to the current Dreadnode run as typed output. Use `log_file_artifact` when you want the raw file uploaded as an artifact instead of rendered media. - When a finding is browser-visible or a screenshot materially improves reproducibility, capture the screenshot and attach it to the run. Treat screenshot logging as standard evidence collection, not an optional flourish. diff --git a/capabilities/web-security/capability.yaml b/capabilities/web-security/capability.yaml index ebaebe9..5f0ef94 100644 --- a/capabilities/web-security/capability.yaml +++ b/capabilities/web-security/capability.yaml @@ -1,14 +1,15 @@ schema: 1 name: web-security -version: "1.1.2" +version: "1.1.3" description: > Web application penetration testing with 60+ attack technique playbooks covering request smuggling, cache poisoning, SSRF, SSTI, DOM vulnerabilities, authentication bypasses, parser differentials, AEM/Sling exploitation, and client-side attacks. Includes HTTP client tooling, Caido proxy integration via MCP, credential management, DNS - rebinding, AWS exploitation with Pacu, phone verification, and - vulnerability verification. + rebinding, AWS exploitation with Pacu, phone verification, + vulnerability verification, and Cloudflare Workers IP rotation via + Flareprox. mcp: servers: @@ -131,3 +132,6 @@ keywords: - dispatcher-bypass - aws-security - cloud-security + - ip-rotation + - waf-bypass + - rate-limit-bypass diff --git a/capabilities/web-security/skills/ip-rotation/SKILL.md b/capabilities/web-security/skills/ip-rotation/SKILL.md new file mode 100644 index 0000000..1b15556 --- /dev/null +++ b/capabilities/web-security/skills/ip-rotation/SKILL.md @@ -0,0 +1,126 @@ +--- +name: ip-rotation +description: Deploy Cloudflare Workers proxies (Flareprox) to bypass rate limiting, IP bans, and geo-blocking during web security testing. Use only when IPROTATE_ENABLED is set and the target is blocking normal requests. Triggers on "rate limited", "IP banned", "blocked by WAF", "429", "403", "geo-blocked", "rotate IP", "IP rotation", "flareprox". +--- + +# IP Rotation with Flareprox + +**Activation gate:** Only use this skill when the environment variable `IPROTATE_ENABLED` is set (any value). If unset, do not suggest, reference, or offer IP rotation. Do not ask the user to enable it. + +Flareprox deploys Cloudflare Worker proxies for IP rotation. It is built into the web-security capability — no external repository or binary is required. + +## When to Use + +Use IP rotation when normal testing hits anti-automation defenses: + +- **Rate limiting:** repeated `429 Too Many Requests` +- **IP ban:** sudden `403 Forbidden` or connection drops after sustained testing +- **WAF block:** Cloudflare/Akamai/Imperva challenges on automated requests +- **Geo-blocking:** target restricts access to specific regions +- **High-volume fuzzing:** content discovery that needs IP diversity + +Do NOT use for: + +- Routine requests that succeed normally +- CORS-sensitive tests (the worker may modify response headers) +- Tests that require exact source-IP attribution + +## How It Works + +The tool deploys Cloudflare Workers that forward requests. You send the target URL in the `X-Target-URL` header or as the `?url=` query parameter. Each worker exits from a different Cloudflare IP, so spreading requests across multiple workers rotates egress IPs. + +Forwarded headers: `accept`, `authorization`, `content-type`, `cookie`, `user-agent`, `x-bug-bounty`, `x-poc-step`. + +## Prerequisites + +Set these environment variables before creating workers: + +- `CF_API_TOKEN` — Cloudflare API token with **Workers Scripts:Edit** permission +- `CF_ACCOUNT_ID` — Cloudflare account ID that owns the workers + +Verify the account has workers.dev enabled. + +## Tool Reference + +All tools are prefixed `flareprox_` and are self-contained. + +### 1. Check status + +```bash +flareprox_status +``` + +Reports whether credentials are configured and how many workers are active. + +### 2. Create workers + +```bash +flareprox_create --count 3 +``` + +Deploys three workers. More workers = more egress IPs to rotate through. + +### 3. Send a request through the proxy + +```bash +flareprox_request --url https://target.com/api/endpoint --method GET +``` + +The tool picks a worker round-robin, sets `X-Target-URL`, and returns the response. + +### 4. Get a proxy URL for manual use + +```bash +flareprox_proxy_url +``` + +Returns a worker URL. Use it with `execute_http` or shell tools by sending the target in `X-Target-URL`: + +```bash +curl -H "X-Target-URL: https://target.com/api/endpoint" "" +``` + +### 5. List active workers + +```bash +flareprox_list +``` + +### 6. Clean up + +```bash +flareprox_cleanup +``` + +Deletes all deployed workers from Cloudflare. Always run when finished. + +## Integration with Other Tools + +- Prefer `flareprox_request` for single requests. +- For complex flows, get a `flareprox_proxy_url` and use it with `execute_http` or `bash`/`curl`. +- If Caido or Burp is available, chain through them for evidence capture: + `target → Flareprox worker → Caido/Burp → internet` is incorrect. The correct chain is `your client → Caido/Burp → Flareprox worker → target`. + +## Lifecycle Example + +```bash +# Verify configuration +flareprox_status + +# Deploy workers +flareprox_create --count 3 + +# Test a request +flareprox_request --url https://target.com/ --method GET + +# Clean up when done +flareprox_cleanup +``` + +## Important Constraints + +- **Always clean up** after a session to avoid leaving worker scripts in the Cloudflare account. +- **Do not use for CORS tests** — Cloudflare or the worker may add response headers. +- **Cloudflare IPs are fingerprintable** — sophisticated bot detection may still block known cloud IP ranges. +- **Each worker is dynamic** — any target can be reached by changing `X-Target-URL`. +- **State persists** at `~/.flareprox/workers.json` in the runtime. diff --git a/capabilities/web-security/tests/conftest.py b/capabilities/web-security/tests/conftest.py index 0aeefb4..e5841e2 100644 --- a/capabilities/web-security/tests/conftest.py +++ b/capabilities/web-security/tests/conftest.py @@ -70,6 +70,12 @@ def decorator(fn): return decorator class Toolset: + def __init__(self, **kwargs: Any) -> None: + for key, value in kwargs.items(): + setattr(self, key, value) + if hasattr(self, "model_post_init"): + self.model_post_init(None) + def get_tools(self): discovered = [] for attr_name in dir(self): diff --git a/capabilities/web-security/tests/test_flareprox.py b/capabilities/web-security/tests/test_flareprox.py new file mode 100644 index 0000000..b1b7612 --- /dev/null +++ b/capabilities/web-security/tests/test_flareprox.py @@ -0,0 +1,265 @@ +"""Tests for the Flareprox IP rotation tool.""" + +from __future__ import annotations + +import importlib.util +import os +import sys +import types +from pathlib import Path +from unittest.mock import patch + +import httpx +import pytest + + +def _install_dreadnode_tools_stub() -> None: + existing = sys.modules.get("dreadnode.agents.tools") + if existing is not None and hasattr(existing, "FunctionCall"): + return + + dreadnode = types.ModuleType("dreadnode") + agents = types.ModuleType("dreadnode.agents") + tools = types.ModuleType("dreadnode.agents.tools") + + class _Tool: + def __init__(self, name: str, description: str, catch: bool) -> None: + self.name = name + self.description = description + self.catch = catch + self.parameters_schema = {"properties": {}} + + def tool_method(*, name: str, catch: bool = False): + def decorator(fn): + fn._tool_metadata = { + "name": name, + "catch": catch, + "description": fn.__doc__ or "", + } + return fn + + return decorator + + class Toolset: + def __init__(self, **kwargs) -> None: + for key, value in kwargs.items(): + setattr(self, key, value) + if hasattr(self, "model_post_init"): + self.model_post_init(None) + + def get_tools(self): + discovered = [] + for attr_name in dir(self): + value = getattr(self, attr_name) + meta = getattr(value, "_tool_metadata", None) + if meta: + discovered.append( + _Tool(meta["name"], meta["description"], meta["catch"]) + ) + return discovered + + tools.Toolset = Toolset + tools.tool_method = tool_method + agents.tools = tools + dreadnode.agents = agents + + sys.modules["dreadnode"] = dreadnode + sys.modules["dreadnode.agents"] = agents + sys.modules["dreadnode.agents.tools"] = tools + + +_install_dreadnode_tools_stub() + +MODULE_PATH = Path(__file__).resolve().parent.parent / "tools" / "flareprox.py" +SPEC = importlib.util.spec_from_file_location("flareprox", MODULE_PATH) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + +Flareprox = MODULE.Flareprox + + +@pytest.fixture +def tmp_state(tmp_path: Path) -> Path: + return tmp_path / "flareprox.json" + + +@pytest.fixture +def toolset(tmp_state: Path) -> Flareprox: + os.environ["CF_API_TOKEN"] = "test-token" + os.environ["CF_ACCOUNT_ID"] = "test-account" + try: + return Flareprox(state_file=str(tmp_state)) + finally: + os.environ.pop("CF_API_TOKEN", None) + os.environ.pop("CF_ACCOUNT_ID", None) + + +class TestToolDiscovery: + def test_tools_discovered(self, toolset: Flareprox) -> None: + names = {tool.name for tool in toolset.get_tools()} + assert names == { + "flareprox_status", + "flareprox_create", + "flareprox_list", + "flareprox_proxy_url", + "flareprox_request", + "flareprox_cleanup", + } + + +class TestStatus: + @pytest.mark.asyncio + async def test_status_unconfigured(self, tmp_state: Path) -> None: + tool = Flareprox(state_file=str(tmp_state)) + result = await tool.flareprox_status() + assert "not configured" in result + + @pytest.mark.asyncio + async def test_status_configured(self, toolset: Flareprox) -> None: + async def fake_get(*args, **kwargs) -> httpx.Response: + return httpx.Response( + 200, + json={ + "success": True, + "result": {"subdomain": "testsub"}, + }, + ) + + with patch("httpx.AsyncClient.get", side_effect=fake_get): + result = await toolset.flareprox_status() + assert "configured: yes" in result + assert "test-account" in result + assert "testsub" in result + + +class TestCreate: + @pytest.mark.asyncio + async def test_create_workers(self, toolset: Flareprox) -> None: + responses = [] + + async def fake_put(url: str, **kwargs) -> httpx.Response: + responses.append(url) + return httpx.Response(200, json={"success": True, "result": {"id": "script-id"}}) + + with patch("httpx.AsyncClient.get", return_value=httpx.Response( + 200, json={"success": True, "result": {"subdomain": "testsub"}} + )): + with patch("httpx.AsyncClient.put", side_effect=fake_put): + result = await toolset.flareprox_create(count=2) + + assert "Created Flareprox workers" in result + assert result.count("https://") == 2 + assert len(toolset._state["workers"]) == 2 + + @pytest.mark.asyncio + async def test_create_without_credentials(self, tmp_state: Path) -> None: + tool = Flareprox(state_file=str(tmp_state)) + result = await tool.flareprox_create(count=1) + assert "CF_API_TOKEN and CF_ACCOUNT_ID must be configured" in result + + +class TestList: + @pytest.mark.asyncio + async def test_list_empty(self, toolset: Flareprox) -> None: + result = await toolset.flareprox_list() + assert "No active Flareprox workers" in result + + @pytest.mark.asyncio + async def test_list_workers(self, toolset: Flareprox) -> None: + toolset._state["workers"] = [ + {"name": "flareprox-aaa", "url": "https://flareprox-aaa.testsub.workers.dev"}, + ] + result = await toolset.flareprox_list() + assert "flareprox-aaa" in result + + +class TestProxyUrl: + @pytest.mark.asyncio + async def test_proxy_url_empty(self, toolset: Flareprox) -> None: + result = await toolset.flareprox_proxy_url() + assert "No active workers" in result + + @pytest.mark.asyncio + async def test_proxy_url_rotates(self, toolset: Flareprox) -> None: + toolset._state["workers"] = [ + {"name": "a", "url": "https://a.testsub.workers.dev"}, + {"name": "b", "url": "https://b.testsub.workers.dev"}, + ] + urls = [await toolset.flareprox_proxy_url() for _ in range(4)] + assert urls[0] == urls[2] + assert urls[1] == urls[3] + assert urls[0] != urls[1] + + +class TestRequest: + @pytest.mark.asyncio + async def test_request_no_workers(self, toolset: Flareprox) -> None: + result = await toolset.flareprox_request("https://example.com/") + assert "No active Flareprox workers" in result + + @pytest.mark.asyncio + async def test_request_success(self, toolset: Flareprox) -> None: + toolset._state["workers"] = [ + {"name": "a", "url": "https://a.testsub.workers.dev"}, + ] + + def fake_request(method: str, url: str, **kwargs) -> httpx.Response: + assert method == "GET" + assert url == "https://a.testsub.workers.dev" + assert kwargs["headers"].get("x-target-url") == "https://example.com/" + return httpx.Response(200, text="hello") + + with patch("httpx.AsyncClient.request", side_effect=fake_request): + result = await toolset.flareprox_request("https://example.com/") + assert "HTTP 200" in result + assert "hello" in result + + @pytest.mark.asyncio + async def test_request_filters_headers(self, toolset: Flareprox) -> None: + toolset._state["workers"] = [ + {"name": "a", "url": "https://a.testsub.workers.dev"}, + ] + + captured: dict = {} + + def fake_request(method: str, url: str, **kwargs) -> httpx.Response: + captured.update(kwargs) + return httpx.Response(200, text="ok") + + with patch("httpx.AsyncClient.request", side_effect=fake_request): + await toolset.flareprox_request( + "https://example.com/", + headers={ + "Authorization": "Bearer token", + "X-Custom": "dropped", + "Cookie": "session=abc", + }, + ) + + headers = {k.lower(): v for k, v in captured["headers"].items()} + assert headers["authorization"] == "Bearer token" + assert headers["cookie"] == "session=abc" + assert "x-custom" not in headers + assert "x-target-url" in headers + + +class TestCleanup: + @pytest.mark.asyncio + async def test_cleanup(self, toolset: Flareprox) -> None: + toolset._state["workers"] = [ + {"name": "flareprox-aaa", "url": "https://flareprox-aaa.testsub.workers.dev"}, + ] + + deleted: list[str] = [] + + async def fake_delete(url: str, **kwargs) -> httpx.Response: + deleted.append(url) + return httpx.Response(200, json={"success": True}) + + with patch("httpx.AsyncClient.delete", side_effect=fake_delete): + result = await toolset.flareprox_cleanup() + + assert "Removed 1 Flareprox worker" in result + assert "/workers/scripts/flareprox-aaa" in deleted[0] + assert toolset._state["workers"] == [] diff --git a/capabilities/web-security/tools/flareprox.py b/capabilities/web-security/tools/flareprox.py new file mode 100644 index 0000000..25278ad --- /dev/null +++ b/capabilities/web-security/tools/flareprox.py @@ -0,0 +1,342 @@ +"""Flareprox: self-contained Cloudflare Workers IP rotation tool. + +Deploys Cloudflare Worker proxies on-demand for IP rotation during web +security testing. No external flareprox binary is required — the worker +script is embedded and deployed via the Cloudflare REST API. + +Required environment variables: + CF_API_TOKEN - Cloudflare API token with Workers Scripts read/write. + CF_ACCOUNT_ID - Cloudflare account ID that owns the workers. + +Optional: + FLAREPROX_STATE_FILE - Path to persisted worker state (default: ~/.flareprox/workers.json) + IPROTATE_ENABLED - Set to opt into IP rotation usage; the skill gates on this. +""" + +from __future__ import annotations + +import json +import os +import secrets +from pathlib import Path +from typing import Annotated + +import httpx +from dreadnode.agents.tools import Toolset, tool_method + +# Minimal Cloudflare Worker that proxies requests to a target specified via +# X-Target-URL header or ?url= query parameter. Only a small allowlist of +# headers is forwarded to keep the implementation simple and safe. +_WORKER_SCRIPT = """ +export default { + async fetch(request, env) { + const url = request.headers.get("X-Target-URL") || new URL(request.url).searchParams.get("url"); + if (!url) { + return new Response("Missing X-Target-URL header or url query parameter", { status: 400 }); + } + + const headers = new Headers(); + for (const name of ["accept", "authorization", "content-type", "cookie", "user-agent", "x-bug-bounty", "x-poc-step"]) { + const value = request.headers.get(name); + if (value) headers.set(name, value); + } + + const init = { method: request.method, headers, redirect: "follow" }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + } + + return fetch(url, init); + } +}; +""".strip() + +# Headers the worker forwards; mirror this client-side so requests behave predictably. +_PASSTHROUGH_HEADERS = { + "accept", + "authorization", + "content-type", + "cookie", + "user-agent", + "x-bug-bounty", + "x-poc-step", +} + +_DEFAULT_TIMEOUT = 30 +_MAX_OUTPUT_CHARS = 50_000 + + +class Flareprox(Toolset): + """Cloudflare Workers IP rotation for bypassing rate limits and IP bans. + + Deploys worker proxies on demand, routes requests through them, and tears + them down when finished. Requires CF_API_TOKEN and CF_ACCOUNT_ID. + """ + + account_id: str | None = None + """Cloudflare account ID. Falls back to CF_ACCOUNT_ID env var.""" + api_token: str | None = None + """Cloudflare API token. Falls back to CF_API_TOKEN env var.""" + state_file: str = "" + """Path to persisted worker state. Defaults to ~/.flareprox/workers.json.""" + + def model_post_init(self, __context: object) -> None: # type: ignore[override] + """Initialize runtime state after pydantic construction.""" + self.account_id = (self.account_id or os.environ.get("CF_ACCOUNT_ID", "")).strip() + self.api_token = (self.api_token or os.environ.get("CF_API_TOKEN", "")).strip() + if not self.state_file: + self.state_file = str(Path.home() / ".flareprox" / "workers.json") + self._subdomain: str | None = None + self._load_state() + + def _load_state(self) -> None: + path = Path(self.state_file) + if path.exists(): + try: + self._state = json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + self._state = {"workers": []} + else: + self._state = {"workers": []} + + def _save_state(self) -> None: + path = Path(self.state_file) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(self._state, indent=2)) + + def _client(self) -> httpx.AsyncClient: + if not self.api_token: + raise RuntimeError("CF_API_TOKEN not configured") + if not self.account_id: + raise RuntimeError("CF_ACCOUNT_ID not configured") + return httpx.AsyncClient( + base_url=f"https://api.cloudflare.com/client/v4/accounts/{self.account_id}", + headers={ + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/javascript+module", + }, + timeout=_DEFAULT_TIMEOUT, + ) + + async def _get_subdomain(self) -> str | None: + if self._subdomain: + return self._subdomain + try: + async with self._client() as client: + response = await client.get("/workers/subdomain") + data = response.json() + if data.get("success"): + self._subdomain = data.get("result", {}).get("subdomain") + return self._subdomain + except Exception: + return None + return None + + def _worker_url(self, name: str, subdomain: str) -> str: + return f"https://{name}.{subdomain}.workers.dev" + + def _configured(self) -> bool: + return bool(self.api_token and self.account_id) + + @tool_method(name="flareprox_status", catch=True) + async def flareprox_status(self) -> str: + """Check Flareprox configuration and count deployed workers. + + Reports whether CF_API_TOKEN and CF_ACCOUNT_ID are set and how many + active workers are tracked in local state. + """ + if not self._configured(): + return ( + "Flareprox is not configured. Set CF_API_TOKEN and CF_ACCOUNT_ID\n" + "environment variables before creating workers." + ) + + subdomain = await self._get_subdomain() + workers = self._state.get("workers", []) + + lines = [ + "Flareprox status:", + " configured: yes", + f" account_id: {self.account_id}", + f" workers.dev subdomain: {subdomain or 'unknown (verify token/permissions)'}", + f" active workers: {len(workers)}", + ] + for worker in workers: + lines.append(f" - {worker['name']} ({worker.get('url', 'no url')})") + return "\n".join(lines) + + @tool_method(name="flareprox_create", catch=True) + async def flareprox_create( + self, + count: Annotated[int, "Number of worker proxies to create (default: 1)"] = 1, + ) -> str: + """Deploy Cloudflare Worker proxies for IP rotation. + + Each worker gets a unique name and a workers.dev URL. Multiple workers + provide more egress IPs to rotate through. + + Args: + count: Number of workers to create. + """ + if not self._configured(): + return "Error: CF_API_TOKEN and CF_ACCOUNT_ID must be configured." + + count = max(1, count) + subdomain = await self._get_subdomain() + if not subdomain: + return ( + "Error: Could not determine workers.dev subdomain. " + "Verify CF_API_TOKEN has Workers Scripts read permission and workers.dev is enabled." + ) + + created = [] + async with self._client() as client: + for _ in range(count): + name = f"flareprox-{secrets.token_hex(4)}" + try: + response = await client.put( + f"/workers/scripts/{name}", + content=_WORKER_SCRIPT, + headers={"Content-Type": "application/javascript+module"}, + ) + data = response.json() + if not data.get("success"): + errors = data.get("errors", []) + return f"Error deploying worker {name}: {errors}" + + url = self._worker_url(name, subdomain) + self._state["workers"].append({"name": name, "url": url}) + created.append(url) + except Exception as exc: + return f"Error deploying worker: {exc}" + + self._save_state() + return "Created Flareprox workers:\n" + "\n".join(f" {url}" for url in created) + + @tool_method(name="flareprox_list", catch=True) + async def flareprox_list(self) -> str: + """List active Flareprox workers tracked in local state.""" + workers = self._state.get("workers", []) + if not workers: + return "No active Flareprox workers. Use flareprox_create to deploy one." + + lines = [f"Active Flareprox workers ({len(workers)}):"] + for worker in workers: + lines.append(f" {worker['name']}: {worker['url']}") + return "\n".join(lines) + + @tool_method(name="flareprox_proxy_url", catch=True) + async def flareprox_proxy_url(self) -> str: + """Return a worker URL to use as an HTTP proxy. + + The URL rotates round-robin across active workers. Use it with curl or + execute_http by sending the target URL in the X-Target-URL header or as + the ?url= query parameter. + """ + workers = self._state.get("workers", []) + if not workers: + return "Error: No active workers. Run flareprox_create first." + + idx = self._state.get("_rr_index", 0) % len(workers) + self._state["_rr_index"] = idx + 1 + self._save_state() + return workers[idx]["url"] + + @tool_method(name="flareprox_request", catch=True) + async def flareprox_request( + self, + url: Annotated[str, "Target URL to fetch through the Flareprox worker"], + method: Annotated[str, "HTTP method"] = "GET", + headers: Annotated[dict[str, str] | None, "Optional request headers"] = None, + body: Annotated[str | None, "Optional request body"] = None, + ) -> str: + """Send an HTTP request through a Flareprox worker. + + Routes the request via a deployed Cloudflare Worker, which forwards to + the target URL specified in the X-Target-URL header. Useful for + bypassing IP-based rate limits and WAF blocks. + + Args: + url: Target URL to fetch. + method: HTTP method. + headers: Optional headers to forward. + body: Optional request body. + """ + workers = self._state.get("workers", []) + if not workers: + return "Error: No active Flareprox workers. Run flareprox_create first." + + idx = self._state.get("_rr_index", 0) % len(workers) + self._state["_rr_index"] = idx + 1 + self._save_state() + worker_url = workers[idx]["url"] + + method = method.upper() + request_headers = {k.lower(): v for k, v in (headers or {}).items()} + # Always set the target via header; the worker prefers this over query param. + request_headers["x-target-url"] = url + + # Drop headers that should not be forwarded through the worker unless + # explicitly allowed. + request_headers = { + k: v for k, v in request_headers.items() if k in _PASSTHROUGH_HEADERS or k == "x-target-url" + } + + try: + async with httpx.AsyncClient(timeout=_DEFAULT_TIMEOUT, follow_redirects=True) as client: + response = await client.request( + method=method, + url=worker_url, + headers=request_headers, + content=body.encode() if body else None, + ) + except httpx.TimeoutException: + return f"Error: Request timed out after {_DEFAULT_TIMEOUT}s (via Flareprox)" + except httpx.ConnectError as exc: + return f"Error: Could not connect to Flareprox worker {worker_url}: {exc}" + except Exception as exc: + return f"Error: Request failed via Flareprox: {exc}" + + response_text = response.text + if len(response_text) > _MAX_OUTPUT_CHARS: + total = len(response_text) + response_text = response_text[:_MAX_OUTPUT_CHARS] + f"\n\n... [TRUNCATED: {total} chars total]" + + return f"HTTP {response.status_code} (via Flareprox {worker_url})\n\n{response_text}" + + @tool_method(name="flareprox_cleanup", catch=True) + async def flareprox_cleanup(self) -> str: + """Delete all deployed Flareprox workers. + + Always run this when IP rotation is no longer needed to avoid leaving + scripts in the Cloudflare account. + """ + workers = self._state.get("workers", []) + if not workers: + return "No active Flareprox workers to clean up." + + removed = [] + errors = [] + async with self._client() as client: + for worker in workers: + name = worker["name"] + try: + response = await client.delete(f"/workers/scripts/{name}") + data = response.json() + if data.get("success"): + removed.append(name) + else: + errors.append(f"{name}: {data.get('errors', [])}") + except Exception as exc: + errors.append(f"{name}: {exc}") + + self._state["workers"] = [] + self._state["_rr_index"] = 0 + self._save_state() + + result = f"Removed {len(removed)} Flareprox worker(s)." + if removed: + result += "\n" + "\n".join(f" - {name}" for name in removed) + if errors: + result += "\nErrors:\n" + "\n".join(f" - {err}" for err in errors) + return result