Files
derp/plugins/httpcheck.py
user 97bbc6a825 feat: route plugin HTTP traffic through SOCKS5 proxy
Add PySocks dependency and shared src/derp/http.py module providing
proxy-aware urlopen() and build_opener() that route through
socks5h://127.0.0.1:1080. Subclassed SocksiPyHandler passes SSL
context through to HTTPS connections.

Swapped 14 external-facing plugins to use the proxied helpers.
Local-only traffic (SearXNG, raw DNS/TLS sockets) stays direct.
Updated test mocks in test_twitch and test_alert accordingly.
2026-02-15 15:53:49 +01:00

108 lines
3.2 KiB
Python

"""Plugin: HTTP status checker (pure stdlib, urllib)."""
from __future__ import annotations
import asyncio
import ssl
import time
import urllib.request
from derp.http import build_opener as _build_opener
from derp.plugin import command
_TIMEOUT = 10
_MAX_REDIRECTS = 10
_USER_AGENT = "derp/1.0"
def _check(url: str) -> dict:
"""Blocking HTTP check. Returns dict with status info."""
result: dict = {
"url": url,
"status": 0,
"reason": "",
"time_ms": 0,
"redirects": [],
"server": "",
"content_type": "",
"error": "",
}
# Build opener that doesn't follow redirects automatically
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
result["redirects"].append((code, newurl))
if len(result["redirects"]) >= _MAX_REDIRECTS:
return None
return urllib.request.HTTPRedirectHandler.redirect_request(
self, req, fp, code, msg, headers, newurl,
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
opener = _build_opener(NoRedirect, context=ctx)
req = urllib.request.Request(url, method="HEAD")
req.add_header("User-Agent", _USER_AGENT)
t0 = time.monotonic()
try:
resp = opener.open(req, timeout=_TIMEOUT)
result["time_ms"] = (time.monotonic() - t0) * 1000
result["status"] = resp.status
result["reason"] = resp.reason
result["server"] = resp.headers.get("Server", "")
result["content_type"] = resp.headers.get("Content-Type", "")
resp.close()
except urllib.error.HTTPError as exc:
result["time_ms"] = (time.monotonic() - t0) * 1000
result["status"] = exc.code
result["reason"] = exc.reason
except urllib.error.URLError as exc:
result["time_ms"] = (time.monotonic() - t0) * 1000
result["error"] = str(exc.reason)
except Exception as exc:
result["time_ms"] = (time.monotonic() - t0) * 1000
result["error"] = str(exc)
return result
@command("httpcheck", help="HTTP check: !httpcheck <url>")
async def cmd_httpcheck(bot, message):
"""Check HTTP status, redirects, and response time.
Usage:
!httpcheck https://example.com
!httpcheck http://10.0.0.1:8080
"""
parts = message.text.split(None, 2)
if len(parts) < 2:
await bot.reply(message, "Usage: !httpcheck <url>")
return
url = parts[1]
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, _check, url)
if result["error"]:
await bot.reply(message, f"{url} -> error: {result['error']} ({result['time_ms']:.0f}ms)")
return
parts_out = [f"{result['status']} {result['reason']}"]
parts_out.append(f"{result['time_ms']:.0f}ms")
if result["redirects"]:
chain = " -> ".join(f"{code} {loc}" for code, loc in result["redirects"])
parts_out.append(f"redirects: {chain}")
if result["server"]:
parts_out.append(f"server: {result['server']}")
await bot.reply(message, f"{url} -> {' | '.join(parts_out)}")