BIG BIG BIG BIG BIG UPDATES

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
gabdevele
2026-05-04 23:59:48 +02:00
co-authored by Copilot
Unverified
parent 46f7cd6a5f
commit 93ba8e9ac2
88 changed files with 7591 additions and 447 deletions
+134
View File
@@ -0,0 +1,134 @@
from models.example import AddonExampleDocument
DOCS_EXAMPLES_URL = "https://docs.mitmproxy.org/stable/addons/examples/"
BUILTIN_EXAMPLES = [
AddonExampleDocument(
id="http-add-response-header",
name="HTTP add response header",
description=(
"Injects a fixed response header on every HTTP response so you can "
"verify that traffic is passing through the proxy response path."
),
category="http",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def response(flow: http.HTTPFlow) -> None:
flow.response.headers["x-theplug"] = "enabled"
""",
),
AddonExampleDocument(
id="http-modify-query",
name="HTTP modify query",
description=(
"Adds or overwrites a query parameter before each request is "
"forwarded upstream. Useful for forced debug or feature flags."
),
category="http",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def request(flow: http.HTTPFlow) -> None:
flow.request.query["debug"] = "1"
""",
),
AddonExampleDocument(
id="http-reply-from-proxy",
name="HTTP reply from proxy",
description=(
"Serves a local HTTP response for a selected path without touching "
"the upstream service. Useful for probes, maintenance, or stubs."
),
category="http",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def request(flow: http.HTTPFlow) -> None:
if flow.request.path == "/healthz":
flow.response = http.Response.make(
200,
b"ok",
{"content-type": "text/plain"},
)
""",
),
AddonExampleDocument(
id="websocket-rewrite",
name="WebSocket rewrite",
description=(
"Rewrites matching bytes inside live WebSocket frames to alter "
"runtime flags or payload markers in transit."
),
category="websocket",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def websocket_message(flow: http.HTTPFlow) -> None:
if flow.websocket is None:
return
message = flow.websocket.messages[-1]
message.content = message.content.replace(b"debug=false", b"debug=true")
""",
),
AddonExampleDocument(
id="tcp-log-messages",
name="TCP log messages",
description=(
"Logs raw TCP message direction and size for each frame so you can "
"observe binary traffic without altering it."
),
category="tcp",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""import logging
from mitmproxy import tcp
def tcp_message(flow: tcp.TCPFlow) -> None:
message = flow.messages[-1]
direction = "client" if message.from_client else "server"
logging.info("%s sent %d bytes", direction, len(message.content))
""",
),
AddonExampleDocument(
id="dns-spoof-a-record",
name="DNS spoof A record",
description=(
"Overrides one DNS A record while forwarding every other query "
"unchanged. Useful for selective service redirection."
),
category="dns",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""import ipaddress
from mitmproxy import dns
def dns_request(flow: dns.DNSFlow) -> None:
question = flow.request.question
if question and question.name == "target.local" and question.type == dns.types.A:
flow.response = flow.request.succeed(
[
dns.ResourceRecord(
name="target.local",
type=dns.types.A,
class_=dns.classes.IN,
ttl=60,
data=ipaddress.ip_address("127.0.0.1").packed,
)
]
)
""",
),
]
+185
View File
@@ -0,0 +1,185 @@
import asyncio
import ssl
from dataclasses import dataclass
from models.proxy import HealthCheckDocument, ProxyDocument
@dataclass(slots=True)
class Target:
name: str
host: str
port: int
protocol: str
async def check_proxy(proxy: ProxyDocument) -> dict[str, object]:
if not proxy.healthcheck.enabled:
return {"enabled": False, "overall": "disabled", "checks": []}
upstream_port = proxy.service.upstream_port or proxy.service.port
checks = await asyncio.gather(
check_target(
Target(
name="upstream",
host=proxy.service.host,
port=upstream_port,
protocol=health_protocol(proxy),
),
proxy.healthcheck,
),
check_target(
Target(
name="proxy",
host=listen_host_for_check(proxy.listen_host),
port=proxy.proxy_port,
protocol=health_protocol(proxy),
),
proxy.healthcheck,
),
)
overall = "healthy" if all(check["healthy"] for check in checks) else "unhealthy"
return {"enabled": True, "overall": overall, "checks": checks}
def health_protocol(proxy: ProxyDocument) -> str:
return proxy.healthcheck.protocol or proxy.protocol
def listen_host_for_check(host: str) -> str:
if host in {"", "0.0.0.0", "::"}:
return "127.0.0.1"
return host
async def check_target(
target: Target,
config: HealthCheckDocument,
) -> dict[str, object]:
try:
match target.protocol:
case "http":
return await check_http(target, config, use_tls=False)
case "https":
return await check_http(target, config, use_tls=True)
case "tcp":
return await check_tcp(target, config, use_tls=False)
case "tls":
return await check_tcp(target, config, use_tls=True)
case "udp" | "dtls" | "quic" | "dns" | "http3":
return await check_udp(target, config)
case _:
return result(target, False, f"Unsupported protocol: {target.protocol}")
except TimeoutError:
return result(target, False, "Timed out")
except OSError as error:
return result(target, False, str(error))
async def check_http(
target: Target,
config: HealthCheckDocument,
*,
use_tls: bool,
) -> dict[str, object]:
reader, writer = await open_connection(target, config, use_tls=use_tls)
try:
path = config.path if config.path.startswith("/") else f"/{config.path}"
request = (
f"{config.method} {path} HTTP/1.1\r\n"
f"Host: {target.host}\r\n"
"Connection: close\r\n\r\n"
)
writer.write(request.encode("ascii"))
await writer.drain()
line = await asyncio.wait_for(reader.readline(), config.timeout_seconds)
finally:
writer.close()
await writer.wait_closed()
parts = line.decode("iso-8859-1", errors="replace").split()
status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None
expected = config.expected_status
healthy = status == expected if expected is not None else status is not None
return result(target, healthy, None, {"status": status})
async def check_tcp(
target: Target,
config: HealthCheckDocument,
*,
use_tls: bool,
) -> dict[str, object]:
_reader, writer = await open_connection(target, config, use_tls=use_tls)
writer.close()
await writer.wait_closed()
return result(target, True)
async def check_udp(target: Target, config: HealthCheckDocument) -> dict[str, object]:
loop = asyncio.get_running_loop()
transport, protocol = await loop.create_datagram_endpoint(
lambda: DatagramProbe(config.expect_response),
remote_addr=(target.host, target.port),
)
try:
payload = config.udp_payload.encode()
transport.sendto(payload)
if not config.expect_response:
return result(target, True, None, {"mode": "send-only"})
data = await asyncio.wait_for(protocol.response, config.timeout_seconds)
return result(target, True, None, {"bytes": len(data)})
finally:
transport.close()
async def open_connection(
target: Target,
config: HealthCheckDocument,
*,
use_tls: bool,
):
if use_tls and config.verify_tls:
context = ssl.create_default_context()
elif use_tls:
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
else:
context = None
return await asyncio.wait_for(
asyncio.open_connection(target.host, target.port, ssl=context),
config.timeout_seconds,
)
class DatagramProbe(asyncio.DatagramProtocol):
def __init__(self, expect_response: bool):
self.response = asyncio.get_running_loop().create_future()
if not expect_response:
self.response.set_result(b"")
def datagram_received(self, data, _addr):
if not self.response.done():
self.response.set_result(data)
def error_received(self, exc):
if not self.response.done():
self.response.set_exception(exc)
def result(
target: Target,
healthy: bool,
error: str | None = None,
details: dict[str, object] | None = None,
) -> dict[str, object]:
return {
"target": target.name,
"host": target.host,
"port": target.port,
"protocol": target.protocol,
"healthy": healthy,
"error": error,
"details": details or {},
}
+283
View File
@@ -0,0 +1,283 @@
import asyncio
import os
import shutil
import signal
from pathlib import Path
import yaml
from integrations import docker
from integrations.docker.ports import get_random_port
from models.proxy import ProxyDocument
from services.mitmproxy.options import MitmOptionError, validate_options
class MitmdumpError(Exception):
pass
class MitmdumpManager:
def __init__(self, work_dir: str = "/tmp/theplug", command: str = "mitmdump"):
self.work_dir = Path(work_dir)
self.command = command
self.processes: dict[int, asyncio.subprocess.Process] = {}
self.locks: dict[int, asyncio.Lock] = {}
async def start(self, proxy: ProxyDocument) -> dict[str, object]:
async with self.lock_for(proxy.proxy_port):
return await self.start_unlocked(proxy)
async def stop(self, proxy: ProxyDocument) -> dict[str, object]:
async with self.lock_for(proxy.proxy_port):
return await self.stop_unlocked(proxy)
async def start_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
if await self.status(proxy) == "running":
raise MitmdumpError("Proxy is already running")
command_path = shutil.which(self.command)
if command_path is None:
raise MitmdumpError("mitmdump executable not found in PATH")
try:
await validate_options(proxy.mitm_options)
except MitmOptionError as error:
raise MitmdumpError(str(error)) from error
upstream_port = await self.prepare_upstream(proxy)
script_path = self.write_script(proxy)
config_path = self.write_config(proxy)
log_path = self.log_path(proxy)
command = self.build_command(
command_path,
proxy,
upstream_port,
script_path,
config_path,
)
try:
process = await self.spawn(command, log_path)
except Exception as error:
await self.restore_upstream(proxy, upstream_port)
raise MitmdumpError(str(error)) from error
failed = await self.exited_early(process)
if failed:
await self.restore_upstream(proxy, upstream_port)
reason = self.read_log_tail(log_path) or "mitmdump exited during startup"
raise MitmdumpError(reason)
self.processes[proxy.proxy_port] = process
return {
"status": "running",
"pid": process.pid,
"script_path": str(script_path),
"config_path": str(config_path),
"log_path": str(log_path),
"service.upstream_port": upstream_port,
"last_error": None,
}
async def stop_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
process = self.processes.pop(proxy.proxy_port, None)
if process is not None and process.returncode is None:
await self.stop_process(process)
elif proxy.pid is not None:
await self.stop_pid(proxy.pid)
await self.restore_upstream(proxy, proxy.service.upstream_port)
return {
"status": "stopped",
"pid": None,
"service.upstream_port": None,
"last_error": None,
}
async def status(self, proxy: ProxyDocument) -> str:
process = self.processes.get(proxy.proxy_port)
if process is not None:
if process.returncode is None:
return "running"
self.processes.pop(proxy.proxy_port, None)
return "stopped"
if proxy.pid is None:
return "stopped" if proxy.status == "running" else proxy.status
try:
os.kill(proxy.pid, 0)
except ProcessLookupError:
return "stopped"
except PermissionError:
return "running"
return "running"
async def prepare_upstream(self, proxy: ProxyDocument) -> int | None:
if proxy.mode_spec is not None or proxy.mode != "reverse":
return None
if proxy.service.upstream_port is not None:
return proxy.service.upstream_port
if proxy.service.name is None or proxy.service.folder is None:
return proxy.service.port
upstream_port = get_random_port()
changed = await docker.change_port(
proxy.service.name,
Path(proxy.service.folder),
proxy.service.port,
upstream_port,
)
if not changed:
raise MitmdumpError("Unable to move Docker service port")
return upstream_port
async def restore_upstream(
self,
proxy: ProxyDocument,
upstream_port: int | None,
) -> None:
if upstream_port is None:
return
if proxy.service.name is None or proxy.service.folder is None:
return
if upstream_port == proxy.service.port:
return
await docker.change_port(
proxy.service.name,
Path(proxy.service.folder),
upstream_port,
proxy.service.port,
)
def build_command(
self,
command_path: str,
proxy: ProxyDocument,
upstream_port: int | None,
script_path: Path,
config_path: Path,
) -> list[str]:
command = [
command_path,
"--mode",
self.mode_for(proxy, upstream_port),
"--listen-host",
proxy.listen_host,
"--set",
f"confdir={config_path.parent}",
"-s",
str(script_path),
]
if self.needs_listen_port(proxy):
command.extend(["--listen-port", str(proxy.proxy_port)])
return command
def mode_for(self, proxy: ProxyDocument, upstream_port: int | None) -> str:
if proxy.mode_spec:
return proxy.mode_spec
match proxy.mode:
case "reverse":
port = upstream_port or proxy.service.port
return (
f"reverse:{proxy.protocol}://{proxy.service.host}:{port}"
f"@{proxy.proxy_port}"
)
case "upstream":
return (
f"upstream:{proxy.protocol}://{proxy.service.host}:"
f"{proxy.service.port}@{proxy.proxy_port}"
)
case "wireguard":
return f"wireguard@{proxy.proxy_port}"
case _:
return proxy.mode
def needs_listen_port(self, proxy: ProxyDocument) -> bool:
if proxy.mode_spec is not None:
return False
return proxy.mode in {"regular", "socks5", "dns", "transparent"}
def write_script(self, proxy: ProxyDocument) -> Path:
path = self.work_dir / "addons" / f"{proxy.proxy_port}.py"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(proxy.code, encoding="utf-8")
return path
def write_config(self, proxy: ProxyDocument) -> Path:
path = self.work_dir / "conf" / str(proxy.proxy_port) / "config.yaml"
path.parent.mkdir(parents=True, exist_ok=True)
options = {"block_global": False, **proxy.mitm_options}
path.write_text(yaml.safe_dump(options, sort_keys=True), encoding="utf-8")
return path
def log_path(self, proxy: ProxyDocument) -> Path:
path = self.work_dir / "logs" / f"{proxy.proxy_port}.log"
path.parent.mkdir(parents=True, exist_ok=True)
return path
async def spawn(self, command: list[str], log_path: Path):
with log_path.open("ab") as log_file:
return await asyncio.create_subprocess_exec(
*command,
stdout=log_file,
stderr=log_file,
start_new_session=True,
)
async def stop_process(self, process) -> None:
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
return
try:
await asyncio.wait_for(process.wait(), timeout=5)
except TimeoutError:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
return
await process.wait()
async def exited_early(self, process) -> bool:
try:
await asyncio.wait_for(process.wait(), timeout=0.5)
except TimeoutError:
return False
return True
async def stop_pid(self, pid: int) -> None:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return
for _ in range(10):
await asyncio.sleep(0.2)
try:
os.kill(pid, 0)
except ProcessLookupError:
return
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
return
def read_log_tail(self, path: Path, limit: int = 4000) -> str:
if not path.exists():
return ""
content = path.read_text(encoding="utf-8", errors="replace")
return content[-limit:].strip()
def lock_for(self, proxy_port: int) -> asyncio.Lock:
lock = self.locks.get(proxy_port)
if lock is None:
lock = asyncio.Lock()
self.locks[proxy_port] = lock
return lock
manager = MitmdumpManager()
+156
View File
@@ -0,0 +1,156 @@
import asyncio
import re
import shutil
from dataclasses import dataclass
from typing import Any
import yaml
RESERVED_OPTIONS = {"mode", "listen_host", "listen_port", "scripts"}
OPTIONS_DOCS_URL = "https://docs.mitmproxy.org/stable/concepts/options/"
@dataclass(slots=True)
class MitmOption:
name: str
value_type: str
default: Any
description: str
choices: list[str]
class MitmOptionError(Exception):
pass
catalog_cache: dict[str, MitmOption] | None = None
async def load_options() -> dict[str, MitmOption]:
global catalog_cache
if catalog_cache is None:
catalog_cache = await read_options()
return catalog_cache
async def read_options(command: str = "mitmdump") -> dict[str, MitmOption]:
command_path = shutil.which(command)
if command_path is None:
raise MitmOptionError("mitmdump executable not found in PATH")
process = await asyncio.create_subprocess_exec(
command_path,
"--options",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
message = stderr.decode("utf-8", errors="replace").strip()
raise MitmOptionError(message or "Unable to load mitmdump options")
text = stdout.decode("utf-8", errors="replace")
defaults = yaml.safe_load(text) or {}
return parse_options(text, defaults)
def parse_options(text: str, defaults: dict[str, Any]) -> dict[str, MitmOption]:
comments: list[str] = []
options: dict[str, MitmOption] = {}
for line in text.splitlines():
stripped = line.strip()
if not stripped:
comments = []
continue
if stripped.startswith("#"):
comments.append(stripped[1:].strip())
continue
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*:", stripped):
continue
name = stripped.split(":", 1)[0]
description = " ".join(comments)
options[name] = MitmOption(
name=name,
value_type=parse_type(description),
default=defaults.get(name),
description=description,
choices=parse_choices(description),
)
comments = []
return options
def parse_type(description: str) -> str:
match = re.search(r"Type ([^.]+)\.", description)
if match is None:
return "unknown"
return match.group(1).strip()
def parse_choices(description: str) -> list[str]:
match = re.search(r"Valid values are ([^.]+)\.", description)
if match is None:
return []
return re.findall(r"'([^']+)'", match.group(1))
async def validate_options(options: dict[str, Any]) -> None:
catalog = await load_options()
errors: list[str] = []
for name, value in options.items():
option = catalog.get(name)
if option is None:
errors.append(f"Unknown mitmdump option: {name}")
continue
if name in RESERVED_OPTIONS:
errors.append(f"Option is managed by thePlug and cannot be set: {name}")
continue
message = validate_value(option, value)
if message:
errors.append(f"{name}: {message}")
if errors:
raise MitmOptionError("; ".join(errors))
def validate_value(option: MitmOption, value: Any) -> str | None:
value_type = option.value_type
if option.choices and str(value) not in option.choices:
return f"expected one of {', '.join(option.choices)}"
if value is None:
if value_type.startswith("optional "):
return None
return "cannot be null"
if value_type == "bool" and not isinstance(value, bool):
return "expected bool"
if value_type == "int" and not isinstance(value, int):
return "expected int"
if value_type == "str" and not isinstance(value, str):
return "expected str"
if value_type == "sequence of str":
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
return "expected list[str]"
if value_type == "optional str" and not isinstance(value, str):
return "expected str or null"
if value_type == "optional int" and not isinstance(value, int):
return "expected int or null"
return None
def public_options(options: dict[str, MitmOption]) -> list[dict[str, Any]]:
return [
{
"name": option.name,
"type": option.value_type,
"default": option.default,
"description": option.description,
"choices": option.choices,
"reserved": option.name in RESERVED_OPTIONS,
"docs_url": f"{OPTIONS_DOCS_URL}#{option.name}",
}
for option in options.values()
]
+164
View File
@@ -0,0 +1,164 @@
import asyncio
import json
import os
import shutil
import tempfile
from typing import Any
TARGET_RUFF_CODES = ("F821", "F822", "F823")
class ProxyCodeValidationError(Exception):
def __init__(
self,
message: str,
*,
errors: list[dict[str, Any]] | None = None,
) -> None:
super().__init__(message)
self.message = message
self.errors = errors or []
def to_http_detail(self) -> dict[str, Any]:
return {
"message": self.message,
"errors": self.errors,
}
def _validate_python_syntax(code: str) -> None:
try:
compile(code, "<proxy_code>", "exec")
except SyntaxError as error:
line = error.lineno if error.lineno is not None else 0
column = error.offset if error.offset is not None else 0
detail = {
"code": "COMPILE",
"message": error.msg,
"location": {"row": line, "column": column},
}
raise ProxyCodeValidationError(
"Proxy code has invalid Python syntax",
errors=[detail],
) from error
def _get_ruff_executable() -> list[str]:
ruff_path = shutil.which("ruff")
if ruff_path:
return [ruff_path]
uv_path = shutil.which("uv")
if uv_path:
return [uv_path, "run", "ruff"]
raise ProxyCodeValidationError("Unable to run ruff: executable not found")
def _parse_ruff_output(output: str) -> list[dict[str, Any]]:
if not output.strip():
return []
try:
parsed = json.loads(output)
except json.JSONDecodeError as error:
raise ProxyCodeValidationError("Unable to parse ruff JSON output") from error
if not isinstance(parsed, list):
raise ProxyCodeValidationError("Unexpected ruff output format")
errors: list[dict[str, Any]] = []
for item in parsed:
if not isinstance(item, dict):
continue
code = item.get("code")
if code not in TARGET_RUFF_CODES:
continue
message = item.get("message")
raw_location = item.get("location")
location = raw_location if isinstance(raw_location, dict) else {}
row = location.get("row", 0)
column = location.get("column", 0)
errors.append(
{
"code": code,
"message": str(message) if message is not None else "",
"location": {"row": row, "column": column},
}
)
return errors
async def _run_ruff_check(code: str) -> list[dict[str, Any]]:
command = _get_ruff_executable()
file_path = ""
try:
with tempfile.NamedTemporaryFile(
mode="w",
suffix=".py",
encoding="utf-8",
delete=False,
) as handle:
handle.write(code)
file_path = handle.name
process = await asyncio.create_subprocess_exec(
*command,
"check",
"--output-format=json",
"--select",
",".join(TARGET_RUFF_CODES),
file_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
finally:
if file_path:
try:
os.unlink(file_path)
except FileNotFoundError:
pass
stdout_text = stdout.decode("utf-8", errors="replace")
stderr_text = stderr.decode("utf-8", errors="replace")
if process.returncode not in (0, 1):
raise ProxyCodeValidationError(
"Ruff check failed to execute",
errors=[
{
"code": "RUFF",
"message": stderr_text.strip() or "Unknown ruff failure",
"location": {"row": 0, "column": 0},
}
],
)
if stderr_text.strip() and process.returncode == 0:
raise ProxyCodeValidationError(
"Ruff emitted unexpected stderr output",
errors=[
{
"code": "RUFF",
"message": stderr_text.strip(),
"location": {"row": 0, "column": 0},
}
],
)
return _parse_ruff_output(stdout_text)
async def validate_proxy_code(code: str) -> None:
_validate_python_syntax(code)
errors = await _run_ruff_check(code)
if errors:
raise ProxyCodeValidationError(
"Proxy code contains ruff violations",
errors=errors,
)