feat: add proxy template origin field and implement socket utilities for log streaming
This commit is contained in:
+19
-128
@@ -5,15 +5,20 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
|||||||
from pymongo.errors import DuplicateKeyError
|
from pymongo.errors import DuplicateKeyError
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.api.proxy.socket_utils import (
|
||||||
from app.core.security import (
|
authenticate_proxy_socket,
|
||||||
InvalidTokenError,
|
proxy_log_path,
|
||||||
authenticate_token,
|
read_log_snapshot,
|
||||||
require_authenticated_user,
|
serialize_health_snapshot,
|
||||||
|
snapshot_log_state,
|
||||||
|
stream_proxy_logs,
|
||||||
|
watch_socket_disconnect,
|
||||||
)
|
)
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import InvalidTokenError, require_authenticated_user
|
||||||
from app.integrations.docker import get_all_services_in_dir
|
from app.integrations.docker import get_all_services_in_dir
|
||||||
from app.integrations.docker.discovery import service_scan_root
|
from app.integrations.docker.discovery import service_scan_root
|
||||||
from app.models.health import HealthHistoryResponse, HealthSnapshot
|
from app.models.health import HealthHistoryResponse
|
||||||
from app.models.proxy import ProxyCreateInput, ProxyUpdateInput
|
from app.models.proxy import ProxyCreateInput, ProxyUpdateInput
|
||||||
from app.models.template import ProxyTemplateCreateInput
|
from app.models.template import ProxyTemplateCreateInput
|
||||||
from app.repositories.health import (
|
from app.repositories.health import (
|
||||||
@@ -51,7 +56,7 @@ from app.services.mitmproxy.runtime_validation import (
|
|||||||
backend_port,
|
backend_port,
|
||||||
ensure_proxy_port_not_reserved,
|
ensure_proxy_port_not_reserved,
|
||||||
)
|
)
|
||||||
from app.services.mitmproxy.templates import BUILTIN_TEMPLATES
|
from app.services.mitmproxy.templates import file_template_ids, list_file_templates
|
||||||
from app.services.mitmproxy.validator import (
|
from app.services.mitmproxy.validator import (
|
||||||
ProxyCodeValidationError,
|
ProxyCodeValidationError,
|
||||||
validate_proxy_code,
|
validate_proxy_code,
|
||||||
@@ -152,125 +157,6 @@ async def get_proxy_or_404(proxy_port: int):
|
|||||||
return proxy
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
async def authenticate_proxy_socket(websocket: WebSocket) -> None:
|
|
||||||
token = websocket.query_params.get("token")
|
|
||||||
if token is None:
|
|
||||||
authorization = websocket.headers.get("authorization", "")
|
|
||||||
scheme, _, value = authorization.partition(" ")
|
|
||||||
if scheme.lower() == "bearer" and value:
|
|
||||||
token = value
|
|
||||||
|
|
||||||
if not token:
|
|
||||||
raise InvalidTokenError
|
|
||||||
|
|
||||||
await authenticate_token(token)
|
|
||||||
|
|
||||||
|
|
||||||
def proxy_log_path(proxy) -> Path:
|
|
||||||
if proxy.log_path:
|
|
||||||
return Path(proxy.log_path)
|
|
||||||
return manager.log_path_for(proxy)
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_health_snapshot(
|
|
||||||
snapshot: HealthSnapshot | None,
|
|
||||||
) -> dict[str, object] | None:
|
|
||||||
if snapshot is None:
|
|
||||||
return None
|
|
||||||
return snapshot.model_dump(mode="json")
|
|
||||||
|
|
||||||
|
|
||||||
async def read_log_snapshot(path: Path, limit: int = 8000) -> str:
|
|
||||||
snapshot, _offset, _marker = await snapshot_log_state(path, limit=limit)
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
|
|
||||||
async def snapshot_log_state(
|
|
||||||
path: Path,
|
|
||||||
limit: int = 8000,
|
|
||||||
marker_width: int = 64,
|
|
||||||
) -> tuple[str, int, bytes]:
|
|
||||||
if not await asyncio.to_thread(path.is_file):
|
|
||||||
return "", 0, b""
|
|
||||||
|
|
||||||
data = await asyncio.to_thread(path.read_bytes)
|
|
||||||
size = len(data)
|
|
||||||
marker = data[max(0, size - marker_width) : size]
|
|
||||||
snapshot = data[-limit:].decode("utf-8", errors="replace")
|
|
||||||
return snapshot, size, marker
|
|
||||||
|
|
||||||
|
|
||||||
def _read_log_marker_bytes(path: Path, offset: int, width: int) -> bytes:
|
|
||||||
if offset <= 0 or not path.is_file():
|
|
||||||
return b""
|
|
||||||
|
|
||||||
start = max(0, offset - width)
|
|
||||||
with path.open("rb") as handle:
|
|
||||||
handle.seek(start)
|
|
||||||
return handle.read(offset - start)
|
|
||||||
|
|
||||||
|
|
||||||
async def read_log_marker(path: Path, offset: int, width: int = 64) -> bytes:
|
|
||||||
return await asyncio.to_thread(_read_log_marker_bytes, path, offset, width)
|
|
||||||
|
|
||||||
|
|
||||||
async def watch_socket_disconnect(websocket: WebSocket) -> None:
|
|
||||||
while True:
|
|
||||||
await websocket.receive_text()
|
|
||||||
|
|
||||||
|
|
||||||
async def stream_proxy_logs(
|
|
||||||
websocket: WebSocket,
|
|
||||||
path: Path,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
marker: bytes,
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if not await asyncio.to_thread(path.exists):
|
|
||||||
await asyncio.sleep(0.4)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
size = await asyncio.to_thread(lambda: path.stat().st_size)
|
|
||||||
except OSError:
|
|
||||||
await asyncio.sleep(0.4)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if marker and size >= offset and await read_log_marker(path, offset) != marker:
|
|
||||||
snapshot, offset, marker = await snapshot_log_state(path)
|
|
||||||
await websocket.send_json({"type": "snapshot", "log": snapshot})
|
|
||||||
await asyncio.sleep(0.4)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if size < offset:
|
|
||||||
snapshot, offset, marker = await snapshot_log_state(path)
|
|
||||||
await websocket.send_json({"type": "snapshot", "log": snapshot})
|
|
||||||
await asyncio.sleep(0.4)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if size > offset:
|
|
||||||
def read_log_chunk(
|
|
||||||
chunk_offset: int = offset,
|
|
||||||
chunk_size: int = size,
|
|
||||||
) -> str:
|
|
||||||
with path.open("rb") as handle:
|
|
||||||
handle.seek(chunk_offset)
|
|
||||||
return handle.read(chunk_size - chunk_offset).decode(
|
|
||||||
"utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
|
|
||||||
chunk = await asyncio.to_thread(read_log_chunk)
|
|
||||||
offset = size
|
|
||||||
marker = await read_log_marker(path, offset)
|
|
||||||
if chunk:
|
|
||||||
await websocket.send_json({"type": "append", "log": chunk})
|
|
||||||
|
|
||||||
await asyncio.sleep(0.4)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", dependencies=auth_dependencies)
|
@router.get("", dependencies=auth_dependencies)
|
||||||
async def list_proxies_handler():
|
async def list_proxies_handler():
|
||||||
return await list_proxy_documents()
|
return await list_proxy_documents()
|
||||||
@@ -295,7 +181,7 @@ async def list_mitmproxy_options():
|
|||||||
|
|
||||||
@router.get("/mitmproxy/templates", dependencies=auth_dependencies)
|
@router.get("/mitmproxy/templates", dependencies=auth_dependencies)
|
||||||
async def list_mitmproxy_templates():
|
async def list_mitmproxy_templates():
|
||||||
return [*BUILTIN_TEMPLATES, *await list_custom_templates()]
|
return [*list_file_templates(), *await list_custom_templates()]
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -305,6 +191,11 @@ async def list_mitmproxy_templates():
|
|||||||
)
|
)
|
||||||
async def create_mitmproxy_template(payload: ProxyTemplateCreateInput):
|
async def create_mitmproxy_template(payload: ProxyTemplateCreateInput):
|
||||||
await _validate_code_or_raise(payload.code)
|
await _validate_code_or_raise(payload.code)
|
||||||
|
if payload.id in file_template_ids():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Template already exists: {payload.id}",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
return await create_custom_template(payload)
|
return await create_custom_template(payload)
|
||||||
except DuplicateKeyError as error:
|
except DuplicateKeyError as error:
|
||||||
@@ -320,7 +211,7 @@ async def create_mitmproxy_template(payload: ProxyTemplateCreateInput):
|
|||||||
dependencies=auth_dependencies,
|
dependencies=auth_dependencies,
|
||||||
)
|
)
|
||||||
async def delete_mitmproxy_template(template_id: str):
|
async def delete_mitmproxy_template(template_id: str):
|
||||||
if any(template.id == template_id for template in BUILTIN_TEMPLATES):
|
if template_id in file_template_ids():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail="Built-in templates cannot be deleted",
|
detail="Built-in templates cannot be deleted",
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import WebSocket
|
||||||
|
|
||||||
|
from app.core.security import InvalidTokenError, authenticate_token
|
||||||
|
from app.models.health import HealthSnapshot
|
||||||
|
from app.services.mitmproxy.manager import manager
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_proxy_socket(websocket: WebSocket) -> None:
|
||||||
|
token = websocket.query_params.get("token")
|
||||||
|
if token is None:
|
||||||
|
authorization = websocket.headers.get("authorization", "")
|
||||||
|
scheme, _, value = authorization.partition(" ")
|
||||||
|
if scheme.lower() == "bearer" and value:
|
||||||
|
token = value
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise InvalidTokenError
|
||||||
|
|
||||||
|
await authenticate_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
def proxy_log_path(proxy) -> Path:
|
||||||
|
if proxy.log_path:
|
||||||
|
return Path(proxy.log_path)
|
||||||
|
return manager.log_path_for(proxy)
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_health_snapshot(
|
||||||
|
snapshot: HealthSnapshot | None,
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
if snapshot is None:
|
||||||
|
return None
|
||||||
|
return snapshot.model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
|
async def read_log_snapshot(path: Path, limit: int = 8000) -> str:
|
||||||
|
snapshot, _offset, _marker = await snapshot_log_state(path, limit=limit)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
async def snapshot_log_state(
|
||||||
|
path: Path,
|
||||||
|
limit: int = 8000,
|
||||||
|
marker_width: int = 64,
|
||||||
|
) -> tuple[str, int, bytes]:
|
||||||
|
if not await asyncio.to_thread(path.is_file):
|
||||||
|
return "", 0, b""
|
||||||
|
|
||||||
|
data = await asyncio.to_thread(path.read_bytes)
|
||||||
|
size = len(data)
|
||||||
|
marker = data[max(0, size - marker_width) : size]
|
||||||
|
snapshot = data[-limit:].decode("utf-8", errors="replace")
|
||||||
|
return snapshot, size, marker
|
||||||
|
|
||||||
|
|
||||||
|
def _read_log_marker_bytes(path: Path, offset: int, width: int) -> bytes:
|
||||||
|
if offset <= 0 or not path.is_file():
|
||||||
|
return b""
|
||||||
|
|
||||||
|
start = max(0, offset - width)
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
handle.seek(start)
|
||||||
|
return handle.read(offset - start)
|
||||||
|
|
||||||
|
|
||||||
|
async def read_log_marker(path: Path, offset: int, width: int = 64) -> bytes:
|
||||||
|
return await asyncio.to_thread(_read_log_marker_bytes, path, offset, width)
|
||||||
|
|
||||||
|
|
||||||
|
async def watch_socket_disconnect(websocket: WebSocket) -> None:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_proxy_logs(
|
||||||
|
websocket: WebSocket,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
marker: bytes,
|
||||||
|
) -> None:
|
||||||
|
while True:
|
||||||
|
if not await asyncio.to_thread(path.exists):
|
||||||
|
await asyncio.sleep(0.4)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
size = await asyncio.to_thread(lambda: path.stat().st_size)
|
||||||
|
except OSError:
|
||||||
|
await asyncio.sleep(0.4)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if marker and size >= offset and await read_log_marker(path, offset) != marker:
|
||||||
|
snapshot, offset, marker = await snapshot_log_state(path)
|
||||||
|
await websocket.send_json({"type": "snapshot", "log": snapshot})
|
||||||
|
await asyncio.sleep(0.4)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if size < offset:
|
||||||
|
snapshot, offset, marker = await snapshot_log_state(path)
|
||||||
|
await websocket.send_json({"type": "snapshot", "log": snapshot})
|
||||||
|
await asyncio.sleep(0.4)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if size > offset:
|
||||||
|
|
||||||
|
def read_log_chunk(
|
||||||
|
chunk_offset: int = offset,
|
||||||
|
chunk_size: int = size,
|
||||||
|
) -> str:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
handle.seek(chunk_offset)
|
||||||
|
return handle.read(chunk_size - chunk_offset).decode(
|
||||||
|
"utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
|
||||||
|
chunk = await asyncio.to_thread(read_log_chunk)
|
||||||
|
offset = size
|
||||||
|
marker = await read_log_marker(path, offset)
|
||||||
|
if chunk:
|
||||||
|
await websocket.send_json({"type": "append", "log": chunk})
|
||||||
|
|
||||||
|
await asyncio.sleep(0.4)
|
||||||
@@ -19,8 +19,14 @@ class ProxyTemplateDocument(BaseModel):
|
|||||||
code: str = Field(..., description="Addon source code")
|
code: str = Field(..., description="Addon source code")
|
||||||
source_url: str | None = Field(default=None, description="Reference URL")
|
source_url: str | None = Field(default=None, description="Reference URL")
|
||||||
built_in: bool = Field(default=False, description="Provided by thePlug")
|
built_in: bool = Field(default=False, description="Provided by thePlug")
|
||||||
|
origin: str = Field(default="custom", description="Template source")
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
|
source_path: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Filesystem source path",
|
||||||
|
exclude=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ProxyTemplateCreateInput(BaseModel):
|
class ProxyTemplateCreateInput(BaseModel):
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ async def create_custom_template(
|
|||||||
code=payload.code,
|
code=payload.code,
|
||||||
source_url=payload.source_url,
|
source_url=payload.source_url,
|
||||||
built_in=False,
|
built_in=False,
|
||||||
|
origin="custom",
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,12 +4,23 @@ import shutil
|
|||||||
import signal
|
import signal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from app.integrations.docker import change_port
|
from app.integrations.docker import change_port
|
||||||
from app.integrations.docker.ports import get_random_port
|
from app.integrations.docker.ports import get_random_port
|
||||||
from app.models.proxy import ProxyDocument
|
from app.models.proxy import ProxyDocument
|
||||||
from app.services.mitmproxy.options import MitmOptionError, validate_options
|
from app.services.mitmproxy.options import MitmOptionError, validate_options
|
||||||
|
from app.services.mitmproxy.runtime_files import (
|
||||||
|
conf_dir_for,
|
||||||
|
config_path_for,
|
||||||
|
ensure_log_path,
|
||||||
|
log_path_for,
|
||||||
|
read_cmdline,
|
||||||
|
read_log_tail,
|
||||||
|
remove_dir,
|
||||||
|
remove_file,
|
||||||
|
script_path_for,
|
||||||
|
write_config,
|
||||||
|
write_script,
|
||||||
|
)
|
||||||
from app.services.mitmproxy.runtime_validation import (
|
from app.services.mitmproxy.runtime_validation import (
|
||||||
ReservedProxyPortError,
|
ReservedProxyPortError,
|
||||||
ensure_proxy_port_not_reserved,
|
ensure_proxy_port_not_reserved,
|
||||||
@@ -300,23 +311,13 @@ class MitmdumpManager:
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
async def write_script(self, proxy: ProxyDocument) -> Path:
|
async def write_script(self, proxy: ProxyDocument) -> Path:
|
||||||
path = self.script_path_for(proxy)
|
return await write_script(self.work_dir, proxy)
|
||||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
|
||||||
await asyncio.to_thread(path.write_text, proxy.code, encoding="utf-8")
|
|
||||||
return path
|
|
||||||
|
|
||||||
async def write_config(self, proxy: ProxyDocument) -> Path:
|
async def write_config(self, proxy: ProxyDocument) -> Path:
|
||||||
path = self.config_path_for(proxy)
|
return await write_config(self.work_dir, proxy)
|
||||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
|
||||||
options = {"block_global": False, **proxy.mitm_options}
|
|
||||||
config_text = yaml.safe_dump(options, sort_keys=True)
|
|
||||||
await asyncio.to_thread(path.write_text, config_text, encoding="utf-8")
|
|
||||||
return path
|
|
||||||
|
|
||||||
async def log_path(self, proxy: ProxyDocument) -> Path:
|
async def log_path(self, proxy: ProxyDocument) -> Path:
|
||||||
path = self.log_path_for(proxy)
|
return await ensure_log_path(self.work_dir, proxy)
|
||||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
|
||||||
return path
|
|
||||||
|
|
||||||
async def spawn(self, command: list[str], log_path: Path):
|
async def spawn(self, command: list[str], log_path: Path):
|
||||||
with log_path.open("ab") as log_file:
|
with log_path.open("ab") as log_file:
|
||||||
@@ -408,49 +409,28 @@ class MitmdumpManager:
|
|||||||
return expected_script in cmdline and expected_confdir in cmdline
|
return expected_script in cmdline and expected_confdir in cmdline
|
||||||
|
|
||||||
def read_cmdline(self, pid: int) -> list[str]:
|
def read_cmdline(self, pid: int) -> list[str]:
|
||||||
path = Path(f"/proc/{pid}/cmdline")
|
return read_cmdline(pid)
|
||||||
try:
|
|
||||||
raw = path.read_bytes()
|
|
||||||
except OSError:
|
|
||||||
return []
|
|
||||||
decoded = raw.decode("utf-8", errors="replace")
|
|
||||||
return [part for part in decoded.split("\x00") if part]
|
|
||||||
|
|
||||||
def script_path_for(self, proxy: ProxyDocument) -> Path:
|
def script_path_for(self, proxy: ProxyDocument) -> Path:
|
||||||
return self.work_dir / "addons" / f"{proxy.proxy_port}.py"
|
return script_path_for(self.work_dir, proxy)
|
||||||
|
|
||||||
def conf_dir_for(self, proxy: ProxyDocument) -> Path:
|
def conf_dir_for(self, proxy: ProxyDocument) -> Path:
|
||||||
return self.work_dir / "conf" / str(proxy.proxy_port)
|
return conf_dir_for(self.work_dir, proxy)
|
||||||
|
|
||||||
def config_path_for(self, proxy: ProxyDocument) -> Path:
|
def config_path_for(self, proxy: ProxyDocument) -> Path:
|
||||||
return self.conf_dir_for(proxy) / "config.yaml"
|
return config_path_for(self.work_dir, proxy)
|
||||||
|
|
||||||
def log_path_for(self, proxy: ProxyDocument) -> Path:
|
def log_path_for(self, proxy: ProxyDocument) -> Path:
|
||||||
return self.work_dir / "logs" / f"{proxy.proxy_port}.log"
|
return log_path_for(self.work_dir, proxy)
|
||||||
|
|
||||||
async def read_log_tail(self, path: Path, limit: int = 4000) -> str:
|
async def read_log_tail(self, path: Path, limit: int = 4000) -> str:
|
||||||
if not await asyncio.to_thread(path.exists):
|
return await read_log_tail(path, limit=limit)
|
||||||
return ""
|
|
||||||
content = await asyncio.to_thread(
|
|
||||||
path.read_text,
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
return content[-limit:].strip()
|
|
||||||
|
|
||||||
def remove_file(self, path: Path) -> None:
|
def remove_file(self, path: Path) -> None:
|
||||||
try:
|
remove_file(path)
|
||||||
path.unlink(missing_ok=True)
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
|
|
||||||
def remove_dir(self, path: Path) -> None:
|
def remove_dir(self, path: Path) -> None:
|
||||||
try:
|
remove_dir(path)
|
||||||
shutil.rmtree(path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
|
|
||||||
def lock_for(self, proxy_port: int) -> asyncio.Lock:
|
def lock_for(self, proxy_port: int) -> asyncio.Lock:
|
||||||
lock = self.locks.get(proxy_port)
|
lock = self.locks.get(proxy_port)
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.models.proxy import ProxyDocument
|
||||||
|
|
||||||
|
|
||||||
|
def script_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||||
|
return work_dir / "addons" / f"{proxy.proxy_port}.py"
|
||||||
|
|
||||||
|
|
||||||
|
def conf_dir_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||||
|
return work_dir / "conf" / str(proxy.proxy_port)
|
||||||
|
|
||||||
|
|
||||||
|
def config_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||||
|
return conf_dir_for(work_dir, proxy) / "config.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def log_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||||
|
return work_dir / "logs" / f"{proxy.proxy_port}.log"
|
||||||
|
|
||||||
|
|
||||||
|
async def write_script(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||||
|
path = script_path_for(work_dir, proxy)
|
||||||
|
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
||||||
|
await asyncio.to_thread(path.write_text, proxy.code, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
async def write_config(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||||
|
path = config_path_for(work_dir, proxy)
|
||||||
|
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
||||||
|
options = {"block_global": False, **proxy.mitm_options}
|
||||||
|
config_text = yaml.safe_dump(options, sort_keys=True)
|
||||||
|
await asyncio.to_thread(path.write_text, config_text, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_log_path(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||||
|
path = log_path_for(work_dir, proxy)
|
||||||
|
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
async def read_log_tail(path: Path, limit: int = 4000) -> str:
|
||||||
|
if not await asyncio.to_thread(path.exists):
|
||||||
|
return ""
|
||||||
|
content = await asyncio.to_thread(
|
||||||
|
path.read_text,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
return content[-limit:].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def remove_file(path: Path) -> None:
|
||||||
|
try:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def remove_dir(path: Path) -> None:
|
||||||
|
try:
|
||||||
|
shutil.rmtree(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def read_cmdline(pid: int) -> list[str]:
|
||||||
|
path = Path(f"/proc/{pid}/cmdline")
|
||||||
|
try:
|
||||||
|
raw = path.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
decoded = raw.decode("utf-8", errors="replace")
|
||||||
|
return [part for part in decoded.split("\x00") if part]
|
||||||
@@ -1,524 +1,139 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from app.models.template import ProxyTemplateDocument
|
from app.models.template import ProxyTemplateDocument
|
||||||
|
|
||||||
DOCS_TEMPLATES_URL = "https://docs.mitmproxy.org/stable/addons/examples/"
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SUPPORTED_FILE_TEMPLATE_PROTOCOLS = frozenset({"http", "tcp"})
|
||||||
|
TEMPLATES_DIR = Path(__file__).resolve().parents[4] / "templates"
|
||||||
|
|
||||||
|
|
||||||
def builtin_template(
|
def template_directory() -> Path:
|
||||||
*,
|
return TEMPLATES_DIR
|
||||||
id: str,
|
|
||||||
name: str,
|
|
||||||
protocol: str,
|
def template_files(directory: Path | None = None) -> list[Path]:
|
||||||
family: str,
|
root = directory or template_directory()
|
||||||
tags: list[str],
|
if not root.exists():
|
||||||
action: str,
|
return []
|
||||||
summary: str,
|
return sorted(
|
||||||
behavior: str,
|
path
|
||||||
code: str,
|
for path in root.iterdir()
|
||||||
) -> ProxyTemplateDocument:
|
if path.is_file() and path.suffix.lower() in {".yaml", ".yml"}
|
||||||
category = protocol if protocol in {"http", "tcp"} else family
|
|
||||||
return ProxyTemplateDocument(
|
|
||||||
id=id,
|
|
||||||
name=name,
|
|
||||||
description=summary,
|
|
||||||
category=category,
|
|
||||||
protocol=protocol,
|
|
||||||
family=family,
|
|
||||||
tags=tags,
|
|
||||||
action=action,
|
|
||||||
summary=summary,
|
|
||||||
behavior=behavior,
|
|
||||||
source_url=DOCS_TEMPLATES_URL,
|
|
||||||
built_in=True,
|
|
||||||
code=code,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
BUILTIN_TEMPLATES = [
|
def _default_category(protocol: str, family: str) -> str:
|
||||||
builtin_template(
|
return protocol if protocol in SUPPORTED_FILE_TEMPLATE_PROTOCOLS else family
|
||||||
id="http-block-path-traversal",
|
|
||||||
name="Block path traversal",
|
|
||||||
protocol="http",
|
|
||||||
family="web-hardening",
|
|
||||||
tags=["path-traversal", "web", "block"],
|
|
||||||
action="block",
|
|
||||||
summary="Stops common path traversal probes in path and query.",
|
|
||||||
behavior=(
|
|
||||||
"Logs the matched traversal token and immediately drops the client "
|
|
||||||
"connection before the request reaches the upstream service."
|
|
||||||
),
|
|
||||||
code="""import logging
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
from mitmproxy import http
|
|
||||||
|
|
||||||
TRAVERSAL_MARKERS = (
|
|
||||||
"../",
|
|
||||||
"..\\\\",
|
|
||||||
"%2e%2e",
|
|
||||||
"%252e%252e",
|
|
||||||
"..%2f",
|
|
||||||
"..%5c",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def request(flow: http.HTTPFlow) -> None:
|
def _string_value(value: object, default: str = "") -> str:
|
||||||
raw_target = (
|
if isinstance(value, str):
|
||||||
f"{flow.request.path}?{flow.request.query}"
|
return value
|
||||||
if flow.request.query
|
if value is None:
|
||||||
else flow.request.path
|
return default
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_template_payload(
|
||||||
|
raw: Mapping[str, object],
|
||||||
|
source: Path,
|
||||||
|
) -> tuple[dict[str, object], str]:
|
||||||
|
protocol = _string_value(raw.get("protocol")).strip().lower()
|
||||||
|
if protocol not in SUPPORTED_FILE_TEMPLATE_PROTOCOLS:
|
||||||
|
expected = sorted(SUPPORTED_FILE_TEMPLATE_PROTOCOLS)
|
||||||
|
raise ValueError(
|
||||||
|
f"unsupported protocol {protocol!r}; expected one of {expected}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "id" not in raw or "name" not in raw or "code" not in raw:
|
||||||
|
raise ValueError("missing one of required fields: id, name, code")
|
||||||
|
|
||||||
|
description = _string_value(
|
||||||
|
raw.get("description") or raw.get("summary") or raw.get("behavior")
|
||||||
)
|
)
|
||||||
normalized = unquote(unquote(raw_target)).lower()
|
family = _string_value(raw.get("family") or protocol)
|
||||||
for marker in TRAVERSAL_MARKERS:
|
source_url = raw.get("source_url")
|
||||||
if marker in normalized:
|
tags = raw.get("tags")
|
||||||
logging.warning(
|
normalized_tags = [str(tag) for tag in tags] if isinstance(tags, list) else []
|
||||||
"[http-block-path-traversal] blocked %s %s from %s",
|
|
||||||
flow.request.method,
|
payload: dict[str, object] = {
|
||||||
flow.request.pretty_url,
|
"id": _string_value(raw["id"]).strip(),
|
||||||
flow.client_conn.peername,
|
"name": _string_value(raw["name"]).strip(),
|
||||||
)
|
"description": description,
|
||||||
flow.kill()
|
"category": _string_value(
|
||||||
return
|
raw.get("category") or _default_category(protocol, family)
|
||||||
""",
|
|
||||||
),
|
|
||||||
builtin_template(
|
|
||||||
id="http-block-blacklisted-substrings",
|
|
||||||
name="Block blacklisted substrings",
|
|
||||||
protocol="http",
|
|
||||||
family="web-hardening",
|
|
||||||
tags=["blacklist-string", "web", "block"],
|
|
||||||
action="block",
|
|
||||||
summary="Blocks requests containing forbidden strings in path, query, or body.",
|
|
||||||
behavior=(
|
|
||||||
"Scans URL and request body for blacklisted markers, then serves a "
|
|
||||||
"blank HTML page with 200 OK and logs the first hit."
|
|
||||||
),
|
),
|
||||||
code="""import logging
|
"protocol": protocol,
|
||||||
|
"family": family,
|
||||||
from mitmproxy import http
|
"tags": normalized_tags,
|
||||||
|
"action": _string_value(raw.get("action") or "custom"),
|
||||||
BLOCKED_STRINGS = (
|
"summary": _string_value(raw.get("summary") or description),
|
||||||
"flag{",
|
"behavior": _string_value(raw.get("behavior") or description),
|
||||||
"give_me_shell",
|
"code": _string_value(raw["code"]),
|
||||||
"debug=1",
|
"source_url": _string_value(source_url) if source_url is not None else None,
|
||||||
"/etc/passwd",
|
"built_in": True,
|
||||||
)
|
"origin": "file",
|
||||||
|
}
|
||||||
|
return payload, str(source)
|
||||||
|
|
||||||
|
|
||||||
def request(flow: http.HTTPFlow) -> None:
|
def _load_template_from_file(path: Path) -> ProxyTemplateDocument | None:
|
||||||
body = flow.request.get_text(strict=False)
|
try:
|
||||||
haystack = "\\n".join(
|
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
[flow.request.path, str(flow.request.query), body]
|
except (OSError, yaml.YAMLError) as error:
|
||||||
).lower()
|
logger.warning("Skipping template file %s: %s", path, error)
|
||||||
for marker in BLOCKED_STRINGS:
|
return None
|
||||||
if marker.lower() in haystack:
|
|
||||||
logging.warning(
|
|
||||||
"[http-block-blacklisted-substrings] blocked marker=%r url=%s",
|
|
||||||
marker,
|
|
||||||
flow.request.pretty_url,
|
|
||||||
)
|
|
||||||
flow.response = http.Response.make(
|
|
||||||
200,
|
|
||||||
b"",
|
|
||||||
{"content-type": "text/html; charset=utf-8"},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
builtin_template(
|
|
||||||
id="http-block-large-body",
|
|
||||||
name="Block large request bodies",
|
|
||||||
protocol="http",
|
|
||||||
family="web-hardening",
|
|
||||||
tags=["length-limit", "body-size", "web", "block"],
|
|
||||||
action="block",
|
|
||||||
summary="Rejects oversized HTTP request bodies before they hit the service.",
|
|
||||||
behavior=(
|
|
||||||
"Checks the buffered request body length, returns 413 Payload Too "
|
|
||||||
"Large, and logs the observed size."
|
|
||||||
),
|
|
||||||
code="""import logging
|
|
||||||
|
|
||||||
from mitmproxy import http
|
if not isinstance(raw, dict):
|
||||||
|
logger.warning("Skipping template file %s: expected mapping root", path)
|
||||||
|
return None
|
||||||
|
normalized_raw = {str(key): value for key, value in raw.items()}
|
||||||
|
|
||||||
MAX_BODY_BYTES = 8192
|
try:
|
||||||
|
payload, source_path = _normalize_template_payload(normalized_raw, path)
|
||||||
|
document = ProxyTemplateDocument.model_validate(payload)
|
||||||
|
except (ValidationError, ValueError) as error:
|
||||||
|
logger.warning("Skipping template file %s: %s", path, error)
|
||||||
|
return None
|
||||||
|
|
||||||
|
document.source_path = source_path
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
def request(flow: http.HTTPFlow) -> None:
|
def list_file_templates(directory: Path | None = None) -> list[ProxyTemplateDocument]:
|
||||||
size = len(flow.request.raw_content or b"")
|
by_id: dict[str, list[ProxyTemplateDocument]] = defaultdict(list)
|
||||||
if size > MAX_BODY_BYTES:
|
for path in template_files(directory):
|
||||||
logging.warning(
|
template = _load_template_from_file(path)
|
||||||
"[http-block-large-body] blocked %s bytes on %s",
|
if template is None:
|
||||||
size,
|
continue
|
||||||
flow.request.pretty_url,
|
by_id[template.id].append(template)
|
||||||
)
|
|
||||||
flow.response = http.Response.make(
|
|
||||||
413,
|
|
||||||
b"",
|
|
||||||
{"content-type": "text/plain"},
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
builtin_template(
|
|
||||||
id="http-block-suspicious-methods",
|
|
||||||
name="Block suspicious HTTP methods",
|
|
||||||
protocol="http",
|
|
||||||
family="web-hardening",
|
|
||||||
tags=["method-filter", "web", "block"],
|
|
||||||
action="block",
|
|
||||||
summary="Denies uncommon or dangerous HTTP methods.",
|
|
||||||
behavior=(
|
|
||||||
"Returns 405 Method Not Allowed for methods outside the allowlist "
|
|
||||||
"and logs the blocked method and target URL."
|
|
||||||
),
|
|
||||||
code="""import logging
|
|
||||||
|
|
||||||
from mitmproxy import http
|
deduplicated: list[ProxyTemplateDocument] = []
|
||||||
|
duplicate_template_message = (
|
||||||
ALLOWED_METHODS = {"GET", "POST", "HEAD"}
|
"Skipping template id %s from %s: duplicate file-backed template id"
|
||||||
|
|
||||||
|
|
||||||
def request(flow: http.HTTPFlow) -> None:
|
|
||||||
if flow.request.method not in ALLOWED_METHODS:
|
|
||||||
logging.warning(
|
|
||||||
"[http-block-suspicious-methods] blocked method=%s url=%s",
|
|
||||||
flow.request.method,
|
|
||||||
flow.request.pretty_url,
|
|
||||||
)
|
|
||||||
flow.response = http.Response.make(
|
|
||||||
405,
|
|
||||||
b"",
|
|
||||||
{"allow": "GET, POST, HEAD"},
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
builtin_template(
|
|
||||||
id="http-block-admin-paths",
|
|
||||||
name="Block admin and debug paths",
|
|
||||||
protocol="http",
|
|
||||||
family="web-hardening",
|
|
||||||
tags=["admin-path", "debug-path", "web", "block"],
|
|
||||||
action="block",
|
|
||||||
summary="Protects common admin, console, and debug endpoints.",
|
|
||||||
behavior=(
|
|
||||||
"Matches the request path against a small denylist and returns a "
|
|
||||||
"blank 404 page before the request is forwarded."
|
|
||||||
),
|
|
||||||
code="""import logging
|
|
||||||
|
|
||||||
from mitmproxy import http
|
|
||||||
|
|
||||||
BLOCKED_PREFIXES = (
|
|
||||||
"/admin",
|
|
||||||
"/debug",
|
|
||||||
"/console",
|
|
||||||
"/actuator",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def request(flow: http.HTTPFlow) -> None:
|
|
||||||
if flow.request.path.startswith(BLOCKED_PREFIXES):
|
|
||||||
logging.warning(
|
|
||||||
"[http-block-admin-paths] blocked path=%s",
|
|
||||||
flow.request.path,
|
|
||||||
)
|
|
||||||
flow.response = http.Response.make(
|
|
||||||
404,
|
|
||||||
b"",
|
|
||||||
{"content-type": "text/html; charset=utf-8"},
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
builtin_template(
|
|
||||||
id="http-sanitize-headers-basic",
|
|
||||||
name="Sanitize risky forwarding headers",
|
|
||||||
protocol="http",
|
|
||||||
family="web-hardening",
|
|
||||||
tags=["rewrite", "headers", "web"],
|
|
||||||
action="rewrite",
|
|
||||||
summary=(
|
|
||||||
"Removes a few risky forwarding headers before the request is "
|
|
||||||
"sent upstream."
|
|
||||||
),
|
|
||||||
behavior=(
|
|
||||||
"Deletes selected override or forwarding headers and logs what was "
|
|
||||||
"rewritten without blocking the request."
|
|
||||||
),
|
|
||||||
code="""import logging
|
|
||||||
|
|
||||||
from mitmproxy import http
|
|
||||||
|
|
||||||
STRIP_HEADERS = (
|
|
||||||
"x-forwarded-host",
|
|
||||||
"x-http-method-override",
|
|
||||||
"x-original-url",
|
|
||||||
"x-rewrite-url",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def request(flow: http.HTTPFlow) -> None:
|
|
||||||
removed = []
|
|
||||||
for header in STRIP_HEADERS:
|
|
||||||
if header in flow.request.headers:
|
|
||||||
removed.append(header)
|
|
||||||
del flow.request.headers[header]
|
|
||||||
if removed:
|
|
||||||
logging.info(
|
|
||||||
"[http-sanitize-headers-basic] stripped headers=%s url=%s",
|
|
||||||
",".join(removed),
|
|
||||||
flow.request.pretty_url,
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
builtin_template(
|
|
||||||
id="http-block-command-injection-markers",
|
|
||||||
name="Block command injection markers",
|
|
||||||
protocol="http",
|
|
||||||
family="web-hardening",
|
|
||||||
tags=["command-injection", "web", "block"],
|
|
||||||
action="block",
|
|
||||||
summary=(
|
|
||||||
"Rejects obvious shell metacharacters and command-substitution "
|
|
||||||
"markers."
|
|
||||||
),
|
|
||||||
behavior=(
|
|
||||||
"Scans URL and body for shell-style injection markers, logs the "
|
|
||||||
"first hit, and drops the client connection."
|
|
||||||
),
|
|
||||||
code="""import logging
|
|
||||||
|
|
||||||
from mitmproxy import http
|
|
||||||
|
|
||||||
BLOCKED_MARKERS = (";", "&&", "||", "$(", "`", "|sh", "|bash")
|
|
||||||
|
|
||||||
|
|
||||||
def request(flow: http.HTTPFlow) -> None:
|
|
||||||
body = flow.request.get_text(strict=False)
|
|
||||||
haystack = "\\n".join(
|
|
||||||
[flow.request.pretty_url, body]
|
|
||||||
).lower()
|
|
||||||
for marker in BLOCKED_MARKERS:
|
|
||||||
if marker in haystack:
|
|
||||||
logging.warning(
|
|
||||||
"[http-block-command-injection-markers] blocked marker=%r url=%s",
|
|
||||||
marker,
|
|
||||||
flow.request.pretty_url,
|
|
||||||
)
|
|
||||||
flow.kill()
|
|
||||||
return
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
builtin_template(
|
|
||||||
id="http-block-sqli-markers",
|
|
||||||
name="Block obvious SQLi markers",
|
|
||||||
protocol="http",
|
|
||||||
family="web-hardening",
|
|
||||||
tags=["sqli", "web", "block"],
|
|
||||||
action="block",
|
|
||||||
summary="Stops common SQL injection probes before they reach the app.",
|
|
||||||
behavior=(
|
|
||||||
"Matches a short list of high-signal SQLi markers in path, query, "
|
|
||||||
"and body, then serves a blank HTML page and logs the hit."
|
|
||||||
),
|
|
||||||
code="""import logging
|
|
||||||
|
|
||||||
from mitmproxy import http
|
|
||||||
|
|
||||||
BLOCKED_MARKERS = (
|
|
||||||
"' or 1=1",
|
|
||||||
'" or 1=1',
|
|
||||||
"union select",
|
|
||||||
"sleep(",
|
|
||||||
"information_schema",
|
|
||||||
"--",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def request(flow: http.HTTPFlow) -> None:
|
|
||||||
body = flow.request.get_text(strict=False)
|
|
||||||
haystack = "\\n".join(
|
|
||||||
[flow.request.path, str(flow.request.query), body]
|
|
||||||
).lower()
|
|
||||||
for marker in BLOCKED_MARKERS:
|
|
||||||
if marker in haystack:
|
|
||||||
logging.warning(
|
|
||||||
"[http-block-sqli-markers] blocked marker=%r url=%s",
|
|
||||||
marker,
|
|
||||||
flow.request.pretty_url,
|
|
||||||
)
|
|
||||||
flow.response = http.Response.make(
|
|
||||||
200,
|
|
||||||
b"",
|
|
||||||
{"content-type": "text/html; charset=utf-8"},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
builtin_template(
|
|
||||||
id="tcp-block-repeated-byte-threshold",
|
|
||||||
name="Block repeated-byte flood",
|
|
||||||
protocol="tcp",
|
|
||||||
family="tcp-hardening",
|
|
||||||
tags=["bof", "repeated-byte", "tcp", "block"],
|
|
||||||
action="block",
|
|
||||||
summary="Closes connections that send suspiciously long runs of the same byte.",
|
|
||||||
behavior=(
|
|
||||||
"Measures the longest repeated-byte run in each client message, logs "
|
|
||||||
"the byte and run length, and closes the TCP flow on threshold hit."
|
|
||||||
),
|
|
||||||
code="""import logging
|
|
||||||
|
|
||||||
from mitmproxy import tcp
|
|
||||||
|
|
||||||
MAX_REPEAT = 128
|
|
||||||
|
|
||||||
|
|
||||||
def longest_run(data: bytes) -> tuple[int, int]:
|
|
||||||
best_byte = -1
|
|
||||||
best_run = 0
|
|
||||||
current_byte = -1
|
|
||||||
current_run = 0
|
|
||||||
for byte in data:
|
|
||||||
if byte == current_byte:
|
|
||||||
current_run += 1
|
|
||||||
else:
|
|
||||||
current_byte = byte
|
|
||||||
current_run = 1
|
|
||||||
if current_run > best_run:
|
|
||||||
best_byte = byte
|
|
||||||
best_run = current_run
|
|
||||||
return best_byte, best_run
|
|
||||||
|
|
||||||
|
|
||||||
def tcp_message(flow: tcp.TCPFlow) -> None:
|
|
||||||
message = flow.messages[-1]
|
|
||||||
if not message.from_client:
|
|
||||||
return
|
|
||||||
repeated_byte, run_length = longest_run(message.content)
|
|
||||||
if run_length > MAX_REPEAT:
|
|
||||||
logging.warning(
|
|
||||||
"[tcp-block-repeated-byte-threshold] blocked byte=0x%02x run=%d",
|
|
||||||
repeated_byte,
|
|
||||||
run_length,
|
|
||||||
)
|
|
||||||
message.drop()
|
|
||||||
flow.kill()
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
builtin_template(
|
|
||||||
id="tcp-block-message-length-threshold",
|
|
||||||
name="Block oversized TCP messages",
|
|
||||||
protocol="tcp",
|
|
||||||
family="tcp-hardening",
|
|
||||||
tags=["length-limit", "tcp", "block"],
|
|
||||||
action="block",
|
|
||||||
summary="Cuts off client messages that exceed a fixed size threshold.",
|
|
||||||
behavior=(
|
|
||||||
"Checks every client TCP message size, logs the offending length, "
|
|
||||||
"drops the message, and kills the flow when the threshold is hit."
|
|
||||||
),
|
|
||||||
code="""import logging
|
|
||||||
|
|
||||||
from mitmproxy import tcp
|
|
||||||
|
|
||||||
MAX_MESSAGE_BYTES = 4096
|
|
||||||
|
|
||||||
|
|
||||||
def tcp_message(flow: tcp.TCPFlow) -> None:
|
|
||||||
message = flow.messages[-1]
|
|
||||||
if not message.from_client:
|
|
||||||
return
|
|
||||||
size = len(message.content)
|
|
||||||
if size > MAX_MESSAGE_BYTES:
|
|
||||||
logging.warning(
|
|
||||||
"[tcp-block-message-length-threshold] blocked message size=%d",
|
|
||||||
size,
|
|
||||||
)
|
|
||||||
message.drop()
|
|
||||||
flow.kill()
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
builtin_template(
|
|
||||||
id="tcp-block-blacklisted-byte-sequences",
|
|
||||||
name="Block blacklisted TCP byte sequences",
|
|
||||||
protocol="tcp",
|
|
||||||
family="tcp-hardening",
|
|
||||||
tags=["blacklist-bytes", "tcp", "block"],
|
|
||||||
action="block",
|
|
||||||
summary=(
|
|
||||||
"Blocks payloads containing forbidden byte sequences or marker "
|
|
||||||
"strings."
|
|
||||||
),
|
|
||||||
behavior=(
|
|
||||||
"Looks for dangerous byte patterns in client messages, then drops "
|
|
||||||
"the message and closes the flow."
|
|
||||||
),
|
|
||||||
code="""import logging
|
|
||||||
|
|
||||||
from mitmproxy import tcp
|
|
||||||
|
|
||||||
BLOCKED_SEQUENCES = (
|
|
||||||
b"/bin/sh",
|
|
||||||
b"cat flag",
|
|
||||||
b"AAAA%p%p",
|
|
||||||
b"\\x90\\x90\\x90\\x90",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def tcp_message(flow: tcp.TCPFlow) -> None:
|
|
||||||
message = flow.messages[-1]
|
|
||||||
if not message.from_client:
|
|
||||||
return
|
|
||||||
for sequence in BLOCKED_SEQUENCES:
|
|
||||||
if sequence in message.content:
|
|
||||||
logging.warning(
|
|
||||||
"[tcp-block-blacklisted-byte-sequences] blocked sequence=%r",
|
|
||||||
sequence,
|
|
||||||
)
|
|
||||||
message.drop()
|
|
||||||
flow.kill()
|
|
||||||
return
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
builtin_template(
|
|
||||||
id="tcp-block-non-printable-threshold",
|
|
||||||
name="Block binary-noise threshold",
|
|
||||||
protocol="tcp",
|
|
||||||
family="tcp-hardening",
|
|
||||||
tags=["non-printable", "binary-noise", "tcp", "block"],
|
|
||||||
action="block",
|
|
||||||
summary=(
|
|
||||||
"Blocks client messages with an excessive amount of non-printable "
|
|
||||||
"bytes."
|
|
||||||
),
|
|
||||||
behavior=(
|
|
||||||
"Treats unusually binary-heavy client payloads as suspicious, logs "
|
|
||||||
"the ratio, and closes the flow when the threshold is exceeded."
|
|
||||||
),
|
|
||||||
code="""import logging
|
|
||||||
|
|
||||||
from mitmproxy import tcp
|
|
||||||
|
|
||||||
NON_PRINTABLE_RATIO = 0.35
|
|
||||||
|
|
||||||
|
|
||||||
def tcp_message(flow: tcp.TCPFlow) -> None:
|
|
||||||
message = flow.messages[-1]
|
|
||||||
if not message.from_client or not message.content:
|
|
||||||
return
|
|
||||||
non_printable = sum(
|
|
||||||
1
|
|
||||||
for byte in message.content
|
|
||||||
if byte < 0x20 and byte not in {0x09, 0x0a, 0x0d}
|
|
||||||
)
|
)
|
||||||
ratio = non_printable / len(message.content)
|
for template_id, templates in by_id.items():
|
||||||
if ratio > NON_PRINTABLE_RATIO:
|
if len(templates) > 1:
|
||||||
logging.warning(
|
for template in templates:
|
||||||
"[tcp-block-non-printable-threshold] blocked ratio=%.2f size=%d",
|
logger.warning(
|
||||||
ratio,
|
duplicate_template_message,
|
||||||
len(message.content),
|
template_id,
|
||||||
)
|
template.source_path or "<unknown>",
|
||||||
message.drop()
|
)
|
||||||
flow.kill()
|
continue
|
||||||
""",
|
deduplicated.append(templates[0])
|
||||||
),
|
|
||||||
]
|
return sorted(deduplicated, key=lambda template: template.name.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def file_template_ids(directory: Path | None = None) -> set[str]:
|
||||||
|
return {template.id for template in list_file_templates(directory)}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from app.core.security import get_auth_context, require_authenticated_user
|
|||||||
from app.models.health import HealthSnapshot
|
from app.models.health import HealthSnapshot
|
||||||
from app.models.proxy import ProxyCreateInput, ProxyDocument
|
from app.models.proxy import ProxyCreateInput, ProxyDocument
|
||||||
from app.models.service import ServiceEndpointDocument
|
from app.models.service import ServiceEndpointDocument
|
||||||
from app.services.mitmproxy.templates import BUILTIN_TEMPLATES
|
from app.services.mitmproxy.templates import list_file_templates
|
||||||
from app.services.mitmproxy.validator import ProxyCodeValidationError
|
from app.services.mitmproxy.validator import ProxyCodeValidationError
|
||||||
|
|
||||||
proxy_router_module = importlib.import_module("app.api.proxy.router")
|
proxy_router_module = importlib.import_module("app.api.proxy.router")
|
||||||
@@ -864,6 +864,8 @@ def test_stop_proxy_updates_runtime_state(client, monkeypatch: object):
|
|||||||
|
|
||||||
|
|
||||||
def test_list_mitmproxy_templates_returns_builtins_and_custom(client, monkeypatch):
|
def test_list_mitmproxy_templates_returns_builtins_and_custom(client, monkeypatch):
|
||||||
|
file_templates = list_file_templates()
|
||||||
|
|
||||||
async def fake_list_custom_templates():
|
async def fake_list_custom_templates():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -872,13 +874,19 @@ def test_list_mitmproxy_templates_returns_builtins_and_custom(client, monkeypatc
|
|||||||
"list_custom_templates",
|
"list_custom_templates",
|
||||||
fake_list_custom_templates,
|
fake_list_custom_templates,
|
||||||
)
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"list_file_templates",
|
||||||
|
lambda: file_templates,
|
||||||
|
)
|
||||||
|
|
||||||
response = client.get("/proxy/mitmproxy/templates")
|
response = client.get("/proxy/mitmproxy/templates")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert len(body) == len(BUILTIN_TEMPLATES)
|
assert len(body) == len(file_templates)
|
||||||
assert body[0]["built_in"] is True
|
assert body[0]["built_in"] is True
|
||||||
|
assert body[0]["origin"] == "file"
|
||||||
assert body[0]["protocol"] in {"http", "tcp"}
|
assert body[0]["protocol"] in {"http", "tcp"}
|
||||||
assert isinstance(body[0]["tags"], list)
|
assert isinstance(body[0]["tags"], list)
|
||||||
assert body[0]["action"] in {"block", "rewrite"}
|
assert body[0]["action"] in {"block", "rewrite"}
|
||||||
@@ -906,6 +914,7 @@ def test_create_mitmproxy_template_validates_and_persists(client, monkeypatch):
|
|||||||
"code": payload.code,
|
"code": payload.code,
|
||||||
"source_url": payload.source_url,
|
"source_url": payload.source_url,
|
||||||
"built_in": False,
|
"built_in": False,
|
||||||
|
"origin": "custom",
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -939,4 +948,33 @@ def test_create_mitmproxy_template_validates_and_persists(client, monkeypatch):
|
|||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
assert response.json()["id"] == "custom-header"
|
assert response.json()["id"] == "custom-header"
|
||||||
assert response.json()["protocol"] == "http"
|
assert response.json()["protocol"] == "http"
|
||||||
|
assert response.json()["origin"] == "custom"
|
||||||
assert captured["validated"] == "def response(flow):\n pass\n"
|
assert captured["validated"] == "def response(flow):\n pass\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_mitmproxy_template_rejects_file_backed_id(client, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"file_template_ids",
|
||||||
|
lambda: {"http-block-path-traversal"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/proxy/mitmproxy/templates",
|
||||||
|
json={
|
||||||
|
"id": "http-block-path-traversal",
|
||||||
|
"name": "Custom header",
|
||||||
|
"description": "Adds a custom response header",
|
||||||
|
"category": "http",
|
||||||
|
"protocol": "http",
|
||||||
|
"family": "custom",
|
||||||
|
"tags": [],
|
||||||
|
"action": "custom",
|
||||||
|
"summary": "Saved custom template.",
|
||||||
|
"behavior": "Applies the current script as-is.",
|
||||||
|
"code": "def response(flow):\n pass\n",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert "Template already exists" in response.json()["detail"]
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
from app.services.mitmproxy.templates import BUILTIN_TEMPLATES
|
import logging
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.services.mitmproxy.templates import list_file_templates
|
||||||
|
|
||||||
|
|
||||||
def test_builtin_templates_use_four_space_indentation():
|
def test_file_templates_use_four_space_indentation():
|
||||||
for template in BUILTIN_TEMPLATES:
|
for template in list_file_templates():
|
||||||
for line in template.code.splitlines():
|
for line in template.code.splitlines():
|
||||||
assert "\t" not in line, f"{template.id} contains a tab: {line!r}"
|
assert "\t" not in line, f"{template.id} contains a tab: {line!r}"
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
@@ -11,3 +15,42 @@ def test_builtin_templates_use_four_space_indentation():
|
|||||||
assert indent % 4 == 0, (
|
assert indent % 4 == 0, (
|
||||||
f"{template.id} has non-4-space indentation: {line!r}"
|
f"{template.id} has non-4-space indentation: {line!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_file_templates_loads_yaml_backed_templates():
|
||||||
|
templates = list_file_templates()
|
||||||
|
|
||||||
|
assert templates
|
||||||
|
assert all(template.origin == "file" for template in templates)
|
||||||
|
assert all(template.built_in is True for template in templates)
|
||||||
|
assert all(template.protocol in {"http", "tcp"} for template in templates)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_file_templates_skips_invalid_files(tmp_path, caplog):
|
||||||
|
path = tmp_path / "invalid.yaml"
|
||||||
|
path.write_text("[]\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
templates = list_file_templates(tmp_path)
|
||||||
|
|
||||||
|
assert templates == []
|
||||||
|
assert "Skipping template file" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_file_templates_skips_duplicate_ids(tmp_path, caplog):
|
||||||
|
payload = {
|
||||||
|
"id": "duplicate",
|
||||||
|
"name": "Duplicate",
|
||||||
|
"protocol": "http",
|
||||||
|
"description": "Duplicate template",
|
||||||
|
"code": "from mitmproxy import http\n",
|
||||||
|
}
|
||||||
|
document = yaml.safe_dump(payload, sort_keys=False)
|
||||||
|
(tmp_path / "one.yaml").write_text(document, encoding="utf-8")
|
||||||
|
(tmp_path / "two.yaml").write_text(document, encoding="utf-8")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
templates = list_file_templates(tmp_path)
|
||||||
|
|
||||||
|
assert templates == []
|
||||||
|
assert "duplicate file-backed template id" in caplog.text
|
||||||
|
|||||||
@@ -1,21 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
container_name: theplug-backend
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- path: ./backend/.env
|
|
||||||
required: false
|
|
||||||
environment:
|
|
||||||
MONGODB_URI: mongodb://${MONGO_USER:-theplug}:${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}@mongodb:27017/${MONGO_DB:-theplug}?authSource=admin
|
|
||||||
MONGODB_DB_NAME: ${MONGO_DB:-theplug}
|
|
||||||
ports:
|
|
||||||
- "${API_PORT:-3671}:3671"
|
|
||||||
depends_on:
|
|
||||||
mongodb:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import { useControlPlaneContext } from "../composables/useControlPlane";
|
|||||||
import UiIcon from "./UiIcon.vue";
|
import UiIcon from "./UiIcon.vue";
|
||||||
|
|
||||||
const plane = useControlPlaneContext();
|
const plane = useControlPlaneContext();
|
||||||
|
const monitoringActive = computed(() => plane.runtimeStatus === "running");
|
||||||
|
const overallLabel = computed(() =>
|
||||||
|
monitoringActive.value ? plane.health?.overall ?? "pending" : "stopped",
|
||||||
|
);
|
||||||
|
|
||||||
const latestTimestamp = computed(() => {
|
const latestTimestamp = computed(() => {
|
||||||
|
if (!monitoringActive.value) return "Monitoring inactive";
|
||||||
if (!plane.health?.timestamp) return "No checks yet";
|
if (!plane.health?.timestamp) return "No checks yet";
|
||||||
return new Date(plane.health.timestamp).toLocaleTimeString([], {
|
return new Date(plane.health.timestamp).toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@@ -16,6 +21,9 @@ const latestTimestamp = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const targetRows = computed(() => {
|
const targetRows = computed(() => {
|
||||||
|
if (!monitoringActive.value) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
const checks = plane.health?.checks ?? [];
|
const checks = plane.health?.checks ?? [];
|
||||||
return [
|
return [
|
||||||
checks.find((check) => check.target === "proxy") ?? null,
|
checks.find((check) => check.target === "proxy") ?? null,
|
||||||
@@ -135,7 +143,11 @@ function formatDuration(value: number | null) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-head-actions">
|
<div class="modal-head-actions">
|
||||||
<button class="ghost button-icon" :disabled="plane.loading" @click="plane.checkHealth">
|
<button
|
||||||
|
class="ghost button-icon"
|
||||||
|
:disabled="plane.loading || !monitoringActive"
|
||||||
|
@click="plane.checkHealth"
|
||||||
|
>
|
||||||
<UiIcon name="pulse" />
|
<UiIcon name="pulse" />
|
||||||
<span>Run now</span>
|
<span>Run now</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -149,9 +161,9 @@ function formatDuration(value: number | null) {
|
|||||||
<div class="modal-body health-modal-body">
|
<div class="modal-body health-modal-body">
|
||||||
<div
|
<div
|
||||||
class="health-runtime-overview"
|
class="health-runtime-overview"
|
||||||
:class="plane.health?.overall ?? 'disabled'"
|
:class="overallLabel"
|
||||||
>
|
>
|
||||||
<strong>{{ plane.health?.overall ?? "pending" }}</strong>
|
<strong>{{ overallLabel }}</strong>
|
||||||
<span>Last check {{ latestTimestamp }}</span>
|
<span>Last check {{ latestTimestamp }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -160,14 +172,38 @@ function formatDuration(value: number | null) {
|
|||||||
v-for="(row, index) in targetRows"
|
v-for="(row, index) in targetRows"
|
||||||
:key="row?.target ?? `pending-${index}`"
|
:key="row?.target ?? `pending-${index}`"
|
||||||
class="health-runtime-card"
|
class="health-runtime-card"
|
||||||
:class="row ? (row.healthy ? 'healthy' : 'unhealthy') : 'pending'"
|
:class="
|
||||||
|
row
|
||||||
|
? row.healthy
|
||||||
|
? 'healthy'
|
||||||
|
: 'unhealthy'
|
||||||
|
: monitoringActive
|
||||||
|
? 'pending'
|
||||||
|
: 'inactive'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<div class="health-runtime-card-head">
|
<div class="health-runtime-card-head">
|
||||||
<strong>{{ row?.target ?? "pending" }}</strong>
|
<strong>{{
|
||||||
<span>{{ row ? (row.healthy ? "ok" : "issue") : "waiting" }}</span>
|
row?.target ?? (index === 0 ? "proxy" : "upstream")
|
||||||
|
}}</strong>
|
||||||
|
<span>{{
|
||||||
|
row
|
||||||
|
? row.healthy
|
||||||
|
? "ok"
|
||||||
|
: "issue"
|
||||||
|
: monitoringActive
|
||||||
|
? "pending"
|
||||||
|
: "inactive"
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted small compact-copy">
|
<p class="muted small compact-copy">
|
||||||
{{ row ? `${row.host}:${row.port} · ${row.protocol}` : "No data yet." }}
|
{{
|
||||||
|
row
|
||||||
|
? `${row.host}:${row.port} · ${row.protocol}`
|
||||||
|
: monitoringActive
|
||||||
|
? "No data yet."
|
||||||
|
: "Start the proxy to enable active checks."
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="benchmarkEnabled && typeof row?.duration_ms === 'number'"
|
v-if="benchmarkEnabled && typeof row?.duration_ms === 'number'"
|
||||||
@@ -210,7 +246,11 @@ function formatDuration(value: number | null) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="muted small">
|
<p v-else class="muted small">
|
||||||
Health history will appear after the first scheduled runs.
|
{{
|
||||||
|
monitoringActive
|
||||||
|
? "Health history will appear after the first scheduled runs."
|
||||||
|
: "Health history starts once the proxy is running."
|
||||||
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ import { useControlPlaneContext } from "../composables/useControlPlane";
|
|||||||
import UiIcon from "./UiIcon.vue";
|
import UiIcon from "./UiIcon.vue";
|
||||||
|
|
||||||
const plane = useControlPlaneContext();
|
const plane = useControlPlaneContext();
|
||||||
|
const monitoringActive = computed(() => plane.runtimeStatus === "running");
|
||||||
|
const overallLabel = computed(() =>
|
||||||
|
monitoringActive.value ? plane.health?.overall ?? "pending" : "stopped",
|
||||||
|
);
|
||||||
|
|
||||||
const latestTimestamp = computed(() => {
|
const latestTimestamp = computed(() => {
|
||||||
|
if (!monitoringActive.value) return "Monitoring inactive";
|
||||||
if (!plane.health?.timestamp) return "No checks yet";
|
if (!plane.health?.timestamp) return "No checks yet";
|
||||||
return new Date(plane.health.timestamp).toLocaleTimeString([], {
|
return new Date(plane.health.timestamp).toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@@ -21,25 +26,35 @@ const proxyCheck = computed(() =>
|
|||||||
const upstreamCheck = computed(() =>
|
const upstreamCheck = computed(() =>
|
||||||
plane.health?.checks.find((check) => check.target === "upstream") ?? null,
|
plane.health?.checks.find((check) => check.target === "upstream") ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const proxyLabel = computed(() => {
|
||||||
|
if (!monitoringActive.value) return "inactive";
|
||||||
|
return proxyCheck.value ? (proxyCheck.value.healthy ? "ok" : "issue") : "pending";
|
||||||
|
});
|
||||||
|
|
||||||
|
const upstreamLabel = computed(() => {
|
||||||
|
if (!monitoringActive.value) return "inactive";
|
||||||
|
return upstreamCheck.value
|
||||||
|
? (upstreamCheck.value.healthy ? "ok" : "issue")
|
||||||
|
: "pending";
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
v-if="plane.selectedProxy"
|
v-if="plane.selectedProxy"
|
||||||
class="health-summary-card"
|
class="health-summary-card"
|
||||||
:class="plane.health?.overall ?? 'pending'"
|
:class="overallLabel"
|
||||||
>
|
>
|
||||||
<div class="health-summary-line">
|
<div class="health-summary-line">
|
||||||
<strong class="health-summary-overall">{{ plane.health?.overall ?? "pending" }}</strong>
|
<strong class="health-summary-overall">{{ overallLabel }}</strong>
|
||||||
<span class="health-summary-item">
|
<span class="health-summary-item">
|
||||||
<small>Proxy</small>
|
<small>Proxy</small>
|
||||||
<strong>{{ proxyCheck ? (proxyCheck.healthy ? "ok" : "issue") : "waiting" }}</strong>
|
<strong>{{ proxyLabel }}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span class="health-summary-item">
|
<span class="health-summary-item">
|
||||||
<small>Upstream</small>
|
<small>Upstream</small>
|
||||||
<strong>
|
<strong>{{ upstreamLabel }}</strong>
|
||||||
{{ upstreamCheck ? (upstreamCheck.healthy ? "ok" : "issue") : "waiting" }}
|
|
||||||
</strong>
|
|
||||||
</span>
|
</span>
|
||||||
<span class="health-summary-item health-summary-time">
|
<span class="health-summary-item health-summary-time">
|
||||||
<small>Last check</small>
|
<small>Last check</small>
|
||||||
@@ -48,7 +63,7 @@ const upstreamCheck = computed(() =>
|
|||||||
<div class="health-summary-actions">
|
<div class="health-summary-actions">
|
||||||
<button
|
<button
|
||||||
class="ghost button-icon"
|
class="ghost button-icon"
|
||||||
:disabled="plane.loading"
|
:disabled="plane.loading || !monitoringActive"
|
||||||
@click="plane.checkHealth"
|
@click="plane.checkHealth"
|
||||||
>
|
>
|
||||||
<UiIcon name="pulse" />
|
<UiIcon name="pulse" />
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const plane = useControlPlaneContext();
|
|||||||
<div class="doc-actions">
|
<div class="doc-actions">
|
||||||
<button @click="plane.applyTemplate(plane.selectedTemplate)">Use template</button>
|
<button @click="plane.applyTemplate(plane.selectedTemplate)">Use template</button>
|
||||||
<button
|
<button
|
||||||
v-if="!plane.selectedTemplate.built_in"
|
v-if="plane.selectedTemplate.origin === 'custom'"
|
||||||
class="ghost"
|
class="ghost"
|
||||||
@click="plane.removeTemplate(plane.selectedTemplate.id)"
|
@click="plane.removeTemplate(plane.selectedTemplate.id)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { computed, type Ref } from "vue";
|
||||||
|
|
||||||
|
import type { MitmOption, ProxyDraft, ProxyTemplate } from "../types";
|
||||||
|
|
||||||
|
export const focusedMitmOptions = new Set([
|
||||||
|
"anticache",
|
||||||
|
"block_global",
|
||||||
|
"connection_strategy",
|
||||||
|
"flow_detail",
|
||||||
|
"http2",
|
||||||
|
"http3",
|
||||||
|
"keep_host_header",
|
||||||
|
"rawtcp",
|
||||||
|
"showhost",
|
||||||
|
"ssl_insecure",
|
||||||
|
"termlog_verbosity",
|
||||||
|
"upstream_cert",
|
||||||
|
"validate_inbound_headers",
|
||||||
|
"websocket",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type OptionScope = "focused" | "all";
|
||||||
|
export type TemplateProtocolFilter = "http" | "tcp";
|
||||||
|
|
||||||
|
export function defaultFor(option: MitmOption) {
|
||||||
|
if (option.default !== null && option.default !== undefined) return option.default;
|
||||||
|
if (option.type === "bool") return false;
|
||||||
|
if (option.type === "int" || option.type === "optional int") return 0;
|
||||||
|
if (option.type === "sequence of str") return [];
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function templateProtocolForDraft(draft: ProxyDraft): "http" | "tcp" {
|
||||||
|
return ["http", "https", "http3"].includes(draft.protocol) ? "http" : "tcp";
|
||||||
|
}
|
||||||
|
|
||||||
|
type CatalogStateOptions = {
|
||||||
|
options: Ref<MitmOption[]>;
|
||||||
|
optionsLoading: Ref<boolean>;
|
||||||
|
optionSearch: Ref<string>;
|
||||||
|
optionScope: Ref<OptionScope>;
|
||||||
|
selectedOptionName: Ref<string>;
|
||||||
|
templates: Ref<ProxyTemplate[]>;
|
||||||
|
templateSearch: Ref<string>;
|
||||||
|
templateProtocolFilter: Ref<TemplateProtocolFilter>;
|
||||||
|
selectedTemplateId: Ref<string>;
|
||||||
|
draft: ProxyDraft;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useControlPlaneCatalog({
|
||||||
|
options,
|
||||||
|
optionsLoading,
|
||||||
|
optionSearch,
|
||||||
|
optionScope,
|
||||||
|
selectedOptionName,
|
||||||
|
templates,
|
||||||
|
templateSearch,
|
||||||
|
templateProtocolFilter,
|
||||||
|
selectedTemplateId,
|
||||||
|
draft,
|
||||||
|
}: CatalogStateOptions) {
|
||||||
|
const filteredOptions = computed(() => {
|
||||||
|
if (optionsLoading.value) return [];
|
||||||
|
const query = optionSearch.value.toLowerCase().trim();
|
||||||
|
const available = options.value.filter((option) => !option.reserved);
|
||||||
|
const scoped = query
|
||||||
|
? available
|
||||||
|
: optionScope.value === "all"
|
||||||
|
? available
|
||||||
|
: available.filter((option) => focusedMitmOptions.has(option.name));
|
||||||
|
const filtered = scoped
|
||||||
|
.filter((option) => {
|
||||||
|
if (!query) return true;
|
||||||
|
return `${option.name} ${option.description}`.toLowerCase().includes(query);
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return filtered.slice(0, query ? 80 : optionScope.value === "all" ? 80 : 18);
|
||||||
|
});
|
||||||
|
|
||||||
|
const optionCount = computed(
|
||||||
|
() => options.value.filter((option) => !option.reserved).length,
|
||||||
|
);
|
||||||
|
const activeOptionCount = computed(() => Object.keys(draft.mitm_options).length);
|
||||||
|
|
||||||
|
const selectedOption = computed(
|
||||||
|
() =>
|
||||||
|
filteredOptions.value.find((option) => option.name === selectedOptionName.value) ??
|
||||||
|
filteredOptions.value[0] ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredTemplates = computed(() => {
|
||||||
|
const query = templateSearch.value.toLowerCase().trim();
|
||||||
|
const protocol = templateProtocolFilter.value;
|
||||||
|
const filtered = templates.value.filter((template) => {
|
||||||
|
if (template.protocol !== protocol) return false;
|
||||||
|
if (!query) return true;
|
||||||
|
return [
|
||||||
|
template.name,
|
||||||
|
template.description,
|
||||||
|
template.summary,
|
||||||
|
template.behavior,
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query);
|
||||||
|
});
|
||||||
|
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return query ? sorted : sorted.slice(0, 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedTemplate = computed(
|
||||||
|
() =>
|
||||||
|
filteredTemplates.value.find((template) => template.id === selectedTemplateId.value) ??
|
||||||
|
filteredTemplates.value[0] ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeOptions = computed(() =>
|
||||||
|
Object.entries(draft.mitm_options)
|
||||||
|
.map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value: Array.isArray(value) ? value.join(", ") : String(value ?? ""),
|
||||||
|
option: options.value.find((item) => item.name === name),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeOptionCount,
|
||||||
|
activeOptions,
|
||||||
|
filteredOptions,
|
||||||
|
filteredTemplates,
|
||||||
|
optionCount,
|
||||||
|
selectedOption,
|
||||||
|
selectedTemplate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { computed, type Ref } from "vue";
|
||||||
|
|
||||||
|
import { bindingsFor, folderBasename } from "./controlPlaneDraft";
|
||||||
|
import type { DockerService, ProxyDocument } from "../types";
|
||||||
|
|
||||||
|
export function useControlPlaneDiscovery(
|
||||||
|
services: Ref<DockerService[]>,
|
||||||
|
proxies: Ref<ProxyDocument[]>,
|
||||||
|
) {
|
||||||
|
const serviceEntries = computed(() =>
|
||||||
|
services.value.flatMap((service) =>
|
||||||
|
bindingsFor(service).map((binding) => {
|
||||||
|
const proxy =
|
||||||
|
proxies.value.find(
|
||||||
|
(item) =>
|
||||||
|
item.service.name === service.name &&
|
||||||
|
item.service.folder === service.folder &&
|
||||||
|
item.service.port === binding.published,
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${service.folder}:${service.name}:${binding.host ?? "*"}:${binding.published}`,
|
||||||
|
service,
|
||||||
|
binding,
|
||||||
|
proxy,
|
||||||
|
listenHost: binding.host ?? "0.0.0.0",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingServiceCount = computed(
|
||||||
|
() => serviceEntries.value.filter((entry) => !entry.proxy).length,
|
||||||
|
);
|
||||||
|
|
||||||
|
const serviceGroups = computed(() => {
|
||||||
|
const groups = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
folder: string;
|
||||||
|
entries: (typeof serviceEntries.value)[number][];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const entry of serviceEntries.value) {
|
||||||
|
const folder = entry.service.folder ?? "";
|
||||||
|
const existing = groups.get(folder);
|
||||||
|
if (existing) {
|
||||||
|
existing.entries.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.set(folder, {
|
||||||
|
id: folder || entry.service.name,
|
||||||
|
title: folderBasename(folder) || "Compose folder",
|
||||||
|
folder,
|
||||||
|
entries: [entry],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groups.values()]
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
entries: [...group.entries].sort((left, right) => {
|
||||||
|
const byName = left.service.name.localeCompare(right.service.name);
|
||||||
|
if (byName !== 0) return byName;
|
||||||
|
return left.binding.published - right.binding.published;
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.title.localeCompare(right.title));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
missingServiceCount,
|
||||||
|
serviceEntries,
|
||||||
|
serviceGroups,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -40,6 +40,10 @@ export function createDefaultDraft(port = 8080, code?: string): ProxyDraft {
|
|||||||
|
|
||||||
def request(flow: http.HTTPFlow) -> None:
|
def request(flow: http.HTTPFlow) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def response(flow: http.HTTPFlow) -> None:
|
||||||
|
pass
|
||||||
`,
|
`,
|
||||||
mode: "reverse",
|
mode: "reverse",
|
||||||
mode_spec: null,
|
mode_spec: null,
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { ref, watch, type Ref } from "vue";
|
||||||
|
|
||||||
|
import { api, buildSocketUrl } from "../api";
|
||||||
|
import type { HealthResult } from "../types";
|
||||||
|
|
||||||
|
type HealthPlaneOptions = {
|
||||||
|
token: Ref<string | null>;
|
||||||
|
selectedPort: Ref<number | null>;
|
||||||
|
onUnauthorized: () => void;
|
||||||
|
onError: (error: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useControlPlaneHealth({
|
||||||
|
token,
|
||||||
|
selectedPort,
|
||||||
|
onUnauthorized,
|
||||||
|
onError,
|
||||||
|
}: HealthPlaneOptions) {
|
||||||
|
const health = ref<HealthResult | null>(null);
|
||||||
|
const healthHistory = ref<HealthResult[]>([]);
|
||||||
|
const healthModalOpen = ref(false);
|
||||||
|
|
||||||
|
let healthSocket: WebSocket | null = null;
|
||||||
|
let healthReconnectTimer: number | null = null;
|
||||||
|
|
||||||
|
function parseHealthFrame(raw: string) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(raw) as HealthResult;
|
||||||
|
if (
|
||||||
|
typeof payload?.proxy_port === "number" &&
|
||||||
|
typeof payload?.timestamp === "string" &&
|
||||||
|
Array.isArray(payload?.checks)
|
||||||
|
) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushHealthSample(snapshot: HealthResult) {
|
||||||
|
const next = [...healthHistory.value];
|
||||||
|
const index = next.findIndex((item) => item.timestamp === snapshot.timestamp);
|
||||||
|
if (index >= 0) {
|
||||||
|
next[index] = snapshot;
|
||||||
|
} else {
|
||||||
|
next.push(snapshot);
|
||||||
|
}
|
||||||
|
next.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
||||||
|
healthHistory.value = next.slice(-40);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncHealthState(silent = false) {
|
||||||
|
const port = selectedPort.value;
|
||||||
|
if (port === null) return;
|
||||||
|
try {
|
||||||
|
const response = await api.healthHistory(port);
|
||||||
|
health.value = response.latest;
|
||||||
|
healthHistory.value = response.history;
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) onError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHealthReconnect(proxyPort: number) {
|
||||||
|
if (typeof window === "undefined" || healthReconnectTimer !== null) return;
|
||||||
|
healthReconnectTimer = window.setTimeout(() => {
|
||||||
|
healthReconnectTimer = null;
|
||||||
|
if (!token.value || selectedPort.value !== proxyPort) return;
|
||||||
|
void openHealthStream(proxyPort);
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openHealthStream(proxyPort: number) {
|
||||||
|
await syncHealthState(true);
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof window === "undefined" ||
|
||||||
|
!token.value ||
|
||||||
|
selectedPort.value !== proxyPort
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = new WebSocket(buildSocketUrl(`/proxy/${proxyPort}/health/ws`));
|
||||||
|
healthSocket = socket;
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (socket !== healthSocket) return;
|
||||||
|
const snapshot = parseHealthFrame(event.data);
|
||||||
|
if (snapshot === null) return;
|
||||||
|
health.value = snapshot;
|
||||||
|
pushHealthSample(snapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = (event) => {
|
||||||
|
if (socket !== healthSocket) return;
|
||||||
|
healthSocket = null;
|
||||||
|
|
||||||
|
if (event.code === 4401) {
|
||||||
|
onUnauthorized();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token.value || selectedPort.value !== proxyPort || event.code === 1000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleHealthReconnect(proxyPort);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHealthStream() {
|
||||||
|
if (typeof window !== "undefined" && healthReconnectTimer !== null) {
|
||||||
|
window.clearTimeout(healthReconnectTimer);
|
||||||
|
healthReconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (healthSocket === null) return;
|
||||||
|
healthSocket.onclose = null;
|
||||||
|
healthSocket.close();
|
||||||
|
healthSocket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openHealthModal() {
|
||||||
|
healthModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeHealthModal() {
|
||||||
|
healthModalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHealth() {
|
||||||
|
stopHealthStream();
|
||||||
|
health.value = null;
|
||||||
|
healthHistory.value = [];
|
||||||
|
healthModalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[selectedPort, token],
|
||||||
|
([port, authToken], [previousPort]) => {
|
||||||
|
if (port !== previousPort) {
|
||||||
|
health.value = null;
|
||||||
|
healthHistory.value = [];
|
||||||
|
}
|
||||||
|
stopHealthStream();
|
||||||
|
if (!authToken || port === null) return;
|
||||||
|
void openHealthStream(port);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
closeHealthModal,
|
||||||
|
health,
|
||||||
|
healthHistory,
|
||||||
|
healthModalOpen,
|
||||||
|
openHealthModal,
|
||||||
|
pushHealthSample,
|
||||||
|
resetHealth,
|
||||||
|
stopHealthStream,
|
||||||
|
syncHealthState,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { computed, ref, watch, type Ref } from "vue";
|
||||||
|
|
||||||
|
import { api, buildSocketUrl } from "../api";
|
||||||
|
|
||||||
|
type LogFrame = { type: "snapshot" | "append"; log: string };
|
||||||
|
|
||||||
|
type LogPlaneOptions = {
|
||||||
|
token: Ref<string | null>;
|
||||||
|
selectedPort: Ref<number | null>;
|
||||||
|
onUnauthorized: () => void;
|
||||||
|
onError: (error: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useControlPlaneLogs({
|
||||||
|
token,
|
||||||
|
selectedPort,
|
||||||
|
onUnauthorized,
|
||||||
|
onError,
|
||||||
|
}: LogPlaneOptions) {
|
||||||
|
const logs = ref("");
|
||||||
|
const logFilter = ref("");
|
||||||
|
const logsPaused = ref(false);
|
||||||
|
const logsCleared = ref(false);
|
||||||
|
|
||||||
|
let logSocket: WebSocket | null = null;
|
||||||
|
let logReconnectTimer: number | null = null;
|
||||||
|
|
||||||
|
const filteredLogs = computed(() => {
|
||||||
|
const source = logs.value || "";
|
||||||
|
const query = logFilter.value.trim().toLowerCase();
|
||||||
|
if (!query) return source;
|
||||||
|
return source
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.toLowerCase().includes(query))
|
||||||
|
.join("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseLogFrame(raw: string): LogFrame | null {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(raw);
|
||||||
|
if (
|
||||||
|
(payload.type === "snapshot" || payload.type === "append") &&
|
||||||
|
typeof payload.log === "string"
|
||||||
|
) {
|
||||||
|
return payload as LogFrame;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { type: "append", log: raw };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncLogs(silent = false) {
|
||||||
|
const port = selectedPort.value;
|
||||||
|
if (port === null) return;
|
||||||
|
try {
|
||||||
|
if (!logsCleared.value) {
|
||||||
|
logs.value = (await api.logs(port)).log;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!silent) onError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
await syncLogs(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleLogReconnect(proxyPort: number) {
|
||||||
|
if (typeof window === "undefined" || logReconnectTimer !== null) return;
|
||||||
|
logReconnectTimer = window.setTimeout(() => {
|
||||||
|
logReconnectTimer = null;
|
||||||
|
if (!token.value || logsPaused.value || selectedPort.value !== proxyPort) return;
|
||||||
|
void openLogStream(proxyPort);
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLogStream(proxyPort: number) {
|
||||||
|
if (!logsCleared.value) {
|
||||||
|
await syncLogs(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof window === "undefined" ||
|
||||||
|
!token.value ||
|
||||||
|
logsPaused.value ||
|
||||||
|
selectedPort.value !== proxyPort
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = new WebSocket(buildSocketUrl(`/proxy/${proxyPort}/logs/ws`));
|
||||||
|
logSocket = socket;
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (socket !== logSocket) return;
|
||||||
|
const frame = parseLogFrame(event.data);
|
||||||
|
if (frame === null) return;
|
||||||
|
if (frame.type === "snapshot") {
|
||||||
|
if (logsCleared.value) return;
|
||||||
|
logs.value = frame.log;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logs.value += frame.log;
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = (event) => {
|
||||||
|
if (socket !== logSocket) return;
|
||||||
|
logSocket = null;
|
||||||
|
|
||||||
|
if (event.code === 4401) {
|
||||||
|
onUnauthorized();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!token.value ||
|
||||||
|
logsPaused.value ||
|
||||||
|
selectedPort.value !== proxyPort ||
|
||||||
|
event.code === 1000
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleLogReconnect(proxyPort);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLogStream() {
|
||||||
|
if (typeof window !== "undefined" && logReconnectTimer !== null) {
|
||||||
|
window.clearTimeout(logReconnectTimer);
|
||||||
|
logReconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (logSocket === null) return;
|
||||||
|
logSocket.onclose = null;
|
||||||
|
logSocket.close();
|
||||||
|
logSocket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLogsPaused() {
|
||||||
|
logsPaused.value = !logsPaused.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLogs() {
|
||||||
|
logs.value = "";
|
||||||
|
logsCleared.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLogs() {
|
||||||
|
stopLogStream();
|
||||||
|
logs.value = "";
|
||||||
|
logFilter.value = "";
|
||||||
|
logsPaused.value = false;
|
||||||
|
logsCleared.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[selectedPort, token, logsPaused],
|
||||||
|
([port, authToken, paused], [previousPort]) => {
|
||||||
|
if (port !== previousPort) {
|
||||||
|
logs.value = "";
|
||||||
|
logFilter.value = "";
|
||||||
|
logsCleared.value = false;
|
||||||
|
}
|
||||||
|
stopLogStream();
|
||||||
|
if (!authToken || port === null || paused) return;
|
||||||
|
void openLogStream(port);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
clearLogs,
|
||||||
|
filteredLogs,
|
||||||
|
loadLogs,
|
||||||
|
logFilter,
|
||||||
|
logs,
|
||||||
|
logsPaused,
|
||||||
|
logsCleared,
|
||||||
|
resetLogs,
|
||||||
|
stopLogStream,
|
||||||
|
syncLogs,
|
||||||
|
toggleLogsPaused,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,25 +12,31 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
api,
|
api,
|
||||||
buildSocketUrl,
|
|
||||||
getToken,
|
getToken,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
setToken,
|
setToken,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import {
|
import {
|
||||||
bindingsFor,
|
|
||||||
createDefaultDraft,
|
createDefaultDraft,
|
||||||
draftForService,
|
draftForService,
|
||||||
draftPayload,
|
draftPayload,
|
||||||
folderBasename,
|
|
||||||
serializeDraft,
|
serializeDraft,
|
||||||
} from "./controlPlaneDraft";
|
} from "./controlPlaneDraft";
|
||||||
|
import {
|
||||||
|
defaultFor,
|
||||||
|
templateProtocolForDraft,
|
||||||
|
type OptionScope,
|
||||||
|
type TemplateProtocolFilter,
|
||||||
|
useControlPlaneCatalog,
|
||||||
|
} from "./controlPlaneCatalog";
|
||||||
|
import { useControlPlaneDiscovery } from "./controlPlaneDiscovery";
|
||||||
|
import { useControlPlaneHealth } from "./controlPlaneHealth";
|
||||||
|
import { useControlPlaneLogs } from "./controlPlaneLogs";
|
||||||
export { createDefaultDraft, modes, protocols } from "./controlPlaneDraft";
|
export { createDefaultDraft, modes, protocols } from "./controlPlaneDraft";
|
||||||
import type {
|
import type {
|
||||||
DockerService,
|
DockerService,
|
||||||
DockerServiceBinding,
|
DockerServiceBinding,
|
||||||
HealthResult,
|
|
||||||
MitmOption,
|
MitmOption,
|
||||||
ProxyDocument,
|
ProxyDocument,
|
||||||
ProxyDraft,
|
ProxyDraft,
|
||||||
@@ -40,26 +46,7 @@ import type {
|
|||||||
const optionScopeStorageKey = "theplug.option.scope";
|
const optionScopeStorageKey = "theplug.option.scope";
|
||||||
const scanRootStorageKey = "theplug.scan.root";
|
const scanRootStorageKey = "theplug.scan.root";
|
||||||
|
|
||||||
const focusedMitmOptions = new Set([
|
|
||||||
"anticache",
|
|
||||||
"block_global",
|
|
||||||
"connection_strategy",
|
|
||||||
"flow_detail",
|
|
||||||
"http2",
|
|
||||||
"http3",
|
|
||||||
"keep_host_header",
|
|
||||||
"rawtcp",
|
|
||||||
"showhost",
|
|
||||||
"ssl_insecure",
|
|
||||||
"termlog_verbosity",
|
|
||||||
"upstream_cert",
|
|
||||||
"validate_inbound_headers",
|
|
||||||
"websocket",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export type RuntimeAction = "start" | "stop" | "restart";
|
export type RuntimeAction = "start" | "stop" | "restart";
|
||||||
export type OptionScope = "focused" | "all";
|
|
||||||
export type TemplateProtocolFilter = "http" | "tcp";
|
|
||||||
type ErrorSurface = "auth" | "wizard" | "discovery" | "workspace";
|
type ErrorSurface = "auth" | "wizard" | "discovery" | "workspace";
|
||||||
|
|
||||||
function readSession(key: string, fallback: string) {
|
function readSession(key: string, fallback: string) {
|
||||||
@@ -128,11 +115,6 @@ export function useControlPlane() {
|
|||||||
const templates = ref<ProxyTemplate[]>([]);
|
const templates = ref<ProxyTemplate[]>([]);
|
||||||
const backendPort = ref<number | null>(null);
|
const backendPort = ref<number | null>(null);
|
||||||
const selectedPort = ref<number | null>(null);
|
const selectedPort = ref<number | null>(null);
|
||||||
const health = ref<HealthResult | null>(null);
|
|
||||||
const healthHistory = ref<HealthResult[]>([]);
|
|
||||||
const logs = ref("");
|
|
||||||
const logFilter = ref("");
|
|
||||||
const logsPaused = ref(false);
|
|
||||||
const optionSearch = ref("");
|
const optionSearch = ref("");
|
||||||
const templateSearch = ref("");
|
const templateSearch = ref("");
|
||||||
const customTemplateName = ref("");
|
const customTemplateName = ref("");
|
||||||
@@ -150,22 +132,41 @@ export function useControlPlane() {
|
|||||||
const wizardOpen = ref(false);
|
const wizardOpen = ref(false);
|
||||||
const wizardMode = ref<"create" | "edit">("create");
|
const wizardMode = ref<"create" | "edit">("create");
|
||||||
const wizardStep = ref(0);
|
const wizardStep = ref(0);
|
||||||
const healthModalOpen = ref(false);
|
|
||||||
const wizardSeed = ref<{
|
const wizardSeed = ref<{
|
||||||
service: DockerService;
|
service: DockerService;
|
||||||
binding: DockerServiceBinding;
|
binding: DockerServiceBinding;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const logsCleared = ref(false);
|
|
||||||
let noticeTimer: number | null = null;
|
let noticeTimer: number | null = null;
|
||||||
let logSocket: WebSocket | null = null;
|
|
||||||
let logReconnectTimer: number | null = null;
|
|
||||||
let healthSocket: WebSocket | null = null;
|
|
||||||
let healthReconnectTimer: number | null = null;
|
|
||||||
let syncingRoute = false;
|
let syncingRoute = false;
|
||||||
|
|
||||||
const selectedProxy = computed(() =>
|
const selectedProxy = computed(() =>
|
||||||
proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value),
|
proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value),
|
||||||
);
|
);
|
||||||
|
const logPlane = useControlPlaneLogs({
|
||||||
|
token,
|
||||||
|
selectedPort,
|
||||||
|
onUnauthorized: () => clearSession("Session expired. Sign in again."),
|
||||||
|
onError: (error) => setError(error, "workspace"),
|
||||||
|
});
|
||||||
|
const discoveryPlane = useControlPlaneDiscovery(services, proxies);
|
||||||
|
const healthPlane = useControlPlaneHealth({
|
||||||
|
token,
|
||||||
|
selectedPort,
|
||||||
|
onUnauthorized: () => clearSession("Session expired. Sign in again."),
|
||||||
|
onError: (error) => setError(error, "workspace"),
|
||||||
|
});
|
||||||
|
const catalogPlane = useControlPlaneCatalog({
|
||||||
|
options,
|
||||||
|
optionsLoading,
|
||||||
|
optionSearch,
|
||||||
|
optionScope,
|
||||||
|
selectedOptionName,
|
||||||
|
templates,
|
||||||
|
templateSearch,
|
||||||
|
templateProtocolFilter,
|
||||||
|
selectedTemplateId,
|
||||||
|
draft,
|
||||||
|
});
|
||||||
|
|
||||||
const runtimeStatus = computed(() => selectedProxy.value?.status ?? "draft");
|
const runtimeStatus = computed(() => selectedProxy.value?.status ?? "draft");
|
||||||
const isRunning = computed(() =>
|
const isRunning = computed(() =>
|
||||||
@@ -211,15 +212,6 @@ export function useControlPlane() {
|
|||||||
const wizardError = computed(() => errors.wizard);
|
const wizardError = computed(() => errors.wizard);
|
||||||
const discoveryError = computed(() => errors.discovery);
|
const discoveryError = computed(() => errors.discovery);
|
||||||
const workspaceError = computed(() => errors.workspace);
|
const workspaceError = computed(() => errors.workspace);
|
||||||
const filteredLogs = computed(() => {
|
|
||||||
const source = logs.value || "";
|
|
||||||
const query = logFilter.value.trim().toLowerCase();
|
|
||||||
if (!query) return source;
|
|
||||||
return source
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.toLowerCase().includes(query))
|
|
||||||
.join("\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildRouteQuery() {
|
function buildRouteQuery() {
|
||||||
const query: Record<string, string | undefined> = {
|
const query: Record<string, string | undefined> = {
|
||||||
@@ -284,71 +276,6 @@ export function useControlPlane() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const serviceEntries = computed(() =>
|
|
||||||
services.value.flatMap((service) =>
|
|
||||||
bindingsFor(service).map((binding) => {
|
|
||||||
const proxy =
|
|
||||||
proxies.value.find(
|
|
||||||
(item) =>
|
|
||||||
item.service.name === service.name &&
|
|
||||||
item.service.folder === service.folder &&
|
|
||||||
item.service.port === binding.published,
|
|
||||||
) ?? null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `${service.folder}:${service.name}:${binding.host ?? "*"}:${binding.published}`,
|
|
||||||
service,
|
|
||||||
binding,
|
|
||||||
proxy,
|
|
||||||
listenHost: binding.host ?? "0.0.0.0",
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const missingServiceCount = computed(
|
|
||||||
() => serviceEntries.value.filter((entry) => !entry.proxy).length,
|
|
||||||
);
|
|
||||||
|
|
||||||
const serviceGroups = computed(() => {
|
|
||||||
const groups = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
folder: string;
|
|
||||||
entries: (typeof serviceEntries.value)[number][];
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const entry of serviceEntries.value) {
|
|
||||||
const folder = entry.service.folder ?? "";
|
|
||||||
const existing = groups.get(folder);
|
|
||||||
if (existing) {
|
|
||||||
existing.entries.push(entry);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
groups.set(folder, {
|
|
||||||
id: folder || entry.service.name,
|
|
||||||
title: folderBasename(folder) || "Compose folder",
|
|
||||||
folder,
|
|
||||||
entries: [entry],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...groups.values()]
|
|
||||||
.map((group) => ({
|
|
||||||
...group,
|
|
||||||
entries: [...group.entries].sort((left, right) => {
|
|
||||||
const byName = left.service.name.localeCompare(right.service.name);
|
|
||||||
if (byName !== 0) return byName;
|
|
||||||
return left.binding.published - right.binding.published;
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
.sort((left, right) => left.title.localeCompare(right.title));
|
|
||||||
});
|
|
||||||
|
|
||||||
const routeState = computed(() => {
|
const routeState = computed(() => {
|
||||||
const livePort =
|
const livePort =
|
||||||
selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null;
|
selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null;
|
||||||
@@ -388,73 +315,6 @@ export function useControlPlane() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredOptions = computed(() => {
|
|
||||||
if (optionsLoading.value) return [];
|
|
||||||
const query = optionSearch.value.toLowerCase().trim();
|
|
||||||
const available = options.value.filter((option) => !option.reserved);
|
|
||||||
const scoped = query
|
|
||||||
? available
|
|
||||||
: optionScope.value === "all"
|
|
||||||
? available
|
|
||||||
: available.filter((option) => focusedMitmOptions.has(option.name));
|
|
||||||
const filtered = scoped
|
|
||||||
.filter((option) => {
|
|
||||||
if (!query) return true;
|
|
||||||
return `${option.name} ${option.description}`.toLowerCase().includes(query);
|
|
||||||
})
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
return filtered.slice(0, query ? 80 : optionScope.value === "all" ? 80 : 18);
|
|
||||||
});
|
|
||||||
|
|
||||||
const optionCount = computed(
|
|
||||||
() => options.value.filter((option) => !option.reserved).length,
|
|
||||||
);
|
|
||||||
const activeOptionCount = computed(() => Object.keys(draft.mitm_options).length);
|
|
||||||
|
|
||||||
const selectedOption = computed(
|
|
||||||
() =>
|
|
||||||
filteredOptions.value.find((option) => option.name === selectedOptionName.value) ??
|
|
||||||
filteredOptions.value[0] ??
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredTemplates = computed(() => {
|
|
||||||
const query = templateSearch.value.toLowerCase().trim();
|
|
||||||
const protocol = templateProtocolFilter.value;
|
|
||||||
const filtered = templates.value.filter((template) => {
|
|
||||||
if (template.protocol !== protocol) return false;
|
|
||||||
if (!query) return true;
|
|
||||||
return [
|
|
||||||
template.name,
|
|
||||||
template.description,
|
|
||||||
template.summary,
|
|
||||||
template.behavior,
|
|
||||||
]
|
|
||||||
.join(" ")
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(query);
|
|
||||||
});
|
|
||||||
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
return query ? sorted : sorted.slice(0, 40);
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedTemplate = computed(
|
|
||||||
() =>
|
|
||||||
filteredTemplates.value.find((template) => template.id === selectedTemplateId.value) ??
|
|
||||||
filteredTemplates.value[0] ??
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeOptions = computed(() =>
|
|
||||||
Object.entries(draft.mitm_options)
|
|
||||||
.map(([name, value]) => ({
|
|
||||||
name,
|
|
||||||
value: Array.isArray(value) ? value.join(", ") : String(value ?? ""),
|
|
||||||
option: options.value.find((item) => item.name === name),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const codeLineCount = computed(() => draft.code.split("\n").length);
|
const codeLineCount = computed(() => draft.code.split("\n").length);
|
||||||
const scriptDirty = computed(() => draft.code !== loadedCode.value);
|
const scriptDirty = computed(() => draft.code !== loadedCode.value);
|
||||||
const draftDirty = computed(
|
const draftDirty = computed(
|
||||||
@@ -514,47 +374,18 @@ export function useControlPlane() {
|
|||||||
writeSession(scanRootStorageKey, value);
|
writeSession(scanRootStorageKey, value);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(filteredOptions, (items) => {
|
watch(catalogPlane.filteredOptions, (items) => {
|
||||||
if (!items.some((item) => item.name === selectedOptionName.value)) {
|
if (!items.some((item) => item.name === selectedOptionName.value)) {
|
||||||
selectedOptionName.value = items[0]?.name ?? "";
|
selectedOptionName.value = items[0]?.name ?? "";
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
watch(filteredTemplates, (items) => {
|
watch(catalogPlane.filteredTemplates, (items) => {
|
||||||
if (!items.some((item) => item.id === selectedTemplateId.value)) {
|
if (!items.some((item) => item.id === selectedTemplateId.value)) {
|
||||||
selectedTemplateId.value = items[0]?.id ?? "";
|
selectedTemplateId.value = items[0]?.id ?? "";
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
watch(
|
|
||||||
[selectedPort, token],
|
|
||||||
([port, authToken], [previousPort]) => {
|
|
||||||
if (port !== previousPort) {
|
|
||||||
health.value = null;
|
|
||||||
healthHistory.value = [];
|
|
||||||
}
|
|
||||||
stopHealthStream();
|
|
||||||
if (!authToken || port === null) return;
|
|
||||||
void openHealthStream(port);
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
[selectedPort, token, logsPaused],
|
|
||||||
([port, authToken, paused], [previousPort]) => {
|
|
||||||
if (port !== previousPort) {
|
|
||||||
logs.value = "";
|
|
||||||
logFilter.value = "";
|
|
||||||
logsCleared.value = false;
|
|
||||||
}
|
|
||||||
stopLogStream();
|
|
||||||
if (!authToken || port === null || paused) return;
|
|
||||||
void openLogStream(port);
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
async function signIn() {
|
async function signIn() {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
await login(username.value, password.value);
|
await login(username.value, password.value);
|
||||||
@@ -671,23 +502,15 @@ export function useControlPlane() {
|
|||||||
typeof seedOrAnnounce === "boolean" ? seedOrAnnounce : announce;
|
typeof seedOrAnnounce === "boolean" ? seedOrAnnounce : announce;
|
||||||
selectedPort.value = null;
|
selectedPort.value = null;
|
||||||
loadedPort.value = null;
|
loadedPort.value = null;
|
||||||
health.value = null;
|
healthPlane.health.value = null;
|
||||||
logs.value = "";
|
logPlane.logs.value = "";
|
||||||
logFilter.value = "";
|
logPlane.logFilter.value = "";
|
||||||
logsCleared.value = false;
|
logPlane.logsCleared.value = false;
|
||||||
const nextDraft = createDefaultDraft(
|
const nextDraft = createDefaultDraft(nextPort());
|
||||||
nextPort(),
|
|
||||||
templates.value.find((template) => template.protocol === "http")?.code ??
|
|
||||||
draft.code,
|
|
||||||
);
|
|
||||||
if (seed) {
|
if (seed) {
|
||||||
Object.assign(
|
Object.assign(
|
||||||
nextDraft,
|
nextDraft,
|
||||||
draftForService(
|
draftForService(seed.service, seed.binding),
|
||||||
seed.service,
|
|
||||||
seed.binding,
|
|
||||||
templates.value.find((template) => template.protocol === "http")?.code,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Object.assign(draft, nextDraft);
|
Object.assign(draft, nextDraft);
|
||||||
@@ -759,15 +582,9 @@ export function useControlPlane() {
|
|||||||
|
|
||||||
async function createMissingServiceProxies() {
|
async function createMissingServiceProxies() {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
for (const entry of serviceEntries.value) {
|
for (const entry of discoveryPlane.serviceEntries.value) {
|
||||||
if (entry.proxy) continue;
|
if (entry.proxy) continue;
|
||||||
await api.createProxy(
|
await api.createProxy(draftForService(entry.service, entry.binding));
|
||||||
draftForService(
|
|
||||||
entry.service,
|
|
||||||
entry.binding,
|
|
||||||
templates.value.find((template) => template.protocol === "http")?.code,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
await Promise.all([refreshProxies(false), refreshServicesRequest(false)]);
|
await Promise.all([refreshProxies(false), refreshServicesRequest(false)]);
|
||||||
const first = proxies.value[0];
|
const first = proxies.value[0];
|
||||||
@@ -867,212 +684,17 @@ export function useControlPlane() {
|
|||||||
if (!proxy) return;
|
if (!proxy) return;
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const snapshot = await api.health(proxy.proxy_port);
|
const snapshot = await api.health(proxy.proxy_port);
|
||||||
health.value = snapshot;
|
healthPlane.health.value = snapshot;
|
||||||
pushHealthSample(snapshot);
|
healthPlane.pushHealthSample(snapshot);
|
||||||
}, undefined, "workspace");
|
}, undefined, "workspace");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openHealthModal() {
|
|
||||||
healthModalOpen.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeHealthModal() {
|
|
||||||
healthModalOpen.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDiscovery() {
|
function openDiscovery() {
|
||||||
clearError();
|
clearError();
|
||||||
closeHealthModal();
|
healthPlane.closeHealthModal();
|
||||||
selectedPort.value = null;
|
selectedPort.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncHealthState(silent = false) {
|
|
||||||
const proxy = selectedProxy.value;
|
|
||||||
if (!proxy) return;
|
|
||||||
try {
|
|
||||||
const response = await api.healthHistory(proxy.proxy_port);
|
|
||||||
health.value = response.latest;
|
|
||||||
healthHistory.value = response.history;
|
|
||||||
} catch (err) {
|
|
||||||
if (!silent) {
|
|
||||||
setError(err, "workspace");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadLogs() {
|
|
||||||
await syncLogs(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncLogs(silent = false) {
|
|
||||||
const proxy = selectedProxy.value;
|
|
||||||
if (!proxy) return;
|
|
||||||
try {
|
|
||||||
if (!logsCleared.value) {
|
|
||||||
logs.value = (await api.logs(proxy.proxy_port)).log;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (!silent) {
|
|
||||||
setError(err, "workspace");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openLogStream(proxyPort: number) {
|
|
||||||
if (!logsCleared.value) {
|
|
||||||
await syncLogs(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof window === "undefined" ||
|
|
||||||
!token.value ||
|
|
||||||
logsPaused.value ||
|
|
||||||
selectedPort.value !== proxyPort
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const socket = new WebSocket(buildSocketUrl(`/proxy/${proxyPort}/logs/ws`));
|
|
||||||
logSocket = socket;
|
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
|
||||||
if (socket !== logSocket) return;
|
|
||||||
const frame = parseLogFrame(event.data);
|
|
||||||
if (frame === null) return;
|
|
||||||
if (frame.type === "snapshot") {
|
|
||||||
if (logsCleared.value) return;
|
|
||||||
logs.value = frame.log;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logs.value += frame.log;
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onclose = (event) => {
|
|
||||||
if (socket !== logSocket) return;
|
|
||||||
logSocket = null;
|
|
||||||
|
|
||||||
if (event.code === 4401) {
|
|
||||||
clearSession("Session expired. Sign in again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!token.value ||
|
|
||||||
logsPaused.value ||
|
|
||||||
selectedPort.value !== proxyPort ||
|
|
||||||
event.code === 1000
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleLogReconnect(proxyPort);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openHealthStream(proxyPort: number) {
|
|
||||||
await syncHealthState(true);
|
|
||||||
|
|
||||||
if (
|
|
||||||
typeof window === "undefined" ||
|
|
||||||
!token.value ||
|
|
||||||
selectedPort.value !== proxyPort
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const socket = new WebSocket(buildSocketUrl(`/proxy/${proxyPort}/health/ws`));
|
|
||||||
healthSocket = socket;
|
|
||||||
|
|
||||||
socket.onmessage = (event) => {
|
|
||||||
if (socket !== healthSocket) return;
|
|
||||||
const snapshot = parseHealthFrame(event.data);
|
|
||||||
if (snapshot === null) return;
|
|
||||||
health.value = snapshot;
|
|
||||||
pushHealthSample(snapshot);
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onclose = (event) => {
|
|
||||||
if (socket !== healthSocket) return;
|
|
||||||
healthSocket = null;
|
|
||||||
|
|
||||||
if (event.code === 4401) {
|
|
||||||
clearSession("Session expired. Sign in again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token.value || selectedPort.value !== proxyPort || event.code === 1000) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleHealthReconnect(proxyPort);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseLogFrame(raw: string) {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(raw);
|
|
||||||
if (
|
|
||||||
(payload.type === "snapshot" || payload.type === "append") &&
|
|
||||||
typeof payload.log === "string"
|
|
||||||
) {
|
|
||||||
return payload as { type: "snapshot" | "append"; log: string };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return { type: "append", log: raw } as const;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleLogReconnect(proxyPort: number) {
|
|
||||||
if (typeof window === "undefined" || logReconnectTimer !== null) return;
|
|
||||||
logReconnectTimer = window.setTimeout(() => {
|
|
||||||
logReconnectTimer = null;
|
|
||||||
if (!token.value || logsPaused.value || selectedPort.value !== proxyPort) return;
|
|
||||||
void openLogStream(proxyPort);
|
|
||||||
}, 1200);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseHealthFrame(raw: string) {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(raw) as HealthResult;
|
|
||||||
if (
|
|
||||||
typeof payload?.proxy_port === "number" &&
|
|
||||||
typeof payload?.timestamp === "string" &&
|
|
||||||
Array.isArray(payload?.checks)
|
|
||||||
) {
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleHealthReconnect(proxyPort: number) {
|
|
||||||
if (typeof window === "undefined" || healthReconnectTimer !== null) return;
|
|
||||||
healthReconnectTimer = window.setTimeout(() => {
|
|
||||||
healthReconnectTimer = null;
|
|
||||||
if (!token.value || selectedPort.value !== proxyPort) return;
|
|
||||||
void openHealthStream(proxyPort);
|
|
||||||
}, 1200);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushHealthSample(snapshot: HealthResult) {
|
|
||||||
const next = [...healthHistory.value];
|
|
||||||
const index = next.findIndex((item) => item.timestamp === snapshot.timestamp);
|
|
||||||
if (index >= 0) {
|
|
||||||
next[index] = snapshot;
|
|
||||||
} else {
|
|
||||||
next.push(snapshot);
|
|
||||||
}
|
|
||||||
next.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
|
||||||
healthHistory.value = next.slice(-40);
|
|
||||||
}
|
|
||||||
|
|
||||||
function templateProtocolForDraft(): "http" | "tcp" {
|
|
||||||
return ["http", "https", "http3"].includes(draft.protocol) ? "http" : "tcp";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCurrentAsTemplate() {
|
async function saveCurrentAsTemplate() {
|
||||||
const name = customTemplateName.value.trim();
|
const name = customTemplateName.value.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -1087,8 +709,8 @@ export function useControlPlane() {
|
|||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
description: `Saved from ${draft.name} (${draft.mode} / ${draft.protocol}).`,
|
description: `Saved from ${draft.name} (${draft.mode} / ${draft.protocol}).`,
|
||||||
category: templateProtocolForDraft(),
|
category: templateProtocolForDraft(draft),
|
||||||
protocol: templateProtocolForDraft(),
|
protocol: templateProtocolForDraft(draft),
|
||||||
family: "custom",
|
family: "custom",
|
||||||
tags: [],
|
tags: [],
|
||||||
action: "custom",
|
action: "custom",
|
||||||
@@ -1099,7 +721,7 @@ export function useControlPlane() {
|
|||||||
});
|
});
|
||||||
await refreshTemplates(false);
|
await refreshTemplates(false);
|
||||||
customTemplateName.value = "";
|
customTemplateName.value = "";
|
||||||
templateProtocolFilter.value = templateProtocolForDraft();
|
templateProtocolFilter.value = templateProtocolForDraft(draft);
|
||||||
selectedTemplateId.value = id;
|
selectedTemplateId.value = id;
|
||||||
}, "Custom template saved", "workspace");
|
}, "Custom template saved", "workspace");
|
||||||
}
|
}
|
||||||
@@ -1191,15 +813,6 @@ export function useControlPlane() {
|
|||||||
optionScope.value = scope;
|
optionScope.value = scope;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLogsPaused() {
|
|
||||||
logsPaused.value = !logsPaused.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearLogs() {
|
|
||||||
logs.value = "";
|
|
||||||
logsCleared.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanDraft(): ProxyDraft {
|
function cleanDraft(): ProxyDraft {
|
||||||
return draftPayload(draft);
|
return draftPayload(draft);
|
||||||
}
|
}
|
||||||
@@ -1210,8 +823,8 @@ export function useControlPlane() {
|
|||||||
loadedPort.value = proxy.proxy_port;
|
loadedPort.value = proxy.proxy_port;
|
||||||
loadedDraftJson.value = serializeDraft(nextDraft);
|
loadedDraftJson.value = serializeDraft(nextDraft);
|
||||||
loadedCode.value = nextDraft.code;
|
loadedCode.value = nextDraft.code;
|
||||||
health.value = null;
|
healthPlane.health.value = null;
|
||||||
logs.value = "";
|
logPlane.logs.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncLoadedCode(code: string) {
|
function syncLoadedCode(code: string) {
|
||||||
@@ -1231,14 +844,6 @@ export function useControlPlane() {
|
|||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultFor(option: MitmOption) {
|
|
||||||
if (option.default !== null && option.default !== undefined) return option.default;
|
|
||||||
if (option.type === "bool") return false;
|
|
||||||
if (option.type === "int" || option.type === "optional int") return 0;
|
|
||||||
if (option.type === "sequence of str") return [];
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeSurface(): ErrorSurface {
|
function activeSurface(): ErrorSurface {
|
||||||
if (!token.value) return "auth";
|
if (!token.value) return "auth";
|
||||||
if (wizardOpen.value) return "wizard";
|
if (wizardOpen.value) return "wizard";
|
||||||
@@ -1300,8 +905,8 @@ export function useControlPlane() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearSession(message = "") {
|
function clearSession(message = "") {
|
||||||
stopHealthStream();
|
healthPlane.stopHealthStream();
|
||||||
stopLogStream();
|
logPlane.stopLogStream();
|
||||||
setToken(null);
|
setToken(null);
|
||||||
token.value = null;
|
token.value = null;
|
||||||
proxies.value = [];
|
proxies.value = [];
|
||||||
@@ -1311,15 +916,12 @@ export function useControlPlane() {
|
|||||||
backendPort.value = null;
|
backendPort.value = null;
|
||||||
selectedPort.value = null;
|
selectedPort.value = null;
|
||||||
loadedPort.value = null;
|
loadedPort.value = null;
|
||||||
health.value = null;
|
healthPlane.resetHealth();
|
||||||
logs.value = "";
|
logPlane.resetLogs();
|
||||||
logFilter.value = "";
|
|
||||||
logsCleared.value = false;
|
|
||||||
wizardOpen.value = false;
|
wizardOpen.value = false;
|
||||||
wizardStep.value = 0;
|
wizardStep.value = 0;
|
||||||
wizardMode.value = "create";
|
wizardMode.value = "create";
|
||||||
wizardSeed.value = null;
|
wizardSeed.value = null;
|
||||||
healthModalOpen.value = false;
|
|
||||||
templateProtocolFilter.value = "http";
|
templateProtocolFilter.value = "http";
|
||||||
Object.assign(draft, createDefaultDraft());
|
Object.assign(draft, createDefaultDraft());
|
||||||
loadedDraftJson.value = serializeDraft(draft);
|
loadedDraftJson.value = serializeDraft(draft);
|
||||||
@@ -1328,44 +930,18 @@ export function useControlPlane() {
|
|||||||
void router.replace({ path: "/proxies", query: {} });
|
void router.replace({ path: "/proxies", query: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopLogStream() {
|
|
||||||
if (typeof window !== "undefined" && logReconnectTimer !== null) {
|
|
||||||
window.clearTimeout(logReconnectTimer);
|
|
||||||
logReconnectTimer = null;
|
|
||||||
}
|
|
||||||
if (logSocket === null) return;
|
|
||||||
logSocket.onclose = null;
|
|
||||||
logSocket.close();
|
|
||||||
logSocket = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopHealthStream() {
|
|
||||||
if (typeof window !== "undefined" && healthReconnectTimer !== null) {
|
|
||||||
window.clearTimeout(healthReconnectTimer);
|
|
||||||
healthReconnectTimer = null;
|
|
||||||
}
|
|
||||||
if (healthSocket === null) return;
|
|
||||||
healthSocket.onclose = null;
|
|
||||||
healthSocket.close();
|
|
||||||
healthSocket = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return proxyRefs({
|
return proxyRefs({
|
||||||
activeOptionCount,
|
activeOptionCount: catalogPlane.activeOptionCount,
|
||||||
activeOptions,
|
activeOptions: catalogPlane.activeOptions,
|
||||||
applyTemplate,
|
applyTemplate,
|
||||||
applyService,
|
applyService,
|
||||||
actionState,
|
actionState,
|
||||||
authMessage,
|
authMessage,
|
||||||
checkHealth,
|
checkHealth,
|
||||||
clearLogs,
|
|
||||||
closeWizard,
|
closeWizard,
|
||||||
codeLineCount,
|
codeLineCount,
|
||||||
canSaveScript,
|
canSaveScript,
|
||||||
canSaveProxy,
|
canSaveProxy,
|
||||||
filteredLogs,
|
|
||||||
logFilter,
|
|
||||||
logsPaused,
|
|
||||||
createMissingServiceProxies,
|
createMissingServiceProxies,
|
||||||
createProxyFromService,
|
createProxyFromService,
|
||||||
customTemplateName,
|
customTemplateName,
|
||||||
@@ -1373,23 +949,17 @@ export function useControlPlane() {
|
|||||||
error,
|
error,
|
||||||
templateSearch,
|
templateSearch,
|
||||||
templateProtocolFilter,
|
templateProtocolFilter,
|
||||||
filteredTemplates,
|
filteredTemplates: catalogPlane.filteredTemplates,
|
||||||
filteredOptions,
|
filteredOptions: catalogPlane.filteredOptions,
|
||||||
health,
|
|
||||||
healthHistory,
|
|
||||||
healthModalOpen,
|
|
||||||
discoveryError,
|
discoveryError,
|
||||||
loginError,
|
loginError,
|
||||||
loadLogs,
|
|
||||||
loading,
|
loading,
|
||||||
logs,
|
|
||||||
newProxy,
|
newProxy,
|
||||||
notice,
|
notice,
|
||||||
openCreateWizard,
|
openCreateWizard,
|
||||||
openDiscovery,
|
openDiscovery,
|
||||||
openEditWizard,
|
openEditWizard,
|
||||||
openHealthModal,
|
optionCount: catalogPlane.optionCount,
|
||||||
optionCount,
|
|
||||||
optionScope,
|
optionScope,
|
||||||
optionSearch,
|
optionSearch,
|
||||||
password,
|
password,
|
||||||
@@ -1407,13 +977,13 @@ export function useControlPlane() {
|
|||||||
saveProxy,
|
saveProxy,
|
||||||
saveScript,
|
saveScript,
|
||||||
selectTemplate,
|
selectTemplate,
|
||||||
selectedTemplate,
|
selectedTemplate: catalogPlane.selectedTemplate,
|
||||||
selectedOption,
|
selectedOption: catalogPlane.selectedOption,
|
||||||
selectedPort,
|
selectedPort,
|
||||||
selectedProxy,
|
selectedProxy,
|
||||||
serviceGroups,
|
serviceGroups: discoveryPlane.serviceGroups,
|
||||||
selectOption,
|
selectOption,
|
||||||
serviceEntries,
|
serviceEntries: discoveryPlane.serviceEntries,
|
||||||
services,
|
services,
|
||||||
templatesLoading,
|
templatesLoading,
|
||||||
proxiesLoading,
|
proxiesLoading,
|
||||||
@@ -1421,12 +991,10 @@ export function useControlPlane() {
|
|||||||
setOption,
|
setOption,
|
||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
closeHealthModal,
|
|
||||||
matchProxyPortToService,
|
matchProxyPortToService,
|
||||||
missingServiceCount,
|
missingServiceCount: discoveryPlane.missingServiceCount,
|
||||||
nextWizardStep,
|
nextWizardStep,
|
||||||
toggleOptionScope,
|
toggleOptionScope,
|
||||||
toggleLogsPaused,
|
|
||||||
token,
|
token,
|
||||||
updateOption,
|
updateOption,
|
||||||
username,
|
username,
|
||||||
@@ -1442,6 +1010,8 @@ export function useControlPlane() {
|
|||||||
canGoNextWizard,
|
canGoNextWizard,
|
||||||
scanRoot,
|
scanRoot,
|
||||||
servicesLoading,
|
servicesLoading,
|
||||||
|
...logPlane,
|
||||||
|
...healthPlane,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export type ProxyTemplate = {
|
|||||||
code: string;
|
code: string;
|
||||||
source_url?: string | null;
|
source_url?: string | null;
|
||||||
built_in: boolean;
|
built_in: boolean;
|
||||||
|
origin: "file" | "custom";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProxyTemplateCreateInput = {
|
export type ProxyTemplateCreateInput = {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
id: http-block-admin-paths
|
||||||
|
name: Block admin and debug paths
|
||||||
|
protocol: http
|
||||||
|
description: Protects common admin, console, and debug endpoints.
|
||||||
|
category: http
|
||||||
|
family: web-hardening
|
||||||
|
tags:
|
||||||
|
- admin-path
|
||||||
|
- debug-path
|
||||||
|
- web
|
||||||
|
- block
|
||||||
|
action: block
|
||||||
|
summary: Protects common admin, console, and debug endpoints.
|
||||||
|
behavior: Matches the request path against a small denylist and returns a blank 404
|
||||||
|
page before the request is forwarded.
|
||||||
|
source_url: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
code: |
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mitmproxy import http
|
||||||
|
|
||||||
|
BLOCKED_PREFIXES = (
|
||||||
|
"/admin",
|
||||||
|
"/debug",
|
||||||
|
"/console",
|
||||||
|
"/actuator",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def request(flow: http.HTTPFlow) -> None:
|
||||||
|
if flow.request.path.startswith(BLOCKED_PREFIXES):
|
||||||
|
logging.warning(
|
||||||
|
"[http-block-admin-paths] blocked path=%s",
|
||||||
|
flow.request.path,
|
||||||
|
)
|
||||||
|
flow.response = http.Response.make(
|
||||||
|
404,
|
||||||
|
b"",
|
||||||
|
{"content-type": "text/html; charset=utf-8"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
id: http-block-blacklisted-substrings
|
||||||
|
name: Block blacklisted substrings
|
||||||
|
protocol: http
|
||||||
|
description: Blocks requests containing forbidden strings in path, query, or body.
|
||||||
|
category: http
|
||||||
|
family: web-hardening
|
||||||
|
tags:
|
||||||
|
- blacklist-string
|
||||||
|
- web
|
||||||
|
- block
|
||||||
|
action: block
|
||||||
|
summary: Blocks requests containing forbidden strings in path, query, or body.
|
||||||
|
behavior: Scans URL and request body for blacklisted markers, then serves a blank
|
||||||
|
HTML page with 200 OK and logs the first hit.
|
||||||
|
source_url: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
code: |
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mitmproxy import http
|
||||||
|
|
||||||
|
BLOCKED_STRINGS = (
|
||||||
|
"flag{",
|
||||||
|
"give_me_shell",
|
||||||
|
"debug=1",
|
||||||
|
"/etc/passwd",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def request(flow: http.HTTPFlow) -> None:
|
||||||
|
body = flow.request.get_text(strict=False)
|
||||||
|
haystack = "\n".join(
|
||||||
|
[flow.request.path, str(flow.request.query), body]
|
||||||
|
).lower()
|
||||||
|
for marker in BLOCKED_STRINGS:
|
||||||
|
if marker.lower() in haystack:
|
||||||
|
logging.warning(
|
||||||
|
"[http-block-blacklisted-substrings] blocked marker=%r url=%s",
|
||||||
|
marker,
|
||||||
|
flow.request.pretty_url,
|
||||||
|
)
|
||||||
|
flow.response = http.Response.make(
|
||||||
|
200,
|
||||||
|
b"",
|
||||||
|
{"content-type": "text/html; charset=utf-8"},
|
||||||
|
)
|
||||||
|
return
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
id: http-block-command-injection-markers
|
||||||
|
name: Block command injection markers
|
||||||
|
protocol: http
|
||||||
|
description: Rejects obvious shell metacharacters and command-substitution markers.
|
||||||
|
category: http
|
||||||
|
family: web-hardening
|
||||||
|
tags:
|
||||||
|
- command-injection
|
||||||
|
- web
|
||||||
|
- block
|
||||||
|
action: block
|
||||||
|
summary: Rejects obvious shell metacharacters and command-substitution markers.
|
||||||
|
behavior: Scans URL and body for shell-style injection markers, logs the first hit,
|
||||||
|
and drops the client connection.
|
||||||
|
source_url: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
code: |
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mitmproxy import http
|
||||||
|
|
||||||
|
BLOCKED_MARKERS = (";", "&&", "||", "$(", "`", "|sh", "|bash")
|
||||||
|
|
||||||
|
|
||||||
|
def request(flow: http.HTTPFlow) -> None:
|
||||||
|
body = flow.request.get_text(strict=False)
|
||||||
|
haystack = "\n".join(
|
||||||
|
[flow.request.pretty_url, body]
|
||||||
|
).lower()
|
||||||
|
for marker in BLOCKED_MARKERS:
|
||||||
|
if marker in haystack:
|
||||||
|
logging.warning(
|
||||||
|
"[http-block-command-injection-markers] blocked marker=%r url=%s",
|
||||||
|
marker,
|
||||||
|
flow.request.pretty_url,
|
||||||
|
)
|
||||||
|
flow.kill()
|
||||||
|
return
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
id: http-block-large-body
|
||||||
|
name: Block large request bodies
|
||||||
|
protocol: http
|
||||||
|
description: Rejects oversized HTTP request bodies before they hit the service.
|
||||||
|
category: http
|
||||||
|
family: web-hardening
|
||||||
|
tags:
|
||||||
|
- length-limit
|
||||||
|
- body-size
|
||||||
|
- web
|
||||||
|
- block
|
||||||
|
action: block
|
||||||
|
summary: Rejects oversized HTTP request bodies before they hit the service.
|
||||||
|
behavior: Checks the buffered request body length, returns 413 Payload Too Large,
|
||||||
|
and logs the observed size.
|
||||||
|
source_url: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
code: |
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mitmproxy import http
|
||||||
|
|
||||||
|
MAX_BODY_BYTES = 8192
|
||||||
|
|
||||||
|
|
||||||
|
def request(flow: http.HTTPFlow) -> None:
|
||||||
|
size = len(flow.request.raw_content or b"")
|
||||||
|
if size > MAX_BODY_BYTES:
|
||||||
|
logging.warning(
|
||||||
|
"[http-block-large-body] blocked %s bytes on %s",
|
||||||
|
size,
|
||||||
|
flow.request.pretty_url,
|
||||||
|
)
|
||||||
|
flow.response = http.Response.make(
|
||||||
|
413,
|
||||||
|
b"",
|
||||||
|
{"content-type": "text/plain"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
id: http-block-path-traversal
|
||||||
|
name: Block path traversal
|
||||||
|
protocol: http
|
||||||
|
description: Stops common path traversal probes in path and query.
|
||||||
|
category: http
|
||||||
|
family: web-hardening
|
||||||
|
tags:
|
||||||
|
- path-traversal
|
||||||
|
- web
|
||||||
|
- block
|
||||||
|
action: block
|
||||||
|
summary: Stops common path traversal probes in path and query.
|
||||||
|
behavior: Logs the matched traversal token and immediately drops the client connection
|
||||||
|
before the request reaches the upstream service.
|
||||||
|
source_url: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
code: |
|
||||||
|
import logging
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
from mitmproxy import http
|
||||||
|
|
||||||
|
TRAVERSAL_MARKERS = (
|
||||||
|
"../",
|
||||||
|
"..\\",
|
||||||
|
"%2e%2e",
|
||||||
|
"%252e%252e",
|
||||||
|
"..%2f",
|
||||||
|
"..%5c",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def request(flow: http.HTTPFlow) -> None:
|
||||||
|
raw_target = (
|
||||||
|
f"{flow.request.path}?{flow.request.query}"
|
||||||
|
if flow.request.query
|
||||||
|
else flow.request.path
|
||||||
|
)
|
||||||
|
normalized = unquote(unquote(raw_target)).lower()
|
||||||
|
for marker in TRAVERSAL_MARKERS:
|
||||||
|
if marker in normalized:
|
||||||
|
logging.warning(
|
||||||
|
"[http-block-path-traversal] blocked %s %s from %s",
|
||||||
|
flow.request.method,
|
||||||
|
flow.request.pretty_url,
|
||||||
|
flow.client_conn.peername,
|
||||||
|
)
|
||||||
|
flow.kill()
|
||||||
|
return
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
id: http-block-sqli-markers
|
||||||
|
name: Block obvious SQLi markers
|
||||||
|
protocol: http
|
||||||
|
description: Stops common SQL injection probes before they reach the app.
|
||||||
|
category: http
|
||||||
|
family: web-hardening
|
||||||
|
tags:
|
||||||
|
- sqli
|
||||||
|
- web
|
||||||
|
- block
|
||||||
|
action: block
|
||||||
|
summary: Stops common SQL injection probes before they reach the app.
|
||||||
|
behavior: Matches a short list of high-signal SQLi markers in path, query, and body,
|
||||||
|
then serves a blank HTML page and logs the hit.
|
||||||
|
source_url: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
code: |
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mitmproxy import http
|
||||||
|
|
||||||
|
BLOCKED_MARKERS = (
|
||||||
|
"' or 1=1",
|
||||||
|
'" or 1=1',
|
||||||
|
"union select",
|
||||||
|
"sleep(",
|
||||||
|
"information_schema",
|
||||||
|
"--",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def request(flow: http.HTTPFlow) -> None:
|
||||||
|
body = flow.request.get_text(strict=False)
|
||||||
|
haystack = "\n".join(
|
||||||
|
[flow.request.path, str(flow.request.query), body]
|
||||||
|
).lower()
|
||||||
|
for marker in BLOCKED_MARKERS:
|
||||||
|
if marker in haystack:
|
||||||
|
logging.warning(
|
||||||
|
"[http-block-sqli-markers] blocked marker=%r url=%s",
|
||||||
|
marker,
|
||||||
|
flow.request.pretty_url,
|
||||||
|
)
|
||||||
|
flow.response = http.Response.make(
|
||||||
|
200,
|
||||||
|
b"",
|
||||||
|
{"content-type": "text/html; charset=utf-8"},
|
||||||
|
)
|
||||||
|
return
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
id: http-block-suspicious-methods
|
||||||
|
name: Block suspicious HTTP methods
|
||||||
|
protocol: http
|
||||||
|
description: Denies uncommon or dangerous HTTP methods.
|
||||||
|
category: http
|
||||||
|
family: web-hardening
|
||||||
|
tags:
|
||||||
|
- method-filter
|
||||||
|
- web
|
||||||
|
- block
|
||||||
|
action: block
|
||||||
|
summary: Denies uncommon or dangerous HTTP methods.
|
||||||
|
behavior: Returns 405 Method Not Allowed for methods outside the allowlist and logs
|
||||||
|
the blocked method and target URL.
|
||||||
|
source_url: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
code: |
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mitmproxy import http
|
||||||
|
|
||||||
|
ALLOWED_METHODS = {"GET", "POST", "HEAD"}
|
||||||
|
|
||||||
|
|
||||||
|
def request(flow: http.HTTPFlow) -> None:
|
||||||
|
if flow.request.method not in ALLOWED_METHODS:
|
||||||
|
logging.warning(
|
||||||
|
"[http-block-suspicious-methods] blocked method=%s url=%s",
|
||||||
|
flow.request.method,
|
||||||
|
flow.request.pretty_url,
|
||||||
|
)
|
||||||
|
flow.response = http.Response.make(
|
||||||
|
405,
|
||||||
|
b"",
|
||||||
|
{"allow": "GET, POST, HEAD"},
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
id: http-sanitize-headers-basic
|
||||||
|
name: Sanitize risky forwarding headers
|
||||||
|
protocol: http
|
||||||
|
description: Removes a few risky forwarding headers before the request is sent upstream.
|
||||||
|
category: http
|
||||||
|
family: web-hardening
|
||||||
|
tags:
|
||||||
|
- rewrite
|
||||||
|
- headers
|
||||||
|
- web
|
||||||
|
action: rewrite
|
||||||
|
summary: Removes a few risky forwarding headers before the request is sent upstream.
|
||||||
|
behavior: Deletes selected override or forwarding headers and logs what was rewritten
|
||||||
|
without blocking the request.
|
||||||
|
source_url: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
code: |
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mitmproxy import http
|
||||||
|
|
||||||
|
STRIP_HEADERS = (
|
||||||
|
"x-forwarded-host",
|
||||||
|
"x-http-method-override",
|
||||||
|
"x-original-url",
|
||||||
|
"x-rewrite-url",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def request(flow: http.HTTPFlow) -> None:
|
||||||
|
removed = []
|
||||||
|
for header in STRIP_HEADERS:
|
||||||
|
if header in flow.request.headers:
|
||||||
|
removed.append(header)
|
||||||
|
del flow.request.headers[header]
|
||||||
|
if removed:
|
||||||
|
logging.info(
|
||||||
|
"[http-sanitize-headers-basic] stripped headers=%s url=%s",
|
||||||
|
",".join(removed),
|
||||||
|
flow.request.pretty_url,
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
id: tcp-block-blacklisted-byte-sequences
|
||||||
|
name: Block blacklisted TCP byte sequences
|
||||||
|
protocol: tcp
|
||||||
|
description: Blocks payloads containing forbidden byte sequences or marker strings.
|
||||||
|
category: tcp
|
||||||
|
family: tcp-hardening
|
||||||
|
tags:
|
||||||
|
- blacklist-bytes
|
||||||
|
- tcp
|
||||||
|
- block
|
||||||
|
action: block
|
||||||
|
summary: Blocks payloads containing forbidden byte sequences or marker strings.
|
||||||
|
behavior: Looks for dangerous byte patterns in client messages, then drops the message
|
||||||
|
and closes the flow.
|
||||||
|
source_url: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
code: |
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mitmproxy import tcp
|
||||||
|
|
||||||
|
BLOCKED_SEQUENCES = (
|
||||||
|
b"/bin/sh",
|
||||||
|
b"cat flag",
|
||||||
|
b"AAAA%p%p",
|
||||||
|
b"\x90\x90\x90\x90",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tcp_message(flow: tcp.TCPFlow) -> None:
|
||||||
|
message = flow.messages[-1]
|
||||||
|
if not message.from_client:
|
||||||
|
return
|
||||||
|
for sequence in BLOCKED_SEQUENCES:
|
||||||
|
if sequence in message.content:
|
||||||
|
logging.warning(
|
||||||
|
"[tcp-block-blacklisted-byte-sequences] blocked sequence=%r",
|
||||||
|
sequence,
|
||||||
|
)
|
||||||
|
message.drop()
|
||||||
|
flow.kill()
|
||||||
|
return
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
id: tcp-block-message-length-threshold
|
||||||
|
name: Block oversized TCP messages
|
||||||
|
protocol: tcp
|
||||||
|
description: Cuts off client messages that exceed a fixed size threshold.
|
||||||
|
category: tcp
|
||||||
|
family: tcp-hardening
|
||||||
|
tags:
|
||||||
|
- length-limit
|
||||||
|
- tcp
|
||||||
|
- block
|
||||||
|
action: block
|
||||||
|
summary: Cuts off client messages that exceed a fixed size threshold.
|
||||||
|
behavior: Checks every client TCP message size, logs the offending length, drops the
|
||||||
|
message, and kills the flow when the threshold is hit.
|
||||||
|
source_url: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
code: |
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mitmproxy import tcp
|
||||||
|
|
||||||
|
MAX_MESSAGE_BYTES = 4096
|
||||||
|
|
||||||
|
|
||||||
|
def tcp_message(flow: tcp.TCPFlow) -> None:
|
||||||
|
message = flow.messages[-1]
|
||||||
|
if not message.from_client:
|
||||||
|
return
|
||||||
|
size = len(message.content)
|
||||||
|
if size > MAX_MESSAGE_BYTES:
|
||||||
|
logging.warning(
|
||||||
|
"[tcp-block-message-length-threshold] blocked message size=%d",
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
message.drop()
|
||||||
|
flow.kill()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
id: tcp-block-non-printable-threshold
|
||||||
|
name: Block binary-noise threshold
|
||||||
|
protocol: tcp
|
||||||
|
description: Blocks client messages with an excessive amount of non-printable bytes.
|
||||||
|
category: tcp
|
||||||
|
family: tcp-hardening
|
||||||
|
tags:
|
||||||
|
- non-printable
|
||||||
|
- binary-noise
|
||||||
|
- tcp
|
||||||
|
- block
|
||||||
|
action: block
|
||||||
|
summary: Blocks client messages with an excessive amount of non-printable bytes.
|
||||||
|
behavior: Treats unusually binary-heavy client payloads as suspicious, logs the ratio,
|
||||||
|
and closes the flow when the threshold is exceeded.
|
||||||
|
source_url: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
code: |
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mitmproxy import tcp
|
||||||
|
|
||||||
|
NON_PRINTABLE_RATIO = 0.35
|
||||||
|
|
||||||
|
|
||||||
|
def tcp_message(flow: tcp.TCPFlow) -> None:
|
||||||
|
message = flow.messages[-1]
|
||||||
|
if not message.from_client or not message.content:
|
||||||
|
return
|
||||||
|
non_printable = sum(
|
||||||
|
1
|
||||||
|
for byte in message.content
|
||||||
|
if byte < 0x20 and byte not in {0x09, 0x0a, 0x0d}
|
||||||
|
)
|
||||||
|
ratio = non_printable / len(message.content)
|
||||||
|
if ratio > NON_PRINTABLE_RATIO:
|
||||||
|
logging.warning(
|
||||||
|
"[tcp-block-non-printable-threshold] blocked ratio=%.2f size=%d",
|
||||||
|
ratio,
|
||||||
|
len(message.content),
|
||||||
|
)
|
||||||
|
message.drop()
|
||||||
|
flow.kill()
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
id: tcp-block-repeated-byte-threshold
|
||||||
|
name: Block repeated-byte flood
|
||||||
|
protocol: tcp
|
||||||
|
description: Closes connections that send suspiciously long runs of the same byte.
|
||||||
|
category: tcp
|
||||||
|
family: tcp-hardening
|
||||||
|
tags:
|
||||||
|
- bof
|
||||||
|
- repeated-byte
|
||||||
|
- tcp
|
||||||
|
- block
|
||||||
|
action: block
|
||||||
|
summary: Closes connections that send suspiciously long runs of the same byte.
|
||||||
|
behavior: Measures the longest repeated-byte run in each client message, logs the
|
||||||
|
byte and run length, and closes the TCP flow on threshold hit.
|
||||||
|
source_url: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
code: |
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mitmproxy import tcp
|
||||||
|
|
||||||
|
MAX_REPEAT = 128
|
||||||
|
|
||||||
|
|
||||||
|
def longest_run(data: bytes) -> tuple[int, int]:
|
||||||
|
best_byte = -1
|
||||||
|
best_run = 0
|
||||||
|
current_byte = -1
|
||||||
|
current_run = 0
|
||||||
|
for byte in data:
|
||||||
|
if byte == current_byte:
|
||||||
|
current_run += 1
|
||||||
|
else:
|
||||||
|
current_byte = byte
|
||||||
|
current_run = 1
|
||||||
|
if current_run > best_run:
|
||||||
|
best_byte = byte
|
||||||
|
best_run = current_run
|
||||||
|
return best_byte, best_run
|
||||||
|
|
||||||
|
|
||||||
|
def tcp_message(flow: tcp.TCPFlow) -> None:
|
||||||
|
message = flow.messages[-1]
|
||||||
|
if not message.from_client:
|
||||||
|
return
|
||||||
|
repeated_byte, run_length = longest_run(message.content)
|
||||||
|
if run_length > MAX_REPEAT:
|
||||||
|
logging.warning(
|
||||||
|
"[tcp-block-repeated-byte-threshold] blocked byte=0x%02x run=%d",
|
||||||
|
repeated_byte,
|
||||||
|
run_length,
|
||||||
|
)
|
||||||
|
message.drop()
|
||||||
|
flow.kill()
|
||||||
Reference in New Issue
Block a user