Compare commits
15
Commits
@@ -1,10 +0,0 @@
|
|||||||
API_PORT=3671
|
|
||||||
MONGO_INITDB_ROOT_PASSWORD=pass
|
|
||||||
MONGODB_URI=mongodb://theplug:pass@mongodb:27017/theplug?authSource=admin
|
|
||||||
MONGODB_DB_NAME=theplug
|
|
||||||
REDIS_URL=redis://redis:6379
|
|
||||||
JWT_SECRET_KEY=change-this-secret
|
|
||||||
JWT_ALGORITHM=HS256
|
|
||||||
JWT_EXPIRE_MINUTES=30
|
|
||||||
AUTH_USERNAME=admin
|
|
||||||
AUTH_PASSWORD=change-this-password
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
name: Build and Push to Registry
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
container: catthehacker/ubuntu:act-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.reloia.uk
|
|
||||||
username: reloia
|
|
||||||
password: ${{ secrets.LOGIN_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and Push (Shell)
|
|
||||||
run: |
|
|
||||||
IMAGE_NAME="git.reloia.uk/reloia/theplug:latest"
|
|
||||||
|
|
||||||
echo "Building $IMAGE_NAME..."
|
|
||||||
docker build -t "$IMAGE_NAME" .
|
|
||||||
|
|
||||||
echo "Pushing $IMAGE_NAME..."
|
|
||||||
docker push "$IMAGE_NAME"
|
|
||||||
|
|
||||||
+10
@@ -5,3 +5,13 @@
|
|||||||
.env
|
.env
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
**/__pycache__/
|
||||||
|
**/.pytest_cache/
|
||||||
|
**/.ruff_cache/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
backend/.venv/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
playground/
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
FROM python:3.14-slim
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY pyproject.toml ./
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip && \
|
|
||||||
pip install --no-cache-dir \
|
|
||||||
"authlib>=1.6.0" \
|
|
||||||
"fastapi[standard]>=0.135.3" \
|
|
||||||
"motor>=3.7.1" \
|
|
||||||
"passlib[bcrypt]>=1.7.4" \
|
|
||||||
"pydantic-settings>=2.13.1" \
|
|
||||||
"redis>=7.4.0"
|
|
||||||
|
|
||||||
COPY app ./src
|
|
||||||
|
|
||||||
EXPOSE 3671
|
|
||||||
|
|
||||||
CMD ["fastapi", "run", "src/main.py", "--host", "0.0.0.0", "--port", "3671"]
|
|
||||||
@@ -1,73 +1,167 @@
|
|||||||
# thePlug - plugger/proxy Barese
|
# thePlug
|
||||||
|
|
||||||
icona di una presa della spina idk
|
Web UI per gestire proxy `mitmdump` davanti a servizi esposti via Docker
|
||||||
|
Compose.
|
||||||
|
|
||||||
## Architettura
|
## Struttura
|
||||||
|
|
||||||
```
|
```text
|
||||||
FastAPI + MongoDB + Redis
|
backend/ FastAPI, MongoDB, mitmdump manager
|
||||||
|
frontend/ Vue 3, Vite, CodeMirror + Python LSP
|
||||||
|
playground/ challenge/service compose locali
|
||||||
|
templates/ template proxy YAML file-backed
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setup Dev
|
## Setup iniziale
|
||||||
|
|
||||||
imposta le variabili d'ambiente
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
./setup.sh
|
||||||
# modifica le variabili
|
|
||||||
nano .env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
variabili auth richieste in `.env`:
|
`setup.sh` fa bootstrap della macchina/progetto:
|
||||||
|
- installa `uv` se manca
|
||||||
|
- installa Python `3.14` via `uv`
|
||||||
|
- crea `backend/.env` se manca
|
||||||
|
- installa dipendenze backend e frontend
|
||||||
|
- builda il frontend
|
||||||
|
- prepara MongoDB e l'immagine Docker del frontend
|
||||||
|
|
||||||
|
## Runtime reale / vulnbox
|
||||||
|
|
||||||
|
Questi sono i comandi pensati per l’ambiente reale di gara.
|
||||||
|
|
||||||
|
### MongoDB
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
JWT_SECRET_KEY=change-this-secret
|
./run.sh mongo up
|
||||||
JWT_ALGORITHM=HS256
|
./run.sh mongo status
|
||||||
JWT_EXPIRE_MINUTES=30
|
./run.sh mongo logs
|
||||||
AUTH_USERNAME=admin
|
./run.sh mongo down
|
||||||
AUTH_PASSWORD=change-this-password
|
|
||||||
```
|
```
|
||||||
|
|
||||||
avvia i servizi e il server locale
|
### Backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d mongodb redis
|
./run.sh backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Frontend Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fastapi dev app/main.py
|
./run.sh frontend up
|
||||||
|
./run.sh frontend status
|
||||||
|
./run.sh frontend logs
|
||||||
|
./run.sh frontend down
|
||||||
```
|
```
|
||||||
|
|
||||||
accedi a FastAPI su: [FastAPI docs](http://localhost:3671/docs)
|
Il frontend in container fa proxy API verso il backend in esecuzione sulla
|
||||||
|
macchina host.
|
||||||
|
|
||||||
## Auth API
|
## Workflow development
|
||||||
|
|
||||||
Gli endpoint auth disponibili sono solo:
|
Questi comandi sono pensati per sviluppo locale, test e playground.
|
||||||
|
|
||||||
|
### MongoDB
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev.sh mongo up
|
||||||
|
./dev.sh mongo status
|
||||||
|
./dev.sh mongo logs
|
||||||
|
./dev.sh mongo down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev.sh backend
|
||||||
|
```
|
||||||
|
|
||||||
|
Docs FastAPI in dev: `http://localhost:3671/docs`.
|
||||||
|
|
||||||
|
### Frontend dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev.sh frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
Questo avvia Vite normalmente sulla macchina host.
|
||||||
|
|
||||||
|
### Playground / servizi locali
|
||||||
|
|
||||||
|
Per vedere i challenge/service disponibili:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev.sh services list
|
||||||
|
```
|
||||||
|
|
||||||
|
Per avviarli o fermarli:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev.sh services up GabibbiTowers
|
||||||
|
./dev.sh services down GabibbiTowers
|
||||||
|
|
||||||
|
./dev.sh services up all
|
||||||
|
./dev.sh services status all
|
||||||
|
./dev.sh services down all
|
||||||
|
```
|
||||||
|
|
||||||
|
`dev.sh` scopre automaticamente i compose da `playground/*/docker-compose.yml`.
|
||||||
|
|
||||||
|
## Flussi consigliati
|
||||||
|
|
||||||
|
Runtime reale:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./setup.sh
|
||||||
|
./run.sh mongo up
|
||||||
|
./run.sh backend
|
||||||
|
./run.sh frontend up
|
||||||
|
```
|
||||||
|
|
||||||
|
Sviluppo locale:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./setup.sh
|
||||||
|
./dev.sh mongo up
|
||||||
|
./dev.sh backend
|
||||||
|
./dev.sh frontend
|
||||||
|
./dev.sh services up <challenge>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv run ruff check .
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## API principali
|
||||||
|
|
||||||
- `POST /auth/login`
|
- `POST /auth/login`
|
||||||
- `POST /auth/logout`
|
- `POST /auth/logout`
|
||||||
|
- `GET /proxy`
|
||||||
|
- `POST /proxy`
|
||||||
|
- `POST /proxy/{port}/start`
|
||||||
|
- `POST /proxy/{port}/stop`
|
||||||
|
- `POST /proxy/{port}/restart`
|
||||||
|
- `GET /proxy/{port}/health`
|
||||||
|
- `GET /proxy/{port}/logs`
|
||||||
|
- `WS /proxy/{port}/logs/ws`
|
||||||
|
- `GET /proxy/mitmproxy/options`
|
||||||
|
- `GET /proxy/mitmproxy/templates`
|
||||||
|
|
||||||
Esempio login:
|
## Riferimenti
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3671/auth/login \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"username":"admin","password":"change-this-password"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
L'access token ottenuto va inviato come bearer token:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3671/proxy \
|
|
||||||
-H "Authorization: Bearer <ACCESS_TOKEN>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Esempio logout (revoca server-side del token):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3671/auth/logout \
|
|
||||||
-H "Authorization: Bearer <ACCESS_TOKEN>"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentazione per Sviluppo
|
|
||||||
|
|
||||||
[MitMProxy examples](https://docs.mitmproxy.org/stable/addons/examples/)
|
|
||||||
|
|
||||||
|
- Mitmproxy addons: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
- Mitmproxy modes: https://docs.mitmproxy.org/stable/concepts/modes/
|
||||||
|
- Mitmproxy options: https://docs.mitmproxy.org/stable/concepts/options/
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
- fixare i test di unità, di integrazione e aggiungere test reali di benchmark.
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from api.auth.router import router
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
|
|
||||||
from core.security import require_authenticated_user
|
|
||||||
from models.proxy import ProxyCreateInput, ProxyUpdateInput
|
|
||||||
from repositories.proxy import (
|
|
||||||
create_proxy,
|
|
||||||
delete_proxy,
|
|
||||||
get_proxy_by_port,
|
|
||||||
update_proxy,
|
|
||||||
)
|
|
||||||
from repositories.proxy import (
|
|
||||||
list_proxies as list_proxy_documents,
|
|
||||||
)
|
|
||||||
from services.proxy.validator import ProxyCodeValidationError, validate_proxy_code
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/proxy",
|
|
||||||
tags=["Proxy"],
|
|
||||||
dependencies=[Depends(require_authenticated_user)],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_code_or_raise(code: str) -> None:
|
|
||||||
try:
|
|
||||||
await validate_proxy_code(code)
|
|
||||||
except ProxyCodeValidationError as error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=error.to_http_detail(),
|
|
||||||
) from error
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_proxies_handler():
|
|
||||||
return await list_proxy_documents()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{proxy_port}")
|
|
||||||
async def get_proxy(proxy_port: int):
|
|
||||||
proxy = await get_proxy_by_port(proxy_port)
|
|
||||||
if proxy is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Proxy not found",
|
|
||||||
)
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_proxy_handler(payload: ProxyCreateInput):
|
|
||||||
existing_proxy = await get_proxy_by_port(payload.proxy_port)
|
|
||||||
if existing_proxy is not None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"Proxy already exists on port {payload.proxy_port}",
|
|
||||||
)
|
|
||||||
|
|
||||||
await _validate_code_or_raise(payload.code)
|
|
||||||
|
|
||||||
return await create_proxy(
|
|
||||||
proxy_port=payload.proxy_port,
|
|
||||||
name=payload.name,
|
|
||||||
service={"address": payload.service.address, "port": payload.service.port},
|
|
||||||
code=payload.code,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{proxy_port}")
|
|
||||||
async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput):
|
|
||||||
if payload.code is not None:
|
|
||||||
await _validate_code_or_raise(payload.code)
|
|
||||||
|
|
||||||
updated_proxy = await update_proxy(proxy_port, payload)
|
|
||||||
if updated_proxy is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Proxy not found",
|
|
||||||
)
|
|
||||||
return updated_proxy
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{proxy_port}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def remove_proxy(proxy_port: int):
|
|
||||||
deleted = await delete_proxy(proxy_port)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Proxy not found",
|
|
||||||
)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import redis.asyncio as aioredis
|
|
||||||
from core.config import settings
|
|
||||||
|
|
||||||
_redis_pool: aioredis.Redis | None = None
|
|
||||||
|
|
||||||
|
|
||||||
async def init_redis() -> None:
|
|
||||||
global _redis_pool
|
|
||||||
_redis_pool = aioredis.from_url(
|
|
||||||
settings.REDIS_URL, encoding="utf-8", decode_responses=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def close_redis() -> None:
|
|
||||||
if _redis_pool:
|
|
||||||
await _redis_pool.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_redis() -> aioredis.Redis:
|
|
||||||
if _redis_pool is None:
|
|
||||||
raise RuntimeError("Redis pool has not been initialized")
|
|
||||||
return _redis_pool
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from integrations.docker.compose import (
|
|
||||||
find_docker_compose,
|
|
||||||
get_all_services,
|
|
||||||
get_services,
|
|
||||||
get_visible_folders,
|
|
||||||
)
|
|
||||||
from integrations.docker.editor import change_port
|
|
||||||
from integrations.docker.models import DockerService
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"DockerService",
|
|
||||||
"find_docker_compose",
|
|
||||||
"get_visible_folders",
|
|
||||||
"get_services",
|
|
||||||
"get_all_services",
|
|
||||||
"change_port",
|
|
||||||
]
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from integrations.docker.models import DockerService
|
|
||||||
from integrations.docker.ports import _get_service_port
|
|
||||||
|
|
||||||
COMPOSE_NAMES = (
|
|
||||||
"compose.yml",
|
|
||||||
"compose.yaml",
|
|
||||||
"docker-compose.yml",
|
|
||||||
"docker-compose.yaml",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_visible_folders(directory: Path) -> list[Path]:
|
|
||||||
return [
|
|
||||||
p for p in Path(directory).iterdir() if p.is_dir() and not p.name.startswith(".")
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def find_docker_compose(path: Path) -> tuple[dict, str] | None:
|
|
||||||
if not path or not path.is_dir():
|
|
||||||
return None
|
|
||||||
|
|
||||||
for name in COMPOSE_NAMES:
|
|
||||||
file = path / name
|
|
||||||
if file.is_file():
|
|
||||||
content = yaml.safe_load(file.read_text()) or {}
|
|
||||||
return content, file.name
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_services(folder: Path) -> list[DockerService] | None:
|
|
||||||
services: list[DockerService] = []
|
|
||||||
compose_data = find_docker_compose(folder)
|
|
||||||
if compose_data is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
docker_compose, _ = compose_data
|
|
||||||
compose_services = docker_compose.get("services") or {}
|
|
||||||
if not compose_services:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for service_name, config in compose_services.items():
|
|
||||||
mappings = config.get("ports") if isinstance(config, dict) else None
|
|
||||||
if not isinstance(mappings, list) or not mappings:
|
|
||||||
continue
|
|
||||||
|
|
||||||
ports = [
|
|
||||||
p
|
|
||||||
for p in (_get_service_port(mapping) for mapping in mappings)
|
|
||||||
if p is not None
|
|
||||||
]
|
|
||||||
if not ports:
|
|
||||||
continue
|
|
||||||
|
|
||||||
services.append({"name": service_name, "ports": ports})
|
|
||||||
|
|
||||||
return services or None
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_services(directory: str | Path) -> list[DockerService] | None:
|
|
||||||
path = Path(directory)
|
|
||||||
if not path.is_dir():
|
|
||||||
return None
|
|
||||||
|
|
||||||
services: list[DockerService] = []
|
|
||||||
for sub_folder in get_visible_folders(path):
|
|
||||||
sub_services = get_services(sub_folder)
|
|
||||||
if sub_services:
|
|
||||||
services.extend(sub_services)
|
|
||||||
|
|
||||||
return services or None
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from integrations.docker.compose import find_docker_compose
|
|
||||||
from integrations.docker.ports import _get_port_index, _replace_port_in_mapping
|
|
||||||
|
|
||||||
|
|
||||||
def _backup_path_for(compose_path: Path) -> Path:
|
|
||||||
if compose_path.suffix in {".yml", ".yaml"}:
|
|
||||||
return compose_path.with_name(f"{compose_path.stem}.bak{compose_path.suffix}")
|
|
||||||
return compose_path.with_name(f"{compose_path.name}.bak")
|
|
||||||
|
|
||||||
|
|
||||||
def change_port(
|
|
||||||
folder: Path,
|
|
||||||
service_name: str,
|
|
||||||
port_from: int,
|
|
||||||
port_to: int,
|
|
||||||
) -> bool:
|
|
||||||
compose_data = find_docker_compose(folder)
|
|
||||||
if compose_data is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
docker_compose, filename = compose_data
|
|
||||||
compose_services = docker_compose.get("services") or {}
|
|
||||||
if not compose_services:
|
|
||||||
return False
|
|
||||||
|
|
||||||
service_config = compose_services.get(service_name)
|
|
||||||
if not isinstance(service_config, dict):
|
|
||||||
return False
|
|
||||||
|
|
||||||
mappings = service_config.get("ports")
|
|
||||||
if not isinstance(mappings, list):
|
|
||||||
return False
|
|
||||||
|
|
||||||
idx = _get_port_index(mappings, port_from)
|
|
||||||
if idx is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
mappings[idx] = _replace_port_in_mapping(mappings[idx], port_from, port_to)
|
|
||||||
|
|
||||||
compose_path = folder / filename
|
|
||||||
backup_path = _backup_path_for(compose_path)
|
|
||||||
backup_path.write_text(compose_path.read_text())
|
|
||||||
compose_path.write_text(yaml.dump(docker_compose, default_flow_style=False, sort_keys=False))
|
|
||||||
|
|
||||||
# TODO: run docker compose commands
|
|
||||||
return True
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
|
|
||||||
class DockerService(TypedDict):
|
|
||||||
name: str
|
|
||||||
ports: list[int]
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
from collections.abc import Mapping
|
|
||||||
|
|
||||||
|
|
||||||
def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None:
|
|
||||||
if isinstance(mapping, int):
|
|
||||||
return mapping
|
|
||||||
|
|
||||||
if isinstance(mapping, Mapping):
|
|
||||||
published = mapping.get("published")
|
|
||||||
if published is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return int(published)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
parts = str(mapping).split(":")
|
|
||||||
match parts:
|
|
||||||
case [""]:
|
|
||||||
return None
|
|
||||||
case [port] | [port, _]:
|
|
||||||
try:
|
|
||||||
return int(port)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
case ["localhost" | "127.0.0.1", _, _]:
|
|
||||||
return None
|
|
||||||
case [_, port, _]:
|
|
||||||
try:
|
|
||||||
return int(port)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
case _:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_port_in_mapping(
|
|
||||||
mapping: str | int | Mapping[str, object],
|
|
||||||
port_from: int,
|
|
||||||
port_to: int,
|
|
||||||
) -> str | dict[str, object]:
|
|
||||||
if isinstance(mapping, Mapping):
|
|
||||||
updated = dict(mapping)
|
|
||||||
if _get_service_port(updated) == port_from:
|
|
||||||
updated["published"] = str(port_to)
|
|
||||||
return updated
|
|
||||||
|
|
||||||
parts = str(mapping).split(":")
|
|
||||||
match parts:
|
|
||||||
case [_]:
|
|
||||||
return str(port_to)
|
|
||||||
case [_, container_port]:
|
|
||||||
return f"{port_to}:{container_port}"
|
|
||||||
case [ip, _, container_port]:
|
|
||||||
return f"{ip}:{port_to}:{container_port}"
|
|
||||||
case _:
|
|
||||||
return str(port_to)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_port_index(mappings: list[str | int | Mapping[str, object]], port: int) -> int | None:
|
|
||||||
for i, mapping in enumerate(mappings):
|
|
||||||
if _get_service_port(mapping) == port:
|
|
||||||
return i
|
|
||||||
return None
|
|
||||||
|
|
||||||
-42
@@ -1,42 +0,0 @@
|
|||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from api.auth.router import router as auth_router
|
|
||||||
from api.proxy.router import router as proxy_router
|
|
||||||
from core.config import settings
|
|
||||||
from core.mongodb import close_mongodb, init_mongodb
|
|
||||||
from core.redis import close_redis, init_redis
|
|
||||||
from core.security import hash_password
|
|
||||||
from repositories.auth import ensure_auth_indexes, ensure_single_account
|
|
||||||
from repositories.proxy import ensure_proxy_indexes
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(_: FastAPI):
|
|
||||||
await init_mongodb()
|
|
||||||
await init_redis()
|
|
||||||
await ensure_auth_indexes()
|
|
||||||
await ensure_single_account(
|
|
||||||
username=settings.AUTH_USERNAME,
|
|
||||||
password_hash=hash_password(settings.AUTH_PASSWORD),
|
|
||||||
)
|
|
||||||
await ensure_proxy_indexes()
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
await close_redis()
|
|
||||||
await close_mongodb()
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title="thePlugAPI",
|
|
||||||
lifespan=lifespan,
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(auth_router)
|
|
||||||
app.include_router(proxy_router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def root():
|
|
||||||
return {"Hello": "World!"}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from models.service import ServiceEndpointDocument
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyDocument(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
proxy_port: int = Field(..., description="Port exposed by the proxy")
|
|
||||||
name: str = Field(..., description="Human readable proxy name")
|
|
||||||
code: str = Field(..., description="Proxy source or identifier")
|
|
||||||
service: ServiceEndpointDocument = Field(
|
|
||||||
...,
|
|
||||||
description="Associated upstream service",
|
|
||||||
)
|
|
||||||
created_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Creation timestamp in UTC",
|
|
||||||
)
|
|
||||||
updated_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Last update timestamp in UTC",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyCreateInput(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
proxy_port: int = Field(..., description="Port exposed by the proxy")
|
|
||||||
name: str = Field(..., description="Human readable proxy name")
|
|
||||||
code: str = Field(..., description="Proxy source or identifier")
|
|
||||||
service: ServiceEndpointDocument = Field(
|
|
||||||
...,
|
|
||||||
description="Associated upstream service",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyUpdateInput(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
name: str | None = Field(default=None, description="Human readable proxy name")
|
|
||||||
code: str | None = Field(default=None, description="Proxy source or identifier")
|
|
||||||
service: ServiceEndpointDocument | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Associated upstream service",
|
|
||||||
)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceEndpointDocument(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
address: str = Field(..., description="Upstream service address")
|
|
||||||
port: int = Field(..., description="Upstream service port")
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from services.proxy.models import ServiceConfig
|
|
||||||
|
|
||||||
|
|
||||||
async def create_proxy(
|
|
||||||
port: int,
|
|
||||||
name: str,
|
|
||||||
service: ServiceConfig,
|
|
||||||
code: str
|
|
||||||
):
|
|
||||||
return
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceConfig(TypedDict):
|
|
||||||
name: str
|
|
||||||
port: int
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
API_PORT=3671
|
||||||
|
FRONTEND_PORT=5173
|
||||||
|
# Use a random value in real environments.
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD=pass
|
||||||
|
MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin
|
||||||
|
MONGODB_DB_NAME=theplug
|
||||||
|
# Use a random value in real environments.
|
||||||
|
JWT_SECRET_KEY=change-me
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_EXPIRE_MINUTES=720
|
||||||
|
AUTH_USERNAME=admin
|
||||||
|
# Use a random value in real environments.
|
||||||
|
AUTH_PASSWORD=change-me
|
||||||
|
# Optional: override the directory whose immediate child folders are scanned
|
||||||
|
# for docker-compose files. Defaults to the current user's home directory.
|
||||||
|
SERVICE_SCAN_ROOT=
|
||||||
|
HEALTHCHECK_INTERVAL_SECONDS=15
|
||||||
|
HEALTH_HISTORY_TTL_SECONDS=86400
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.api.auth.router import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -3,14 +3,14 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
|
|
||||||
from core.security import (
|
from app.core.security import (
|
||||||
AuthContext,
|
AuthContext,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
get_auth_context,
|
get_auth_context,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from models.auth import LoginInput, TokenResponse
|
from app.models.auth import LoginInput, TokenResponse
|
||||||
from repositories.auth import get_user_by_username, revoke_token
|
from app.repositories.auth import get_user_by_username, revoke_token
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/auth",
|
prefix="/auth",
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
from app.core.security import (
|
||||||
|
InvalidTokenError,
|
||||||
|
authenticate_token,
|
||||||
|
require_authenticated_user,
|
||||||
|
)
|
||||||
|
from app.services.editor import python_lsp
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/proxy/editor/python", tags=["Editor"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/session", dependencies=[Depends(require_authenticated_user)])
|
||||||
|
async def python_editor_session():
|
||||||
|
if python_lsp.lsp_binary() is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="basedpyright-langserver is not installed",
|
||||||
|
)
|
||||||
|
|
||||||
|
session = python_lsp.get_session()
|
||||||
|
return {
|
||||||
|
"root_uri": session.root_uri,
|
||||||
|
"file_uri": session.file_uri,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_editor_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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws")
|
||||||
|
async def python_editor_socket(websocket: WebSocket):
|
||||||
|
try:
|
||||||
|
await authenticate_editor_socket(websocket)
|
||||||
|
except InvalidTokenError:
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
|
||||||
|
if python_lsp.lsp_binary() is None:
|
||||||
|
await websocket.close(code=1011, reason="basedpyright unavailable")
|
||||||
|
return
|
||||||
|
|
||||||
|
session = python_lsp.get_session()
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await python_lsp.bridge(websocket, session)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
except RuntimeError:
|
||||||
|
await websocket.close(code=1011, reason="language server failed")
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||||
|
from pymongo.errors import DuplicateKeyError
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
from app.api.proxy.socket_utils import (
|
||||||
|
authenticate_proxy_socket,
|
||||||
|
proxy_log_path,
|
||||||
|
read_log_snapshot,
|
||||||
|
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.discovery import service_scan_root
|
||||||
|
from app.models.health import HealthHistoryResponse
|
||||||
|
from app.models.proxy import ProxyCreateInput, ProxyUpdateInput
|
||||||
|
from app.models.template import ProxyTemplateCreateInput
|
||||||
|
from app.repositories.health import (
|
||||||
|
delete_health_history,
|
||||||
|
get_latest_health_sample,
|
||||||
|
list_health_history,
|
||||||
|
record_health_sample,
|
||||||
|
)
|
||||||
|
from app.repositories.proxy import (
|
||||||
|
create_proxy,
|
||||||
|
delete_proxy,
|
||||||
|
get_proxy_by_port,
|
||||||
|
update_proxy,
|
||||||
|
update_proxy_runtime,
|
||||||
|
)
|
||||||
|
from app.repositories.proxy import (
|
||||||
|
list_proxies as list_proxy_documents,
|
||||||
|
)
|
||||||
|
from app.repositories.templates import (
|
||||||
|
create_custom_template,
|
||||||
|
delete_custom_template,
|
||||||
|
list_custom_templates,
|
||||||
|
)
|
||||||
|
from app.services.mitmproxy.health import check_proxy
|
||||||
|
from app.services.mitmproxy.health_monitor import health_monitor
|
||||||
|
from app.services.mitmproxy.manager import MitmdumpError, manager
|
||||||
|
from app.services.mitmproxy.options import (
|
||||||
|
MitmOptionError,
|
||||||
|
load_options,
|
||||||
|
public_options,
|
||||||
|
validate_options,
|
||||||
|
)
|
||||||
|
from app.services.mitmproxy.runtime_validation import (
|
||||||
|
ReservedProxyPortError,
|
||||||
|
backend_port,
|
||||||
|
ensure_proxy_port_not_reserved,
|
||||||
|
)
|
||||||
|
from app.services.mitmproxy.templates import file_template_ids, list_file_templates
|
||||||
|
from app.services.mitmproxy.validator import (
|
||||||
|
ProxyCodeValidationError,
|
||||||
|
validate_proxy_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
SUPPORTED_MODES = {
|
||||||
|
"regular",
|
||||||
|
"local",
|
||||||
|
"wireguard",
|
||||||
|
"reverse",
|
||||||
|
"transparent",
|
||||||
|
"tun",
|
||||||
|
"upstream",
|
||||||
|
"socks5",
|
||||||
|
"dns",
|
||||||
|
}
|
||||||
|
SUPPORTED_PROTOCOLS = {
|
||||||
|
"http",
|
||||||
|
"https",
|
||||||
|
"tcp",
|
||||||
|
"tls",
|
||||||
|
"udp",
|
||||||
|
"dtls",
|
||||||
|
"dns",
|
||||||
|
"http3",
|
||||||
|
"quic",
|
||||||
|
}
|
||||||
|
DEFAULT_HEALTH_HISTORY_LIMIT = 40
|
||||||
|
MAX_HEALTH_HISTORY_LIMIT = 200
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/proxy",
|
||||||
|
tags=["Proxy"],
|
||||||
|
)
|
||||||
|
auth_dependencies = [Depends(require_authenticated_user)]
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_code_or_raise(code: str) -> None:
|
||||||
|
try:
|
||||||
|
await validate_proxy_code(code)
|
||||||
|
except ProxyCodeValidationError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=error.to_http_detail(),
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mode(mode: str) -> None:
|
||||||
|
if mode not in SUPPORTED_MODES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Unsupported proxy mode: {mode}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mode_spec(mode_spec: str | None) -> None:
|
||||||
|
if mode_spec is None:
|
||||||
|
return
|
||||||
|
mode = mode_spec.split(":", 1)[0].split("@", 1)[0]
|
||||||
|
validate_mode(mode)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_protocol(protocol: str) -> None:
|
||||||
|
if protocol not in SUPPORTED_PROTOCOLS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Unsupported reverse proxy protocol: {protocol}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_mitm_options(options: dict[str, object]) -> None:
|
||||||
|
try:
|
||||||
|
await validate_options(options)
|
||||||
|
except MitmOptionError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(error),
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
def validate_proxy_port(proxy_port: int) -> None:
|
||||||
|
try:
|
||||||
|
ensure_proxy_port_not_reserved(proxy_port)
|
||||||
|
except ReservedProxyPortError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(error),
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
async def get_proxy_or_404(proxy_port: int):
|
||||||
|
proxy = await get_proxy_by_port(proxy_port)
|
||||||
|
if proxy is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Proxy not found",
|
||||||
|
)
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=auth_dependencies)
|
||||||
|
async def list_proxies_handler():
|
||||||
|
return await list_proxy_documents()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/services", dependencies=auth_dependencies)
|
||||||
|
async def list_services(scan_root: str | None = None):
|
||||||
|
_ = settings
|
||||||
|
root = Path(scan_root).expanduser() if scan_root else service_scan_root()
|
||||||
|
services = get_all_services_in_dir(root)
|
||||||
|
return {
|
||||||
|
"scan_root": str(root),
|
||||||
|
"services": services,
|
||||||
|
"backend_port": backend_port(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mitmproxy/options", dependencies=auth_dependencies)
|
||||||
|
async def list_mitmproxy_options():
|
||||||
|
return public_options(await load_options())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mitmproxy/templates", dependencies=auth_dependencies)
|
||||||
|
async def list_mitmproxy_templates():
|
||||||
|
return [*list_file_templates(), *await list_custom_templates()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/mitmproxy/templates",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=auth_dependencies,
|
||||||
|
)
|
||||||
|
async def create_mitmproxy_template(payload: ProxyTemplateCreateInput):
|
||||||
|
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:
|
||||||
|
return await create_custom_template(payload)
|
||||||
|
except DuplicateKeyError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Template already exists: {payload.id}",
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/mitmproxy/templates/{template_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=auth_dependencies,
|
||||||
|
)
|
||||||
|
async def delete_mitmproxy_template(template_id: str):
|
||||||
|
if template_id in file_template_ids():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Built-in templates cannot be deleted",
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = await delete_custom_template(template_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Template not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{proxy_port}", dependencies=auth_dependencies)
|
||||||
|
async def get_proxy(proxy_port: int):
|
||||||
|
return await get_proxy_or_404(proxy_port)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=auth_dependencies)
|
||||||
|
async def create_proxy_handler(payload: ProxyCreateInput):
|
||||||
|
validate_proxy_port(payload.proxy_port)
|
||||||
|
existing_proxy = await get_proxy_by_port(payload.proxy_port)
|
||||||
|
if existing_proxy is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Proxy already exists on port {payload.proxy_port}",
|
||||||
|
)
|
||||||
|
|
||||||
|
await _validate_code_or_raise(payload.code)
|
||||||
|
validate_mode(payload.mode)
|
||||||
|
validate_mode_spec(payload.mode_spec)
|
||||||
|
validate_protocol(payload.protocol)
|
||||||
|
await validate_mitm_options(payload.mitm_options)
|
||||||
|
|
||||||
|
return await create_proxy(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{proxy_port}", dependencies=auth_dependencies)
|
||||||
|
async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput):
|
||||||
|
if payload.code is not None:
|
||||||
|
await _validate_code_or_raise(payload.code)
|
||||||
|
if payload.mode is not None:
|
||||||
|
validate_mode(payload.mode)
|
||||||
|
if "mode_spec" in payload.model_fields_set:
|
||||||
|
validate_mode_spec(payload.mode_spec)
|
||||||
|
if payload.protocol is not None:
|
||||||
|
validate_protocol(payload.protocol)
|
||||||
|
if payload.mitm_options is not None:
|
||||||
|
await validate_mitm_options(payload.mitm_options)
|
||||||
|
|
||||||
|
updated_proxy = await update_proxy(proxy_port, payload)
|
||||||
|
if updated_proxy is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Proxy not found",
|
||||||
|
)
|
||||||
|
return updated_proxy
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{proxy_port}/start", dependencies=auth_dependencies)
|
||||||
|
async def start_proxy(proxy_port: int):
|
||||||
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
|
|
||||||
|
try:
|
||||||
|
runtime = await manager.start(proxy)
|
||||||
|
except MitmdumpError as error:
|
||||||
|
updated = await update_proxy_runtime(
|
||||||
|
proxy_port,
|
||||||
|
{"status": "error", "last_error": str(error)},
|
||||||
|
)
|
||||||
|
return updated or proxy
|
||||||
|
|
||||||
|
return await update_proxy_runtime(proxy_port, runtime)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{proxy_port}/stop", dependencies=auth_dependencies)
|
||||||
|
async def stop_proxy(proxy_port: int):
|
||||||
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
|
runtime = await manager.stop(proxy)
|
||||||
|
return await update_proxy_runtime(proxy_port, runtime)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{proxy_port}/restart", dependencies=auth_dependencies)
|
||||||
|
async def restart_proxy(proxy_port: int):
|
||||||
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
|
stopped = await manager.stop(proxy)
|
||||||
|
proxy = await update_proxy_runtime(proxy_port, stopped) or proxy
|
||||||
|
try:
|
||||||
|
runtime = await manager.start(proxy)
|
||||||
|
except MitmdumpError as error:
|
||||||
|
updated = await update_proxy_runtime(
|
||||||
|
proxy_port,
|
||||||
|
{"status": "error", "last_error": str(error)},
|
||||||
|
)
|
||||||
|
return updated or proxy
|
||||||
|
return await update_proxy_runtime(proxy_port, runtime)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{proxy_port}/status", dependencies=auth_dependencies)
|
||||||
|
async def proxy_status(proxy_port: int):
|
||||||
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
|
runtime_status = await manager.status(proxy)
|
||||||
|
if runtime_status != proxy.status:
|
||||||
|
values: dict[str, object] = {"status": runtime_status}
|
||||||
|
if runtime_status != "running":
|
||||||
|
values["pid"] = None
|
||||||
|
proxy = await update_proxy_runtime(proxy_port, values) or proxy
|
||||||
|
return {"proxy_port": proxy.proxy_port, "status": runtime_status, "pid": proxy.pid}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{proxy_port}/health", dependencies=auth_dependencies)
|
||||||
|
async def proxy_health(proxy_port: int):
|
||||||
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
|
snapshot = await check_proxy(proxy)
|
||||||
|
if proxy.healthcheck.enabled:
|
||||||
|
await record_health_sample(snapshot)
|
||||||
|
await health_monitor.publish(proxy.proxy_port, snapshot)
|
||||||
|
return serialize_health_snapshot(snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{proxy_port}/health/history", dependencies=auth_dependencies)
|
||||||
|
async def proxy_health_history(
|
||||||
|
proxy_port: int,
|
||||||
|
limit: int = DEFAULT_HEALTH_HISTORY_LIMIT,
|
||||||
|
):
|
||||||
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
|
limit = max(1, min(limit, MAX_HEALTH_HISTORY_LIMIT))
|
||||||
|
latest = await get_latest_health_sample(proxy.proxy_port)
|
||||||
|
history = await list_health_history(proxy.proxy_port, limit=limit)
|
||||||
|
response = HealthHistoryResponse(
|
||||||
|
proxy_port=proxy.proxy_port,
|
||||||
|
latest=latest,
|
||||||
|
history=history,
|
||||||
|
)
|
||||||
|
return response.model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{proxy_port}/logs", dependencies=auth_dependencies)
|
||||||
|
async def proxy_logs(proxy_port: int):
|
||||||
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
|
path = proxy_log_path(proxy)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"proxy_port": proxy.proxy_port,
|
||||||
|
"log": await read_log_snapshot(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/{proxy_port}/logs/ws")
|
||||||
|
async def proxy_logs_socket(websocket: WebSocket, proxy_port: int):
|
||||||
|
disconnect_task: asyncio.Task[None] | None = None
|
||||||
|
stream_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
await authenticate_proxy_socket(websocket)
|
||||||
|
except InvalidTokenError:
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
|
||||||
|
proxy = await get_proxy_by_port(proxy_port)
|
||||||
|
if proxy is None:
|
||||||
|
await websocket.close(code=4404)
|
||||||
|
return
|
||||||
|
|
||||||
|
path = proxy_log_path(proxy)
|
||||||
|
initial_snapshot, offset, marker = await snapshot_log_state(path)
|
||||||
|
await websocket.accept()
|
||||||
|
await websocket.send_json({"type": "snapshot", "log": initial_snapshot})
|
||||||
|
|
||||||
|
disconnect_task = asyncio.create_task(watch_socket_disconnect(websocket))
|
||||||
|
stream_task = asyncio.create_task(
|
||||||
|
stream_proxy_logs(websocket, path, offset=offset, marker=marker)
|
||||||
|
)
|
||||||
|
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
{disconnect_task, stream_task},
|
||||||
|
return_when=asyncio.FIRST_EXCEPTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
if pending:
|
||||||
|
await asyncio.gather(*pending, return_exceptions=True)
|
||||||
|
|
||||||
|
for task in done:
|
||||||
|
try:
|
||||||
|
task.result()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
except RuntimeError:
|
||||||
|
await websocket.close(code=1011, reason="log stream failed")
|
||||||
|
return
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
for task in (disconnect_task, stream_task):
|
||||||
|
if task is None or task.done():
|
||||||
|
continue
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/{proxy_port}/health/ws")
|
||||||
|
async def proxy_health_socket(websocket: WebSocket, proxy_port: int):
|
||||||
|
disconnect_task: asyncio.Task[None] | None = None
|
||||||
|
queue_task: asyncio.Task[None] | None = None
|
||||||
|
queue = None
|
||||||
|
|
||||||
|
async def forward_health() -> None:
|
||||||
|
assert queue is not None
|
||||||
|
while True:
|
||||||
|
snapshot = await queue.get()
|
||||||
|
await websocket.send_json(serialize_health_snapshot(snapshot))
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
await authenticate_proxy_socket(websocket)
|
||||||
|
except InvalidTokenError:
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
|
||||||
|
proxy = await get_proxy_by_port(proxy_port)
|
||||||
|
if proxy is None:
|
||||||
|
await websocket.close(code=4404)
|
||||||
|
return
|
||||||
|
|
||||||
|
await websocket.accept()
|
||||||
|
latest = await get_latest_health_sample(proxy.proxy_port)
|
||||||
|
if latest is None:
|
||||||
|
latest = await check_proxy(proxy)
|
||||||
|
await websocket.send_json(serialize_health_snapshot(latest))
|
||||||
|
|
||||||
|
queue = health_monitor.subscribe(proxy.proxy_port)
|
||||||
|
disconnect_task = asyncio.create_task(watch_socket_disconnect(websocket))
|
||||||
|
queue_task = asyncio.create_task(forward_health())
|
||||||
|
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
{disconnect_task, queue_task},
|
||||||
|
return_when=asyncio.FIRST_EXCEPTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
if pending:
|
||||||
|
await asyncio.gather(*pending, return_exceptions=True)
|
||||||
|
|
||||||
|
for task in done:
|
||||||
|
try:
|
||||||
|
task.result()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
except RuntimeError:
|
||||||
|
await websocket.close(code=1011, reason="health stream failed")
|
||||||
|
return
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
if queue is not None:
|
||||||
|
health_monitor.unsubscribe(proxy_port, queue)
|
||||||
|
for task in (disconnect_task, queue_task):
|
||||||
|
if task is None or task.done():
|
||||||
|
continue
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{proxy_port}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=auth_dependencies,
|
||||||
|
)
|
||||||
|
async def remove_proxy(proxy_port: int):
|
||||||
|
proxy = await get_proxy_by_port(proxy_port)
|
||||||
|
if proxy is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Proxy not found",
|
||||||
|
)
|
||||||
|
await manager.stop(proxy)
|
||||||
|
manager.cleanup_runtime_artifacts(proxy)
|
||||||
|
await delete_health_history(proxy.proxy_port)
|
||||||
|
deleted = await delete_proxy(proxy_port)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Proxy not found",
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -4,7 +4,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=(".env", "backend/.env"),
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
@@ -12,12 +12,15 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
MONGODB_URI: str
|
MONGODB_URI: str
|
||||||
MONGODB_DB_NAME: str
|
MONGODB_DB_NAME: str
|
||||||
REDIS_URL: str
|
|
||||||
JWT_SECRET_KEY: str
|
JWT_SECRET_KEY: str
|
||||||
JWT_ALGORITHM: str = "HS256"
|
JWT_ALGORITHM: str = "HS256"
|
||||||
JWT_EXPIRE_MINUTES: int = Field(default=30, gt=0)
|
JWT_EXPIRE_MINUTES: int = Field(default=720, gt=0)
|
||||||
AUTH_USERNAME: str
|
AUTH_USERNAME: str
|
||||||
AUTH_PASSWORD: str
|
AUTH_PASSWORD: str
|
||||||
|
API_PORT: int = Field(default=3671, ge=1, le=65535)
|
||||||
|
SERVICE_SCAN_ROOT: str | None = None
|
||||||
|
HEALTHCHECK_INTERVAL_SECONDS: int = Field(default=15, ge=5, le=300)
|
||||||
|
HEALTH_HISTORY_TTL_SECONDS: int = Field(default=86400, ge=300)
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings() # pyright: ignore[reportCallIssue]
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
from core.config import settings
|
from typing import Any
|
||||||
|
|
||||||
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
class _MongoDB:
|
class _MongoDB:
|
||||||
client: AsyncIOMotorClient | None = None
|
client: AsyncIOMotorClient[dict[str, Any]] | None = None
|
||||||
|
|
||||||
def get_database(self) -> AsyncIOMotorDatabase:
|
def get_database(self) -> AsyncIOMotorDatabase[dict[str, Any]]:
|
||||||
if self.client is None:
|
if self.client is None:
|
||||||
raise RuntimeError("MongoDB client has not been initialized")
|
raise RuntimeError("MongoDB client has not been initialized")
|
||||||
return self.client[settings.MONGODB_DB_NAME]
|
return self.client[settings.MONGODB_DB_NAME]
|
||||||
|
|
||||||
|
|
||||||
mongodb = _MongoDB()
|
mongodb = _MongoDB()
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +29,7 @@ async def close_mongodb() -> None:
|
|||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
async def get_database() -> AsyncIOMotorDatabase:
|
async def get_database() -> AsyncIOMotorDatabase[dict[str, Any]]:
|
||||||
return mongodb.get_database()
|
return mongodb.get_database()
|
||||||
|
|
||||||
|
|
||||||
@@ -7,13 +7,14 @@ from authlib.jose import jwt # type: ignore
|
|||||||
from authlib.jose.errors import JoseError # type: ignore
|
from authlib.jose.errors import JoseError # type: ignore
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from passlib.context import CryptContext
|
from pwdlib import PasswordHash
|
||||||
|
from pwdlib.hashers.argon2 import Argon2Hasher
|
||||||
|
|
||||||
from core.config import settings
|
from app.core.config import settings
|
||||||
from models.auth import AccessTokenPayload, AuthUserDocument
|
from app.models.auth import AccessTokenPayload, AuthUserDocument
|
||||||
from repositories.auth import get_user_by_username, is_token_revoked
|
from app.repositories.auth import get_user_by_username, is_token_revoked
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = PasswordHash((Argon2Hasher(),))
|
||||||
bearer_scheme = HTTPBearer(auto_error=False)
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -53,14 +54,18 @@ def create_access_token(*, subject: str) -> str:
|
|||||||
"alg": settings.JWT_ALGORITHM,
|
"alg": settings.JWT_ALGORITHM,
|
||||||
"typ": "JWT",
|
"typ": "JWT",
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"sub": subject,
|
"sub": subject,
|
||||||
"jti": str(uuid4()),
|
"jti": str(uuid4()),
|
||||||
"exp": expires_at,
|
"exp": expires_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(header, payload, settings.JWT_SECRET_KEY)
|
token = jwt.encode(header, payload, settings.JWT_SECRET_KEY)
|
||||||
|
|
||||||
if isinstance(token, bytes):
|
if isinstance(token, bytes):
|
||||||
return token.decode("utf-8")
|
return token.decode("utf-8")
|
||||||
|
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
@@ -69,10 +74,26 @@ def decode_access_token(token: str) -> AccessTokenPayload:
|
|||||||
claims = jwt.decode(token, settings.JWT_SECRET_KEY)
|
claims = jwt.decode(token, settings.JWT_SECRET_KEY)
|
||||||
claims.validate()
|
claims.validate()
|
||||||
return AccessTokenPayload.model_validate(dict(claims))
|
return AccessTokenPayload.model_validate(dict(claims))
|
||||||
except (JoseError, ValueError, TypeError):
|
except JoseError, ValueError, TypeError:
|
||||||
raise InvalidTokenError from None
|
raise InvalidTokenError from None
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_token(token: str) -> AuthContext:
|
||||||
|
try:
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
except InvalidTokenError:
|
||||||
|
raise InvalidTokenError from None
|
||||||
|
|
||||||
|
if await is_token_revoked(payload.jti):
|
||||||
|
raise InvalidTokenError
|
||||||
|
|
||||||
|
user = await get_user_by_username(payload.sub)
|
||||||
|
if user is None:
|
||||||
|
raise InvalidTokenError
|
||||||
|
|
||||||
|
return AuthContext(token=token, user=user, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
async def get_auth_context(
|
async def get_auth_context(
|
||||||
credentials: Annotated[
|
credentials: Annotated[
|
||||||
HTTPAuthorizationCredentials | None,
|
HTTPAuthorizationCredentials | None,
|
||||||
@@ -82,21 +103,11 @@ async def get_auth_context(
|
|||||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||||
raise token_auth_error()
|
raise token_auth_error()
|
||||||
|
|
||||||
token = credentials.credentials
|
|
||||||
try:
|
try:
|
||||||
payload = decode_access_token(token)
|
return await authenticate_token(credentials.credentials)
|
||||||
except InvalidTokenError:
|
except InvalidTokenError:
|
||||||
raise token_auth_error() from None
|
raise token_auth_error() from None
|
||||||
|
|
||||||
if await is_token_revoked(payload.jti):
|
|
||||||
raise token_auth_error()
|
|
||||||
|
|
||||||
user = await get_user_by_username(payload.sub)
|
|
||||||
if user is None:
|
|
||||||
raise token_auth_error()
|
|
||||||
|
|
||||||
return AuthContext(token=token, user=user, payload=payload)
|
|
||||||
|
|
||||||
|
|
||||||
async def require_authenticated_user(
|
async def require_authenticated_user(
|
||||||
auth_context: Annotated[AuthContext, Depends(get_auth_context)],
|
auth_context: Annotated[AuthContext, Depends(get_auth_context)],
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from app.integrations.docker.compose import (
|
||||||
|
find_docker_compose,
|
||||||
|
get_all_services_in_dir,
|
||||||
|
get_services,
|
||||||
|
)
|
||||||
|
from app.integrations.docker.editor import change_port
|
||||||
|
from app.integrations.docker.models import DockerContainer
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DockerContainer",
|
||||||
|
"find_docker_compose",
|
||||||
|
"get_services",
|
||||||
|
"get_all_services_in_dir",
|
||||||
|
"change_port",
|
||||||
|
]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.integrations.docker.models import DockerContainer, DockerPortBinding
|
||||||
|
from app.integrations.docker.ports import _get_service_binding
|
||||||
|
|
||||||
|
COMPOSE_NAMES = (
|
||||||
|
"compose.yml",
|
||||||
|
"compose.yaml",
|
||||||
|
"docker-compose.yml",
|
||||||
|
"docker-compose.yaml",
|
||||||
|
)
|
||||||
|
|
||||||
|
type ComposeDocument = dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_visible_folders(directory: Path) -> list[Path]:
|
||||||
|
try:
|
||||||
|
entries = list(Path(directory).iterdir())
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
folders: list[Path] = []
|
||||||
|
for entry in entries:
|
||||||
|
if entry.name.startswith("."):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if entry.is_dir():
|
||||||
|
folders.append(entry)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return folders
|
||||||
|
|
||||||
|
|
||||||
|
def find_docker_compose(path: Path) -> tuple[ComposeDocument, Path] | None:
|
||||||
|
try:
|
||||||
|
if not path or not path.is_dir():
|
||||||
|
return None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for name in COMPOSE_NAMES:
|
||||||
|
file = path / name
|
||||||
|
try:
|
||||||
|
if file.is_file():
|
||||||
|
content = yaml.safe_load(file.read_text()) or {}
|
||||||
|
return content, file
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_services(folder: Path) -> list[DockerContainer] | None:
|
||||||
|
services: list[DockerContainer] = []
|
||||||
|
compose_data = find_docker_compose(folder)
|
||||||
|
if compose_data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
docker_compose, _ = compose_data
|
||||||
|
compose_services = docker_compose.get("services") or {}
|
||||||
|
if not isinstance(compose_services, dict) or not compose_services:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for service_name, config in compose_services.items():
|
||||||
|
mappings = config.get("ports") if isinstance(config, dict) else None
|
||||||
|
if not isinstance(mappings, list) or not mappings:
|
||||||
|
continue
|
||||||
|
|
||||||
|
bindings: list[DockerPortBinding] = [
|
||||||
|
{"published": published, "host": host}
|
||||||
|
for published, host in (
|
||||||
|
_get_service_binding(mapping) for mapping in mappings
|
||||||
|
)
|
||||||
|
if published is not None
|
||||||
|
]
|
||||||
|
if not bindings:
|
||||||
|
continue
|
||||||
|
|
||||||
|
services.append(
|
||||||
|
{
|
||||||
|
"name": service_name,
|
||||||
|
"ports": [binding["published"] for binding in bindings],
|
||||||
|
"bindings": bindings,
|
||||||
|
"folder": folder,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return services
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_services_in_dir(
|
||||||
|
directory: str | Path,
|
||||||
|
) -> list[DockerContainer]:
|
||||||
|
path = Path(directory)
|
||||||
|
try:
|
||||||
|
if not path.is_dir():
|
||||||
|
return []
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
services: list[DockerContainer] = []
|
||||||
|
for sub_folder in _get_visible_folders(path):
|
||||||
|
sub_services = get_services(sub_folder)
|
||||||
|
if sub_services:
|
||||||
|
services.extend(sub_services)
|
||||||
|
|
||||||
|
return services
|
||||||
|
|
||||||
|
|
||||||
|
async def run_docker_command(
|
||||||
|
file: Path,
|
||||||
|
command: list[str],
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
if not command:
|
||||||
|
return None
|
||||||
|
|
||||||
|
full_command = ["docker", "compose", "-f", f"{file}", *command]
|
||||||
|
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*full_command,
|
||||||
|
cwd=str(file.parent),
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
|
||||||
|
if process.returncode == 0:
|
||||||
|
return {"status": "success", "output": stdout.decode()}
|
||||||
|
else:
|
||||||
|
return {"status": "error", "reason": stderr.decode()}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def service_scan_root() -> Path:
|
||||||
|
configured = settings.SERVICE_SCAN_ROOT
|
||||||
|
if configured:
|
||||||
|
return Path(configured).expanduser()
|
||||||
|
return Path.home()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.integrations.docker.compose import (
|
||||||
|
ComposeDocument,
|
||||||
|
find_docker_compose,
|
||||||
|
run_docker_command,
|
||||||
|
)
|
||||||
|
from app.integrations.docker.ports import _get_port_index, _replace_port_in_mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _render_compose(document: ComposeDocument) -> str:
|
||||||
|
return yaml.dump(document, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_compose(path: Path, content: str) -> None:
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_compose_text(path: Path) -> str:
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
async def change_port(
|
||||||
|
service_name: str,
|
||||||
|
folder: Path,
|
||||||
|
port_from: int,
|
||||||
|
port_to: int,
|
||||||
|
host_to: str | None = None,
|
||||||
|
rewrite_host: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
compose_data = find_docker_compose(folder)
|
||||||
|
if compose_data is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
docker_compose, compose_path = compose_data
|
||||||
|
compose_services = docker_compose.get("services") or {}
|
||||||
|
if not isinstance(compose_services, dict) or not compose_services:
|
||||||
|
return False
|
||||||
|
|
||||||
|
service_config = compose_services.get(service_name)
|
||||||
|
if not isinstance(service_config, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
mappings = service_config.get("ports")
|
||||||
|
if not isinstance(mappings, list):
|
||||||
|
return False
|
||||||
|
|
||||||
|
idx = _get_port_index(mappings, port_from)
|
||||||
|
if idx is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
mappings[idx] = _replace_port_in_mapping(
|
||||||
|
mappings[idx],
|
||||||
|
port_from,
|
||||||
|
port_to,
|
||||||
|
host_to=host_to,
|
||||||
|
rewrite_host=rewrite_host,
|
||||||
|
)
|
||||||
|
|
||||||
|
original_text = await asyncio.to_thread(_read_compose_text, compose_path)
|
||||||
|
updated_text = _render_compose(docker_compose)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_write_compose, compose_path, updated_text)
|
||||||
|
result = await run_docker_command(
|
||||||
|
compose_path,
|
||||||
|
["up", "-d", service_name],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_write_compose, compose_path, original_text)
|
||||||
|
except OSError as restore_error:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to restore original compose file: {compose_path}"
|
||||||
|
) from restore_error
|
||||||
|
return False
|
||||||
|
|
||||||
|
if result is None or result.get("status") != "success":
|
||||||
|
await asyncio.to_thread(_write_compose, compose_path, original_text)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class DockerPortBinding(TypedDict):
|
||||||
|
published: int
|
||||||
|
host: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class DockerContainer(TypedDict):
|
||||||
|
name: str
|
||||||
|
ports: list[int]
|
||||||
|
bindings: list[DockerPortBinding]
|
||||||
|
folder: Path
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import socket
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def get_random_port() -> int:
|
||||||
|
# Binding to port 0 asks the OS for an available ephemeral port.
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("", 0))
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
port = s.getsockname()[1]
|
||||||
|
return port
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Could not find an available port: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service_binding(
|
||||||
|
mapping: str | int | Mapping[str, object],
|
||||||
|
) -> tuple[int | None, str | None]:
|
||||||
|
if isinstance(mapping, int):
|
||||||
|
return mapping, None
|
||||||
|
|
||||||
|
if isinstance(mapping, Mapping):
|
||||||
|
published = mapping.get("published")
|
||||||
|
if published is None:
|
||||||
|
return None, None
|
||||||
|
if not isinstance(published, (str, int)):
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
port = int(published)
|
||||||
|
except ValueError:
|
||||||
|
return None, None
|
||||||
|
host = mapping.get("host_ip")
|
||||||
|
return port, str(host) if host not in {None, ""} else None
|
||||||
|
|
||||||
|
parts = str(mapping).split(":")
|
||||||
|
match parts:
|
||||||
|
case [""]:
|
||||||
|
return None, None
|
||||||
|
case [port] | [port, _]:
|
||||||
|
try:
|
||||||
|
return int(port), None
|
||||||
|
except ValueError:
|
||||||
|
return None, None
|
||||||
|
case [host, port, _]:
|
||||||
|
try:
|
||||||
|
return int(port), host or None
|
||||||
|
except ValueError:
|
||||||
|
return None, None
|
||||||
|
case _:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None:
|
||||||
|
port, _host = _get_service_binding(mapping)
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_port_in_mapping(
|
||||||
|
mapping: str | int | Mapping[str, object],
|
||||||
|
port_from: int,
|
||||||
|
port_to: int,
|
||||||
|
host_to: str | None = None,
|
||||||
|
rewrite_host: bool = False,
|
||||||
|
) -> str | dict[str, object]:
|
||||||
|
if isinstance(mapping, Mapping):
|
||||||
|
updated = dict(mapping)
|
||||||
|
if _get_service_port(updated) == port_from:
|
||||||
|
updated["published"] = str(port_to)
|
||||||
|
if rewrite_host:
|
||||||
|
if host_to is None:
|
||||||
|
updated.pop("host_ip", None)
|
||||||
|
else:
|
||||||
|
updated["host_ip"] = host_to
|
||||||
|
return updated
|
||||||
|
|
||||||
|
parts = str(mapping).split(":")
|
||||||
|
match parts:
|
||||||
|
case [_]:
|
||||||
|
return str(port_to)
|
||||||
|
case [_, container_port]:
|
||||||
|
if rewrite_host and host_to:
|
||||||
|
return f"{host_to}:{port_to}:{container_port}"
|
||||||
|
return f"{port_to}:{container_port}"
|
||||||
|
case [ip, _, container_port]:
|
||||||
|
if rewrite_host:
|
||||||
|
if host_to:
|
||||||
|
return f"{host_to}:{port_to}:{container_port}"
|
||||||
|
return f"{port_to}:{container_port}"
|
||||||
|
return f"{ip}:{port_to}:{container_port}"
|
||||||
|
case _:
|
||||||
|
return str(port_to)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_port_index(
|
||||||
|
mappings: list[str | int | Mapping[str, object]],
|
||||||
|
port: int,
|
||||||
|
) -> int | None:
|
||||||
|
for i, mapping in enumerate(mappings):
|
||||||
|
if _get_service_port(mapping) == port:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.api.auth.router import router as auth_router
|
||||||
|
from app.api.editor.router import router as editor_router
|
||||||
|
from app.api.proxy.router import router as proxy_router
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.mongodb import close_mongodb, init_mongodb
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.integrations.docker import get_all_services_in_dir
|
||||||
|
from app.integrations.docker.discovery import service_scan_root
|
||||||
|
from app.repositories.auth import ensure_auth_indexes, ensure_single_account
|
||||||
|
from app.repositories.health import ensure_health_indexes
|
||||||
|
from app.repositories.proxy import ensure_proxy_indexes
|
||||||
|
from app.repositories.templates import ensure_template_indexes
|
||||||
|
from app.services.mitmproxy.health_monitor import health_monitor
|
||||||
|
from app.services.mitmproxy.recovery import reconcile_runtime
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
await init_mongodb()
|
||||||
|
await ensure_auth_indexes()
|
||||||
|
await ensure_single_account(
|
||||||
|
username=settings.AUTH_USERNAME,
|
||||||
|
password_hash=hash_password(settings.AUTH_PASSWORD),
|
||||||
|
)
|
||||||
|
await ensure_proxy_indexes()
|
||||||
|
await ensure_template_indexes()
|
||||||
|
await ensure_health_indexes()
|
||||||
|
recovered = await reconcile_runtime()
|
||||||
|
if recovered:
|
||||||
|
recovered_count = len(recovered)
|
||||||
|
error_count = sum(
|
||||||
|
1
|
||||||
|
for _proxy_port, values in recovered
|
||||||
|
if values.get("status") == "error"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[thePlug] Reconciled {recovered_count} persisted proxy runtimes"
|
||||||
|
f" ({error_count} with errors).",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
scan_root = service_scan_root()
|
||||||
|
services = get_all_services_in_dir(scan_root) or []
|
||||||
|
if not services:
|
||||||
|
warning = (
|
||||||
|
"\n" + "!" * 60 + "\n"
|
||||||
|
" WARNING: No services found in scan root!\n"
|
||||||
|
f" Scanned: {scan_root}\n" + "!" * 60 + "\n"
|
||||||
|
)
|
||||||
|
print(warning, file=sys.stderr)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await health_monitor.start()
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await health_monitor.stop()
|
||||||
|
await close_mongodb()
|
||||||
|
|
||||||
|
|
||||||
|
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="thePlugAPI",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/docs" if DEBUG else None,
|
||||||
|
redoc_url="/redoc" if DEBUG else None,
|
||||||
|
openapi_url="/openapi.json" if DEBUG else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(editor_router)
|
||||||
|
app.include_router(proxy_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {"Hello": "World!"}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheckEntry(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
target: str = Field(..., description="Logical target name")
|
||||||
|
host: str = Field(..., description="Resolved host checked at runtime")
|
||||||
|
port: int = Field(..., description="Resolved port checked at runtime")
|
||||||
|
protocol: str = Field(..., description="Protocol used for the check")
|
||||||
|
healthy: bool = Field(..., description="Whether the check succeeded")
|
||||||
|
error: str | None = Field(default=None, description="Optional failure reason")
|
||||||
|
duration_ms: float | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Measured response latency in milliseconds when benchmarking "
|
||||||
|
"is enabled"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
details: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class HealthSnapshot(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
proxy_port: int = Field(..., description="Proxy port this snapshot belongs to")
|
||||||
|
timestamp: datetime = Field(..., description="When the snapshot was produced")
|
||||||
|
enabled: bool = Field(..., description="Whether healthchecks are enabled")
|
||||||
|
overall: str = Field(..., description="Overall health status")
|
||||||
|
checks: list[HealthCheckEntry] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class HealthHistoryResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
proxy_port: int
|
||||||
|
latest: HealthSnapshot | None = None
|
||||||
|
history: list[HealthSnapshot] = Field(default_factory=list)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.service import ServiceEndpointDocument
|
||||||
|
|
||||||
|
|
||||||
|
class HealthCheckDocument(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
enabled: bool = Field(default=True, description="Enable active health checks")
|
||||||
|
protocol: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Override protocol used by the health check",
|
||||||
|
)
|
||||||
|
path: str = Field(default="/", description="HTTP path to check")
|
||||||
|
method: str = Field(default="GET", description="HTTP method to use")
|
||||||
|
expected_status: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Expected HTTP status code",
|
||||||
|
)
|
||||||
|
udp_payload: str = Field(default="", description="UDP payload to send")
|
||||||
|
expect_response: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Wait for a response when the protocol supports it",
|
||||||
|
)
|
||||||
|
verify_tls: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Verify TLS certificates during health checks",
|
||||||
|
)
|
||||||
|
benchmark_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Measure response latency during health checks when supported",
|
||||||
|
)
|
||||||
|
timeout_seconds: float = Field(
|
||||||
|
default=2.0,
|
||||||
|
gt=0,
|
||||||
|
le=30,
|
||||||
|
description="Per-target health check timeout",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyDocument(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
proxy_port: int = Field(..., description="Port exposed by the proxy")
|
||||||
|
name: str = Field(..., description="Human readable proxy name")
|
||||||
|
code: str = Field(..., description="Proxy source or identifier")
|
||||||
|
mode: str = Field(default="reverse", description="mitmproxy proxy mode")
|
||||||
|
mode_spec: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
|
||||||
|
)
|
||||||
|
protocol: str = Field(default="http", description="mitmproxy reverse protocol")
|
||||||
|
listen_host: str = Field(default="0.0.0.0", description="Proxy listen host")
|
||||||
|
mitm_options: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="mitmdump options passed through --set",
|
||||||
|
)
|
||||||
|
healthcheck: HealthCheckDocument = Field(default_factory=HealthCheckDocument)
|
||||||
|
status: str = Field(default="stopped", description="Runtime proxy status")
|
||||||
|
pid: int | None = Field(default=None, description="mitmdump process id")
|
||||||
|
script_path: str | None = Field(default=None, description="Generated addon path")
|
||||||
|
config_path: str | None = Field(default=None, description="Generated config path")
|
||||||
|
log_path: str | None = Field(default=None, description="mitmdump log path")
|
||||||
|
last_error: str | None = Field(default=None, description="Last runtime error")
|
||||||
|
service: ServiceEndpointDocument = Field(
|
||||||
|
...,
|
||||||
|
description="Associated upstream service",
|
||||||
|
)
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Creation timestamp in UTC",
|
||||||
|
)
|
||||||
|
updated_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Last update timestamp in UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyCreateInput(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
proxy_port: int = Field(..., description="Port exposed by the proxy")
|
||||||
|
name: str = Field(..., description="Human readable proxy name")
|
||||||
|
code: str = Field(..., description="Proxy source or identifier")
|
||||||
|
mode: str = Field(default="reverse", description="mitmproxy proxy mode")
|
||||||
|
mode_spec: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
|
||||||
|
)
|
||||||
|
protocol: str = Field(default="http", description="mitmproxy reverse protocol")
|
||||||
|
listen_host: str = Field(default="0.0.0.0", description="Proxy listen host")
|
||||||
|
mitm_options: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
healthcheck: HealthCheckDocument = Field(default_factory=HealthCheckDocument)
|
||||||
|
service: ServiceEndpointDocument = Field(
|
||||||
|
...,
|
||||||
|
description="Associated upstream service",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyUpdateInput(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, description="Human readable proxy name")
|
||||||
|
code: str | None = Field(default=None, description="Proxy source or identifier")
|
||||||
|
mode: str | None = Field(default=None, description="mitmproxy proxy mode")
|
||||||
|
mode_spec: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
|
||||||
|
)
|
||||||
|
protocol: str | None = Field(default=None, description="mitmproxy reverse protocol")
|
||||||
|
listen_host: str | None = Field(default=None, description="Proxy listen host")
|
||||||
|
mitm_options: dict[str, Any] | None = Field(default=None)
|
||||||
|
healthcheck: HealthCheckDocument | None = Field(default=None)
|
||||||
|
service: ServiceEndpointDocument | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Associated upstream service",
|
||||||
|
)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceEndpointDocument(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
host: str = Field(default="127.0.0.1", description="Upstream service host")
|
||||||
|
port: int = Field(..., description="Original upstream service port")
|
||||||
|
bind_host: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Original bind host exposed by Docker Compose",
|
||||||
|
)
|
||||||
|
name: str | None = Field(default=None, description="Docker Compose service name")
|
||||||
|
folder: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Docker Compose project folder",
|
||||||
|
)
|
||||||
|
upstream_port: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Current port used by mitmdump as upstream",
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyTemplateDocument(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
id: str = Field(..., description="Stable template identifier")
|
||||||
|
name: str = Field(..., description="Display name")
|
||||||
|
description: str = Field(default="", description="Short explanation")
|
||||||
|
category: str = Field(default="custom", description="Template category")
|
||||||
|
protocol: str = Field(default="custom", description="Primary proxy protocol")
|
||||||
|
family: str = Field(default="custom", description="Template family")
|
||||||
|
tags: list[str] = Field(default_factory=list, description="Filter tags")
|
||||||
|
action: str = Field(default="custom", description="Primary template action")
|
||||||
|
summary: str = Field(default="", description="Short operational summary")
|
||||||
|
behavior: str = Field(default="", description="Runtime behavior description")
|
||||||
|
code: str = Field(..., description="Addon source code")
|
||||||
|
source_url: str | None = Field(default=None, description="Reference URL")
|
||||||
|
built_in: bool = Field(default=False, description="Provided by thePlug")
|
||||||
|
origin: str = Field(default="custom", description="Template source")
|
||||||
|
created_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):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
id: str = Field(..., description="Stable template identifier")
|
||||||
|
name: str = Field(..., description="Display name")
|
||||||
|
description: str = Field(default="", description="Short explanation")
|
||||||
|
category: str = Field(default="custom", description="Template category")
|
||||||
|
protocol: str = Field(default="custom", description="Primary proxy protocol")
|
||||||
|
family: str = Field(default="custom", description="Template family")
|
||||||
|
tags: list[str] = Field(default_factory=list, description="Filter tags")
|
||||||
|
action: str = Field(default="custom", description="Primary template action")
|
||||||
|
summary: str = Field(default="", description="Short operational summary")
|
||||||
|
behavior: str = Field(default="", description="Runtime behavior description")
|
||||||
|
code: str = Field(..., description="Addon source code")
|
||||||
|
source_url: str | None = Field(default=None, description="Reference URL")
|
||||||
@@ -4,8 +4,8 @@ from typing import Any
|
|||||||
|
|
||||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||||
|
|
||||||
from core.mongodb import get_database
|
from app.core.mongodb import get_database
|
||||||
from models.auth import AuthUserDocument
|
from app.models.auth import AuthUserDocument
|
||||||
|
|
||||||
USERS_COLLECTION_NAME = "auth_users"
|
USERS_COLLECTION_NAME = "auth_users"
|
||||||
REVOKED_COLLECTION_NAME = "revoked_tokens"
|
REVOKED_COLLECTION_NAME = "revoked_tokens"
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.mongodb import get_database
|
||||||
|
from app.models.health import HealthSnapshot
|
||||||
|
|
||||||
|
COLLECTION_NAME = "health_history"
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
async def collection() -> AsyncIOMotorCollection[dict[str, Any]]:
|
||||||
|
database = await get_database()
|
||||||
|
return database[COLLECTION_NAME]
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_health_indexes() -> None:
|
||||||
|
samples = await collection()
|
||||||
|
await samples.create_index(
|
||||||
|
[("proxy_port", 1), ("timestamp", -1)],
|
||||||
|
name="ix_health_proxy_port_timestamp",
|
||||||
|
)
|
||||||
|
await samples.create_index(
|
||||||
|
"expires_at",
|
||||||
|
expireAfterSeconds=0,
|
||||||
|
name="ttl_health_history",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def deserialize_health_sample(raw: dict[str, Any] | None) -> HealthSnapshot | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
document = dict(raw)
|
||||||
|
document.pop("_id", None)
|
||||||
|
document.pop("expires_at", None)
|
||||||
|
return HealthSnapshot.model_validate(document)
|
||||||
|
|
||||||
|
|
||||||
|
async def record_health_sample(snapshot: HealthSnapshot) -> HealthSnapshot:
|
||||||
|
samples = await collection()
|
||||||
|
document = snapshot.model_dump(mode="python")
|
||||||
|
document["expires_at"] = document["timestamp"] + timedelta(
|
||||||
|
seconds=settings.HEALTH_HISTORY_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
await samples.insert_one(document)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
async def get_latest_health_sample(proxy_port: int) -> HealthSnapshot | None:
|
||||||
|
samples = await collection()
|
||||||
|
raw = await samples.find_one(
|
||||||
|
{"proxy_port": proxy_port},
|
||||||
|
sort=[("timestamp", -1)],
|
||||||
|
)
|
||||||
|
return deserialize_health_sample(raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_health_history(
|
||||||
|
proxy_port: int,
|
||||||
|
limit: int = 40,
|
||||||
|
) -> list[HealthSnapshot]:
|
||||||
|
samples = await collection()
|
||||||
|
cursor = samples.find({"proxy_port": proxy_port}).sort("timestamp", -1).limit(limit)
|
||||||
|
history: list[HealthSnapshot] = []
|
||||||
|
async for raw in cursor:
|
||||||
|
snapshot = deserialize_health_sample(raw)
|
||||||
|
if snapshot is not None:
|
||||||
|
history.append(snapshot)
|
||||||
|
history.reverse()
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_health_history(proxy_port: int) -> None:
|
||||||
|
samples = await collection()
|
||||||
|
await samples.delete_many({"proxy_port": proxy_port})
|
||||||
@@ -4,11 +4,9 @@ from typing import Any
|
|||||||
|
|
||||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||||
from pymongo import ReturnDocument
|
from pymongo import ReturnDocument
|
||||||
from services.proxy.models import ServiceConfig
|
|
||||||
|
|
||||||
from core.mongodb import get_database
|
from app.core.mongodb import get_database
|
||||||
from models.proxy import ProxyDocument, ProxyUpdateInput
|
from app.models.proxy import ProxyCreateInput, ProxyDocument, ProxyUpdateInput
|
||||||
from models.service import ServiceEndpointDocument
|
|
||||||
|
|
||||||
COLLECTION_NAME = "proxies"
|
COLLECTION_NAME = "proxies"
|
||||||
|
|
||||||
@@ -35,21 +33,19 @@ async def ensure_proxy_indexes() -> None:
|
|||||||
await collection.create_index("proxy_port", unique=True, name="uq_proxy_port")
|
await collection.create_index("proxy_port", unique=True, name="uq_proxy_port")
|
||||||
|
|
||||||
|
|
||||||
async def create_proxy(
|
async def create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
|
||||||
proxy_port: int,
|
|
||||||
name: str,
|
|
||||||
service: ServiceConfig,
|
|
||||||
code: str,
|
|
||||||
) -> ProxyDocument:
|
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
proxy = ProxyDocument(
|
proxy = ProxyDocument(
|
||||||
proxy_port=proxy_port,
|
proxy_port=payload.proxy_port,
|
||||||
name=name,
|
name=payload.name,
|
||||||
code=code,
|
code=payload.code,
|
||||||
service=ServiceEndpointDocument(
|
mode=payload.mode,
|
||||||
address=service["address"],
|
mode_spec=payload.mode_spec,
|
||||||
port=service["port"],
|
protocol=payload.protocol,
|
||||||
),
|
listen_host=payload.listen_host,
|
||||||
|
mitm_options=payload.mitm_options,
|
||||||
|
healthcheck=payload.healthcheck,
|
||||||
|
service=payload.service,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
@@ -96,6 +92,22 @@ async def update_proxy(
|
|||||||
return _deserialize_proxy(raw)
|
return _deserialize_proxy(raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_proxy_runtime(
|
||||||
|
proxy_port: int,
|
||||||
|
values: dict[str, object],
|
||||||
|
) -> ProxyDocument | None:
|
||||||
|
payload = dict(values)
|
||||||
|
payload["updated_at"] = _utcnow()
|
||||||
|
|
||||||
|
collection = await _collection()
|
||||||
|
raw = await collection.find_one_and_update(
|
||||||
|
{"proxy_port": proxy_port},
|
||||||
|
{"$set": payload},
|
||||||
|
return_document=ReturnDocument.AFTER,
|
||||||
|
)
|
||||||
|
return _deserialize_proxy(raw)
|
||||||
|
|
||||||
|
|
||||||
async def delete_proxy(proxy_port: int) -> bool:
|
async def delete_proxy(proxy_port: int) -> bool:
|
||||||
collection = await _collection()
|
collection = await _collection()
|
||||||
result = await collection.delete_one({"proxy_port": proxy_port})
|
result = await collection.delete_one({"proxy_port": proxy_port})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||||
|
|
||||||
|
from app.core.mongodb import get_database
|
||||||
|
from app.models.template import ProxyTemplateCreateInput, ProxyTemplateDocument
|
||||||
|
|
||||||
|
COLLECTION_NAME = "addon_templates"
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
async def collection() -> AsyncIOMotorCollection[dict[str, Any]]:
|
||||||
|
database = await get_database()
|
||||||
|
return database[COLLECTION_NAME]
|
||||||
|
|
||||||
|
|
||||||
|
def deserialize(raw: Mapping[str, Any] | None) -> ProxyTemplateDocument | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
document = dict(raw)
|
||||||
|
document.pop("_id", None)
|
||||||
|
return ProxyTemplateDocument.model_validate(document)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_template_indexes() -> None:
|
||||||
|
templates = await collection()
|
||||||
|
await templates.create_index("id", unique=True, name="uq_addon_template_id")
|
||||||
|
|
||||||
|
|
||||||
|
async def list_custom_templates() -> list[ProxyTemplateDocument]:
|
||||||
|
templates = await collection()
|
||||||
|
cursor = templates.find({}).sort("name", 1)
|
||||||
|
|
||||||
|
result: list[ProxyTemplateDocument] = []
|
||||||
|
async for raw in cursor:
|
||||||
|
template = deserialize(raw)
|
||||||
|
if template is not None:
|
||||||
|
result.append(template)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def create_custom_template(
|
||||||
|
payload: ProxyTemplateCreateInput,
|
||||||
|
) -> ProxyTemplateDocument:
|
||||||
|
now = utcnow()
|
||||||
|
template = ProxyTemplateDocument(
|
||||||
|
id=payload.id,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
category=payload.category,
|
||||||
|
protocol=payload.protocol,
|
||||||
|
family=payload.family,
|
||||||
|
tags=payload.tags,
|
||||||
|
action=payload.action,
|
||||||
|
summary=payload.summary,
|
||||||
|
behavior=payload.behavior,
|
||||||
|
code=payload.code,
|
||||||
|
source_url=payload.source_url,
|
||||||
|
built_in=False,
|
||||||
|
origin="custom",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = await collection()
|
||||||
|
await templates.insert_one(template.model_dump(exclude_none=True))
|
||||||
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_custom_template(template_id: str) -> bool:
|
||||||
|
templates = await collection()
|
||||||
|
result = await templates.delete_one({"id": template_id})
|
||||||
|
return result.deleted_count > 0
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WORKSPACE_ROOT = Path(tempfile.gettempdir()) / "theplug-lsp"
|
||||||
|
CONFIG_FILE = "pyrightconfig.json"
|
||||||
|
ENTRY_FILE = "proxy_addon.py"
|
||||||
|
|
||||||
|
STUB_FILES = {
|
||||||
|
"typings/mitmproxy/__init__.pyi": """from . import (
|
||||||
|
command,
|
||||||
|
ctx,
|
||||||
|
dns,
|
||||||
|
http,
|
||||||
|
tcp,
|
||||||
|
tls,
|
||||||
|
udp,
|
||||||
|
websocket,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"command",
|
||||||
|
"ctx",
|
||||||
|
"dns",
|
||||||
|
"http",
|
||||||
|
"tcp",
|
||||||
|
"tls",
|
||||||
|
"udp",
|
||||||
|
"websocket",
|
||||||
|
]
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/ctx.pyi": """class _Logger:
|
||||||
|
def debug(self, message: object) -> None: ...
|
||||||
|
def info(self, message: object) -> None: ...
|
||||||
|
def warn(self, message: object) -> None: ...
|
||||||
|
def error(self, message: object) -> None: ...
|
||||||
|
def alert(self, message: object) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class _Options:
|
||||||
|
def __getattr__(self, name: str) -> object: ...
|
||||||
|
|
||||||
|
|
||||||
|
log: _Logger
|
||||||
|
options: _Options
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/http.pyi": """from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class Headers(dict[str, str]):
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class Request:
|
||||||
|
host: str
|
||||||
|
path: str
|
||||||
|
headers: Headers
|
||||||
|
query: dict[str, str]
|
||||||
|
content: bytes
|
||||||
|
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
headers: Headers
|
||||||
|
status_code: int
|
||||||
|
content: bytes
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def make(
|
||||||
|
cls,
|
||||||
|
status_code: int,
|
||||||
|
content: bytes | str = b"",
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> "Response": ...
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketMessage:
|
||||||
|
content: bytes
|
||||||
|
from_client: bool
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketData:
|
||||||
|
messages: list[WebSocketMessage]
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPFlow:
|
||||||
|
request: Request
|
||||||
|
response: Response | None
|
||||||
|
websocket: WebSocketData | None
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/tcp.pyi": """class TCPMessage:
|
||||||
|
content: bytes
|
||||||
|
from_client: bool
|
||||||
|
|
||||||
|
|
||||||
|
class TCPFlow:
|
||||||
|
messages: list[TCPMessage]
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/dns.pyi": """class Question:
|
||||||
|
name: str
|
||||||
|
type: int
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceRecord:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
type: int,
|
||||||
|
class_: int,
|
||||||
|
ttl: int,
|
||||||
|
data: bytes,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Message:
|
||||||
|
question: Question | None
|
||||||
|
|
||||||
|
def succeed(self, records: list[ResourceRecord]) -> "Message": ...
|
||||||
|
|
||||||
|
|
||||||
|
class _Types:
|
||||||
|
A: int
|
||||||
|
|
||||||
|
|
||||||
|
class _Classes:
|
||||||
|
IN: int
|
||||||
|
|
||||||
|
|
||||||
|
types: _Types
|
||||||
|
classes: _Classes
|
||||||
|
|
||||||
|
|
||||||
|
class DNSFlow:
|
||||||
|
request: Message
|
||||||
|
response: Message | None
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/command.pyi": """from collections.abc import Callable
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
F = TypeVar("F", bound=Callable[..., object])
|
||||||
|
|
||||||
|
|
||||||
|
def command(name: str) -> Callable[[F], F]: ...
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/websocket.pyi": """class WebSocketMessage:
|
||||||
|
content: bytes
|
||||||
|
from_client: bool
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/tls.pyi": """class ClientHelloData: ...
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/udp.pyi": """class UDPMessage:
|
||||||
|
content: bytes
|
||||||
|
from_client: bool
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LspFrameBuffer:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.buffer = bytearray()
|
||||||
|
|
||||||
|
def feed(self, chunk: bytes) -> list[str]:
|
||||||
|
self.buffer.extend(chunk)
|
||||||
|
messages: list[str] = []
|
||||||
|
while True:
|
||||||
|
header_end = self.buffer.find(b"\r\n\r\n")
|
||||||
|
if header_end < 0:
|
||||||
|
return messages
|
||||||
|
|
||||||
|
header = self.buffer[:header_end].decode("ascii", errors="ignore")
|
||||||
|
size = content_length(header)
|
||||||
|
if size is None:
|
||||||
|
del self.buffer[: header_end + 4]
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame_end = header_end + 4 + size
|
||||||
|
if len(self.buffer) < frame_end:
|
||||||
|
return messages
|
||||||
|
|
||||||
|
body = bytes(self.buffer[header_end + 4 : frame_end])
|
||||||
|
del self.buffer[:frame_end]
|
||||||
|
messages.append(body.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PythonEditorSession:
|
||||||
|
root_path: Path
|
||||||
|
file_path: Path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def root_uri(self) -> str:
|
||||||
|
return self.root_path.as_uri()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_uri(self) -> str:
|
||||||
|
return self.file_path.as_uri()
|
||||||
|
|
||||||
|
|
||||||
|
def content_length(header: str) -> int | None:
|
||||||
|
for line in header.split("\r\n"):
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
name, value = line.split(":", 1)
|
||||||
|
if name.lower() == "content-length":
|
||||||
|
return int(value.strip())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def encode_message(message: str) -> bytes:
|
||||||
|
payload = message.encode("utf-8")
|
||||||
|
header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")
|
||||||
|
return header + payload
|
||||||
|
|
||||||
|
|
||||||
|
def lsp_binary() -> str | None:
|
||||||
|
return shutil.which("basedpyright-langserver")
|
||||||
|
|
||||||
|
|
||||||
|
def get_session() -> PythonEditorSession:
|
||||||
|
root_path = WORKSPACE_ROOT
|
||||||
|
file_path = root_path / ENTRY_FILE
|
||||||
|
|
||||||
|
root_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path.touch(exist_ok=True)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"include": [ENTRY_FILE],
|
||||||
|
"stubPath": "typings",
|
||||||
|
"typeCheckingMode": "basic",
|
||||||
|
"pythonVersion": "3.14",
|
||||||
|
"reportMissingTypeStubs": False,
|
||||||
|
"reportMissingImports": "warning",
|
||||||
|
"useLibraryCodeForTypes": True,
|
||||||
|
}
|
||||||
|
(root_path / CONFIG_FILE).write_text(
|
||||||
|
json.dumps(config, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
for relative_path, content in STUB_FILES.items():
|
||||||
|
path = root_path / relative_path
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
return PythonEditorSession(root_path=root_path, file_path=file_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def open_process(session: PythonEditorSession) -> asyncio.subprocess.Process:
|
||||||
|
binary = lsp_binary()
|
||||||
|
if binary is None:
|
||||||
|
raise RuntimeError("basedpyright-langserver is not installed")
|
||||||
|
|
||||||
|
return await asyncio.create_subprocess_exec(
|
||||||
|
binary,
|
||||||
|
"--stdio",
|
||||||
|
cwd=session.root_path,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def forward_client(websocket, writer: asyncio.StreamWriter) -> None:
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_text()
|
||||||
|
writer.write(encode_message(message))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
|
||||||
|
async def forward_server(reader: asyncio.StreamReader, websocket) -> None:
|
||||||
|
frames = LspFrameBuffer()
|
||||||
|
while True:
|
||||||
|
chunk = await reader.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
for message in frames.feed(chunk):
|
||||||
|
await websocket.send_text(message)
|
||||||
|
|
||||||
|
|
||||||
|
async def drain_errors(reader: asyncio.StreamReader) -> None:
|
||||||
|
while True:
|
||||||
|
chunk = await reader.read(4096)
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def bridge(websocket, session: PythonEditorSession) -> None:
|
||||||
|
process = await open_process(session)
|
||||||
|
stdin = process.stdin
|
||||||
|
stdout = process.stdout
|
||||||
|
stderr = process.stderr
|
||||||
|
|
||||||
|
if stdin is None or stdout is None or stderr is None:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
raise RuntimeError("basedpyright-langserver did not expose stdio pipes")
|
||||||
|
|
||||||
|
send_task = asyncio.create_task(forward_client(websocket, stdin))
|
||||||
|
receive_task = asyncio.create_task(forward_server(stdout, websocket))
|
||||||
|
error_task = asyncio.create_task(drain_errors(stderr))
|
||||||
|
|
||||||
|
try:
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
{send_task, receive_task, error_task},
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
for task in done:
|
||||||
|
task.result()
|
||||||
|
finally:
|
||||||
|
if process.returncode is None:
|
||||||
|
stdin.close()
|
||||||
|
await stdin.wait_closed()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=1)
|
||||||
|
except TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import asyncio
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app.models.health import HealthCheckEntry, HealthSnapshot
|
||||||
|
from app.models.proxy import HealthCheckDocument, ProxyDocument
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Target:
|
||||||
|
name: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
protocol: str
|
||||||
|
|
||||||
|
|
||||||
|
async def check_proxy(proxy: ProxyDocument) -> HealthSnapshot:
|
||||||
|
timestamp = datetime.now(UTC)
|
||||||
|
if not proxy.healthcheck.enabled:
|
||||||
|
return HealthSnapshot(
|
||||||
|
proxy_port=proxy.proxy_port,
|
||||||
|
timestamp=timestamp,
|
||||||
|
enabled=False,
|
||||||
|
overall="disabled",
|
||||||
|
checks=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
upstream_port = proxy.service.upstream_port or proxy.service.port
|
||||||
|
checks = list(
|
||||||
|
await asyncio.gather(
|
||||||
|
check_target(
|
||||||
|
Target(
|
||||||
|
name="upstream",
|
||||||
|
host=proxy.service.host,
|
||||||
|
port=upstream_port,
|
||||||
|
protocol=health_protocol(proxy),
|
||||||
|
),
|
||||||
|
proxy.healthcheck,
|
||||||
|
),
|
||||||
|
check_target(
|
||||||
|
Target(
|
||||||
|
name="proxy",
|
||||||
|
host=listen_host_for_check(proxy.listen_host),
|
||||||
|
port=proxy.proxy_port,
|
||||||
|
protocol=health_protocol(proxy),
|
||||||
|
),
|
||||||
|
proxy.healthcheck,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
overall = "healthy" if all(check.healthy for check in checks) else "unhealthy"
|
||||||
|
return HealthSnapshot(
|
||||||
|
proxy_port=proxy.proxy_port,
|
||||||
|
timestamp=timestamp,
|
||||||
|
enabled=True,
|
||||||
|
overall=overall,
|
||||||
|
checks=checks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def health_protocol(proxy: ProxyDocument) -> str:
|
||||||
|
return proxy.healthcheck.protocol or proxy.protocol
|
||||||
|
|
||||||
|
|
||||||
|
def listen_host_for_check(host: str) -> str:
|
||||||
|
if host in {"", "0.0.0.0", "::"}:
|
||||||
|
return "127.0.0.1"
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
async def check_target(
|
||||||
|
target: Target,
|
||||||
|
config: HealthCheckDocument,
|
||||||
|
) -> HealthCheckEntry:
|
||||||
|
try:
|
||||||
|
match target.protocol:
|
||||||
|
case "http":
|
||||||
|
return await check_http(target, config, use_tls=False)
|
||||||
|
case "https":
|
||||||
|
return await check_http(target, config, use_tls=True)
|
||||||
|
case "tcp":
|
||||||
|
return await check_tcp(target, config, use_tls=False)
|
||||||
|
case "tls":
|
||||||
|
return await check_tcp(target, config, use_tls=True)
|
||||||
|
case "udp" | "dtls" | "quic" | "dns" | "http3":
|
||||||
|
return await check_udp(target, config)
|
||||||
|
case _:
|
||||||
|
return result(target, False, f"Unsupported protocol: {target.protocol}")
|
||||||
|
except TimeoutError:
|
||||||
|
return result(target, False, "Timed out")
|
||||||
|
except OSError as error:
|
||||||
|
return result(target, False, str(error))
|
||||||
|
|
||||||
|
|
||||||
|
async def check_http(
|
||||||
|
target: Target,
|
||||||
|
config: HealthCheckDocument,
|
||||||
|
*,
|
||||||
|
use_tls: bool,
|
||||||
|
) -> HealthCheckEntry:
|
||||||
|
started = time.perf_counter()
|
||||||
|
reader, writer = await open_connection(target, config, use_tls=use_tls)
|
||||||
|
try:
|
||||||
|
path = config.path if config.path.startswith("/") else f"/{config.path}"
|
||||||
|
request = (
|
||||||
|
f"{config.method} {path} HTTP/1.1\r\n"
|
||||||
|
f"Host: {target.host}\r\n"
|
||||||
|
"Connection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
writer.write(request.encode("ascii"))
|
||||||
|
await writer.drain()
|
||||||
|
line = await asyncio.wait_for(reader.readline(), config.timeout_seconds)
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
parts = line.decode("iso-8859-1", errors="replace").split()
|
||||||
|
status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None
|
||||||
|
expected = config.expected_status
|
||||||
|
healthy = status == expected if expected is not None else status is not None
|
||||||
|
return result(
|
||||||
|
target,
|
||||||
|
healthy,
|
||||||
|
None,
|
||||||
|
{"status": status},
|
||||||
|
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_tcp(
|
||||||
|
target: Target,
|
||||||
|
config: HealthCheckDocument,
|
||||||
|
*,
|
||||||
|
use_tls: bool,
|
||||||
|
) -> HealthCheckEntry:
|
||||||
|
started = time.perf_counter()
|
||||||
|
_reader, writer = await open_connection(target, config, use_tls=use_tls)
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
return result(
|
||||||
|
target,
|
||||||
|
True,
|
||||||
|
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_udp(target: Target, config: HealthCheckDocument) -> HealthCheckEntry:
|
||||||
|
started = time.perf_counter()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
transport, protocol = await loop.create_datagram_endpoint(
|
||||||
|
lambda: DatagramProbe(config.expect_response),
|
||||||
|
remote_addr=(target.host, target.port),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = config.udp_payload.encode()
|
||||||
|
transport.sendto(payload)
|
||||||
|
if not config.expect_response:
|
||||||
|
return result(target, True, None, {"mode": "send-only"})
|
||||||
|
data = await asyncio.wait_for(protocol.response, config.timeout_seconds)
|
||||||
|
return result(
|
||||||
|
target,
|
||||||
|
True,
|
||||||
|
None,
|
||||||
|
{"bytes": len(data)},
|
||||||
|
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def open_connection(
|
||||||
|
target: Target,
|
||||||
|
config: HealthCheckDocument,
|
||||||
|
*,
|
||||||
|
use_tls: bool,
|
||||||
|
):
|
||||||
|
if use_tls and config.verify_tls:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
elif use_tls:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
else:
|
||||||
|
context = None
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(target.host, target.port, ssl=context),
|
||||||
|
config.timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatagramProbe(asyncio.DatagramProtocol):
|
||||||
|
def __init__(self, expect_response: bool):
|
||||||
|
self.response = asyncio.get_running_loop().create_future()
|
||||||
|
if not expect_response:
|
||||||
|
self.response.set_result(b"")
|
||||||
|
|
||||||
|
def datagram_received(self, data, addr):
|
||||||
|
if not self.response.done():
|
||||||
|
self.response.set_result(data)
|
||||||
|
|
||||||
|
def error_received(self, exc):
|
||||||
|
if not self.response.done():
|
||||||
|
self.response.set_exception(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def result(
|
||||||
|
target: Target,
|
||||||
|
healthy: bool,
|
||||||
|
error: str | None = None,
|
||||||
|
details: dict[str, object] | None = None,
|
||||||
|
benchmark_ms: float | None = None,
|
||||||
|
) -> HealthCheckEntry:
|
||||||
|
return HealthCheckEntry(
|
||||||
|
target=target.name,
|
||||||
|
host=target.host,
|
||||||
|
port=target.port,
|
||||||
|
protocol=target.protocol,
|
||||||
|
healthy=healthy,
|
||||||
|
error=error,
|
||||||
|
duration_ms=benchmark_ms,
|
||||||
|
details=details or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def elapsed_ms(started: float) -> float:
|
||||||
|
return round((time.perf_counter() - started) * 1000, 2)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.health import HealthSnapshot
|
||||||
|
from app.models.proxy import ProxyDocument
|
||||||
|
from app.repositories.health import record_health_sample
|
||||||
|
from app.repositories.proxy import list_proxies
|
||||||
|
from app.services.mitmproxy.health import check_proxy
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HealthMonitor:
|
||||||
|
def __init__(self):
|
||||||
|
self.task: asyncio.Task[None] | None = None
|
||||||
|
self.subscribers: dict[int, set[asyncio.Queue[HealthSnapshot]]] = {}
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self.task is None or self.task.done():
|
||||||
|
self.task = asyncio.create_task(self.run())
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self.task is None:
|
||||||
|
return
|
||||||
|
self.task.cancel()
|
||||||
|
await asyncio.gather(self.task, return_exceptions=True)
|
||||||
|
self.task = None
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
next_cycle = loop.time()
|
||||||
|
while True:
|
||||||
|
delay = next_cycle - loop.time()
|
||||||
|
if delay > 0:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
cycle_started = loop.time()
|
||||||
|
try:
|
||||||
|
await self.run_cycle()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Health monitor cycle failed")
|
||||||
|
next_cycle = loop.time() + min(
|
||||||
|
1.0,
|
||||||
|
float(settings.HEALTHCHECK_INTERVAL_SECONDS),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
next_cycle = cycle_started + settings.HEALTHCHECK_INTERVAL_SECONDS
|
||||||
|
|
||||||
|
async def run_cycle(self) -> None:
|
||||||
|
proxies = [
|
||||||
|
proxy
|
||||||
|
for proxy in await list_proxies()
|
||||||
|
if proxy.status == "running" and proxy.healthcheck.enabled
|
||||||
|
]
|
||||||
|
async with asyncio.TaskGroup() as group:
|
||||||
|
for proxy in proxies:
|
||||||
|
group.create_task(self.run_check(proxy))
|
||||||
|
|
||||||
|
async def run_check(self, proxy: ProxyDocument) -> None:
|
||||||
|
try:
|
||||||
|
snapshot = await check_proxy(proxy)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Health check failed for proxy %s",
|
||||||
|
proxy.proxy_port,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await record_health_sample(snapshot)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Health snapshot persistence failed for proxy %s",
|
||||||
|
proxy.proxy_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.publish(proxy.proxy_port, snapshot)
|
||||||
|
|
||||||
|
async def publish(self, proxy_port: int, snapshot: HealthSnapshot) -> None:
|
||||||
|
for queue in list(self.subscribers.get(proxy_port, set())):
|
||||||
|
try:
|
||||||
|
queue.put_nowait(snapshot)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
logger.debug(
|
||||||
|
"Dropped health update for slow subscriber on proxy %s",
|
||||||
|
proxy_port,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
def subscribe(self, proxy_port: int) -> asyncio.Queue[HealthSnapshot]:
|
||||||
|
queue: asyncio.Queue[HealthSnapshot] = asyncio.Queue(maxsize=8)
|
||||||
|
listeners = self.subscribers.setdefault(proxy_port, set())
|
||||||
|
listeners.add(queue)
|
||||||
|
return queue
|
||||||
|
|
||||||
|
def unsubscribe(
|
||||||
|
self,
|
||||||
|
proxy_port: int,
|
||||||
|
queue: asyncio.Queue[HealthSnapshot],
|
||||||
|
) -> None:
|
||||||
|
listeners = self.subscribers.get(proxy_port)
|
||||||
|
if listeners is None:
|
||||||
|
return
|
||||||
|
listeners.discard(queue)
|
||||||
|
if not listeners:
|
||||||
|
self.subscribers.pop(proxy_port, None)
|
||||||
|
|
||||||
|
|
||||||
|
health_monitor = HealthMonitor()
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.integrations.docker import change_port
|
||||||
|
from app.integrations.docker.ports import get_random_port
|
||||||
|
from app.models.proxy import ProxyDocument
|
||||||
|
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 (
|
||||||
|
ReservedProxyPortError,
|
||||||
|
ensure_proxy_port_not_reserved,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MitmdumpError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MitmdumpManager:
|
||||||
|
def __init__(self, work_dir: str = "/tmp/theplug", command: str = "mitmdump"):
|
||||||
|
self.work_dir = Path(work_dir)
|
||||||
|
self.command = command
|
||||||
|
self.processes: dict[int, asyncio.subprocess.Process] = {}
|
||||||
|
self.attached_pids: dict[int, int] = {}
|
||||||
|
self.locks: dict[int, asyncio.Lock] = {}
|
||||||
|
self.upstream_port_attempts = 8
|
||||||
|
|
||||||
|
async def start(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||||
|
async with self.lock_for(proxy.proxy_port):
|
||||||
|
return await self.start_unlocked(proxy)
|
||||||
|
|
||||||
|
async def stop(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||||
|
async with self.lock_for(proxy.proxy_port):
|
||||||
|
return await self.stop_unlocked(proxy)
|
||||||
|
|
||||||
|
def cleanup_runtime_artifacts(self, proxy: ProxyDocument) -> None:
|
||||||
|
script_path = (
|
||||||
|
Path(proxy.script_path)
|
||||||
|
if proxy.script_path
|
||||||
|
else self.script_path_for(proxy)
|
||||||
|
)
|
||||||
|
log_path = Path(proxy.log_path) if proxy.log_path else self.log_path_for(proxy)
|
||||||
|
config_path = (
|
||||||
|
Path(proxy.config_path)
|
||||||
|
if proxy.config_path
|
||||||
|
else self.config_path_for(proxy)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.remove_file(script_path)
|
||||||
|
self.remove_file(log_path)
|
||||||
|
self.remove_file(config_path)
|
||||||
|
self.remove_dir(self.conf_dir_for(proxy))
|
||||||
|
|
||||||
|
async def start_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
ensure_proxy_port_not_reserved(proxy.proxy_port)
|
||||||
|
except ReservedProxyPortError as error:
|
||||||
|
raise MitmdumpError(str(error)) from error
|
||||||
|
|
||||||
|
if await self.status(proxy) == "running":
|
||||||
|
raise MitmdumpError("Proxy is already running")
|
||||||
|
|
||||||
|
command_path = shutil.which(self.command)
|
||||||
|
if command_path is None:
|
||||||
|
raise MitmdumpError("mitmdump executable not found in PATH")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await validate_options(proxy.mitm_options)
|
||||||
|
except MitmOptionError as error:
|
||||||
|
raise MitmdumpError(str(error)) from error
|
||||||
|
upstream_port = await self.prepare_upstream(proxy)
|
||||||
|
script_path = await self.write_script(proxy)
|
||||||
|
config_path = await self.write_config(proxy)
|
||||||
|
log_path = await self.log_path(proxy)
|
||||||
|
command = self.build_command(
|
||||||
|
command_path,
|
||||||
|
proxy,
|
||||||
|
upstream_port,
|
||||||
|
script_path,
|
||||||
|
config_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = await self.spawn(command, log_path)
|
||||||
|
except Exception as error:
|
||||||
|
await self.restore_upstream(proxy, upstream_port)
|
||||||
|
raise MitmdumpError(str(error)) from error
|
||||||
|
|
||||||
|
failed = await self.exited_early(process)
|
||||||
|
if failed:
|
||||||
|
await self.restore_upstream(proxy, upstream_port)
|
||||||
|
reason = (
|
||||||
|
await self.read_log_tail(log_path)
|
||||||
|
or "mitmdump exited during startup"
|
||||||
|
)
|
||||||
|
raise MitmdumpError(reason)
|
||||||
|
|
||||||
|
self.processes[proxy.proxy_port] = process
|
||||||
|
self.attached_pids.pop(proxy.proxy_port, None)
|
||||||
|
return {
|
||||||
|
"status": "running",
|
||||||
|
"pid": process.pid,
|
||||||
|
"script_path": str(script_path),
|
||||||
|
"config_path": str(config_path),
|
||||||
|
"log_path": str(log_path),
|
||||||
|
"service.upstream_port": upstream_port,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def stop_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||||
|
process = self.processes.pop(proxy.proxy_port, None)
|
||||||
|
self.attached_pids.pop(proxy.proxy_port, None)
|
||||||
|
if process is not None and process.returncode is None:
|
||||||
|
await self.stop_process(process)
|
||||||
|
elif proxy.pid is not None:
|
||||||
|
await self.stop_pid(proxy.pid)
|
||||||
|
|
||||||
|
await self.restore_upstream(proxy, proxy.service.upstream_port)
|
||||||
|
return {
|
||||||
|
"status": "stopped",
|
||||||
|
"pid": None,
|
||||||
|
"service.upstream_port": None,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def status(self, proxy: ProxyDocument) -> str:
|
||||||
|
process = self.processes.get(proxy.proxy_port)
|
||||||
|
if process is not None:
|
||||||
|
if process.returncode is None:
|
||||||
|
return "running"
|
||||||
|
self.processes.pop(proxy.proxy_port, None)
|
||||||
|
self.attached_pids.pop(proxy.proxy_port, None)
|
||||||
|
return "stopped"
|
||||||
|
|
||||||
|
pid = self.attached_pids.get(proxy.proxy_port, proxy.pid)
|
||||||
|
if pid is None:
|
||||||
|
return "stopped" if proxy.status == "running" else proxy.status
|
||||||
|
|
||||||
|
if not self.is_process_alive(pid):
|
||||||
|
self.attached_pids.pop(proxy.proxy_port, None)
|
||||||
|
return "stopped"
|
||||||
|
if not self.matches_proxy_process(proxy, pid):
|
||||||
|
self.attached_pids.pop(proxy.proxy_port, None)
|
||||||
|
return "stopped"
|
||||||
|
return "running"
|
||||||
|
|
||||||
|
async def reconcile(self, proxy: ProxyDocument) -> dict[str, object] | None:
|
||||||
|
if not self.needs_recovery(proxy):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if proxy.pid is not None and self.is_process_alive(proxy.pid):
|
||||||
|
if self.matches_proxy_process(proxy, proxy.pid):
|
||||||
|
self.reattach(proxy)
|
||||||
|
return {
|
||||||
|
"status": "running",
|
||||||
|
"pid": proxy.pid,
|
||||||
|
"script_path": str(self.script_path_for(proxy)),
|
||||||
|
"config_path": str(self.config_path_for(proxy)),
|
||||||
|
"log_path": str(self.log_path_for(proxy)),
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.attached_pids.pop(proxy.proxy_port, None)
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"pid": None,
|
||||||
|
"last_error": (
|
||||||
|
f"Found a live pid {proxy.pid}, but its command does not match "
|
||||||
|
"the expected mitmdump runtime for this proxy."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.needs_upstream_restore(proxy):
|
||||||
|
restored = await self.restore_upstream(proxy, proxy.service.upstream_port)
|
||||||
|
if not restored:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"pid": None,
|
||||||
|
"last_error": (
|
||||||
|
"Proxy process is gone and the upstream service port "
|
||||||
|
"could not be restored automatically."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"status": "stopped",
|
||||||
|
"pid": None,
|
||||||
|
"service.upstream_port": None,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if proxy.status == "running" or proxy.pid is not None:
|
||||||
|
return {"status": "stopped", "pid": None, "last_error": None}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def prepare_upstream(self, proxy: ProxyDocument) -> int | None:
|
||||||
|
if proxy.mode_spec is not None or proxy.mode != "reverse":
|
||||||
|
return None
|
||||||
|
if proxy.service.upstream_port is not None:
|
||||||
|
return proxy.service.upstream_port
|
||||||
|
|
||||||
|
if proxy.service.name is None or proxy.service.folder is None:
|
||||||
|
return proxy.service.port
|
||||||
|
|
||||||
|
attempted_ports: set[int] = {proxy.proxy_port, proxy.service.port}
|
||||||
|
for _attempt in range(self.upstream_port_attempts):
|
||||||
|
upstream_port = self.pick_upstream_port(attempted_ports)
|
||||||
|
attempted_ports.add(upstream_port)
|
||||||
|
changed = await change_port(
|
||||||
|
proxy.service.name,
|
||||||
|
Path(proxy.service.folder),
|
||||||
|
proxy.service.port,
|
||||||
|
upstream_port,
|
||||||
|
host_to="127.0.0.1",
|
||||||
|
rewrite_host=True,
|
||||||
|
)
|
||||||
|
if changed:
|
||||||
|
return upstream_port
|
||||||
|
|
||||||
|
raise MitmdumpError("Unable to move Docker service port after retries")
|
||||||
|
|
||||||
|
async def restore_upstream(
|
||||||
|
self,
|
||||||
|
proxy: ProxyDocument,
|
||||||
|
upstream_port: int | None,
|
||||||
|
) -> bool:
|
||||||
|
if upstream_port is None:
|
||||||
|
return True
|
||||||
|
if proxy.service.name is None or proxy.service.folder is None:
|
||||||
|
return True
|
||||||
|
if upstream_port == proxy.service.port:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return await change_port(
|
||||||
|
proxy.service.name,
|
||||||
|
Path(proxy.service.folder),
|
||||||
|
upstream_port,
|
||||||
|
proxy.service.port,
|
||||||
|
host_to=proxy.service.bind_host,
|
||||||
|
rewrite_host=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_command(
|
||||||
|
self,
|
||||||
|
command_path: str,
|
||||||
|
proxy: ProxyDocument,
|
||||||
|
upstream_port: int | None,
|
||||||
|
script_path: Path,
|
||||||
|
config_path: Path,
|
||||||
|
) -> list[str]:
|
||||||
|
command = [
|
||||||
|
command_path,
|
||||||
|
"--mode",
|
||||||
|
self.mode_for(proxy, upstream_port),
|
||||||
|
"--listen-host",
|
||||||
|
proxy.listen_host,
|
||||||
|
"--set",
|
||||||
|
f"confdir={config_path.parent}",
|
||||||
|
"-s",
|
||||||
|
str(script_path),
|
||||||
|
]
|
||||||
|
if self.needs_listen_port(proxy):
|
||||||
|
command.extend(["--listen-port", str(proxy.proxy_port)])
|
||||||
|
return command
|
||||||
|
|
||||||
|
def mode_for(self, proxy: ProxyDocument, upstream_port: int | None) -> str:
|
||||||
|
if proxy.mode_spec:
|
||||||
|
return proxy.mode_spec
|
||||||
|
|
||||||
|
match proxy.mode:
|
||||||
|
case "reverse":
|
||||||
|
port = upstream_port or proxy.service.port
|
||||||
|
return (
|
||||||
|
f"reverse:{proxy.protocol}://{proxy.service.host}:{port}"
|
||||||
|
f"@{proxy.proxy_port}"
|
||||||
|
)
|
||||||
|
case "upstream":
|
||||||
|
return (
|
||||||
|
f"upstream:{proxy.protocol}://{proxy.service.host}:"
|
||||||
|
f"{proxy.service.port}@{proxy.proxy_port}"
|
||||||
|
)
|
||||||
|
case "wireguard":
|
||||||
|
return f"wireguard@{proxy.proxy_port}"
|
||||||
|
case _:
|
||||||
|
return proxy.mode
|
||||||
|
|
||||||
|
def needs_listen_port(self, proxy: ProxyDocument) -> bool:
|
||||||
|
if proxy.mode_spec is not None:
|
||||||
|
return False
|
||||||
|
return proxy.mode in {"regular", "socks5", "dns", "transparent"}
|
||||||
|
|
||||||
|
def pick_upstream_port(self, excluded_ports: set[int]) -> int:
|
||||||
|
while True:
|
||||||
|
candidate = get_random_port()
|
||||||
|
if candidate not in excluded_ports:
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
async def write_script(self, proxy: ProxyDocument) -> Path:
|
||||||
|
return await write_script(self.work_dir, proxy)
|
||||||
|
|
||||||
|
async def write_config(self, proxy: ProxyDocument) -> Path:
|
||||||
|
return await write_config(self.work_dir, proxy)
|
||||||
|
|
||||||
|
async def log_path(self, proxy: ProxyDocument) -> Path:
|
||||||
|
return await ensure_log_path(self.work_dir, proxy)
|
||||||
|
|
||||||
|
async def spawn(self, command: list[str], log_path: Path):
|
||||||
|
with log_path.open("ab") as log_file:
|
||||||
|
return await asyncio.create_subprocess_exec(
|
||||||
|
*command,
|
||||||
|
stdout=log_file,
|
||||||
|
stderr=log_file,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop_process(self, process) -> None:
|
||||||
|
await self.stop_pid(process.pid)
|
||||||
|
|
||||||
|
async def exited_early(self, process) -> bool:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=0.5)
|
||||||
|
except TimeoutError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def stop_pid(self, pid: int) -> None:
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(pid)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
except PermissionError:
|
||||||
|
pgid = pid
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.killpg(pgid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
except PermissionError:
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
if not self.is_process_alive(pid):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.killpg(pgid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
except PermissionError:
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
|
||||||
|
def reattach(self, proxy: ProxyDocument) -> None:
|
||||||
|
if proxy.pid is not None:
|
||||||
|
self.attached_pids[proxy.proxy_port] = proxy.pid
|
||||||
|
|
||||||
|
def needs_recovery(self, proxy: ProxyDocument) -> bool:
|
||||||
|
return (
|
||||||
|
proxy.status == "running"
|
||||||
|
or proxy.pid is not None
|
||||||
|
or self.needs_upstream_restore(proxy)
|
||||||
|
)
|
||||||
|
|
||||||
|
def needs_upstream_restore(self, proxy: ProxyDocument) -> bool:
|
||||||
|
upstream_port = proxy.service.upstream_port
|
||||||
|
return upstream_port is not None and upstream_port != proxy.service.port
|
||||||
|
|
||||||
|
def is_process_alive(self, pid: int) -> bool:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def matches_proxy_process(self, proxy: ProxyDocument, pid: int) -> bool:
|
||||||
|
cmdline = self.read_cmdline(pid)
|
||||||
|
if not cmdline:
|
||||||
|
return False
|
||||||
|
|
||||||
|
expected_command = Path(self.command).name
|
||||||
|
if not any(Path(part).name == expected_command for part in cmdline):
|
||||||
|
return False
|
||||||
|
|
||||||
|
expected_script = str(self.script_path_for(proxy))
|
||||||
|
expected_confdir = f"confdir={self.conf_dir_for(proxy)}"
|
||||||
|
return expected_script in cmdline and expected_confdir in cmdline
|
||||||
|
|
||||||
|
def read_cmdline(self, pid: int) -> list[str]:
|
||||||
|
return read_cmdline(pid)
|
||||||
|
|
||||||
|
def script_path_for(self, proxy: ProxyDocument) -> Path:
|
||||||
|
return script_path_for(self.work_dir, proxy)
|
||||||
|
|
||||||
|
def conf_dir_for(self, proxy: ProxyDocument) -> Path:
|
||||||
|
return conf_dir_for(self.work_dir, proxy)
|
||||||
|
|
||||||
|
def config_path_for(self, proxy: ProxyDocument) -> Path:
|
||||||
|
return config_path_for(self.work_dir, proxy)
|
||||||
|
|
||||||
|
def log_path_for(self, proxy: ProxyDocument) -> Path:
|
||||||
|
return log_path_for(self.work_dir, proxy)
|
||||||
|
|
||||||
|
async def read_log_tail(self, path: Path, limit: int = 4000) -> str:
|
||||||
|
return await read_log_tail(path, limit=limit)
|
||||||
|
|
||||||
|
def remove_file(self, path: Path) -> None:
|
||||||
|
remove_file(path)
|
||||||
|
|
||||||
|
def remove_dir(self, path: Path) -> None:
|
||||||
|
remove_dir(path)
|
||||||
|
|
||||||
|
def lock_for(self, proxy_port: int) -> asyncio.Lock:
|
||||||
|
lock = self.locks.get(proxy_port)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
self.locks[proxy_port] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
manager = MitmdumpManager()
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
RESERVED_OPTIONS = {"mode", "listen_host", "listen_port", "scripts"}
|
||||||
|
OPTIONS_DOCS_URL = "https://docs.mitmproxy.org/stable/concepts/options/"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class MitmOption:
|
||||||
|
name: str
|
||||||
|
value_type: str
|
||||||
|
default: Any
|
||||||
|
description: str
|
||||||
|
choices: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class MitmOptionError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
catalog_cache: dict[str, MitmOption] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def load_options() -> dict[str, MitmOption]:
|
||||||
|
global catalog_cache
|
||||||
|
if catalog_cache is None:
|
||||||
|
catalog_cache = await read_options()
|
||||||
|
return catalog_cache
|
||||||
|
|
||||||
|
|
||||||
|
async def read_options(command: str = "mitmdump") -> dict[str, MitmOption]:
|
||||||
|
command_path = shutil.which(command)
|
||||||
|
if command_path is None:
|
||||||
|
raise MitmOptionError("mitmdump executable not found in PATH")
|
||||||
|
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
command_path,
|
||||||
|
"--options",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
if process.returncode != 0:
|
||||||
|
message = stderr.decode("utf-8", errors="replace").strip()
|
||||||
|
raise MitmOptionError(message or "Unable to load mitmdump options")
|
||||||
|
|
||||||
|
text = stdout.decode("utf-8", errors="replace")
|
||||||
|
defaults = yaml.safe_load(text) or {}
|
||||||
|
return parse_options(text, defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_options(text: str, defaults: dict[str, Any]) -> dict[str, MitmOption]:
|
||||||
|
comments: list[str] = []
|
||||||
|
options: dict[str, MitmOption] = {}
|
||||||
|
|
||||||
|
for line in text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
comments = []
|
||||||
|
continue
|
||||||
|
if stripped.startswith("#"):
|
||||||
|
comments.append(stripped[1:].strip())
|
||||||
|
continue
|
||||||
|
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*:", stripped):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = stripped.split(":", 1)[0]
|
||||||
|
description = " ".join(comments)
|
||||||
|
options[name] = MitmOption(
|
||||||
|
name=name,
|
||||||
|
value_type=parse_type(description),
|
||||||
|
default=defaults.get(name),
|
||||||
|
description=description,
|
||||||
|
choices=parse_choices(description),
|
||||||
|
)
|
||||||
|
comments = []
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def parse_type(description: str) -> str:
|
||||||
|
match = re.search(r"Type ([^.]+)\.", description)
|
||||||
|
if match is None:
|
||||||
|
return "unknown"
|
||||||
|
return match.group(1).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_choices(description: str) -> list[str]:
|
||||||
|
match = re.search(r"Valid values are ([^.]+)\.", description)
|
||||||
|
if match is None:
|
||||||
|
return []
|
||||||
|
return re.findall(r"'([^']+)'", match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_options(options: dict[str, Any]) -> None:
|
||||||
|
catalog = await load_options()
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for name, value in options.items():
|
||||||
|
option = catalog.get(name)
|
||||||
|
if option is None:
|
||||||
|
errors.append(f"Unknown mitmdump option: {name}")
|
||||||
|
continue
|
||||||
|
if name in RESERVED_OPTIONS:
|
||||||
|
errors.append(f"Option is managed by thePlug and cannot be set: {name}")
|
||||||
|
continue
|
||||||
|
message = validate_value(option, value)
|
||||||
|
if message:
|
||||||
|
errors.append(f"{name}: {message}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise MitmOptionError("; ".join(errors))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_value(option: MitmOption, value: Any) -> str | None:
|
||||||
|
value_type = option.value_type
|
||||||
|
if option.choices and str(value) not in option.choices:
|
||||||
|
return f"expected one of {', '.join(option.choices)}"
|
||||||
|
if value is None:
|
||||||
|
if value_type.startswith("optional "):
|
||||||
|
return None
|
||||||
|
return "cannot be null"
|
||||||
|
if value_type == "bool" and not isinstance(value, bool):
|
||||||
|
return "expected bool"
|
||||||
|
if value_type == "int" and not isinstance(value, int):
|
||||||
|
return "expected int"
|
||||||
|
if value_type == "str" and not isinstance(value, str):
|
||||||
|
return "expected str"
|
||||||
|
if value_type == "sequence of str":
|
||||||
|
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
||||||
|
return "expected list[str]"
|
||||||
|
if value_type == "optional str" and not isinstance(value, str):
|
||||||
|
return "expected str or null"
|
||||||
|
if value_type == "optional int" and not isinstance(value, int):
|
||||||
|
return "expected int or null"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def public_options(options: dict[str, MitmOption]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": option.name,
|
||||||
|
"type": option.value_type,
|
||||||
|
"default": option.default,
|
||||||
|
"description": option.description,
|
||||||
|
"choices": option.choices,
|
||||||
|
"reserved": option.name in RESERVED_OPTIONS,
|
||||||
|
"docs_url": f"{OPTIONS_DOCS_URL}#{option.name}",
|
||||||
|
}
|
||||||
|
for option in options.values()
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from app.repositories.proxy import list_proxies, update_proxy_runtime
|
||||||
|
from app.services.mitmproxy.manager import manager
|
||||||
|
|
||||||
|
|
||||||
|
async def reconcile_runtime() -> list[tuple[int, dict[str, object]]]:
|
||||||
|
changes: list[tuple[int, dict[str, object]]] = []
|
||||||
|
|
||||||
|
for proxy in await list_proxies():
|
||||||
|
updates = await manager.reconcile(proxy)
|
||||||
|
if updates is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
await update_proxy_runtime(proxy.proxy_port, updates)
|
||||||
|
changes.append((proxy.proxy_port, updates))
|
||||||
|
|
||||||
|
return changes
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class ReservedProxyPortError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def backend_port() -> int:
|
||||||
|
return settings.API_PORT
|
||||||
|
|
||||||
|
|
||||||
|
def reserved_proxy_port_message(proxy_port: int) -> str:
|
||||||
|
return (
|
||||||
|
f"Proxy port {proxy_port} is reserved for the backend API "
|
||||||
|
"and cannot be used by mitmdump."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_proxy_port_not_reserved(proxy_port: int) -> None:
|
||||||
|
if proxy_port == backend_port():
|
||||||
|
raise ReservedProxyPortError(reserved_proxy_port_message(proxy_port))
|
||||||
@@ -0,0 +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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SUPPORTED_FILE_TEMPLATE_PROTOCOLS = frozenset({"http", "tcp"})
|
||||||
|
TEMPLATES_DIR = Path(__file__).resolve().parents[4] / "templates"
|
||||||
|
|
||||||
|
|
||||||
|
def template_directory() -> Path:
|
||||||
|
return TEMPLATES_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def template_files(directory: Path | None = None) -> list[Path]:
|
||||||
|
root = directory or template_directory()
|
||||||
|
if not root.exists():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
path
|
||||||
|
for path in root.iterdir()
|
||||||
|
if path.is_file() and path.suffix.lower() in {".yaml", ".yml"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_category(protocol: str, family: str) -> str:
|
||||||
|
return protocol if protocol in SUPPORTED_FILE_TEMPLATE_PROTOCOLS else family
|
||||||
|
|
||||||
|
|
||||||
|
def _string_value(value: object, default: str = "") -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
family = _string_value(raw.get("family") or protocol)
|
||||||
|
source_url = raw.get("source_url")
|
||||||
|
tags = raw.get("tags")
|
||||||
|
normalized_tags = [str(tag) for tag in tags] if isinstance(tags, list) else []
|
||||||
|
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"id": _string_value(raw["id"]).strip(),
|
||||||
|
"name": _string_value(raw["name"]).strip(),
|
||||||
|
"description": description,
|
||||||
|
"category": _string_value(
|
||||||
|
raw.get("category") or _default_category(protocol, family)
|
||||||
|
),
|
||||||
|
"protocol": protocol,
|
||||||
|
"family": family,
|
||||||
|
"tags": normalized_tags,
|
||||||
|
"action": _string_value(raw.get("action") or "custom"),
|
||||||
|
"summary": _string_value(raw.get("summary") or description),
|
||||||
|
"behavior": _string_value(raw.get("behavior") or description),
|
||||||
|
"code": _string_value(raw["code"]),
|
||||||
|
"source_url": _string_value(source_url) if source_url is not None else None,
|
||||||
|
"built_in": True,
|
||||||
|
"origin": "file",
|
||||||
|
}
|
||||||
|
return payload, str(source)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_template_from_file(path: Path) -> ProxyTemplateDocument | None:
|
||||||
|
try:
|
||||||
|
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, yaml.YAMLError) as error:
|
||||||
|
logger.warning("Skipping template file %s: %s", path, error)
|
||||||
|
return None
|
||||||
|
|
||||||
|
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()}
|
||||||
|
|
||||||
|
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 list_file_templates(directory: Path | None = None) -> list[ProxyTemplateDocument]:
|
||||||
|
by_id: dict[str, list[ProxyTemplateDocument]] = defaultdict(list)
|
||||||
|
for path in template_files(directory):
|
||||||
|
template = _load_template_from_file(path)
|
||||||
|
if template is None:
|
||||||
|
continue
|
||||||
|
by_id[template.id].append(template)
|
||||||
|
|
||||||
|
deduplicated: list[ProxyTemplateDocument] = []
|
||||||
|
duplicate_template_message = (
|
||||||
|
"Skipping template id %s from %s: duplicate file-backed template id"
|
||||||
|
)
|
||||||
|
for template_id, templates in by_id.items():
|
||||||
|
if len(templates) > 1:
|
||||||
|
for template in templates:
|
||||||
|
logger.warning(
|
||||||
|
duplicate_template_message,
|
||||||
|
template_id,
|
||||||
|
template.source_path or "<unknown>",
|
||||||
|
)
|
||||||
|
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)}
|
||||||
@@ -5,7 +5,7 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
TARGET_RUFF_CODES = ("F821", "F822", "F823", "E999")
|
TARGET_RUFF_CODES = ("F821", "F822", "F823")
|
||||||
|
|
||||||
|
|
||||||
class ProxyCodeValidationError(Exception):
|
class ProxyCodeValidationError(Exception):
|
||||||
@@ -5,12 +5,16 @@ description = "Add your description here"
|
|||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"authlib>=1.6.0",
|
"authlib>=1.6.0",
|
||||||
|
"basedpyright>=1.39.0",
|
||||||
"fastapi[standard]>=0.135.3",
|
"fastapi[standard]>=0.135.3",
|
||||||
|
"locust>=2.43.4",
|
||||||
|
"mitmproxy>=12.2.2",
|
||||||
"motor>=3.7.1",
|
"motor>=3.7.1",
|
||||||
"passlib[bcrypt]>=1.7.4",
|
"pwdlib[argon2,bcrypt]>=0.3.0",
|
||||||
|
"pydantic>=2.12.5",
|
||||||
"pydantic-settings>=2.13.1",
|
"pydantic-settings>=2.13.1",
|
||||||
"pyyaml>=6.0.3",
|
"pyyaml>=6.0.3",
|
||||||
"redis>=7.4.0",
|
"ruff>=0.15.10",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
@@ -26,11 +30,19 @@ indent-style = "space"
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["app"]
|
pythonpath = ["."]
|
||||||
addopts = "-q"
|
addopts = "-q"
|
||||||
|
markers = [
|
||||||
|
"unit: fast isolated tests",
|
||||||
|
"integration: tests using real app wiring and persistence",
|
||||||
|
"api: HTTP API behavior tests",
|
||||||
|
"websocket: websocket behavior tests",
|
||||||
|
"slow: slower-running tests",
|
||||||
|
"requires_docker: tests that require Docker or containerized dependencies",
|
||||||
|
"perf: performance and benchmark scenarios",
|
||||||
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
"ruff>=0.15.10",
|
|
||||||
]
|
]
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import importlib
|
import importlib
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from core.security import AuthContext, get_auth_context
|
from app.core.security import AuthContext, get_auth_context
|
||||||
from models.auth import AccessTokenPayload, AuthUserDocument
|
from app.models.auth import AccessTokenPayload, AuthUserDocument
|
||||||
|
|
||||||
auth_router_module = importlib.import_module("api.auth.router")
|
auth_router_module = importlib.import_module("app.api.auth.router")
|
||||||
|
|
||||||
|
|
||||||
def test_login_success_returns_bearer_token(client, monkeypatch: object):
|
def test_login_success_returns_bearer_token(client, monkeypatch: object):
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import pytest
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
from app.services.editor import python_lsp
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_editor_session(client, monkeypatch):
|
||||||
|
session = python_lsp.PythonEditorSession(
|
||||||
|
root_path=python_lsp.WORKSPACE_ROOT,
|
||||||
|
file_path=python_lsp.WORKSPACE_ROOT / python_lsp.ENTRY_FILE,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.editor.router.python_lsp.get_session", lambda: session
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/proxy/editor/python/session")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"root_uri": session.root_uri,
|
||||||
|
"file_uri": session.file_uri,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_editor_socket_rejects_missing_token(client):
|
||||||
|
with pytest.raises(WebSocketDisconnect) as error:
|
||||||
|
with client.websocket_connect("/proxy/editor/python/ws"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert error.value.code == 4401
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_editor_socket_accepts_token(client, monkeypatch):
|
||||||
|
accepted = {"value": False}
|
||||||
|
|
||||||
|
async def fake_authenticate(_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fake_bridge(websocket, session) -> None:
|
||||||
|
accepted["value"] = True
|
||||||
|
await websocket.send_text(session.file_uri)
|
||||||
|
await websocket.close()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.editor.router.authenticate_editor_socket",
|
||||||
|
fake_authenticate,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("app.api.editor.router.python_lsp.bridge", fake_bridge)
|
||||||
|
|
||||||
|
with client.websocket_connect("/proxy/editor/python/ws?token=test-token") as socket:
|
||||||
|
assert socket.receive_text().endswith("/proxy_addon.py")
|
||||||
|
|
||||||
|
assert accepted["value"] is True
|
||||||
@@ -0,0 +1,980 @@
|
|||||||
|
import asyncio
|
||||||
|
import importlib
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
from app.core.security import get_auth_context, require_authenticated_user
|
||||||
|
from app.models.health import HealthSnapshot
|
||||||
|
from app.models.proxy import ProxyCreateInput, ProxyDocument
|
||||||
|
from app.models.service import ServiceEndpointDocument
|
||||||
|
from app.services.mitmproxy.templates import list_file_templates
|
||||||
|
from app.services.mitmproxy.validator import ProxyCodeValidationError
|
||||||
|
|
||||||
|
proxy_router_module = importlib.import_module("app.api.proxy.router")
|
||||||
|
|
||||||
|
|
||||||
|
def make_proxy_document(
|
||||||
|
*,
|
||||||
|
proxy_port: int,
|
||||||
|
name: str = "svc",
|
||||||
|
code: str = "proxy-code",
|
||||||
|
port: int = 9000,
|
||||||
|
log_path: str | None = None,
|
||||||
|
) -> ProxyDocument:
|
||||||
|
return ProxyDocument(
|
||||||
|
proxy_port=proxy_port,
|
||||||
|
name=name,
|
||||||
|
code=code,
|
||||||
|
service=ServiceEndpointDocument(port=port),
|
||||||
|
log_path=log_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_health_snapshot(
|
||||||
|
proxy_port: int,
|
||||||
|
*,
|
||||||
|
overall: str = "healthy",
|
||||||
|
timestamp: datetime | None = None,
|
||||||
|
) -> HealthSnapshot:
|
||||||
|
return HealthSnapshot(
|
||||||
|
proxy_port=proxy_port,
|
||||||
|
timestamp=timestamp or datetime.now(UTC),
|
||||||
|
enabled=True,
|
||||||
|
overall=overall,
|
||||||
|
checks=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_requires_auth_without_dependency_overrides(client):
|
||||||
|
client.app.dependency_overrides.pop(require_authenticated_user, None)
|
||||||
|
client.app.dependency_overrides.pop(get_auth_context, None)
|
||||||
|
|
||||||
|
response = client.get("/proxy")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["detail"] == "Could not validate credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_proxies_returns_documents(client, monkeypatch: object):
|
||||||
|
docs = [
|
||||||
|
make_proxy_document(proxy_port=8080, name="api"),
|
||||||
|
make_proxy_document(proxy_port=8081, name="worker"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def fake_list_proxies() -> list[ProxyDocument]:
|
||||||
|
return docs
|
||||||
|
|
||||||
|
monkeypatch.setattr(proxy_router_module, "list_proxy_documents", fake_list_proxies)
|
||||||
|
|
||||||
|
response = client.get("/proxy")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert [item["proxy_port"] for item in body] == [8080, 8081]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_services_uses_query_scan_root(client, monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_get_all_services_in_dir(root):
|
||||||
|
captured["root"] = str(root)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "echo",
|
||||||
|
"ports": [9081],
|
||||||
|
"bindings": [],
|
||||||
|
"folder": "/root/play",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_all_services_in_dir",
|
||||||
|
fake_get_all_services_in_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/proxy/services", params={"scan_root": "/root"})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"backend_port": 3671,
|
||||||
|
"scan_root": "/root",
|
||||||
|
"services": [
|
||||||
|
{"name": "echo", "ports": [9081], "bindings": [], "folder": "/root/play"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert captured["root"] == "/root"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_proxy_returns_404_when_not_found(client, monkeypatch: object):
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/proxy/9999")
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()["detail"] == "Proxy not found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_proxy_returns_409_on_port_conflict(client, monkeypatch: object):
|
||||||
|
existing = make_proxy_document(proxy_port=4088)
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 4088
|
||||||
|
return existing
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/proxy",
|
||||||
|
json={
|
||||||
|
"proxy_port": 4088,
|
||||||
|
"name": "new",
|
||||||
|
"code": "abc",
|
||||||
|
"service": {"port": 8080},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["detail"] == "Proxy already exists on port 4088"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_proxy_rejects_backend_api_port(client, monkeypatch: object):
|
||||||
|
create_called = False
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 3671
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fake_create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
|
||||||
|
nonlocal create_called
|
||||||
|
create_called = True
|
||||||
|
return make_proxy_document(proxy_port=payload.proxy_port)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/proxy",
|
||||||
|
json={
|
||||||
|
"proxy_port": 3671,
|
||||||
|
"name": "backend-clash",
|
||||||
|
"code": "abc",
|
||||||
|
"service": {"port": 8080},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["detail"] == (
|
||||||
|
"Proxy port 3671 is reserved for the backend API "
|
||||||
|
"and cannot be used by mitmdump."
|
||||||
|
)
|
||||||
|
assert create_called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_proxy_returns_created_document(client, monkeypatch: object):
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fake_create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
|
||||||
|
captured["proxy_port"] = payload.proxy_port
|
||||||
|
captured["name"] = payload.name
|
||||||
|
captured["service"] = payload.service.model_dump(exclude_none=True)
|
||||||
|
captured["code"] = payload.code
|
||||||
|
captured["protocol"] = payload.protocol
|
||||||
|
return make_proxy_document(
|
||||||
|
proxy_port=payload.proxy_port,
|
||||||
|
name=payload.name,
|
||||||
|
code=payload.code,
|
||||||
|
port=payload.service.port,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_validate_proxy_code(code: str) -> None:
|
||||||
|
assert code == "def proxy_handler():\n return 'ok'\n"
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"validate_proxy_code",
|
||||||
|
fake_validate_proxy_code,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/proxy",
|
||||||
|
json={
|
||||||
|
"proxy_port": 4040,
|
||||||
|
"name": "edge",
|
||||||
|
"code": "def proxy_handler():\n return 'ok'\n",
|
||||||
|
"service": {"port": 8181},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.json()["proxy_port"] == 4040
|
||||||
|
assert captured == {
|
||||||
|
"proxy_port": 4040,
|
||||||
|
"name": "edge",
|
||||||
|
"service": {"host": "127.0.0.1", "port": 8181},
|
||||||
|
"code": "def proxy_handler():\n return 'ok'\n",
|
||||||
|
"protocol": "http",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_proxy_returns_422_when_code_validation_fails(
|
||||||
|
client,
|
||||||
|
monkeypatch: object,
|
||||||
|
):
|
||||||
|
create_called = False
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 4000
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fake_validate_proxy_code(code: str) -> None:
|
||||||
|
assert code == "invalid-code"
|
||||||
|
raise ProxyCodeValidationError(
|
||||||
|
"Proxy code contains ruff violations",
|
||||||
|
errors=[
|
||||||
|
{
|
||||||
|
"code": "F821",
|
||||||
|
"message": "Undefined name `missing_symbol`",
|
||||||
|
"location": {"row": 1, "column": 1},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_create_proxy(
|
||||||
|
*,
|
||||||
|
proxy_port: int,
|
||||||
|
name: str,
|
||||||
|
service: dict[str, object],
|
||||||
|
code: str,
|
||||||
|
) -> ProxyDocument:
|
||||||
|
nonlocal create_called
|
||||||
|
create_called = True
|
||||||
|
return make_proxy_document(proxy_port=proxy_port, name=name, code=code)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"validate_proxy_code",
|
||||||
|
fake_validate_proxy_code,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/proxy",
|
||||||
|
json={
|
||||||
|
"proxy_port": 4000,
|
||||||
|
"name": "broken",
|
||||||
|
"code": "invalid-code",
|
||||||
|
"service": {"port": 9000},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
body = response.json()["detail"]
|
||||||
|
assert body["message"] == "Proxy code contains ruff violations"
|
||||||
|
assert body["errors"][0]["code"] == "F821"
|
||||||
|
assert create_called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_proxy_returns_422_when_code_validation_fails(
|
||||||
|
client,
|
||||||
|
monkeypatch: object,
|
||||||
|
):
|
||||||
|
update_called = False
|
||||||
|
|
||||||
|
async def fake_validate_proxy_code(code: str) -> None:
|
||||||
|
assert code == "invalid-code"
|
||||||
|
raise ProxyCodeValidationError(
|
||||||
|
"Proxy code has invalid Python syntax",
|
||||||
|
errors=[
|
||||||
|
{
|
||||||
|
"code": "COMPILE",
|
||||||
|
"message": "invalid syntax",
|
||||||
|
"location": {"row": 1, "column": 1},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_update_proxy(
|
||||||
|
proxy_port: int,
|
||||||
|
payload: object,
|
||||||
|
) -> ProxyDocument | None:
|
||||||
|
nonlocal update_called
|
||||||
|
update_called = True
|
||||||
|
return make_proxy_document(proxy_port=proxy_port)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"validate_proxy_code",
|
||||||
|
fake_validate_proxy_code,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_router_module, "update_proxy", fake_update_proxy)
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
"/proxy/8123",
|
||||||
|
json={"code": "invalid-code"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
body = response.json()["detail"]
|
||||||
|
assert body["message"] == "Proxy code has invalid Python syntax"
|
||||||
|
assert body["errors"][0]["code"] == "COMPILE"
|
||||||
|
assert update_called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_proxy_persists_code_changes(client, monkeypatch: object):
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
updated_code = "def response(flow):\n flow.response.text = 'ok'\n"
|
||||||
|
|
||||||
|
async def fake_validate_proxy_code(code: str) -> None:
|
||||||
|
captured["validated"] = code
|
||||||
|
|
||||||
|
async def fake_update_proxy(
|
||||||
|
proxy_port: int,
|
||||||
|
payload: object,
|
||||||
|
) -> ProxyDocument | None:
|
||||||
|
captured["proxy_port"] = proxy_port
|
||||||
|
captured["code"] = payload.code
|
||||||
|
return make_proxy_document(
|
||||||
|
proxy_port=proxy_port,
|
||||||
|
code=payload.code,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"validate_proxy_code",
|
||||||
|
fake_validate_proxy_code,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_router_module, "update_proxy", fake_update_proxy)
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
"/proxy/8123",
|
||||||
|
json={"code": updated_code},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["code"] == updated_code
|
||||||
|
assert captured == {
|
||||||
|
"validated": updated_code,
|
||||||
|
"proxy_port": 8123,
|
||||||
|
"code": updated_code,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_proxy_updates_runtime_state(client, monkeypatch: object):
|
||||||
|
proxy = make_proxy_document(proxy_port=4040)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
class FakeManager:
|
||||||
|
async def start(self, document: ProxyDocument) -> dict[str, object]:
|
||||||
|
assert document.proxy_port == 4040
|
||||||
|
return {
|
||||||
|
"status": "running",
|
||||||
|
"pid": 1234,
|
||||||
|
"script_path": "/tmp/theplug/addons/4040.py",
|
||||||
|
"log_path": "/tmp/theplug/logs/4040.log",
|
||||||
|
"service.upstream_port": 9000,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_update_proxy_runtime(
|
||||||
|
proxy_port: int,
|
||||||
|
values: dict[str, object],
|
||||||
|
) -> ProxyDocument:
|
||||||
|
captured["proxy_port"] = proxy_port
|
||||||
|
captured["values"] = values
|
||||||
|
return proxy.model_copy(update={"status": "running", "pid": 1234})
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"update_proxy_runtime",
|
||||||
|
fake_update_proxy_runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post("/proxy/4040/start")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "running"
|
||||||
|
assert captured["proxy_port"] == 4040
|
||||||
|
assert captured["values"]["pid"] == 1234
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_proxy_records_runtime_error(client, monkeypatch: object):
|
||||||
|
proxy = make_proxy_document(proxy_port=4040)
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
class FakeManager:
|
||||||
|
async def start(self, document: ProxyDocument) -> dict[str, object]:
|
||||||
|
assert document.proxy_port == 4040
|
||||||
|
raise proxy_router_module.MitmdumpError("mitmdump executable not found")
|
||||||
|
|
||||||
|
async def fake_update_proxy_runtime(
|
||||||
|
proxy_port: int,
|
||||||
|
values: dict[str, object],
|
||||||
|
) -> ProxyDocument:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
assert values == {
|
||||||
|
"status": "error",
|
||||||
|
"last_error": "mitmdump executable not found",
|
||||||
|
}
|
||||||
|
return proxy.model_copy(
|
||||||
|
update={"status": "error", "last_error": values["last_error"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"update_proxy_runtime",
|
||||||
|
fake_update_proxy_runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post("/proxy/4040/start")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "error"
|
||||||
|
assert response.json()["last_error"] == "mitmdump executable not found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_logs_socket_rejects_missing_token(client):
|
||||||
|
with pytest.raises(WebSocketDisconnect) as error:
|
||||||
|
with client.websocket_connect("/proxy/4040/logs/ws"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert error.value.code == 4401
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_logs_socket_streams_snapshot_and_append(client, monkeypatch, tmp_path):
|
||||||
|
log_path = tmp_path / "proxy.log"
|
||||||
|
log_path.write_text("line one\n", encoding="utf-8")
|
||||||
|
proxy = make_proxy_document(proxy_port=4040, log_path=str(log_path))
|
||||||
|
|
||||||
|
async def fake_authenticate(_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"authenticate_proxy_socket",
|
||||||
|
fake_authenticate,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
with client.websocket_connect("/proxy/4040/logs/ws?token=test-token") as socket:
|
||||||
|
snapshot = socket.receive_json()
|
||||||
|
assert snapshot == {"type": "snapshot", "log": "line one\n"}
|
||||||
|
|
||||||
|
with log_path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write("line two\n")
|
||||||
|
handle.flush()
|
||||||
|
|
||||||
|
appended = socket.receive_json()
|
||||||
|
assert appended == {"type": "append", "log": "line two\n"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_logs_socket_resets_snapshot_after_log_rotation(
|
||||||
|
client,
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
log_path = tmp_path / "proxy.log"
|
||||||
|
log_path.write_text("line one\n", encoding="utf-8")
|
||||||
|
proxy = make_proxy_document(proxy_port=4040, log_path=str(log_path))
|
||||||
|
|
||||||
|
async def fake_authenticate(_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"authenticate_proxy_socket",
|
||||||
|
fake_authenticate,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
with client.websocket_connect("/proxy/4040/logs/ws?token=test-token") as socket:
|
||||||
|
snapshot = socket.receive_json()
|
||||||
|
assert snapshot == {"type": "snapshot", "log": "line one\n"}
|
||||||
|
|
||||||
|
log_path.write_text("fresh start\n", encoding="utf-8")
|
||||||
|
|
||||||
|
rotated = socket.receive_json()
|
||||||
|
assert rotated == {"type": "snapshot", "log": "fresh start\n"}
|
||||||
|
|
||||||
|
with log_path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write("line two\n")
|
||||||
|
handle.flush()
|
||||||
|
|
||||||
|
appended = socket.receive_json()
|
||||||
|
assert appended == {"type": "append", "log": "line two\n"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_proxy_stops_runtime_and_cleans_artifacts(client, monkeypatch: object):
|
||||||
|
proxy = make_proxy_document(
|
||||||
|
proxy_port=4040,
|
||||||
|
log_path="/tmp/theplug/logs/4040.log",
|
||||||
|
).model_copy(
|
||||||
|
update={
|
||||||
|
"script_path": "/tmp/theplug/addons/4040.py",
|
||||||
|
"config_path": "/tmp/theplug/conf/4040/config.yaml",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
class FakeManager:
|
||||||
|
async def stop(self, document: ProxyDocument) -> dict[str, object]:
|
||||||
|
captured["stopped"] = document.proxy_port
|
||||||
|
return {
|
||||||
|
"status": "stopped",
|
||||||
|
"pid": None,
|
||||||
|
"service.upstream_port": None,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def cleanup_runtime_artifacts(self, document: ProxyDocument) -> None:
|
||||||
|
captured["cleaned"] = {
|
||||||
|
"proxy_port": document.proxy_port,
|
||||||
|
"script_path": document.script_path,
|
||||||
|
"config_path": document.config_path,
|
||||||
|
"log_path": document.log_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_delete_proxy(proxy_port: int) -> bool:
|
||||||
|
captured["deleted"] = proxy_port
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def fake_delete_health_history(proxy_port: int) -> None:
|
||||||
|
captured["history_deleted"] = proxy_port
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"delete_health_history",
|
||||||
|
fake_delete_health_history,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_router_module, "delete_proxy", fake_delete_proxy)
|
||||||
|
|
||||||
|
response = client.delete("/proxy/4040")
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert captured == {
|
||||||
|
"stopped": 4040,
|
||||||
|
"cleaned": {
|
||||||
|
"proxy_port": 4040,
|
||||||
|
"script_path": "/tmp/theplug/addons/4040.py",
|
||||||
|
"config_path": "/tmp/theplug/conf/4040/config.yaml",
|
||||||
|
"log_path": "/tmp/theplug/logs/4040.log",
|
||||||
|
},
|
||||||
|
"deleted": 4040,
|
||||||
|
"history_deleted": 4040,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_health_runs_immediate_check_and_publishes(client, monkeypatch: object):
|
||||||
|
proxy = make_proxy_document(proxy_port=4040)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
snapshot = make_health_snapshot(4040)
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
async def fake_check_proxy(document: ProxyDocument):
|
||||||
|
captured["checked"] = document.proxy_port
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
async def fake_record_health_sample(document):
|
||||||
|
captured["recorded"] = document
|
||||||
|
return document
|
||||||
|
|
||||||
|
class FakeHealthMonitor:
|
||||||
|
async def publish(self, proxy_port: int, document: HealthSnapshot) -> None:
|
||||||
|
captured["published"] = (proxy_port, document)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_router_module, "check_proxy", fake_check_proxy)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"record_health_sample",
|
||||||
|
fake_record_health_sample,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_router_module, "health_monitor", FakeHealthMonitor())
|
||||||
|
|
||||||
|
response = client.get("/proxy/4040/health")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["overall"] == "healthy"
|
||||||
|
assert captured["checked"] == 4040
|
||||||
|
assert captured["recorded"] == snapshot
|
||||||
|
assert captured["published"] == (4040, snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_health_history_returns_latest_and_history(client, monkeypatch: object):
|
||||||
|
proxy = make_proxy_document(proxy_port=4040)
|
||||||
|
timestamp = datetime.now(UTC)
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
async def fake_get_latest_health_sample(proxy_port: int):
|
||||||
|
assert proxy_port == 4040
|
||||||
|
return make_health_snapshot(4040, timestamp=timestamp)
|
||||||
|
|
||||||
|
async def fake_list_health_history(proxy_port: int, limit: int = 40):
|
||||||
|
assert proxy_port == 4040
|
||||||
|
assert limit == 40
|
||||||
|
return [make_health_snapshot(4040, timestamp=timestamp)]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_latest_health_sample",
|
||||||
|
fake_get_latest_health_sample,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"list_health_history",
|
||||||
|
fake_list_health_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/proxy/4040/health/history")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["proxy_port"] == 4040
|
||||||
|
assert body["latest"]["overall"] == "healthy"
|
||||||
|
assert len(body["history"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_health_history_clamps_requested_limit(client, monkeypatch: object):
|
||||||
|
proxy = make_proxy_document(proxy_port=4040)
|
||||||
|
captured: dict[str, int] = {}
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
async def fake_get_latest_health_sample(_proxy_port: int) -> HealthSnapshot | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fake_list_health_history(proxy_port: int, limit: int = 40):
|
||||||
|
assert proxy_port == 4040
|
||||||
|
captured["limit"] = limit
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_latest_health_sample",
|
||||||
|
fake_get_latest_health_sample,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"list_health_history",
|
||||||
|
fake_list_health_history,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/proxy/4040/health/history", params={"limit": 9999})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert captured["limit"] == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_health_socket_streams_latest_and_updates(client, monkeypatch: object):
|
||||||
|
proxy = make_proxy_document(proxy_port=4040)
|
||||||
|
timestamp = datetime.now(UTC)
|
||||||
|
follow_up = make_health_snapshot(4040, overall="unhealthy")
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def fake_authenticate(_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
async def fake_get_latest_health_sample(proxy_port: int):
|
||||||
|
assert proxy_port == 4040
|
||||||
|
return make_health_snapshot(4040, timestamp=timestamp)
|
||||||
|
|
||||||
|
class FakeHealthMonitor:
|
||||||
|
def subscribe(self, proxy_port: int):
|
||||||
|
assert proxy_port == 4040
|
||||||
|
queue = asyncio.Queue()
|
||||||
|
queue.put_nowait(follow_up)
|
||||||
|
return queue
|
||||||
|
|
||||||
|
def unsubscribe(self, proxy_port: int, _queue) -> None:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
captured["unsubscribed"] = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"authenticate_proxy_socket",
|
||||||
|
fake_authenticate,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_latest_health_sample",
|
||||||
|
fake_get_latest_health_sample,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_router_module, "health_monitor", FakeHealthMonitor())
|
||||||
|
|
||||||
|
with client.websocket_connect("/proxy/4040/health/ws?token=test-token") as socket:
|
||||||
|
first = socket.receive_json()
|
||||||
|
assert first["overall"] == "healthy"
|
||||||
|
second = socket.receive_json()
|
||||||
|
assert second["overall"] == "unhealthy"
|
||||||
|
|
||||||
|
assert captured["unsubscribed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_proxy_updates_runtime_state(client, monkeypatch: object):
|
||||||
|
proxy = make_proxy_document(proxy_port=4040).model_copy(
|
||||||
|
update={"status": "running", "pid": 1234}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
class FakeManager:
|
||||||
|
async def stop(self, document: ProxyDocument) -> dict[str, object]:
|
||||||
|
assert document.pid == 1234
|
||||||
|
return {
|
||||||
|
"status": "stopped",
|
||||||
|
"pid": None,
|
||||||
|
"service.upstream_port": None,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_update_proxy_runtime(
|
||||||
|
proxy_port: int,
|
||||||
|
values: dict[str, object],
|
||||||
|
) -> ProxyDocument:
|
||||||
|
assert proxy_port == 4040
|
||||||
|
assert values["status"] == "stopped"
|
||||||
|
return proxy.model_copy(update={"status": "stopped", "pid": None})
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"get_proxy_by_port",
|
||||||
|
fake_get_proxy_by_port,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"update_proxy_runtime",
|
||||||
|
fake_update_proxy_runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post("/proxy/4040/stop")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_mitmproxy_templates_returns_builtins_and_custom(client, monkeypatch):
|
||||||
|
file_templates = list_file_templates()
|
||||||
|
|
||||||
|
async def fake_list_custom_templates():
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"list_custom_templates",
|
||||||
|
fake_list_custom_templates,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"list_file_templates",
|
||||||
|
lambda: file_templates,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/proxy/mitmproxy/templates")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert len(body) == len(file_templates)
|
||||||
|
assert body[0]["built_in"] is True
|
||||||
|
assert body[0]["origin"] == "file"
|
||||||
|
assert body[0]["protocol"] in {"http", "tcp"}
|
||||||
|
assert isinstance(body[0]["tags"], list)
|
||||||
|
assert body[0]["action"] in {"block", "rewrite"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_mitmproxy_template_validates_and_persists(client, monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_validate_proxy_code(code: str) -> None:
|
||||||
|
captured["validated"] = code
|
||||||
|
|
||||||
|
async def fake_create_custom_template(payload):
|
||||||
|
captured["payload"] = payload
|
||||||
|
return {
|
||||||
|
"id": payload.id,
|
||||||
|
"name": payload.name,
|
||||||
|
"description": payload.description,
|
||||||
|
"category": payload.category,
|
||||||
|
"protocol": payload.protocol,
|
||||||
|
"family": payload.family,
|
||||||
|
"tags": payload.tags,
|
||||||
|
"action": payload.action,
|
||||||
|
"summary": payload.summary,
|
||||||
|
"behavior": payload.behavior,
|
||||||
|
"code": payload.code,
|
||||||
|
"source_url": payload.source_url,
|
||||||
|
"built_in": False,
|
||||||
|
"origin": "custom",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"validate_proxy_code",
|
||||||
|
fake_validate_proxy_code,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"create_custom_template",
|
||||||
|
fake_create_custom_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/proxy/mitmproxy/templates",
|
||||||
|
json={
|
||||||
|
"id": "custom-header",
|
||||||
|
"name": "Custom header",
|
||||||
|
"description": "Adds a custom response header",
|
||||||
|
"category": "http",
|
||||||
|
"protocol": "http",
|
||||||
|
"family": "custom",
|
||||||
|
"tags": ["observe"],
|
||||||
|
"action": "custom",
|
||||||
|
"summary": "Saved custom template.",
|
||||||
|
"behavior": "Applies the current script as-is.",
|
||||||
|
"code": "def response(flow):\n pass\n",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.json()["id"] == "custom-header"
|
||||||
|
assert response.json()["protocol"] == "http"
|
||||||
|
assert response.json()["origin"] == "custom"
|
||||||
|
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"]
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
|
|
||||||
|
os.environ.setdefault(
|
||||||
|
"MONGODB_URI",
|
||||||
|
"mongodb://theplug:pass@localhost:27017/theplug?authSource=admin",
|
||||||
|
)
|
||||||
|
os.environ.setdefault("MONGODB_DB_NAME", "theplug")
|
||||||
|
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key")
|
||||||
|
os.environ.setdefault("JWT_ALGORITHM", "HS256")
|
||||||
|
os.environ.setdefault("JWT_EXPIRE_MINUTES", "720")
|
||||||
|
os.environ.setdefault("AUTH_USERNAME", "admin")
|
||||||
|
os.environ.setdefault("AUTH_PASSWORD", "admin")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_user():
|
||||||
|
from app.models.auth import AuthUserDocument
|
||||||
|
|
||||||
|
return AuthUserDocument(
|
||||||
|
username="admin",
|
||||||
|
password_hash="hashed-password",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_context(fake_user):
|
||||||
|
from app.core.security import AuthContext
|
||||||
|
from app.models.auth import AccessTokenPayload
|
||||||
|
|
||||||
|
exp = int((datetime.now(UTC) + timedelta(minutes=720)).timestamp())
|
||||||
|
payload = AccessTokenPayload(sub=fake_user.username, jti="test-jti", exp=exp)
|
||||||
|
return AuthContext(token="test-token", user=fake_user, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(fake_user, auth_context) -> FastAPI:
|
||||||
|
from app.api.auth.router import router as auth_router
|
||||||
|
from app.api.editor.router import router as editor_router
|
||||||
|
from app.api.proxy.router import router as proxy_router
|
||||||
|
from app.core.security import (
|
||||||
|
AuthContext,
|
||||||
|
get_auth_context,
|
||||||
|
require_authenticated_user,
|
||||||
|
)
|
||||||
|
from app.models.auth import AuthUserDocument
|
||||||
|
|
||||||
|
app_instance = FastAPI()
|
||||||
|
app_instance.include_router(auth_router)
|
||||||
|
app_instance.include_router(editor_router)
|
||||||
|
app_instance.include_router(proxy_router)
|
||||||
|
|
||||||
|
async def _override_current_user() -> AuthUserDocument:
|
||||||
|
return fake_user
|
||||||
|
|
||||||
|
async def _override_auth_context() -> AuthContext:
|
||||||
|
return auth_context
|
||||||
|
|
||||||
|
app_instance.dependency_overrides[require_authenticated_user] = (
|
||||||
|
_override_current_user
|
||||||
|
)
|
||||||
|
app_instance.dependency_overrides[get_auth_context] = _override_auth_context
|
||||||
|
return app_instance
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app: FastAPI):
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
async def _mongo_ping(uri: str) -> str | None:
|
||||||
|
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=500)
|
||||||
|
try:
|
||||||
|
await client.admin.command("ping")
|
||||||
|
except Exception as error: # pragma: no cover - exercised via skip path
|
||||||
|
return str(error)
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _drop_database(uri: str, database_name: str) -> None:
|
||||||
|
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=500)
|
||||||
|
try:
|
||||||
|
await client.drop_database(database_name)
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def integration_runtime(tmp_path: Path) -> Iterator[dict[str, str]]:
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.mitmproxy.manager import manager
|
||||||
|
|
||||||
|
mongodb_uri = settings.MONGODB_URI
|
||||||
|
ping_error = asyncio.run(_mongo_ping(mongodb_uri))
|
||||||
|
if ping_error is not None:
|
||||||
|
pytest.skip(f"MongoDB is not available for integration tests: {ping_error}")
|
||||||
|
|
||||||
|
db_name = f"theplug_test_{uuid4().hex}"
|
||||||
|
scan_root = tmp_path / "scan-root"
|
||||||
|
runtime_root = tmp_path / "runtime"
|
||||||
|
scan_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
runtime_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
original_db_name = settings.MONGODB_DB_NAME
|
||||||
|
original_scan_root = settings.SERVICE_SCAN_ROOT
|
||||||
|
original_auth_username = settings.AUTH_USERNAME
|
||||||
|
original_auth_password = settings.AUTH_PASSWORD
|
||||||
|
original_manager_work_dir = manager.work_dir
|
||||||
|
|
||||||
|
settings.MONGODB_DB_NAME = db_name
|
||||||
|
settings.SERVICE_SCAN_ROOT = str(scan_root)
|
||||||
|
settings.AUTH_USERNAME = "integration-admin"
|
||||||
|
settings.AUTH_PASSWORD = "integration-admin"
|
||||||
|
manager.work_dir = runtime_root
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield {
|
||||||
|
"db_name": db_name,
|
||||||
|
"scan_root": str(scan_root),
|
||||||
|
"runtime_root": str(runtime_root),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
manager.work_dir = original_manager_work_dir
|
||||||
|
manager.processes.clear()
|
||||||
|
manager.attached_pids.clear()
|
||||||
|
settings.MONGODB_DB_NAME = original_db_name
|
||||||
|
settings.SERVICE_SCAN_ROOT = original_scan_root
|
||||||
|
settings.AUTH_USERNAME = original_auth_username
|
||||||
|
settings.AUTH_PASSWORD = original_auth_password
|
||||||
|
asyncio.run(_drop_database(mongodb_uri, db_name))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def real_app(integration_runtime) -> FastAPI:
|
||||||
|
from app.main import app as real_app_instance
|
||||||
|
|
||||||
|
real_app_instance.dependency_overrides.clear()
|
||||||
|
return real_app_instance
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def real_client(real_app: FastAPI):
|
||||||
|
with TestClient(real_app) as test_client:
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_token(real_client: TestClient) -> str:
|
||||||
|
response = real_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
json={"username": "integration-admin", "password": "integration-admin"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
return response.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_headers(auth_token: str) -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {auth_token}"}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.api]
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_logout_revokes_real_token(real_client):
|
||||||
|
login_response = real_client.post(
|
||||||
|
"/auth/login",
|
||||||
|
json={"username": "integration-admin", "password": "integration-admin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
token = login_response.json()["access_token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
list_response = real_client.get("/proxy", headers=headers)
|
||||||
|
assert list_response.status_code == 200
|
||||||
|
assert list_response.json() == []
|
||||||
|
|
||||||
|
logout_response = real_client.post("/auth/logout", headers=headers)
|
||||||
|
assert logout_response.status_code == 204
|
||||||
|
|
||||||
|
revoked_response = real_client.get("/proxy", headers=headers)
|
||||||
|
assert revoked_response.status_code == 401
|
||||||
|
assert revoked_response.json()["detail"] == "Could not validate credentials"
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.api]
|
||||||
|
|
||||||
|
VALID_PROXY_CODE = """def request(flow):
|
||||||
|
return None
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_crud_round_trip_uses_real_app_and_mongo(real_client, auth_headers):
|
||||||
|
create_response = real_client.post(
|
||||||
|
"/proxy",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"proxy_port": 49081,
|
||||||
|
"name": "integration-echo",
|
||||||
|
"code": VALID_PROXY_CODE,
|
||||||
|
"protocol": "http",
|
||||||
|
"service": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 49082,
|
||||||
|
},
|
||||||
|
"healthcheck": {
|
||||||
|
"enabled": True,
|
||||||
|
"path": "/healthz",
|
||||||
|
"benchmark_enabled": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert create_response.status_code == 201
|
||||||
|
created = create_response.json()
|
||||||
|
assert created["proxy_port"] == 49081
|
||||||
|
assert created["name"] == "integration-echo"
|
||||||
|
assert created["healthcheck"]["benchmark_enabled"] is True
|
||||||
|
|
||||||
|
list_response = real_client.get("/proxy", headers=auth_headers)
|
||||||
|
assert list_response.status_code == 200
|
||||||
|
assert [item["proxy_port"] for item in list_response.json()] == [49081]
|
||||||
|
|
||||||
|
get_response = real_client.get("/proxy/49081", headers=auth_headers)
|
||||||
|
assert get_response.status_code == 200
|
||||||
|
assert get_response.json()["service"]["port"] == 49082
|
||||||
|
|
||||||
|
update_response = real_client.put(
|
||||||
|
"/proxy/49081",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"name": "integration-echo-updated",
|
||||||
|
"code": VALID_PROXY_CODE.replace("None", '"updated"'),
|
||||||
|
"healthcheck": {
|
||||||
|
"enabled": True,
|
||||||
|
"path": "/ready",
|
||||||
|
"benchmark_enabled": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert update_response.status_code == 200
|
||||||
|
updated = update_response.json()
|
||||||
|
assert updated["name"] == "integration-echo-updated"
|
||||||
|
assert updated["code"].endswith('return "updated"\n')
|
||||||
|
assert updated["healthcheck"]["path"] == "/ready"
|
||||||
|
assert updated["healthcheck"]["benchmark_enabled"] is False
|
||||||
|
|
||||||
|
history_response = real_client.get(
|
||||||
|
"/proxy/49081/health/history",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert history_response.status_code == 200
|
||||||
|
history_payload = history_response.json()
|
||||||
|
assert history_payload["proxy_port"] == 49081
|
||||||
|
assert history_payload["latest"] is None
|
||||||
|
assert history_payload["history"] == []
|
||||||
|
|
||||||
|
delete_response = real_client.delete("/proxy/49081", headers=auth_headers)
|
||||||
|
assert delete_response.status_code == 204
|
||||||
|
|
||||||
|
missing_response = real_client.get("/proxy/49081", headers=auth_headers)
|
||||||
|
assert missing_response.status_code == 404
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.integrations.docker.compose import get_all_services_in_dir
|
||||||
|
from app.integrations.docker.editor import change_port
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_services_in_dir_scans_immediate_children(tmp_path: Path):
|
||||||
|
root = tmp_path / "workspace"
|
||||||
|
project = root / "playground"
|
||||||
|
project.mkdir(parents=True)
|
||||||
|
(project / "docker-compose.yml").write_text(
|
||||||
|
"""
|
||||||
|
services:
|
||||||
|
echo:
|
||||||
|
image: hashicorp/http-echo:1.0
|
||||||
|
ports:
|
||||||
|
- "9081:5678"
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
services = get_all_services_in_dir(root)
|
||||||
|
|
||||||
|
assert services == [
|
||||||
|
{
|
||||||
|
"name": "echo",
|
||||||
|
"ports": [9081],
|
||||||
|
"bindings": [{"published": 9081, "host": None}],
|
||||||
|
"folder": project,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_services_in_dir_skips_nested_grandchildren(tmp_path: Path):
|
||||||
|
root = tmp_path / "workspace"
|
||||||
|
nested = root / "group" / "playground"
|
||||||
|
nested.mkdir(parents=True)
|
||||||
|
(nested / "docker-compose.yml").write_text(
|
||||||
|
"""
|
||||||
|
services:
|
||||||
|
echo:
|
||||||
|
image: hashicorp/http-echo:1.0
|
||||||
|
ports:
|
||||||
|
- "9081:5678"
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
services = get_all_services_in_dir(root)
|
||||||
|
|
||||||
|
assert services == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_services_in_dir_returns_empty_when_root_is_inaccessible(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
root = tmp_path / "workspace"
|
||||||
|
root.mkdir()
|
||||||
|
original_iterdir = Path.iterdir
|
||||||
|
|
||||||
|
def fake_iterdir(self: Path):
|
||||||
|
if self == root:
|
||||||
|
raise PermissionError("denied")
|
||||||
|
return original_iterdir(self)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "iterdir", fake_iterdir)
|
||||||
|
|
||||||
|
assert get_all_services_in_dir(root) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_all_services_in_dir_skips_inaccessible_child_folder(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
root = tmp_path / "workspace"
|
||||||
|
allowed = root / "allowed"
|
||||||
|
blocked = root / "blocked"
|
||||||
|
allowed.mkdir(parents=True)
|
||||||
|
blocked.mkdir(parents=True)
|
||||||
|
(allowed / "docker-compose.yml").write_text(
|
||||||
|
"""
|
||||||
|
services:
|
||||||
|
echo:
|
||||||
|
image: hashicorp/http-echo:1.0
|
||||||
|
ports:
|
||||||
|
- "9081:5678"
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
original_is_dir = Path.is_dir
|
||||||
|
|
||||||
|
def fake_is_dir(self: Path):
|
||||||
|
if self == blocked:
|
||||||
|
raise PermissionError("denied")
|
||||||
|
return original_is_dir(self)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "is_dir", fake_is_dir)
|
||||||
|
|
||||||
|
services = get_all_services_in_dir(root)
|
||||||
|
|
||||||
|
assert services == [
|
||||||
|
{
|
||||||
|
"name": "echo",
|
||||||
|
"ports": [9081],
|
||||||
|
"bindings": [{"published": 9081, "host": None}],
|
||||||
|
"folder": allowed,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_port_restores_original_compose_when_reload_fails(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
folder = tmp_path / "workspace"
|
||||||
|
folder.mkdir()
|
||||||
|
compose_path = folder / "docker-compose.yml"
|
||||||
|
original = """
|
||||||
|
services:
|
||||||
|
echo:
|
||||||
|
image: hashicorp/http-echo:1.0
|
||||||
|
ports:
|
||||||
|
- "9081:5678"
|
||||||
|
""".strip()
|
||||||
|
compose_path.write_text(original, encoding="utf-8")
|
||||||
|
|
||||||
|
async def fake_run_docker_command(
|
||||||
|
_file: Path,
|
||||||
|
_command: list[str],
|
||||||
|
) -> dict | None:
|
||||||
|
return {"status": "error", "reason": "boom"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.integrations.docker.editor.run_docker_command",
|
||||||
|
fake_run_docker_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = asyncio.run(change_port("echo", folder, 9081, 54001))
|
||||||
|
|
||||||
|
assert changed is False
|
||||||
|
assert compose_path.read_text(encoding="utf-8") == original
|
||||||
|
assert (folder / "docker-compose.bak.yml").exists() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_port_restores_original_compose_when_reload_raises(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
folder = tmp_path / "workspace"
|
||||||
|
folder.mkdir()
|
||||||
|
compose_path = folder / "docker-compose.yml"
|
||||||
|
original = """
|
||||||
|
services:
|
||||||
|
echo:
|
||||||
|
image: hashicorp/http-echo:1.0
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:54001:5678"
|
||||||
|
""".strip()
|
||||||
|
compose_path.write_text(original, encoding="utf-8")
|
||||||
|
|
||||||
|
async def fake_run_docker_command(
|
||||||
|
_file: Path,
|
||||||
|
_command: list[str],
|
||||||
|
) -> dict | None:
|
||||||
|
raise RuntimeError("docker unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.integrations.docker.editor.run_docker_command",
|
||||||
|
fake_run_docker_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = asyncio.run(
|
||||||
|
change_port(
|
||||||
|
"echo",
|
||||||
|
folder,
|
||||||
|
54001,
|
||||||
|
9081,
|
||||||
|
host_to=None,
|
||||||
|
rewrite_host=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert changed is False
|
||||||
|
assert compose_path.read_text(encoding="utf-8") == original
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from app.integrations.docker.ports import _get_service_binding, _replace_port_in_mapping
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_service_binding_includes_loopback_hosts():
|
||||||
|
assert _get_service_binding("127.0.0.1:8080:80") == (8080, "127.0.0.1")
|
||||||
|
assert _get_service_binding("localhost:9081:5678") == (9081, "localhost")
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_port_in_mapping_can_force_loopback_host():
|
||||||
|
updated = _replace_port_in_mapping(
|
||||||
|
"9081:5678",
|
||||||
|
9081,
|
||||||
|
54001,
|
||||||
|
host_to="127.0.0.1",
|
||||||
|
rewrite_host=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated == "127.0.0.1:54001:5678"
|
||||||
|
|
||||||
|
|
||||||
|
def test_replace_port_in_mapping_can_restore_unspecified_host():
|
||||||
|
restored = _replace_port_in_mapping(
|
||||||
|
"127.0.0.1:54001:5678",
|
||||||
|
54001,
|
||||||
|
9081,
|
||||||
|
host_to=None,
|
||||||
|
rewrite_host=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert restored == "9081:5678"
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.models.health import HealthSnapshot
|
||||||
|
from app.models.proxy import ProxyDocument
|
||||||
|
from app.models.service import ServiceEndpointDocument
|
||||||
|
from app.services.mitmproxy.health import check_proxy
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_proxy_reports_upstream_and_proxy_healthy():
|
||||||
|
async def run():
|
||||||
|
upstream = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
||||||
|
proxy = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
||||||
|
try:
|
||||||
|
upstream_port = upstream.sockets[0].getsockname()[1]
|
||||||
|
proxy_port = proxy.sockets[0].getsockname()[1]
|
||||||
|
document = ProxyDocument(
|
||||||
|
proxy_port=proxy_port,
|
||||||
|
name="health",
|
||||||
|
code="",
|
||||||
|
service=ServiceEndpointDocument(port=upstream_port),
|
||||||
|
)
|
||||||
|
|
||||||
|
health = await check_proxy(document)
|
||||||
|
finally:
|
||||||
|
upstream.close()
|
||||||
|
proxy.close()
|
||||||
|
await upstream.wait_closed()
|
||||||
|
await proxy.wait_closed()
|
||||||
|
|
||||||
|
assert isinstance(health, HealthSnapshot)
|
||||||
|
assert health.overall == "healthy"
|
||||||
|
assert [check.target for check in health.checks] == [
|
||||||
|
"upstream",
|
||||||
|
"proxy",
|
||||||
|
]
|
||||||
|
assert all(check.duration_ms is None for check in health.checks)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_proxy_reports_benchmark_latency_when_enabled():
|
||||||
|
async def run():
|
||||||
|
upstream = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
||||||
|
proxy = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
||||||
|
try:
|
||||||
|
upstream_port = upstream.sockets[0].getsockname()[1]
|
||||||
|
proxy_port = proxy.sockets[0].getsockname()[1]
|
||||||
|
document = ProxyDocument(
|
||||||
|
proxy_port=proxy_port,
|
||||||
|
name="health",
|
||||||
|
code="",
|
||||||
|
healthcheck={"benchmark_enabled": True},
|
||||||
|
service=ServiceEndpointDocument(port=upstream_port),
|
||||||
|
)
|
||||||
|
|
||||||
|
health = await check_proxy(document)
|
||||||
|
finally:
|
||||||
|
upstream.close()
|
||||||
|
proxy.close()
|
||||||
|
await upstream.wait_closed()
|
||||||
|
await proxy.wait_closed()
|
||||||
|
|
||||||
|
assert isinstance(health, HealthSnapshot)
|
||||||
|
assert all(
|
||||||
|
check.duration_ms is not None and check.duration_ms >= 0
|
||||||
|
for check in health.checks
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
async def http_handler(reader, writer):
|
||||||
|
await reader.readline()
|
||||||
|
writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.health import HealthSnapshot
|
||||||
|
from app.models.proxy import ProxyDocument
|
||||||
|
from app.models.service import ServiceEndpointDocument
|
||||||
|
from app.services.mitmproxy.health_monitor import HealthMonitor
|
||||||
|
|
||||||
|
|
||||||
|
def make_proxy(**updates):
|
||||||
|
data = {
|
||||||
|
"proxy_port": 8080,
|
||||||
|
"name": "svc",
|
||||||
|
"code": "",
|
||||||
|
"status": "running",
|
||||||
|
"service": ServiceEndpointDocument(port=9000),
|
||||||
|
}
|
||||||
|
data.update(updates)
|
||||||
|
return ProxyDocument(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def make_snapshot(proxy_port: int, *, overall: str = "healthy") -> HealthSnapshot:
|
||||||
|
return HealthSnapshot(
|
||||||
|
proxy_port=proxy_port,
|
||||||
|
timestamp=datetime.now(UTC),
|
||||||
|
enabled=True,
|
||||||
|
overall=overall,
|
||||||
|
checks=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cycle_records_and_publishes_running_healthchecks(monkeypatch):
|
||||||
|
monitor = HealthMonitor()
|
||||||
|
proxy = make_proxy()
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
snapshot = make_snapshot(8080)
|
||||||
|
|
||||||
|
async def fake_list_proxies():
|
||||||
|
return [proxy]
|
||||||
|
|
||||||
|
async def fake_check_proxy(document):
|
||||||
|
captured["checked"] = document.proxy_port
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
async def fake_record_health_sample(document):
|
||||||
|
captured["recorded"] = document
|
||||||
|
return document
|
||||||
|
|
||||||
|
async def fake_publish(proxy_port, document):
|
||||||
|
captured["published"] = (proxy_port, document)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.health_monitor.list_proxies",
|
||||||
|
fake_list_proxies,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.health_monitor.check_proxy",
|
||||||
|
fake_check_proxy,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.health_monitor.record_health_sample",
|
||||||
|
fake_record_health_sample,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(monitor, "publish", fake_publish)
|
||||||
|
|
||||||
|
asyncio.run(monitor.run_cycle())
|
||||||
|
|
||||||
|
assert captured["checked"] == 8080
|
||||||
|
assert captured["recorded"] == snapshot
|
||||||
|
assert captured["published"] == (8080, snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cycle_skips_stopped_or_disabled_proxies(monkeypatch):
|
||||||
|
monitor = HealthMonitor()
|
||||||
|
running_disabled = make_proxy(healthcheck={"enabled": False})
|
||||||
|
stopped = make_proxy(proxy_port=8081, status="stopped")
|
||||||
|
called = False
|
||||||
|
|
||||||
|
async def fake_list_proxies():
|
||||||
|
return [running_disabled, stopped]
|
||||||
|
|
||||||
|
async def fake_check_proxy(_document):
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return make_snapshot(8080)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.health_monitor.list_proxies",
|
||||||
|
fake_list_proxies,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.health_monitor.check_proxy",
|
||||||
|
fake_check_proxy,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(monitor.run_cycle())
|
||||||
|
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cycle_checks_proxies_in_parallel(monkeypatch):
|
||||||
|
monitor = HealthMonitor()
|
||||||
|
proxies = [
|
||||||
|
make_proxy(proxy_port=8080),
|
||||||
|
make_proxy(proxy_port=8081),
|
||||||
|
make_proxy(proxy_port=8082),
|
||||||
|
]
|
||||||
|
started: list[int] = []
|
||||||
|
release = asyncio.Event()
|
||||||
|
|
||||||
|
async def fake_list_proxies():
|
||||||
|
return proxies
|
||||||
|
|
||||||
|
async def fake_check_proxy(document):
|
||||||
|
started.append(document.proxy_port)
|
||||||
|
if len(started) == len(proxies):
|
||||||
|
release.set()
|
||||||
|
await release.wait()
|
||||||
|
return make_snapshot(document.proxy_port)
|
||||||
|
|
||||||
|
async def fake_record_health_sample(document):
|
||||||
|
return document
|
||||||
|
|
||||||
|
async def fake_publish(_proxy_port, _document):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.health_monitor.list_proxies",
|
||||||
|
fake_list_proxies,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.health_monitor.check_proxy",
|
||||||
|
fake_check_proxy,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.health_monitor.record_health_sample",
|
||||||
|
fake_record_health_sample,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(monitor, "publish", fake_publish)
|
||||||
|
|
||||||
|
asyncio.run(asyncio.wait_for(monitor.run_cycle(), timeout=1))
|
||||||
|
|
||||||
|
assert started == [8080, 8081, 8082]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cycle_continues_when_one_proxy_check_fails(monkeypatch):
|
||||||
|
monitor = HealthMonitor()
|
||||||
|
proxies = [
|
||||||
|
make_proxy(proxy_port=8080),
|
||||||
|
make_proxy(proxy_port=8081),
|
||||||
|
]
|
||||||
|
captured: list[int] = []
|
||||||
|
|
||||||
|
async def fake_list_proxies():
|
||||||
|
return proxies
|
||||||
|
|
||||||
|
async def fake_check_proxy(document):
|
||||||
|
if document.proxy_port == 8080:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
return make_snapshot(document.proxy_port)
|
||||||
|
|
||||||
|
async def fake_record_health_sample(document):
|
||||||
|
captured.append(document.proxy_port)
|
||||||
|
return document
|
||||||
|
|
||||||
|
async def fake_publish(proxy_port, _document):
|
||||||
|
captured.append(proxy_port + 10_000)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.health_monitor.list_proxies",
|
||||||
|
fake_list_proxies,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.health_monitor.check_proxy",
|
||||||
|
fake_check_proxy,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.health_monitor.record_health_sample",
|
||||||
|
fake_record_health_sample,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(monitor, "publish", fake_publish)
|
||||||
|
|
||||||
|
asyncio.run(monitor.run_cycle())
|
||||||
|
|
||||||
|
assert captured == [8081, 18081]
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_retries_after_cycle_failure(monkeypatch):
|
||||||
|
monitor = HealthMonitor()
|
||||||
|
calls = {"run_cycle": 0}
|
||||||
|
sleep_calls: list[float] = []
|
||||||
|
|
||||||
|
async def fake_run_cycle():
|
||||||
|
calls["run_cycle"] += 1
|
||||||
|
if calls["run_cycle"] == 1:
|
||||||
|
raise RuntimeError("temporary failure")
|
||||||
|
raise asyncio.CancelledError
|
||||||
|
|
||||||
|
async def fake_sleep(seconds):
|
||||||
|
sleep_calls.append(seconds)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(monitor, "run_cycle", fake_run_cycle)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.health_monitor.asyncio.sleep",
|
||||||
|
fake_sleep,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(monitor.run())
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert calls["run_cycle"] == 2
|
||||||
|
assert len(sleep_calls) == 1
|
||||||
|
assert sleep_calls[0] == pytest.approx(1.0, abs=0.01)
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.proxy import ProxyDocument
|
||||||
|
from app.models.service import ServiceEndpointDocument
|
||||||
|
from app.services.mitmproxy.manager import MitmdumpError, MitmdumpManager
|
||||||
|
|
||||||
|
|
||||||
|
def make_proxy(**updates):
|
||||||
|
data = {
|
||||||
|
"proxy_port": 8080,
|
||||||
|
"name": "svc",
|
||||||
|
"code": "",
|
||||||
|
"service": ServiceEndpointDocument(port=9000),
|
||||||
|
}
|
||||||
|
data.update(updates)
|
||||||
|
return ProxyDocument(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reverse_mode_is_built_from_service():
|
||||||
|
manager = MitmdumpManager()
|
||||||
|
proxy = make_proxy(protocol="tcp")
|
||||||
|
|
||||||
|
command = manager.build_command(
|
||||||
|
"mitmdump",
|
||||||
|
proxy,
|
||||||
|
9100,
|
||||||
|
Path("/tmp/addon.py"),
|
||||||
|
Path("/tmp/conf/config.yaml"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "reverse:tcp://127.0.0.1:9100@8080" in command
|
||||||
|
assert "--listen-port" not in command
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_mode_spec_overrides_generated_mode():
|
||||||
|
manager = MitmdumpManager()
|
||||||
|
proxy = make_proxy(mode="regular", mode_spec="wireguard:/tmp/wg.conf@51820")
|
||||||
|
|
||||||
|
command = manager.build_command(
|
||||||
|
"mitmdump",
|
||||||
|
proxy,
|
||||||
|
None,
|
||||||
|
Path("/tmp/addon.py"),
|
||||||
|
Path("/tmp/conf/config.yaml"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "wireguard:/tmp/wg.conf@51820" in command
|
||||||
|
assert "--listen-port" not in command
|
||||||
|
|
||||||
|
|
||||||
|
def test_regular_mode_uses_proxy_port_as_listen_port():
|
||||||
|
manager = MitmdumpManager()
|
||||||
|
proxy = make_proxy(mode="regular")
|
||||||
|
|
||||||
|
command = manager.build_command(
|
||||||
|
"mitmdump",
|
||||||
|
proxy,
|
||||||
|
None,
|
||||||
|
Path("/tmp/addon.py"),
|
||||||
|
Path("/tmp/conf/config.yaml"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "regular" in command
|
||||||
|
assert command[-2:] == ["--listen-port", "8080"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_rejects_reserved_backend_port():
|
||||||
|
manager = MitmdumpManager()
|
||||||
|
proxy = make_proxy(proxy_port=3671)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
await manager.start(proxy)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
MitmdumpError,
|
||||||
|
match="Proxy port 3671 is reserved for the backend API",
|
||||||
|
):
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_document_without_pid_is_treated_as_stopped():
|
||||||
|
manager = MitmdumpManager()
|
||||||
|
proxy = make_proxy(status="running", pid=None)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
return await manager.status(proxy)
|
||||||
|
|
||||||
|
assert asyncio.run(run()) == "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_proxy_process_uses_expected_script_and_confdir():
|
||||||
|
manager = MitmdumpManager()
|
||||||
|
proxy = make_proxy()
|
||||||
|
|
||||||
|
manager.read_cmdline = lambda _pid: [
|
||||||
|
"/home/test/.venv/bin/python",
|
||||||
|
"/home/test/.venv/bin/mitmdump",
|
||||||
|
"--mode",
|
||||||
|
"reverse:http://127.0.0.1:9000@8080",
|
||||||
|
"--listen-host",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--set",
|
||||||
|
"confdir=/tmp/theplug/conf/8080",
|
||||||
|
"-s",
|
||||||
|
"/tmp/theplug/addons/8080.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
assert manager.matches_proxy_process(proxy, 1234) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_reattaches_matching_process():
|
||||||
|
manager = MitmdumpManager()
|
||||||
|
proxy = make_proxy(
|
||||||
|
status="running",
|
||||||
|
pid=4321,
|
||||||
|
service=ServiceEndpointDocument(port=9000, upstream_port=54003),
|
||||||
|
)
|
||||||
|
manager.is_process_alive = lambda _pid: True
|
||||||
|
manager.matches_proxy_process = lambda _proxy, _pid: True
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
return await manager.reconcile(proxy)
|
||||||
|
|
||||||
|
values = asyncio.run(run())
|
||||||
|
|
||||||
|
assert values is not None
|
||||||
|
assert values["status"] == "running"
|
||||||
|
assert values["pid"] == 4321
|
||||||
|
assert manager.attached_pids[8080] == 4321
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_marks_error_for_mismatched_live_process():
|
||||||
|
manager = MitmdumpManager()
|
||||||
|
proxy = make_proxy(
|
||||||
|
status="running",
|
||||||
|
pid=4321,
|
||||||
|
service=ServiceEndpointDocument(port=9000, upstream_port=54003),
|
||||||
|
)
|
||||||
|
manager.is_process_alive = lambda _pid: True
|
||||||
|
manager.matches_proxy_process = lambda _proxy, _pid: False
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
return await manager.reconcile(proxy)
|
||||||
|
|
||||||
|
values = asyncio.run(run())
|
||||||
|
|
||||||
|
assert values is not None
|
||||||
|
assert values["status"] == "error"
|
||||||
|
assert values["pid"] is None
|
||||||
|
assert "does not match" in str(values["last_error"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_restores_upstream_when_process_is_gone():
|
||||||
|
manager = MitmdumpManager()
|
||||||
|
proxy = make_proxy(
|
||||||
|
status="running",
|
||||||
|
pid=4321,
|
||||||
|
service=ServiceEndpointDocument(
|
||||||
|
port=9000,
|
||||||
|
upstream_port=54003,
|
||||||
|
name="echo",
|
||||||
|
folder="/tmp/playground",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
manager.is_process_alive = lambda _pid: False
|
||||||
|
|
||||||
|
async def fake_restore(_proxy, _upstream_port):
|
||||||
|
return True
|
||||||
|
|
||||||
|
manager.restore_upstream = fake_restore
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
return await manager.reconcile(proxy)
|
||||||
|
|
||||||
|
values = asyncio.run(run())
|
||||||
|
|
||||||
|
assert values == {
|
||||||
|
"status": "stopped",
|
||||||
|
"pid": None,
|
||||||
|
"service.upstream_port": None,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_runtime_artifacts_removes_generated_files(tmp_path: Path):
|
||||||
|
manager = MitmdumpManager(work_dir=str(tmp_path / "theplug"))
|
||||||
|
proxy = make_proxy(
|
||||||
|
script_path=str(manager.script_path_for(make_proxy())),
|
||||||
|
config_path=str(manager.config_path_for(make_proxy())),
|
||||||
|
log_path=str(manager.log_path_for(make_proxy())),
|
||||||
|
)
|
||||||
|
|
||||||
|
script_path = Path(proxy.script_path)
|
||||||
|
config_path = Path(proxy.config_path)
|
||||||
|
log_path = Path(proxy.log_path)
|
||||||
|
script_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
script_path.write_text("print('hello')", encoding="utf-8")
|
||||||
|
config_path.write_text("block_global: false\n", encoding="utf-8")
|
||||||
|
log_path.write_text("old log\n", encoding="utf-8")
|
||||||
|
|
||||||
|
manager.cleanup_runtime_artifacts(proxy)
|
||||||
|
|
||||||
|
assert script_path.exists() is False
|
||||||
|
assert log_path.exists() is False
|
||||||
|
assert config_path.exists() is False
|
||||||
|
assert manager.conf_dir_for(proxy).exists() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_upstream_retries_until_port_remap_succeeds(monkeypatch):
|
||||||
|
manager = MitmdumpManager()
|
||||||
|
proxy = make_proxy(
|
||||||
|
mode="reverse",
|
||||||
|
service=ServiceEndpointDocument(
|
||||||
|
port=9000,
|
||||||
|
name="echo",
|
||||||
|
folder="/tmp/playground",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
attempted_ports: list[int] = []
|
||||||
|
candidate_ports = iter([54001, 54002, 54003])
|
||||||
|
|
||||||
|
async def fake_change_port(
|
||||||
|
_service_name,
|
||||||
|
_folder,
|
||||||
|
_port_from,
|
||||||
|
port_to,
|
||||||
|
**_kwargs,
|
||||||
|
):
|
||||||
|
attempted_ports.append(port_to)
|
||||||
|
return port_to == 54003
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.manager.get_random_port",
|
||||||
|
lambda: next(candidate_ports),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.manager.change_port",
|
||||||
|
fake_change_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
upstream_port = asyncio.run(manager.prepare_upstream(proxy))
|
||||||
|
|
||||||
|
assert upstream_port == 54003
|
||||||
|
assert attempted_ports == [54001, 54002, 54003]
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_upstream_fails_after_retry_budget(monkeypatch):
|
||||||
|
manager = MitmdumpManager()
|
||||||
|
manager.upstream_port_attempts = 3
|
||||||
|
proxy = make_proxy(
|
||||||
|
mode="reverse",
|
||||||
|
service=ServiceEndpointDocument(
|
||||||
|
port=9000,
|
||||||
|
name="echo",
|
||||||
|
folder="/tmp/playground",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
candidate_ports = iter([54001, 54002, 54003])
|
||||||
|
|
||||||
|
async def fake_change_port(*_args, **_kwargs):
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.manager.get_random_port",
|
||||||
|
lambda: next(candidate_ports),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.mitmproxy.manager.change_port",
|
||||||
|
fake_change_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
MitmdumpError,
|
||||||
|
match="Unable to move Docker service port after retries",
|
||||||
|
):
|
||||||
|
asyncio.run(manager.prepare_upstream(proxy))
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.mitmproxy.options import (
|
||||||
|
MitmOption,
|
||||||
|
parse_options,
|
||||||
|
public_options,
|
||||||
|
validate_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_options_extracts_types_and_choices():
|
||||||
|
text = """
|
||||||
|
# Enable feature. Type bool.
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Valid values are 'eager', 'lazy'.
|
||||||
|
strategy: eager
|
||||||
|
|
||||||
|
# Hosts. Type sequence of str.
|
||||||
|
hosts: []
|
||||||
|
"""
|
||||||
|
options = parse_options(
|
||||||
|
text,
|
||||||
|
{"enabled": False, "strategy": "eager", "hosts": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert options["enabled"].value_type == "bool"
|
||||||
|
assert options["strategy"].choices == ["eager", "lazy"]
|
||||||
|
assert options["hosts"].value_type == "sequence of str"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("option", "value", "message"),
|
||||||
|
[
|
||||||
|
(MitmOption("anticache", "bool", False, "", []), "true", "expected bool"),
|
||||||
|
(MitmOption("flow_detail", "int", 1, "", []), "1", "expected int"),
|
||||||
|
(MitmOption("allow_hosts", "sequence of str", [], "", []), "x", "list[str]"),
|
||||||
|
(MitmOption("strategy", "unknown", "eager", "", ["eager"]), "lazy", "one of"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_value_rejects_wrong_types(option, value, message):
|
||||||
|
assert message in validate_value(option, value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_options_include_docs_links():
|
||||||
|
options = {
|
||||||
|
"flow_detail": MitmOption("flow_detail", "int", 1, "Flow detail.", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
public = public_options(options)
|
||||||
|
|
||||||
|
assert public[0]["docs_url"].endswith("#flow_detail")
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from app.services.editor import python_lsp
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_message():
|
||||||
|
message = '{"jsonrpc":"2.0"}'
|
||||||
|
|
||||||
|
payload = python_lsp.encode_message(message)
|
||||||
|
|
||||||
|
assert payload.endswith(message.encode("utf-8"))
|
||||||
|
assert payload.startswith(
|
||||||
|
f"Content-Length: {len(message.encode('utf-8'))}\r\n\r\n".encode("ascii")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_buffer_reads_split_messages():
|
||||||
|
buffer = python_lsp.LspFrameBuffer()
|
||||||
|
first = python_lsp.encode_message('{"id":1}')
|
||||||
|
second = python_lsp.encode_message('{"id":2}')
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
first[:12],
|
||||||
|
first[12:] + second[:10],
|
||||||
|
second[10:],
|
||||||
|
]
|
||||||
|
|
||||||
|
messages: list[str] = []
|
||||||
|
for part in parts:
|
||||||
|
messages.extend(buffer.feed(part))
|
||||||
|
|
||||||
|
assert messages == ['{"id":1}', '{"id":2}']
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.models.proxy import ProxyDocument
|
||||||
|
from app.models.service import ServiceEndpointDocument
|
||||||
|
from app.services.mitmproxy import recovery as recovery_module
|
||||||
|
|
||||||
|
|
||||||
|
def make_proxy(**updates):
|
||||||
|
data = {
|
||||||
|
"proxy_port": 8080,
|
||||||
|
"name": "svc",
|
||||||
|
"code": "",
|
||||||
|
"service": ServiceEndpointDocument(port=9000),
|
||||||
|
}
|
||||||
|
data.update(updates)
|
||||||
|
return ProxyDocument(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_runtime_persists_manager_updates(monkeypatch):
|
||||||
|
proxy = make_proxy(status="running", pid=1234)
|
||||||
|
captured: list[tuple[int, dict[str, object]]] = []
|
||||||
|
|
||||||
|
async def fake_list_proxies():
|
||||||
|
return [proxy]
|
||||||
|
|
||||||
|
class FakeManager:
|
||||||
|
async def reconcile(self, document: ProxyDocument):
|
||||||
|
assert document.proxy_port == 8080
|
||||||
|
return {"status": "running", "pid": 1234, "last_error": None}
|
||||||
|
|
||||||
|
async def fake_update_proxy_runtime(proxy_port: int, values: dict[str, object]):
|
||||||
|
captured.append((proxy_port, values))
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
monkeypatch.setattr(recovery_module, "list_proxies", fake_list_proxies)
|
||||||
|
monkeypatch.setattr(recovery_module, "manager", FakeManager())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
recovery_module,
|
||||||
|
"update_proxy_runtime",
|
||||||
|
fake_update_proxy_runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
changes = asyncio.run(recovery_module.reconcile_runtime())
|
||||||
|
|
||||||
|
assert changes == [(8080, {"status": "running", "pid": 1234, "last_error": None})]
|
||||||
|
assert captured == [(8080, {"status": "running", "pid": 1234, "last_error": None})]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_runtime_skips_unchanged_proxies(monkeypatch):
|
||||||
|
proxy = make_proxy()
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
async def fake_list_proxies():
|
||||||
|
return [proxy]
|
||||||
|
|
||||||
|
class FakeManager:
|
||||||
|
async def reconcile(self, _document: ProxyDocument):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def fake_update_proxy_runtime(_proxy_port: int, _values: dict[str, object]):
|
||||||
|
nonlocal updated
|
||||||
|
updated = True
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
monkeypatch.setattr(recovery_module, "list_proxies", fake_list_proxies)
|
||||||
|
monkeypatch.setattr(recovery_module, "manager", FakeManager())
|
||||||
|
monkeypatch.setattr(
|
||||||
|
recovery_module,
|
||||||
|
"update_proxy_runtime",
|
||||||
|
fake_update_proxy_runtime,
|
||||||
|
)
|
||||||
|
|
||||||
|
changes = asyncio.run(recovery_module.reconcile_runtime())
|
||||||
|
|
||||||
|
assert changes == []
|
||||||
|
assert updated is False
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.services.mitmproxy.templates import list_file_templates
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_templates_use_four_space_indentation():
|
||||||
|
for template in list_file_templates():
|
||||||
|
for line in template.code.splitlines():
|
||||||
|
assert "\t" not in line, f"{template.id} contains a tab: {line!r}"
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
indent = len(line) - len(line.lstrip(" "))
|
||||||
|
assert indent % 4 == 0, (
|
||||||
|
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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app.core.security import create_access_token, decode_access_token
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_access_token_uses_twelve_hour_default_window(monkeypatch):
|
||||||
|
monkeypatch.setattr("app.core.security.settings.JWT_EXPIRE_MINUTES", 720)
|
||||||
|
|
||||||
|
issued_at = datetime.now(UTC)
|
||||||
|
token = create_access_token(subject="admin")
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
expires_at = datetime.fromtimestamp(payload.exp, UTC)
|
||||||
|
|
||||||
|
lifetime_seconds = (expires_at - issued_at).total_seconds()
|
||||||
|
|
||||||
|
assert 11.9 * 60 * 60 <= lifetime_seconds <= 12.1 * 60 * 60
|
||||||
Generated
+901
-26
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||||
|
die() { echo -e "${RED}[x] FATAL:${NC} $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BACKEND_DIR="$ROOT_DIR/backend"
|
||||||
|
FRONTEND_DIR="$ROOT_DIR/frontend"
|
||||||
|
PLAYGROUND_DIR="$ROOT_DIR/playground"
|
||||||
|
ENV_FILE="$BACKEND_DIR/.env"
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_env() {
|
||||||
|
[[ -f "$ENV_FILE" ]] || die "Missing $ENV_FILE. Run ./setup.sh first."
|
||||||
|
}
|
||||||
|
|
||||||
|
env_value() {
|
||||||
|
local key="$1"
|
||||||
|
local default_value="${2:-}"
|
||||||
|
local value
|
||||||
|
value=$(grep -E "^${key}=" "$ENV_FILE" | cut -d '=' -f2- || true)
|
||||||
|
if [[ -n "$value" ]]; then
|
||||||
|
printf '%s\n' "$value"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$default_value"
|
||||||
|
}
|
||||||
|
|
||||||
|
compose() {
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.yml" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
service_compose_path() {
|
||||||
|
local name="$1"
|
||||||
|
local path="$PLAYGROUND_DIR/$name/docker-compose.yml"
|
||||||
|
[[ -f "$path" ]] || die "Unknown service group: $name"
|
||||||
|
printf '%s\n' "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
service_names() {
|
||||||
|
find "$PLAYGROUND_DIR" -mindepth 2 -maxdepth 2 -name docker-compose.yml -printf '%h\n' \
|
||||||
|
| xargs -r -n1 basename \
|
||||||
|
| sort
|
||||||
|
}
|
||||||
|
|
||||||
|
run_backend() {
|
||||||
|
require_command uv
|
||||||
|
require_env
|
||||||
|
local api_port
|
||||||
|
api_port=$(env_value API_PORT 3671)
|
||||||
|
info "Starting backend dev server on http://0.0.0.0:$api_port"
|
||||||
|
(
|
||||||
|
cd "$BACKEND_DIR"
|
||||||
|
uv run fastapi dev app/main.py --host 0.0.0.0 --port "$api_port"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
run_frontend() {
|
||||||
|
require_command npm
|
||||||
|
require_env
|
||||||
|
info "Starting frontend Vite dev server"
|
||||||
|
(
|
||||||
|
cd "$FRONTEND_DIR"
|
||||||
|
npm run dev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
run_mongo() {
|
||||||
|
require_command docker
|
||||||
|
require_env
|
||||||
|
local action="${1:-status}"
|
||||||
|
case "$action" in
|
||||||
|
up)
|
||||||
|
compose up -d mongodb
|
||||||
|
;;
|
||||||
|
down)
|
||||||
|
compose stop mongodb
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
compose ps mongodb
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
compose logs -f mongodb
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unknown mongo action: $action"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
run_services() {
|
||||||
|
require_command docker
|
||||||
|
local action="${1:-list}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
list)
|
||||||
|
service_names
|
||||||
|
;;
|
||||||
|
up|down|status)
|
||||||
|
local target="${1:-all}"
|
||||||
|
if [[ "$target" == "all" ]]; then
|
||||||
|
while IFS= read -r name; do
|
||||||
|
[[ -n "$name" ]] || continue
|
||||||
|
local compose_file
|
||||||
|
compose_file=$(service_compose_path "$name")
|
||||||
|
case "$action" in
|
||||||
|
up) docker compose -f "$compose_file" up -d ;;
|
||||||
|
down) docker compose -f "$compose_file" down ;;
|
||||||
|
status) docker compose -f "$compose_file" ps ;;
|
||||||
|
esac
|
||||||
|
done < <(service_names)
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local compose_file
|
||||||
|
compose_file=$(service_compose_path "$target")
|
||||||
|
case "$action" in
|
||||||
|
up) docker compose -f "$compose_file" up -d ;;
|
||||||
|
down) docker compose -f "$compose_file" down ;;
|
||||||
|
status) docker compose -f "$compose_file" ps ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unknown services action: $action"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
./dev.sh backend
|
||||||
|
./dev.sh frontend
|
||||||
|
./dev.sh mongo [up|down|status|logs]
|
||||||
|
./dev.sh services [list|up|down|status] [name|all]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
./dev.sh mongo up
|
||||||
|
./dev.sh backend
|
||||||
|
./dev.sh frontend
|
||||||
|
./dev.sh services list
|
||||||
|
./dev.sh services up GabibbiTowers
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local command="${1:-help}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
case "$command" in
|
||||||
|
backend)
|
||||||
|
run_backend "$@"
|
||||||
|
;;
|
||||||
|
frontend)
|
||||||
|
run_frontend "$@"
|
||||||
|
;;
|
||||||
|
mongo)
|
||||||
|
run_mongo "$@"
|
||||||
|
;;
|
||||||
|
services)
|
||||||
|
run_services "$@"
|
||||||
|
;;
|
||||||
|
help|-h|--help)
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unknown command: $command"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+13
-47
@@ -1,68 +1,34 @@
|
|||||||
services:
|
services:
|
||||||
api:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
container_name: theplug-frontend
|
||||||
container_name: theplug-api
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "${API_PORT:-3671}:3671"
|
|
||||||
environment:
|
environment:
|
||||||
MONGODB_URI: mongodb://${MONGO_USER:-theplug}:${MONGO_PASSWORD:-theplug_dev_password}@mongodb:27017/${MONGO_DB:-theplug}?authSource=admin
|
API_UPSTREAM: "host.docker.internal:${API_PORT:-3671}"
|
||||||
MONGODB_DB_NAME: ${MONGO_DB:-theplug}
|
ports:
|
||||||
REDIS_URL: redis://redis:6379/0
|
- "${FRONTEND_PORT:-5173}:80"
|
||||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-this-secret}
|
extra_hosts:
|
||||||
JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256}
|
- "host.docker.internal:host-gateway"
|
||||||
JWT_EXPIRE_MINUTES: ${JWT_EXPIRE_MINUTES:-30}
|
|
||||||
AUTH_USERNAME: ${AUTH_USERNAME:-admin}
|
|
||||||
AUTH_PASSWORD: ${AUTH_PASSWORD:-change-this-password}
|
|
||||||
depends_on:
|
|
||||||
mongodb:
|
|
||||||
condition: service_healthy
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:3671/').read()\""]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
mongodb:
|
mongodb:
|
||||||
image: mongo:8
|
image: mongo:8.2
|
||||||
container_name: theplug-mongodb
|
container_name: theplug-mongodb
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug}
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug}
|
||||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-pass}
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}
|
||||||
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
|
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
|
||||||
ports:
|
ports:
|
||||||
- "27017:27017"
|
- "27017:27017"
|
||||||
volumes:
|
volumes:
|
||||||
- mongodb_data:/data/db
|
- theplug_mongodb:/data/db
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "mongosh --quiet --username ${MONGO_USER:-theplug} --password ${MONGO_PASSWORD:-theplug_dev_password} --authenticationDatabase admin --eval \"db.adminCommand({ ping: 1 }).ok\" | grep 1"]
|
test: ["CMD-SHELL", "mongosh --quiet --username ${MONGO_USER:-theplug} --password ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password} --authenticationDatabase admin --eval \"db.adminCommand({ ping: 1 }).ok\" | grep 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:8-alpine
|
|
||||||
container_name: theplug-redis
|
|
||||||
restart: unless-stopped
|
|
||||||
command: ["redis-server", "--appendonly", "yes"]
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
theplug_mongodb:
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 5s
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mongodb_data:
|
|
||||||
redis_data:
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
npm-debug.log
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.29-alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>thePlug</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://${API_UPSTREAM}/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1692
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "theplug-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0",
|
||||||
|
"typecheck": "vue-tsc -b"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.20.1",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
|
"@codemirror/lint": "^6.9.5",
|
||||||
|
"@codemirror/lsp-client": "^6.2.3",
|
||||||
|
"@codemirror/search": "^6.7.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.41.1",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
|
"vue": "^3.5.26",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.2.7",
|
||||||
|
"vue-tsc": "^3.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, provide } from "vue";
|
||||||
|
|
||||||
|
import AppHeader from "./components/AppHeader.vue";
|
||||||
|
import AppToast from "./components/AppToast.vue";
|
||||||
|
import LoginView from "./components/LoginView.vue";
|
||||||
|
import ProxyList from "./components/ProxyList.vue";
|
||||||
|
import ProxyWorkbench from "./components/ProxyWorkbench.vue";
|
||||||
|
import {
|
||||||
|
controlPlaneKey,
|
||||||
|
useControlPlane,
|
||||||
|
} from "./composables/useControlPlane";
|
||||||
|
|
||||||
|
const plane = useControlPlane();
|
||||||
|
provide(controlPlaneKey, plane);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (plane.token) void plane.refreshAll();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LoginView
|
||||||
|
v-if="!plane.token"
|
||||||
|
v-model:username="plane.username"
|
||||||
|
v-model:password="plane.password"
|
||||||
|
:loading="plane.loading"
|
||||||
|
:error="plane.authMessage || plane.loginError"
|
||||||
|
@submit="plane.signIn"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main v-else class="app-shell">
|
||||||
|
<AppToast v-if="plane.notice" :message="plane.notice" />
|
||||||
|
|
||||||
|
<AppHeader
|
||||||
|
:loading="plane.loading"
|
||||||
|
:show-discovery="Boolean(plane.selectedProxy)"
|
||||||
|
@discovery="plane.openDiscovery"
|
||||||
|
@refresh="plane.refreshAll"
|
||||||
|
@create="plane.openCreateWizard()"
|
||||||
|
@logout="plane.signOut"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="app-body">
|
||||||
|
<ProxyList
|
||||||
|
:proxies="plane.proxies"
|
||||||
|
:selected-port="plane.selectedPort"
|
||||||
|
@select="plane.selectedPort = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProxyWorkbench />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type {
|
||||||
|
HealthHistoryResponse,
|
||||||
|
HealthResult,
|
||||||
|
MitmOption,
|
||||||
|
PythonEditorSession,
|
||||||
|
ProxyDocument,
|
||||||
|
ProxyDraft,
|
||||||
|
ProxyTemplate,
|
||||||
|
ProxyTemplateCreateInput,
|
||||||
|
ServiceDiscoveryResponse,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "theplug.token";
|
||||||
|
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string | null) {
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSocketUrl(path: string) {
|
||||||
|
const token = getToken();
|
||||||
|
const base = new URL(API_BASE, window.location.origin);
|
||||||
|
base.protocol = base.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
base.pathname = `${base.pathname.replace(/\/$/, "")}${path}`;
|
||||||
|
if (token) {
|
||||||
|
base.searchParams.set("token", token);
|
||||||
|
}
|
||||||
|
return base.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
if (init.body && !headers.has("Content-Type")) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = body?.detail ?? response.statusText;
|
||||||
|
const message =
|
||||||
|
typeof detail === "string" ? detail : JSON.stringify(detail);
|
||||||
|
throw new ApiError(response.status, message);
|
||||||
|
}
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username: string, password: string) {
|
||||||
|
const response = await request<{ access_token: string }>("/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
setToken(response.access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
await request<void>("/auth/logout", { method: "POST" }).catch(() => undefined);
|
||||||
|
setToken(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
proxies: () => request<ProxyDocument[]>("/proxy"),
|
||||||
|
services: (scanRoot?: string) =>
|
||||||
|
request<ServiceDiscoveryResponse>(
|
||||||
|
`/proxy/services${scanRoot ? `?scan_root=${encodeURIComponent(scanRoot)}` : ""}`,
|
||||||
|
),
|
||||||
|
options: () => request<MitmOption[]>("/proxy/mitmproxy/options"),
|
||||||
|
templates: () => request<ProxyTemplate[]>("/proxy/mitmproxy/templates"),
|
||||||
|
editorSession: () =>
|
||||||
|
request<PythonEditorSession>("/proxy/editor/python/session"),
|
||||||
|
createTemplate: (payload: ProxyTemplateCreateInput) =>
|
||||||
|
request<ProxyTemplate>("/proxy/mitmproxy/templates", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
deleteTemplate: (id: string) =>
|
||||||
|
request<void>(`/proxy/mitmproxy/templates/${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
createProxy: (payload: ProxyDraft) =>
|
||||||
|
request<ProxyDocument>("/proxy", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
updateProxy: (port: number, payload: Partial<ProxyDraft>) =>
|
||||||
|
request<ProxyDocument>(`/proxy/${port}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
deleteProxy: (port: number) =>
|
||||||
|
request<void>(`/proxy/${port}`, { method: "DELETE" }),
|
||||||
|
start: (port: number) =>
|
||||||
|
request<ProxyDocument>(`/proxy/${port}/start`, { method: "POST" }),
|
||||||
|
stop: (port: number) =>
|
||||||
|
request<ProxyDocument>(`/proxy/${port}/stop`, { method: "POST" }),
|
||||||
|
restart: (port: number) =>
|
||||||
|
request<ProxyDocument>(`/proxy/${port}/restart`, { method: "POST" }),
|
||||||
|
health: (port: number) => request<HealthResult>(`/proxy/${port}/health`),
|
||||||
|
healthHistory: (port: number) =>
|
||||||
|
request<HealthHistoryResponse>(`/proxy/${port}/health/history`),
|
||||||
|
logs: (port: number) => request<{ log: string }>(`/proxy/${port}/logs`),
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AppLogo from "./AppLogo.vue";
|
||||||
|
import UiIcon from "./UiIcon.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
loading: boolean;
|
||||||
|
showDiscovery: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
discovery: [];
|
||||||
|
refresh: [];
|
||||||
|
create: [];
|
||||||
|
logout: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="brand">
|
||||||
|
<AppLogo />
|
||||||
|
<div>
|
||||||
|
<strong>thePlug</strong>
|
||||||
|
<small>il proxy barese</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-meta">
|
||||||
|
<span v-if="loading" class="muted">saving</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button v-if="showDiscovery" class="ghost button-icon" :disabled="loading" @click="emit('discovery')">
|
||||||
|
<UiIcon name="back" />
|
||||||
|
<span>Services</span>
|
||||||
|
</button>
|
||||||
|
<button class="ghost button-icon" :disabled="loading" @click="emit('refresh')">
|
||||||
|
<UiIcon name="refresh" />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
|
<button class="primary button-icon" @click="emit('create')">
|
||||||
|
<UiIcon name="plus" />
|
||||||
|
<span>New</span>
|
||||||
|
</button>
|
||||||
|
<button class="ghost button-icon" @click="emit('logout')">
|
||||||
|
<UiIcon name="logout" />
|
||||||
|
<span>Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg class="logo" viewBox="0 0 64 64" role="img" aria-label="thePlug logo">
|
||||||
|
<rect x="10" y="10" width="44" height="44" rx="10" />
|
||||||
|
<path d="M26 19v10M38 19v10" />
|
||||||
|
<path d="M22 29h20v6a10 10 0 0 1-10 10 10 10 0 0 1-10-10v-6z" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
message: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-toast" role="status" aria-live="polite">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { basicSetup, EditorView } from "codemirror";
|
||||||
|
import { keymap } from "@codemirror/view";
|
||||||
|
import { python } from "@codemirror/lang-python";
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import { api } from "../api";
|
||||||
|
import { connectPythonLsp } from "../editor/pythonLsp";
|
||||||
|
|
||||||
|
const model = defineModel<string>({ required: true });
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [];
|
||||||
|
}>();
|
||||||
|
const host = ref<HTMLElement | null>(null);
|
||||||
|
const error = ref("");
|
||||||
|
let editor: EditorView | null = null;
|
||||||
|
let languageClient: Awaited<ReturnType<typeof connectPythonLsp>>["client"] | null =
|
||||||
|
null;
|
||||||
|
let languageSocket: WebSocket | null = null;
|
||||||
|
|
||||||
|
const editorTheme = EditorView.theme(
|
||||||
|
{
|
||||||
|
"&": {
|
||||||
|
height: "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
|
color: "#edf2f7",
|
||||||
|
backgroundColor: "#0b0f12",
|
||||||
|
fontSize: "14px",
|
||||||
|
},
|
||||||
|
".cm-editor": {
|
||||||
|
height: "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
|
},
|
||||||
|
".cm-scroller": {
|
||||||
|
overflow: "auto",
|
||||||
|
fontFamily: "IBM Plex Mono, JetBrains Mono, ui-monospace, monospace",
|
||||||
|
lineHeight: "1.6",
|
||||||
|
},
|
||||||
|
".cm-content": {
|
||||||
|
minWidth: "fit-content",
|
||||||
|
padding: "14px 0",
|
||||||
|
caretColor: "#e7fbff",
|
||||||
|
},
|
||||||
|
".cm-gutters": {
|
||||||
|
border: "none",
|
||||||
|
backgroundColor: "#0b0f12",
|
||||||
|
color: "#6f7c86",
|
||||||
|
},
|
||||||
|
".cm-activeLine": {
|
||||||
|
backgroundColor: "#12181d",
|
||||||
|
},
|
||||||
|
".cm-activeLineGutter": {
|
||||||
|
backgroundColor: "#12181d",
|
||||||
|
},
|
||||||
|
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||||
|
backgroundColor: "#1b2630",
|
||||||
|
},
|
||||||
|
".cm-tooltip": {
|
||||||
|
border: "1px solid #24303a",
|
||||||
|
backgroundColor: "#10151a",
|
||||||
|
color: "#edf2f7",
|
||||||
|
},
|
||||||
|
".cm-panels": {
|
||||||
|
backgroundColor: "#10151a",
|
||||||
|
color: "#edf2f7",
|
||||||
|
},
|
||||||
|
".cm-searchMatch": {
|
||||||
|
backgroundColor: "#23303b",
|
||||||
|
outline: "1px solid #334455",
|
||||||
|
},
|
||||||
|
".cm-cursor": {
|
||||||
|
borderLeftColor: "#e7fbff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ dark: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function emitSave() {
|
||||||
|
emit("save");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!host.value) return;
|
||||||
|
void mountEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(model, (value) => {
|
||||||
|
if (!editor) return;
|
||||||
|
const current = editor.state.doc.toString();
|
||||||
|
if (current === value) return;
|
||||||
|
editor.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: current.length,
|
||||||
|
insert: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
languageClient?.disconnect();
|
||||||
|
languageClient = null;
|
||||||
|
languageSocket?.close();
|
||||||
|
languageSocket = null;
|
||||||
|
editor?.destroy();
|
||||||
|
editor = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mountEditor() {
|
||||||
|
if (!host.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
error.value = "";
|
||||||
|
const session = await api.editorSession();
|
||||||
|
const connection = await connectPythonLsp(session);
|
||||||
|
languageClient = connection.client;
|
||||||
|
languageSocket = connection.socket;
|
||||||
|
void languageClient.initializing.catch(() => {
|
||||||
|
error.value = "Python language tools are unavailable.";
|
||||||
|
});
|
||||||
|
|
||||||
|
editor = new EditorView({
|
||||||
|
doc: model.value,
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
python(),
|
||||||
|
editorTheme,
|
||||||
|
keymap.of([{ key: "Mod-s", run: emitSave }]),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (!update.docChanged) return;
|
||||||
|
const value = update.state.doc.toString();
|
||||||
|
if (value !== model.value) model.value = value;
|
||||||
|
}),
|
||||||
|
languageClient.plugin(session.file_uri, "python"),
|
||||||
|
],
|
||||||
|
parent: host.value,
|
||||||
|
});
|
||||||
|
} catch (value) {
|
||||||
|
const message =
|
||||||
|
value instanceof Error ? value.message : "Unable to start the editor";
|
||||||
|
error.value = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="code-editor-wrap">
|
||||||
|
<p v-if="error" class="field-error editor-error">{{ error }}</p>
|
||||||
|
<div ref="host" class="code-editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { defineAsyncComponent } from "vue";
|
||||||
|
|
||||||
|
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||||
|
import UiIcon from "./UiIcon.vue";
|
||||||
|
|
||||||
|
const CodeEditor = defineAsyncComponent(() => import("./CodeEditor.vue"));
|
||||||
|
const plane = useControlPlaneContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="editor-pane panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h2>Proxy script</h2>
|
||||||
|
<div class="editor-tools">
|
||||||
|
<button
|
||||||
|
class="ghost button-icon editor-save"
|
||||||
|
:disabled="!plane.canSaveScript"
|
||||||
|
@click="plane.saveScript"
|
||||||
|
>
|
||||||
|
<UiIcon name="save" />
|
||||||
|
<span>Save script</span>
|
||||||
|
<span class="shortcut-hint">Ctrl/Cmd+S</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-shell">
|
||||||
|
<CodeEditor v-model="plane.draft.code" @save="plane.saveScript" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||||
|
import UiIcon from "./UiIcon.vue";
|
||||||
|
|
||||||
|
const plane = useControlPlaneContext();
|
||||||
|
const monitoringActive = computed(() => plane.runtimeStatus === "running");
|
||||||
|
const overallLabel = computed(() =>
|
||||||
|
monitoringActive.value ? plane.health?.overall ?? "pending" : "stopped",
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestTimestamp = computed(() => {
|
||||||
|
if (!monitoringActive.value) return "Monitoring inactive";
|
||||||
|
if (!plane.health?.timestamp) return "No checks yet";
|
||||||
|
return new Date(plane.health.timestamp).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetRows = computed(() => {
|
||||||
|
if (!monitoringActive.value) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
const checks = plane.health?.checks ?? [];
|
||||||
|
return [
|
||||||
|
checks.find((check) => check.target === "proxy") ?? null,
|
||||||
|
checks.find((check) => check.target === "upstream") ?? null,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const benchmarkEnabled = computed(
|
||||||
|
() =>
|
||||||
|
plane.selectedProxy?.healthcheck.benchmark_enabled ??
|
||||||
|
plane.draft.healthcheck.benchmark_enabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
function benchmarkSeries(target: "proxy" | "upstream") {
|
||||||
|
return plane.healthHistory
|
||||||
|
.map((sample, index) => {
|
||||||
|
const check = sample.checks.find((item) => item.target === target);
|
||||||
|
const duration =
|
||||||
|
typeof check?.duration_ms === "number" ? check.duration_ms : null;
|
||||||
|
if (duration === null) return null;
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is { index: number; duration: number } => item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestBenchmark(target: "proxy" | "upstream") {
|
||||||
|
const check = plane.health?.checks.find((item) => item.target === target);
|
||||||
|
return typeof check?.duration_ms === "number" ? check.duration_ms : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function averageBenchmark(target: "proxy" | "upstream") {
|
||||||
|
const values = benchmarkSeries(target).map((item) => item.duration);
|
||||||
|
if (!values.length) return null;
|
||||||
|
const total = values.reduce((sum, value) => sum + value, 0);
|
||||||
|
return Math.round((total / values.length) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const benchmarkSummary = computed(() => ({
|
||||||
|
proxyLast: latestBenchmark("proxy"),
|
||||||
|
proxyAvg: averageBenchmark("proxy"),
|
||||||
|
upstreamLast: latestBenchmark("upstream"),
|
||||||
|
upstreamAvg: averageBenchmark("upstream"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const chartPoints = computed(() => {
|
||||||
|
const history = plane.healthHistory;
|
||||||
|
const width = 220;
|
||||||
|
const height = 54;
|
||||||
|
const build = (target: "proxy" | "upstream") => {
|
||||||
|
const points = history
|
||||||
|
.map((sample, index) => {
|
||||||
|
const check = sample.checks.find((item) => item.target === target);
|
||||||
|
if (!check) return null;
|
||||||
|
const x = history.length === 1 ? width / 2 : (index / (history.length - 1)) * width;
|
||||||
|
const y = check.healthy ? 10 : height - 10;
|
||||||
|
return `${x},${y}`;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
return points.join(" ");
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
proxy: build("proxy"),
|
||||||
|
upstream: build("upstream"),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const benchmarkChart = computed(() => {
|
||||||
|
const width = 220;
|
||||||
|
const height = 72;
|
||||||
|
const proxySeries = benchmarkSeries("proxy");
|
||||||
|
const upstreamSeries = benchmarkSeries("upstream");
|
||||||
|
const allValues = [...proxySeries, ...upstreamSeries].map((item) => item.duration);
|
||||||
|
const maxDuration = allValues.length ? Math.max(...allValues) : 0;
|
||||||
|
const domainMax = maxDuration > 0 ? maxDuration : 1;
|
||||||
|
|
||||||
|
const toPoints = (series: { index: number; duration: number }[]) =>
|
||||||
|
series
|
||||||
|
.map((item) => {
|
||||||
|
const x =
|
||||||
|
plane.healthHistory.length <= 1
|
||||||
|
? width / 2
|
||||||
|
: (item.index / (plane.healthHistory.length - 1)) * width;
|
||||||
|
const y = height - 10 - (item.duration / domainMax) * (height - 20);
|
||||||
|
return `${x},${y}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
proxy: toPoints(proxySeries),
|
||||||
|
upstream: toPoints(upstreamSeries),
|
||||||
|
hasData: allValues.length > 0,
|
||||||
|
maxDuration: Math.round(domainMax * 100) / 100,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDuration(value: number | null) {
|
||||||
|
return value === null ? "n/a" : `${value.toFixed(2)} ms`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="plane.healthModalOpen" class="modal-backdrop" @click.self="plane.closeHealthModal">
|
||||||
|
<section class="modal-shell health-modal-shell">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<h2>Health</h2>
|
||||||
|
<p class="modal-step-copy">
|
||||||
|
Runtime checks for the live proxy path and the real upstream service.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-head-actions">
|
||||||
|
<button
|
||||||
|
class="ghost button-icon"
|
||||||
|
:disabled="plane.loading || !monitoringActive"
|
||||||
|
@click="plane.checkHealth"
|
||||||
|
>
|
||||||
|
<UiIcon name="pulse" />
|
||||||
|
<span>Run now</span>
|
||||||
|
</button>
|
||||||
|
<button class="ghost button-icon" @click="plane.closeHealthModal">
|
||||||
|
<UiIcon name="close" />
|
||||||
|
<span>Close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body health-modal-body">
|
||||||
|
<div
|
||||||
|
class="health-runtime-overview"
|
||||||
|
:class="overallLabel"
|
||||||
|
>
|
||||||
|
<strong>{{ overallLabel }}</strong>
|
||||||
|
<span>Last check {{ latestTimestamp }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="health-runtime-list">
|
||||||
|
<article
|
||||||
|
v-for="(row, index) in targetRows"
|
||||||
|
:key="row?.target ?? `pending-${index}`"
|
||||||
|
class="health-runtime-card"
|
||||||
|
:class="
|
||||||
|
row
|
||||||
|
? row.healthy
|
||||||
|
? 'healthy'
|
||||||
|
: 'unhealthy'
|
||||||
|
: monitoringActive
|
||||||
|
? 'pending'
|
||||||
|
: 'inactive'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="health-runtime-card-head">
|
||||||
|
<strong>{{
|
||||||
|
row?.target ?? (index === 0 ? "proxy" : "upstream")
|
||||||
|
}}</strong>
|
||||||
|
<span>{{
|
||||||
|
row
|
||||||
|
? row.healthy
|
||||||
|
? "ok"
|
||||||
|
: "issue"
|
||||||
|
: monitoringActive
|
||||||
|
? "pending"
|
||||||
|
: "inactive"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted small compact-copy">
|
||||||
|
{{
|
||||||
|
row
|
||||||
|
? `${row.host}:${row.port} · ${row.protocol}`
|
||||||
|
: monitoringActive
|
||||||
|
? "No data yet."
|
||||||
|
: "Start the proxy to enable active checks."
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="benchmarkEnabled && typeof row?.duration_ms === 'number'"
|
||||||
|
class="muted small compact-copy"
|
||||||
|
>
|
||||||
|
Last response {{ formatDuration(row.duration_ms) }}
|
||||||
|
</p>
|
||||||
|
<p v-if="row?.error" class="error compact health-inline-error">
|
||||||
|
{{ row.error }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="health-chart-card">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h3>Recent timeline</h3>
|
||||||
|
<span class="muted small">{{ plane.healthHistory.length }} samples</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="plane.healthHistory.length" class="health-chart-grid">
|
||||||
|
<div class="health-chart-row">
|
||||||
|
<span>Proxy</span>
|
||||||
|
<svg
|
||||||
|
class="health-chart"
|
||||||
|
:viewBox="`0 0 ${chartPoints.width} ${chartPoints.height}`"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<polyline class="health-chart-line proxy" :points="chartPoints.proxy" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="health-chart-row">
|
||||||
|
<span>Upstream</span>
|
||||||
|
<svg
|
||||||
|
class="health-chart"
|
||||||
|
:viewBox="`0 0 ${chartPoints.width} ${chartPoints.height}`"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<polyline class="health-chart-line upstream" :points="chartPoints.upstream" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="muted small">
|
||||||
|
{{
|
||||||
|
monitoringActive
|
||||||
|
? "Health history will appear after the first scheduled runs."
|
||||||
|
: "Health history starts once the proxy is running."
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="benchmarkEnabled" class="health-chart-card">
|
||||||
|
<div class="panel-title">
|
||||||
|
<h3>Benchmark</h3>
|
||||||
|
<span class="muted small">Response latency</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="benchmark-summary-grid">
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Proxy last</span>
|
||||||
|
<strong>{{ formatDuration(benchmarkSummary.proxyLast) }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Proxy avg</span>
|
||||||
|
<strong>{{ formatDuration(benchmarkSummary.proxyAvg) }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Upstream last</span>
|
||||||
|
<strong>{{ formatDuration(benchmarkSummary.upstreamLast) }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Upstream avg</span>
|
||||||
|
<strong>{{ formatDuration(benchmarkSummary.upstreamAvg) }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="benchmarkChart.hasData" class="health-chart-grid">
|
||||||
|
<div class="health-chart-row">
|
||||||
|
<span>Proxy</span>
|
||||||
|
<svg
|
||||||
|
class="health-chart benchmark-chart"
|
||||||
|
:viewBox="`0 0 ${benchmarkChart.width} ${benchmarkChart.height}`"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<polyline class="health-chart-line proxy" :points="benchmarkChart.proxy" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="health-chart-row">
|
||||||
|
<span>Upstream</span>
|
||||||
|
<svg
|
||||||
|
class="health-chart benchmark-chart"
|
||||||
|
:viewBox="`0 0 ${benchmarkChart.width} ${benchmarkChart.height}`"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<polyline class="health-chart-line upstream" :points="benchmarkChart.upstream" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="muted small compact-copy">
|
||||||
|
Scale 0 → {{ benchmarkChart.maxDuration.toFixed(2) }} ms
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p v-else class="muted small">
|
||||||
|
Collecting benchmark data from scheduled health runs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { protocols, useControlPlaneContext } from "../composables/useControlPlane";
|
||||||
|
import UiIcon from "./UiIcon.vue";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
|
||||||
|
embedded: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const plane = useControlPlaneContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel compact-panel'">
|
||||||
|
<div v-if="!props.embedded" class="panel-title">
|
||||||
|
<div><h2>Checks</h2></div>
|
||||||
|
<button
|
||||||
|
class="button-icon"
|
||||||
|
:disabled="!plane.selectedProxy"
|
||||||
|
@click="plane.checkHealth"
|
||||||
|
>
|
||||||
|
<UiIcon name="refresh" />
|
||||||
|
<span>Run</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!props.embedded" class="metric-grid">
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">State</span>
|
||||||
|
<strong>{{ plane.draft.healthcheck.enabled ? "Enabled" : "Disabled" }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Path</span>
|
||||||
|
<strong>{{ plane.draft.healthcheck.path }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Method</span>
|
||||||
|
<strong>{{ plane.draft.healthcheck.method }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Timeout</span>
|
||||||
|
<strong>{{ plane.draft.healthcheck.timeout_seconds }}s</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Benchmark</span>
|
||||||
|
<strong>{{ plane.draft.healthcheck.benchmark_enabled ? "Enabled" : "Disabled" }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid tight-grid">
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input v-model="plane.draft.healthcheck.enabled" type="checkbox" />
|
||||||
|
<span>Enabled</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Protocol override
|
||||||
|
<select v-model="plane.draft.healthcheck.protocol">
|
||||||
|
<option :value="null">Use proxy protocol</option>
|
||||||
|
<option v-for="protocol in protocols" :key="protocol" :value="protocol">
|
||||||
|
{{ protocol }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Method
|
||||||
|
<select v-model="plane.draft.healthcheck.method">
|
||||||
|
<option>GET</option>
|
||||||
|
<option>HEAD</option>
|
||||||
|
<option>POST</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Path
|
||||||
|
<input v-model="plane.draft.healthcheck.path" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Expected status
|
||||||
|
<input
|
||||||
|
v-model.number="plane.draft.healthcheck.expected_status"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Timeout
|
||||||
|
<input
|
||||||
|
v-model.number="plane.draft.healthcheck.timeout_seconds"
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
UDP payload
|
||||||
|
<input
|
||||||
|
v-model="plane.draft.healthcheck.udp_payload"
|
||||||
|
placeholder="used for udp-like checks"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input v-model="plane.draft.healthcheck.expect_response" type="checkbox" />
|
||||||
|
<span>Expect response</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input v-model="plane.draft.healthcheck.verify_tls" type="checkbox" />
|
||||||
|
<span>Verify TLS</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input v-model="plane.draft.healthcheck.benchmark_enabled" type="checkbox" />
|
||||||
|
<span>Benchmark response time</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="plane.health" class="health-box" :class="plane.health.overall">
|
||||||
|
<strong>{{ plane.health.overall }}</strong>
|
||||||
|
<div class="health-list">
|
||||||
|
<div v-for="check in plane.health.checks" :key="check.target" class="health-row">
|
||||||
|
<span>{{ check.target }}</span>
|
||||||
|
<span>{{ check.protocol }}</span>
|
||||||
|
<span>{{ check.healthy ? "ok" : check.error }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||||
|
import UiIcon from "./UiIcon.vue";
|
||||||
|
|
||||||
|
const plane = useControlPlaneContext();
|
||||||
|
const monitoringActive = computed(() => plane.runtimeStatus === "running");
|
||||||
|
const overallLabel = computed(() =>
|
||||||
|
monitoringActive.value ? plane.health?.overall ?? "pending" : "stopped",
|
||||||
|
);
|
||||||
|
|
||||||
|
const latestTimestamp = computed(() => {
|
||||||
|
if (!monitoringActive.value) return "Monitoring inactive";
|
||||||
|
if (!plane.health?.timestamp) return "No checks yet";
|
||||||
|
return new Date(plane.health.timestamp).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxyCheck = computed(() =>
|
||||||
|
plane.health?.checks.find((check) => check.target === "proxy") ?? null,
|
||||||
|
);
|
||||||
|
const upstreamCheck = computed(() =>
|
||||||
|
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>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
v-if="plane.selectedProxy"
|
||||||
|
class="health-summary-card"
|
||||||
|
:class="overallLabel"
|
||||||
|
>
|
||||||
|
<div class="health-summary-line">
|
||||||
|
<strong class="health-summary-overall">{{ overallLabel }}</strong>
|
||||||
|
<span class="health-summary-item">
|
||||||
|
<small>Proxy</small>
|
||||||
|
<strong>{{ proxyLabel }}</strong>
|
||||||
|
</span>
|
||||||
|
<span class="health-summary-item">
|
||||||
|
<small>Upstream</small>
|
||||||
|
<strong>{{ upstreamLabel }}</strong>
|
||||||
|
</span>
|
||||||
|
<span class="health-summary-item health-summary-time">
|
||||||
|
<small>Last check</small>
|
||||||
|
<strong>{{ latestTimestamp }}</strong>
|
||||||
|
</span>
|
||||||
|
<div class="health-summary-actions">
|
||||||
|
<button
|
||||||
|
class="ghost button-icon"
|
||||||
|
:disabled="plane.loading || !monitoringActive"
|
||||||
|
@click="plane.checkHealth"
|
||||||
|
>
|
||||||
|
<UiIcon name="pulse" />
|
||||||
|
<span>Run</span>
|
||||||
|
</button>
|
||||||
|
<button class="ghost button-icon" @click="plane.openHealthModal">
|
||||||
|
<span>Details</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AppLogo from "./AppLogo.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const username = defineModel<string>("username", { required: true });
|
||||||
|
const password = defineModel<string>("password", { required: true });
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="login-screen">
|
||||||
|
<section class="login-card">
|
||||||
|
<div class="login-brand">
|
||||||
|
<AppLogo />
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">thePlug</p>
|
||||||
|
<h1>Sign in</h1>
|
||||||
|
<p class="muted">Manage proxy routes, runtime state, and mitmdump scripts.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="emit('submit')">
|
||||||
|
<label>Username<input v-model="username" autocomplete="username" /></label>
|
||||||
|
<label>Password<input v-model="password" type="password" autocomplete="current-password" /></label>
|
||||||
|
<button class="primary" :disabled="loading">Sign in</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="error" class="error auth-alert">{{ error }}</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user