Compare commits
2
Commits
@@ -0,0 +1,3 @@
|
||||
API_PORT=3671
|
||||
MONGO_INITDB_ROOT_PASSWORD=pass
|
||||
MONGODB_URL=mongodb://theplug:pass@mongodb:27017/theplug?authSource=admin
|
||||
@@ -0,0 +1,32 @@
|
||||
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"
|
||||
|
||||
+1
-16
@@ -1,17 +1,2 @@
|
||||
.idea
|
||||
.venv/
|
||||
.ruff_cache/
|
||||
.vscode/
|
||||
.env
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
**/__pycache__/
|
||||
**/.pytest_cache/
|
||||
**/.ruff_cache/
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
backend/.venv/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
playground/
|
||||
.env
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
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 fastapi uvicorn
|
||||
|
||||
COPY app ./src
|
||||
|
||||
EXPOSE 3671
|
||||
|
||||
CMD ["fastapi", "run", "src/main.py", "--host", "0.0.0.0", "--port", "3671"]
|
||||
@@ -1,167 +1,34 @@
|
||||
# thePlug
|
||||
# thePlug - plugger/proxy Barese
|
||||
|
||||
Web UI per gestire proxy `mitmdump` davanti a servizi esposti via Docker
|
||||
Compose.
|
||||
icona di una presa della spina idk
|
||||
|
||||
## Struttura
|
||||
## Architettura
|
||||
|
||||
```text
|
||||
backend/ FastAPI, MongoDB, mitmdump manager
|
||||
frontend/ Vue 3, Vite, CodeMirror + Python LSP
|
||||
playground/ challenge/service compose locali
|
||||
templates/ template proxy YAML file-backed
|
||||
```
|
||||
FastAPI + MongoDB + Redis
|
||||
```
|
||||
|
||||
## Setup iniziale
|
||||
### Setup Dev
|
||||
|
||||
imposta le variabili d'ambiente
|
||||
|
||||
```bash
|
||||
./setup.sh
|
||||
cp .env.example .env
|
||||
# modifica le variabili
|
||||
nano .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
|
||||
|
||||
avvia i servizi e il server locale
|
||||
```bash
|
||||
./run.sh mongo up
|
||||
./run.sh mongo status
|
||||
./run.sh mongo logs
|
||||
./run.sh mongo down
|
||||
docker compose up -d mongodb redis
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
./run.sh backend
|
||||
fastapi dev app/main.py
|
||||
```
|
||||
|
||||
### Frontend Docker
|
||||
accedi a FastAPI su: [FastAPI docs](http://localhost:3671/docs)
|
||||
|
||||
```bash
|
||||
./run.sh frontend up
|
||||
./run.sh frontend status
|
||||
./run.sh frontend logs
|
||||
./run.sh frontend down
|
||||
```
|
||||
## Documentazione per Sviluppo
|
||||
|
||||
Il frontend in container fa proxy API verso il backend in esecuzione sulla
|
||||
macchina host.
|
||||
[MitMProxy examples](https://docs.mitmproxy.org/stable/addons/examples/)
|
||||
|
||||
## Workflow development
|
||||
|
||||
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/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`
|
||||
|
||||
## Riferimenti
|
||||
|
||||
- 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/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- fixare i test di unità, di integrazione e aggiungere test reali di benchmark.
|
||||
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/proxy",
|
||||
tags=["Proxy"]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_proxies():
|
||||
return
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_proxy():
|
||||
return
|
||||
|
||||
|
||||
@router.put("/{proxy_port}/")
|
||||
async def edit_proxy():
|
||||
return
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from api.proxy import router as proxy_router
|
||||
|
||||
app = FastAPI(
|
||||
title="thePlugAPI"
|
||||
)
|
||||
|
||||
app.include_router(proxy_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"Hello": "World!"}
|
||||
@@ -0,0 +1,10 @@
|
||||
from utils.proxy.types import ServiceConfig
|
||||
|
||||
|
||||
async def create_proxy(
|
||||
port: int,
|
||||
name: str,
|
||||
service: ServiceConfig,
|
||||
code: str
|
||||
):
|
||||
return
|
||||
@@ -0,0 +1,6 @@
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class ServiceConfig(TypedDict):
|
||||
address: str
|
||||
port: int
|
||||
@@ -1,18 +0,0 @@
|
||||
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
|
||||
@@ -1,3 +0,0 @@
|
||||
from app.api.auth.router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,42 +0,0 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
from app.core.security import (
|
||||
AuthContext,
|
||||
create_access_token,
|
||||
get_auth_context,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.auth import LoginInput, TokenResponse
|
||||
from app.repositories.auth import get_user_by_username, revoke_token
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/auth",
|
||||
tags=["Auth"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login_handler(payload: LoginInput):
|
||||
user = await get_user_by_username(payload.username)
|
||||
if user is None or not verify_password(payload.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token = create_access_token(subject=user.username)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout_handler(
|
||||
auth_context: Annotated[AuthContext, Depends(get_auth_context)],
|
||||
) -> Response:
|
||||
expires_at = datetime.fromtimestamp(auth_context.payload.exp, tz=UTC)
|
||||
if expires_at > datetime.now(UTC):
|
||||
await revoke_token(jti=auth_context.payload.jti, expires_at=expires_at)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1,63 +0,0 @@
|
||||
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")
|
||||
@@ -1,504 +0,0 @@
|
||||
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",
|
||||
)
|
||||
@@ -1,127 +0,0 @@
|
||||
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)
|
||||
@@ -1,26 +0,0 @@
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=(".env", "backend/.env"),
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
MONGODB_URI: str
|
||||
MONGODB_DB_NAME: str
|
||||
JWT_SECRET_KEY: str
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRE_MINUTES: int = Field(default=720, gt=0)
|
||||
AUTH_USERNAME: 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() # pyright: ignore[reportCallIssue]
|
||||
@@ -1,40 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class _MongoDB:
|
||||
client: AsyncIOMotorClient[dict[str, Any]] | None = None
|
||||
|
||||
def get_database(self) -> AsyncIOMotorDatabase[dict[str, Any]]:
|
||||
if self.client is None:
|
||||
raise RuntimeError("MongoDB client has not been initialized")
|
||||
return self.client[settings.MONGODB_DB_NAME]
|
||||
|
||||
|
||||
mongodb = _MongoDB()
|
||||
|
||||
|
||||
async def init_mongodb() -> None:
|
||||
mongodb.client = AsyncIOMotorClient(settings.MONGODB_URI)
|
||||
await ensure_mongodb_available()
|
||||
|
||||
|
||||
async def close_mongodb() -> None:
|
||||
client = mongodb.client
|
||||
mongodb.client = None
|
||||
if client is not None:
|
||||
client.close()
|
||||
|
||||
|
||||
async def get_database() -> AsyncIOMotorDatabase[dict[str, Any]]:
|
||||
return mongodb.get_database()
|
||||
|
||||
|
||||
async def ensure_mongodb_available() -> None:
|
||||
client = mongodb.client
|
||||
if client is None:
|
||||
raise RuntimeError("MongoDB client has not been initialized")
|
||||
await client.admin.command("ping")
|
||||
@@ -1,115 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Annotated
|
||||
from uuid import uuid4
|
||||
|
||||
from authlib.jose import jwt # type: ignore
|
||||
from authlib.jose.errors import JoseError # type: ignore
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pwdlib import PasswordHash
|
||||
from pwdlib.hashers.argon2 import Argon2Hasher
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.auth import AccessTokenPayload, AuthUserDocument
|
||||
from app.repositories.auth import get_user_by_username, is_token_revoked
|
||||
|
||||
pwd_context = PasswordHash((Argon2Hasher(),))
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuthContext:
|
||||
token: str
|
||||
user: AuthUserDocument
|
||||
payload: AccessTokenPayload
|
||||
|
||||
|
||||
class InvalidTokenError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def token_auth_error() -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, password_hash: str) -> bool:
|
||||
return pwd_context.verify(plain_password, password_hash)
|
||||
|
||||
|
||||
def create_access_token(*, subject: str) -> str:
|
||||
now = datetime.now(UTC)
|
||||
expires_delta = timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
||||
expires_at = int((now + expires_delta).timestamp())
|
||||
|
||||
header = {
|
||||
"alg": settings.JWT_ALGORITHM,
|
||||
"typ": "JWT",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"jti": str(uuid4()),
|
||||
"exp": expires_at,
|
||||
}
|
||||
|
||||
token = jwt.encode(header, payload, settings.JWT_SECRET_KEY)
|
||||
|
||||
if isinstance(token, bytes):
|
||||
return token.decode("utf-8")
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> AccessTokenPayload:
|
||||
try:
|
||||
claims = jwt.decode(token, settings.JWT_SECRET_KEY)
|
||||
claims.validate()
|
||||
return AccessTokenPayload.model_validate(dict(claims))
|
||||
except JoseError, ValueError, TypeError:
|
||||
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(
|
||||
credentials: Annotated[
|
||||
HTTPAuthorizationCredentials | None,
|
||||
Depends(bearer_scheme),
|
||||
],
|
||||
) -> AuthContext:
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise token_auth_error()
|
||||
|
||||
try:
|
||||
return await authenticate_token(credentials.credentials)
|
||||
except InvalidTokenError:
|
||||
raise token_auth_error() from None
|
||||
|
||||
|
||||
async def require_authenticated_user(
|
||||
auth_context: Annotated[AuthContext, Depends(get_auth_context)],
|
||||
) -> AuthUserDocument:
|
||||
return auth_context.user
|
||||
@@ -1,15 +0,0 @@
|
||||
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",
|
||||
]
|
||||
@@ -1,135 +0,0 @@
|
||||
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()}
|
||||
@@ -1,10 +0,0 @@
|
||||
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()
|
||||
@@ -1,85 +0,0 @@
|
||||
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
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
@@ -1,102 +0,0 @@
|
||||
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
|
||||
@@ -1,83 +0,0 @@
|
||||
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!"}
|
||||
@@ -1,43 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class AuthUserDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
username: str = Field(
|
||||
...,
|
||||
description="Unique username for the single account",
|
||||
)
|
||||
password_hash: str = Field(..., description="Hashed password")
|
||||
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 LoginInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
username: str = Field(..., description="Username")
|
||||
password: str = Field(..., description="Plain text password")
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
access_token: str = Field(..., description="JWT access token")
|
||||
token_type: str = Field(default="bearer", description="Token type")
|
||||
|
||||
|
||||
class AccessTokenPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
sub: str = Field(..., description="Subject username")
|
||||
jti: str = Field(..., description="Unique token identifier")
|
||||
exp: int = Field(..., description="Token expiration as unix timestamp")
|
||||
@@ -1,41 +0,0 @@
|
||||
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)
|
||||
@@ -1,120 +0,0 @@
|
||||
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",
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
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",
|
||||
)
|
||||
@@ -1,46 +0,0 @@
|
||||
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")
|
||||
@@ -1,103 +0,0 @@
|
||||
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.auth import AuthUserDocument
|
||||
|
||||
USERS_COLLECTION_NAME = "auth_users"
|
||||
REVOKED_COLLECTION_NAME = "revoked_tokens"
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
async def users_collection() -> AsyncIOMotorCollection[dict[str, Any]]:
|
||||
database = await get_database()
|
||||
return database[USERS_COLLECTION_NAME]
|
||||
|
||||
|
||||
async def revoked_tokens_collection() -> AsyncIOMotorCollection[dict[str, Any]]:
|
||||
database = await get_database()
|
||||
return database[REVOKED_COLLECTION_NAME]
|
||||
|
||||
|
||||
def deserialize_user(raw: Mapping[str, Any] | None) -> AuthUserDocument | None:
|
||||
if raw is None:
|
||||
return None
|
||||
document = dict(raw)
|
||||
document.pop("_id", None)
|
||||
return AuthUserDocument.model_validate(document)
|
||||
|
||||
|
||||
async def ensure_auth_indexes() -> None:
|
||||
users = await users_collection()
|
||||
revoked = await revoked_tokens_collection()
|
||||
|
||||
await users.create_index("username", unique=True, name="uq_auth_username")
|
||||
await revoked.create_index("jti", unique=True, name="uq_revoked_jti")
|
||||
await revoked.create_index(
|
||||
"expires_at",
|
||||
expireAfterSeconds=0,
|
||||
name="ttl_revoked_tokens",
|
||||
)
|
||||
|
||||
|
||||
async def ensure_single_account(*, username: str, password_hash: str) -> None:
|
||||
users = await users_collection()
|
||||
now = utcnow()
|
||||
|
||||
current = await users.find_one({}, sort=[("created_at", 1)])
|
||||
if current is None:
|
||||
await users.insert_one(
|
||||
{
|
||||
"username": username,
|
||||
"password_hash": password_hash,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
current_id = current["_id"]
|
||||
await users.update_one(
|
||||
{"_id": current_id},
|
||||
{
|
||||
"$set": {
|
||||
"username": username,
|
||||
"password_hash": password_hash,
|
||||
"updated_at": now,
|
||||
}
|
||||
},
|
||||
)
|
||||
await users.delete_many({"_id": {"$ne": current_id}})
|
||||
|
||||
|
||||
async def get_user_by_username(username: str) -> AuthUserDocument | None:
|
||||
users = await users_collection()
|
||||
raw = await users.find_one({"username": username})
|
||||
return deserialize_user(raw)
|
||||
|
||||
|
||||
async def revoke_token(*, jti: str, expires_at: datetime) -> None:
|
||||
revoked = await revoked_tokens_collection()
|
||||
await revoked.update_one(
|
||||
{"jti": jti},
|
||||
{
|
||||
"$set": {
|
||||
"jti": jti,
|
||||
"expires_at": expires_at,
|
||||
"revoked_at": utcnow(),
|
||||
}
|
||||
},
|
||||
upsert=True,
|
||||
)
|
||||
|
||||
|
||||
async def is_token_revoked(jti: str) -> bool:
|
||||
revoked = await revoked_tokens_collection()
|
||||
raw = await revoked.find_one({"jti": jti}, projection={"_id": 1})
|
||||
return raw is not None
|
||||
@@ -1,80 +0,0 @@
|
||||
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})
|
||||
@@ -1,114 +0,0 @@
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||
from pymongo import ReturnDocument
|
||||
|
||||
from app.core.mongodb import get_database
|
||||
from app.models.proxy import ProxyCreateInput, ProxyDocument, ProxyUpdateInput
|
||||
|
||||
COLLECTION_NAME = "proxies"
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
async def _collection() -> AsyncIOMotorCollection[dict[str, Any]]:
|
||||
database = await get_database()
|
||||
return database[COLLECTION_NAME]
|
||||
|
||||
|
||||
def _deserialize_proxy(raw: Mapping[str, Any] | None) -> ProxyDocument | None:
|
||||
if raw is None:
|
||||
return None
|
||||
document = dict(raw)
|
||||
document.pop("_id", None)
|
||||
return ProxyDocument.model_validate(document)
|
||||
|
||||
|
||||
async def ensure_proxy_indexes() -> None:
|
||||
collection = await _collection()
|
||||
await collection.create_index("proxy_port", unique=True, name="uq_proxy_port")
|
||||
|
||||
|
||||
async def create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
|
||||
now = _utcnow()
|
||||
proxy = ProxyDocument(
|
||||
proxy_port=payload.proxy_port,
|
||||
name=payload.name,
|
||||
code=payload.code,
|
||||
mode=payload.mode,
|
||||
mode_spec=payload.mode_spec,
|
||||
protocol=payload.protocol,
|
||||
listen_host=payload.listen_host,
|
||||
mitm_options=payload.mitm_options,
|
||||
healthcheck=payload.healthcheck,
|
||||
service=payload.service,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
collection = await _collection()
|
||||
await collection.insert_one(proxy.model_dump(exclude_none=True))
|
||||
return proxy
|
||||
|
||||
|
||||
async def get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||
collection = await _collection()
|
||||
raw = await collection.find_one({"proxy_port": proxy_port})
|
||||
return _deserialize_proxy(raw)
|
||||
|
||||
|
||||
async def list_proxies() -> list[ProxyDocument]:
|
||||
collection = await _collection()
|
||||
cursor = collection.find({}).sort("proxy_port", 1)
|
||||
|
||||
proxies: list[ProxyDocument] = []
|
||||
async for raw in cursor:
|
||||
proxy = _deserialize_proxy(raw)
|
||||
if proxy is not None:
|
||||
proxies.append(proxy)
|
||||
return proxies
|
||||
|
||||
|
||||
async def update_proxy(
|
||||
proxy_port: int,
|
||||
updates: ProxyUpdateInput,
|
||||
) -> ProxyDocument | None:
|
||||
payload = updates.model_dump(exclude_none=True)
|
||||
if not payload:
|
||||
return await get_proxy_by_port(proxy_port)
|
||||
|
||||
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 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:
|
||||
collection = await _collection()
|
||||
result = await collection.delete_one({"proxy_port": proxy_port})
|
||||
return result.deleted_count > 0
|
||||
@@ -1,78 +0,0 @@
|
||||
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
|
||||
@@ -1,323 +0,0 @@
|
||||
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()
|
||||
@@ -1,228 +0,0 @@
|
||||
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)
|
||||
@@ -1,117 +0,0 @@
|
||||
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()
|
||||
@@ -1,443 +0,0 @@
|
||||
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()
|
||||
@@ -1,156 +0,0 @@
|
||||
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()
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
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
|
||||
@@ -1,82 +0,0 @@
|
||||
import asyncio
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from app.models.proxy import ProxyDocument
|
||||
|
||||
|
||||
def script_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
return work_dir / "addons" / f"{proxy.proxy_port}.py"
|
||||
|
||||
|
||||
def conf_dir_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
return work_dir / "conf" / str(proxy.proxy_port)
|
||||
|
||||
|
||||
def config_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
return conf_dir_for(work_dir, proxy) / "config.yaml"
|
||||
|
||||
|
||||
def log_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
return work_dir / "logs" / f"{proxy.proxy_port}.log"
|
||||
|
||||
|
||||
async def write_script(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
path = script_path_for(work_dir, proxy)
|
||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(path.write_text, proxy.code, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
async def write_config(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
path = config_path_for(work_dir, proxy)
|
||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
||||
options = {"block_global": False, **proxy.mitm_options}
|
||||
config_text = yaml.safe_dump(options, sort_keys=True)
|
||||
await asyncio.to_thread(path.write_text, config_text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
async def ensure_log_path(work_dir: Path, proxy: ProxyDocument) -> Path:
|
||||
path = log_path_for(work_dir, proxy)
|
||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
async def read_log_tail(path: Path, limit: int = 4000) -> str:
|
||||
if not await asyncio.to_thread(path.exists):
|
||||
return ""
|
||||
content = await asyncio.to_thread(
|
||||
path.read_text,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return content[-limit:].strip()
|
||||
|
||||
|
||||
def remove_file(path: Path) -> None:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
def remove_dir(path: Path) -> None:
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
def read_cmdline(pid: int) -> list[str]:
|
||||
path = Path(f"/proc/{pid}/cmdline")
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError:
|
||||
return []
|
||||
decoded = raw.decode("utf-8", errors="replace")
|
||||
return [part for part in decoded.split("\x00") if part]
|
||||
@@ -1,21 +0,0 @@
|
||||
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))
|
||||
@@ -1,139 +0,0 @@
|
||||
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)}
|
||||
@@ -1,164 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
TARGET_RUFF_CODES = ("F821", "F822", "F823")
|
||||
|
||||
|
||||
class ProxyCodeValidationError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
errors: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.errors = errors or []
|
||||
|
||||
def to_http_detail(self) -> dict[str, Any]:
|
||||
return {
|
||||
"message": self.message,
|
||||
"errors": self.errors,
|
||||
}
|
||||
|
||||
|
||||
def _validate_python_syntax(code: str) -> None:
|
||||
try:
|
||||
compile(code, "<proxy_code>", "exec")
|
||||
except SyntaxError as error:
|
||||
line = error.lineno if error.lineno is not None else 0
|
||||
column = error.offset if error.offset is not None else 0
|
||||
detail = {
|
||||
"code": "COMPILE",
|
||||
"message": error.msg,
|
||||
"location": {"row": line, "column": column},
|
||||
}
|
||||
raise ProxyCodeValidationError(
|
||||
"Proxy code has invalid Python syntax",
|
||||
errors=[detail],
|
||||
) from error
|
||||
|
||||
|
||||
def _get_ruff_executable() -> list[str]:
|
||||
ruff_path = shutil.which("ruff")
|
||||
if ruff_path:
|
||||
return [ruff_path]
|
||||
|
||||
uv_path = shutil.which("uv")
|
||||
if uv_path:
|
||||
return [uv_path, "run", "ruff"]
|
||||
|
||||
raise ProxyCodeValidationError("Unable to run ruff: executable not found")
|
||||
|
||||
|
||||
def _parse_ruff_output(output: str) -> list[dict[str, Any]]:
|
||||
if not output.strip():
|
||||
return []
|
||||
|
||||
try:
|
||||
parsed = json.loads(output)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ProxyCodeValidationError("Unable to parse ruff JSON output") from error
|
||||
|
||||
if not isinstance(parsed, list):
|
||||
raise ProxyCodeValidationError("Unexpected ruff output format")
|
||||
|
||||
errors: list[dict[str, Any]] = []
|
||||
for item in parsed:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
code = item.get("code")
|
||||
if code not in TARGET_RUFF_CODES:
|
||||
continue
|
||||
|
||||
message = item.get("message")
|
||||
raw_location = item.get("location")
|
||||
location = raw_location if isinstance(raw_location, dict) else {}
|
||||
row = location.get("row", 0)
|
||||
column = location.get("column", 0)
|
||||
|
||||
errors.append(
|
||||
{
|
||||
"code": code,
|
||||
"message": str(message) if message is not None else "",
|
||||
"location": {"row": row, "column": column},
|
||||
}
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
async def _run_ruff_check(code: str) -> list[dict[str, Any]]:
|
||||
command = _get_ruff_executable()
|
||||
|
||||
file_path = ""
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".py",
|
||||
encoding="utf-8",
|
||||
delete=False,
|
||||
) as handle:
|
||||
handle.write(code)
|
||||
file_path = handle.name
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
"check",
|
||||
"--output-format=json",
|
||||
"--select",
|
||||
",".join(TARGET_RUFF_CODES),
|
||||
file_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
finally:
|
||||
if file_path:
|
||||
try:
|
||||
os.unlink(file_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
stdout_text = stdout.decode("utf-8", errors="replace")
|
||||
stderr_text = stderr.decode("utf-8", errors="replace")
|
||||
|
||||
if process.returncode not in (0, 1):
|
||||
raise ProxyCodeValidationError(
|
||||
"Ruff check failed to execute",
|
||||
errors=[
|
||||
{
|
||||
"code": "RUFF",
|
||||
"message": stderr_text.strip() or "Unknown ruff failure",
|
||||
"location": {"row": 0, "column": 0},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
if stderr_text.strip() and process.returncode == 0:
|
||||
raise ProxyCodeValidationError(
|
||||
"Ruff emitted unexpected stderr output",
|
||||
errors=[
|
||||
{
|
||||
"code": "RUFF",
|
||||
"message": stderr_text.strip(),
|
||||
"location": {"row": 0, "column": 0},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
return _parse_ruff_output(stdout_text)
|
||||
|
||||
|
||||
async def validate_proxy_code(code: str) -> None:
|
||||
_validate_python_syntax(code)
|
||||
errors = await _run_ruff_check(code)
|
||||
if errors:
|
||||
raise ProxyCodeValidationError(
|
||||
"Proxy code contains ruff violations",
|
||||
errors=errors,
|
||||
)
|
||||
@@ -1,48 +0,0 @@
|
||||
[project]
|
||||
name = "theplug"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"authlib>=1.6.0",
|
||||
"basedpyright>=1.39.0",
|
||||
"fastapi[standard]>=0.135.3",
|
||||
"locust>=2.43.4",
|
||||
"mitmproxy>=12.2.2",
|
||||
"motor>=3.7.1",
|
||||
"pwdlib[argon2,bcrypt]>=0.3.0",
|
||||
"pydantic>=2.12.5",
|
||||
"pydantic-settings>=2.13.1",
|
||||
"pyyaml>=6.0.3",
|
||||
"ruff>=0.15.10",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
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]
|
||||
dev = [
|
||||
"pytest>=9.0.3",
|
||||
]
|
||||
@@ -1,112 +0,0 @@
|
||||
import importlib
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.core.security import AuthContext, get_auth_context
|
||||
from app.models.auth import AccessTokenPayload, AuthUserDocument
|
||||
|
||||
auth_router_module = importlib.import_module("app.api.auth.router")
|
||||
|
||||
|
||||
def test_login_success_returns_bearer_token(client, monkeypatch: object):
|
||||
user = AuthUserDocument(username="admin", password_hash="stored-hash")
|
||||
|
||||
async def fake_get_user_by_username(username: str) -> AuthUserDocument | None:
|
||||
assert username == "admin"
|
||||
return user
|
||||
|
||||
def fake_verify_password(plain_password: str, password_hash: str) -> bool:
|
||||
return plain_password == "secret" and password_hash == "stored-hash"
|
||||
|
||||
def fake_create_access_token(*, subject: str) -> str:
|
||||
assert subject == "admin"
|
||||
return "token-123"
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_router_module,
|
||||
"get_user_by_username",
|
||||
fake_get_user_by_username,
|
||||
)
|
||||
monkeypatch.setattr(auth_router_module, "verify_password", fake_verify_password)
|
||||
monkeypatch.setattr(
|
||||
auth_router_module,
|
||||
"create_access_token",
|
||||
fake_create_access_token,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
json={"username": "admin", "password": "secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"access_token": "token-123", "token_type": "bearer"}
|
||||
|
||||
|
||||
def test_login_invalid_credentials_returns_401(client, monkeypatch: object):
|
||||
async def fake_get_user_by_username(_: str) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_router_module,
|
||||
"get_user_by_username",
|
||||
fake_get_user_by_username,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
json={"username": "admin", "password": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Invalid username or password"
|
||||
assert response.headers["www-authenticate"] == "Bearer"
|
||||
|
||||
|
||||
def test_logout_revokes_active_token(client, monkeypatch: object):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_revoke_token(*, jti: str, expires_at: datetime) -> None:
|
||||
captured["jti"] = jti
|
||||
captured["expires_at"] = expires_at
|
||||
|
||||
monkeypatch.setattr(auth_router_module, "revoke_token", fake_revoke_token)
|
||||
|
||||
response = client.post("/auth/logout")
|
||||
|
||||
assert response.status_code == 204
|
||||
assert captured["jti"] == "test-jti"
|
||||
assert isinstance(captured["expires_at"], datetime)
|
||||
|
||||
|
||||
def test_logout_skips_revocation_when_token_is_expired(
|
||||
client,
|
||||
fake_user: AuthUserDocument,
|
||||
monkeypatch: object,
|
||||
):
|
||||
state = {"called": False}
|
||||
|
||||
async def fake_revoke_token(*, jti: str, expires_at: datetime) -> None:
|
||||
state["called"] = True
|
||||
|
||||
monkeypatch.setattr(auth_router_module, "revoke_token", fake_revoke_token)
|
||||
|
||||
expired_payload = AccessTokenPayload(
|
||||
sub=fake_user.username,
|
||||
jti="expired-jti",
|
||||
exp=int((datetime.now(UTC) - timedelta(minutes=1)).timestamp()),
|
||||
)
|
||||
expired_context = AuthContext(
|
||||
token="expired-token",
|
||||
user=fake_user,
|
||||
payload=expired_payload,
|
||||
)
|
||||
|
||||
async def override_auth_context() -> AuthContext:
|
||||
return expired_context
|
||||
|
||||
client.app.dependency_overrides[get_auth_context] = override_auth_context
|
||||
|
||||
response = client.post("/auth/logout")
|
||||
|
||||
assert response.status_code == 204
|
||||
assert state["called"] is False
|
||||
@@ -1,59 +0,0 @@
|
||||
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
|
||||
@@ -1,980 +0,0 @@
|
||||
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"]
|
||||
@@ -1,171 +0,0 @@
|
||||
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}"}
|
||||
@@ -1,25 +0,0 @@
|
||||
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"
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
@@ -1,186 +0,0 @@
|
||||
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
|
||||
@@ -1,30 +0,0 @@
|
||||
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"
|
||||
@@ -1,77 +0,0 @@
|
||||
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()
|
||||
@@ -1,218 +0,0 @@
|
||||
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)
|
||||
@@ -1,281 +0,0 @@
|
||||
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))
|
||||
@@ -1,52 +0,0 @@
|
||||
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")
|
||||
@@ -1,30 +0,0 @@
|
||||
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}']
|
||||
@@ -1,76 +0,0 @@
|
||||
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
|
||||
@@ -1,56 +0,0 @@
|
||||
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
|
||||
@@ -1,16 +0,0 @@
|
||||
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
-1908
File diff suppressed because it is too large
Load Diff
@@ -1,182 +0,0 @@
|
||||
#!/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 "$@"
|
||||
+50
-22
@@ -1,34 +1,62 @@
|
||||
services:
|
||||
frontend:
|
||||
api:
|
||||
build:
|
||||
context: ./frontend
|
||||
container_name: theplug-frontend
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: theplug-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
API_UPSTREAM: "host.docker.internal:${API_PORT:-3671}"
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-5173}:80"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
mongodb:
|
||||
image: mongo:8.2
|
||||
container_name: theplug-mongodb
|
||||
restart: unless-stopped
|
||||
- "${API_PORT:-3671}:3671"
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}
|
||||
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- theplug_mongodb:/data/db
|
||||
MONGODB_URL: mongodb://${MONGO_USER:-theplug}:${MONGO_PASSWORD:-theplug_dev_password}@mongodb:27017/${MONGO_DB:-theplug}?authSource=admin
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
depends_on:
|
||||
mongodb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
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"]
|
||||
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:
|
||||
image: mongo:8
|
||||
container_name: theplug-mongodb
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-pass}
|
||||
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- mongodb_data:/data/db
|
||||
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"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
container_name: theplug-redis
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
theplug_mongodb:
|
||||
mongodb_data:
|
||||
redis_data:
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
npm-debug.log
|
||||
@@ -1,16 +0,0 @@
|
||||
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
|
||||
@@ -1,12 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,22 +0,0 @@
|
||||
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
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<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>
|
||||
@@ -1,128 +0,0 @@
|
||||
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`),
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
<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>
|
||||
@@ -1,7 +0,0 @@
|
||||
<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>
|
||||
@@ -1,11 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-toast" role="status" aria-live="polite">
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,152 +0,0 @@
|
||||
<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>
|
||||
@@ -1,31 +0,0 @@
|
||||
<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>
|
||||
@@ -1,314 +0,0 @@
|
||||
<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>
|
||||
@@ -1,122 +0,0 @@
|
||||
<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>
|
||||
@@ -1,78 +0,0 @@
|
||||
<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>
|
||||
@@ -1,35 +0,0 @@
|
||||
<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>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel stack-panel tool-panel logs-panel">
|
||||
<div class="panel-title">
|
||||
<div><h2>Logs</h2></div>
|
||||
<div class="doc-actions">
|
||||
<button class="ghost button-icon" :disabled="!plane.selectedProxy" @click="plane.toggleLogsPaused">
|
||||
<UiIcon :name="plane.logsPaused ? 'resume' : 'pause'" />
|
||||
<span>{{ plane.logsPaused ? "Resume" : "Pause" }}</span>
|
||||
</button>
|
||||
<button class="ghost button-icon" @click="plane.clearLogs">
|
||||
<UiIcon name="clear" />
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input v-model="plane.logFilter" placeholder="Filter logs" />
|
||||
|
||||
<pre class="terminal">{{
|
||||
plane.filteredLogs || "Logs will appear here automatically."
|
||||
}}</pre>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,357 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MitmOption } from "../types";
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
|
||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
|
||||
embedded: false,
|
||||
});
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
|
||||
function isBool(option?: MitmOption | null) {
|
||||
return option?.type === "bool";
|
||||
}
|
||||
|
||||
function isNumber(option?: MitmOption | null) {
|
||||
return option?.type === "int" || option?.type === "optional int";
|
||||
}
|
||||
|
||||
function isSequence(option?: MitmOption | null) {
|
||||
return option?.type === "sequence of str";
|
||||
}
|
||||
|
||||
function formatDefault(option?: MitmOption | null) {
|
||||
if (!option) return "n/a";
|
||||
if (Array.isArray(option.default)) {
|
||||
return option.default.length ? option.default.join(", ") : "[]";
|
||||
}
|
||||
if (option.default === null || option.default === "") return "none";
|
||||
return String(option.default);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel tool-panel'">
|
||||
<div v-if="!props.embedded" class="panel-title">
|
||||
<h2>Options</h2>
|
||||
<div class="title-actions">
|
||||
<span class="counter">{{ plane.activeOptionCount }}</span>
|
||||
<div class="segmented">
|
||||
<button
|
||||
:class="{ active: plane.optionScope === 'focused' }"
|
||||
@click="plane.toggleOptionScope('focused')"
|
||||
>
|
||||
Common
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: plane.optionScope === 'all' }"
|
||||
@click="plane.toggleOptionScope('all')"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.embedded" class="option-step-layout">
|
||||
<aside class="option-browser option-browser-embedded">
|
||||
<div class="option-toolbar">
|
||||
<input v-model="plane.optionSearch" placeholder="Search options" />
|
||||
<div class="segmented">
|
||||
<button
|
||||
:class="{ active: plane.optionScope === 'focused' }"
|
||||
@click="plane.toggleOptionScope('focused')"
|
||||
>
|
||||
Common
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: plane.optionScope === 'all' }"
|
||||
@click="plane.toggleOptionScope('all')"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="plane.optionsLoading" class="option-browser-meta">
|
||||
<span class="option-count">...</span>
|
||||
<p class="muted small">Loading mitmdump options…</p>
|
||||
</div>
|
||||
<div v-else class="option-browser-meta">
|
||||
<span class="option-count">{{ plane.filteredOptions.length }}</span>
|
||||
<p class="muted small">
|
||||
{{
|
||||
plane.optionScope === "focused"
|
||||
? "Common options for the usual proxy setup."
|
||||
: `All ${plane.optionCount} public mitmdump options.`
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="plane.optionsLoading" class="option-empty-state">
|
||||
<p class="muted small">The option catalog is still loading.</p>
|
||||
</div>
|
||||
<div v-else-if="plane.filteredOptions.length" class="option-table">
|
||||
<button
|
||||
v-for="option in plane.filteredOptions"
|
||||
:key="option.name"
|
||||
class="option-entry"
|
||||
:class="{ selected: plane.selectedOption?.name === option.name }"
|
||||
@click="plane.selectOption(option.name)"
|
||||
>
|
||||
<strong>{{ option.name }}</strong>
|
||||
<small>{{ option.type }}</small>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="option-empty-state">
|
||||
<p class="muted small">No options matched the current filter.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="option-step-main">
|
||||
<article
|
||||
v-if="!plane.optionsLoading && plane.selectedOption"
|
||||
class="option-doc option-doc-embedded"
|
||||
>
|
||||
<div class="option-doc-head">
|
||||
<div>
|
||||
<h3>{{ plane.selectedOption.name }}</h3>
|
||||
<p class="doc-copy">{{ plane.selectedOption.description }}</p>
|
||||
</div>
|
||||
<span class="type-chip">{{ plane.selectedOption.type }}</span>
|
||||
</div>
|
||||
|
||||
<dl class="doc-facts doc-facts-compact">
|
||||
<div class="doc-fact">
|
||||
<dt>Default</dt>
|
||||
<dd>{{ formatDefault(plane.selectedOption) }}</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="plane.selectedOption.choices.length"
|
||||
class="doc-fact"
|
||||
>
|
||||
<dt>Choices</dt>
|
||||
<dd>{{ plane.selectedOption.choices.join(", ") }}</dd>
|
||||
</div>
|
||||
<div v-if="plane.selectedOption.reserved" class="doc-fact">
|
||||
<dt>State</dt>
|
||||
<dd>Managed by thePlug</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="doc-actions">
|
||||
<button
|
||||
class="button-icon"
|
||||
:disabled="plane.selectedOption.reserved"
|
||||
@click="plane.setOption(plane.selectedOption)"
|
||||
>
|
||||
<UiIcon name="plus" />
|
||||
<span>Add option</span>
|
||||
</button>
|
||||
<a
|
||||
class="button-link"
|
||||
v-if="plane.selectedOption.docs_url"
|
||||
:href="plane.selectedOption.docs_url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<UiIcon name="link" />
|
||||
Official docs
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section class="option-active-card">
|
||||
<div class="option-active-head">
|
||||
<h3>Configured options</h3>
|
||||
<span class="counter">{{ plane.activeOptionCount }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="plane.activeOptions.length" class="active-options compact-table">
|
||||
<div class="table-head">
|
||||
<span>Option</span>
|
||||
<span>Value</span>
|
||||
<span />
|
||||
</div>
|
||||
<div class="table-body compact-scroll">
|
||||
<div v-for="item in plane.activeOptions" :key="item.name" class="table-row">
|
||||
<button class="table-link" @click="plane.selectOption(item.name)">
|
||||
{{ item.name }}
|
||||
</button>
|
||||
<label v-if="isBool(item.option)" class="inline-toggle">
|
||||
<input
|
||||
:checked="item.value === 'true'"
|
||||
type="checkbox"
|
||||
@change="
|
||||
plane.updateOption(
|
||||
item.name,
|
||||
String(($event.target as HTMLInputElement).checked),
|
||||
item.option,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span>{{ item.value }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else
|
||||
:value="item.value"
|
||||
:type="isNumber(item.option) ? 'number' : 'text'"
|
||||
:placeholder="isSequence(item.option) ? 'comma separated values' : ''"
|
||||
@input="
|
||||
plane.updateOption(
|
||||
item.name,
|
||||
($event.target as HTMLInputElement).value,
|
||||
item.option,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<button class="ghost table-action" @click="plane.removeOption(item.name)">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted small">No custom options configured yet.</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="active-options compact-table">
|
||||
<div class="table-head">
|
||||
<span>Active</span>
|
||||
<span>Value</span>
|
||||
<span />
|
||||
</div>
|
||||
<div v-if="plane.activeOptions.length" class="table-body compact-scroll">
|
||||
<div v-for="item in plane.activeOptions" :key="item.name" class="table-row">
|
||||
<button class="table-link" @click="plane.selectOption(item.name)">
|
||||
{{ item.name }}
|
||||
</button>
|
||||
<label v-if="isBool(item.option)" class="inline-toggle">
|
||||
<input
|
||||
:checked="item.value === 'true'"
|
||||
type="checkbox"
|
||||
@change="
|
||||
plane.updateOption(
|
||||
item.name,
|
||||
String(($event.target as HTMLInputElement).checked),
|
||||
item.option,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span>{{ item.value }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else
|
||||
:value="item.value"
|
||||
:type="isNumber(item.option) ? 'number' : 'text'"
|
||||
:placeholder="isSequence(item.option) ? 'comma separated values' : ''"
|
||||
@input="
|
||||
plane.updateOption(
|
||||
item.name,
|
||||
($event.target as HTMLInputElement).value,
|
||||
item.option,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<button class="ghost table-action" @click="plane.removeOption(item.name)">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted small">No active options.</p>
|
||||
</div>
|
||||
|
||||
<div class="option-shell">
|
||||
<div class="option-browser">
|
||||
<div class="option-search-row">
|
||||
<input v-model="plane.optionSearch" placeholder="Search options" />
|
||||
<span class="option-count">
|
||||
{{ plane.optionsLoading ? "..." : plane.filteredOptions.length }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="plane.optionsLoading" class="muted small compact-copy">
|
||||
Loading mitmdump options…
|
||||
</p>
|
||||
<p v-else class="muted small compact-copy">
|
||||
{{
|
||||
plane.optionScope === "focused"
|
||||
? "Showing the options that are usually useful in this UI."
|
||||
: `Showing all ${plane.optionCount} public mitmdump options.`
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div v-if="plane.optionsLoading" class="option-empty-state">
|
||||
<p class="muted small">The option catalog is still loading.</p>
|
||||
</div>
|
||||
<div v-else-if="plane.filteredOptions.length" class="option-table">
|
||||
<button
|
||||
v-for="option in plane.filteredOptions"
|
||||
:key="option.name"
|
||||
class="option-entry"
|
||||
:class="{ selected: plane.selectedOption?.name === option.name }"
|
||||
@click="plane.selectOption(option.name)"
|
||||
>
|
||||
<strong>{{ option.name }}</strong>
|
||||
<small>{{ option.type }}</small>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="option-empty-state">
|
||||
<p class="muted small">No options matched the current filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article v-if="!plane.optionsLoading && plane.selectedOption" class="option-doc">
|
||||
<div class="option-doc-head">
|
||||
<div>
|
||||
<h3>{{ plane.selectedOption.name }}</h3>
|
||||
</div>
|
||||
<span class="type-chip">{{ plane.selectedOption.type }}</span>
|
||||
</div>
|
||||
|
||||
<p class="doc-copy">{{ plane.selectedOption.description }}</p>
|
||||
|
||||
<dl class="doc-facts">
|
||||
<div class="doc-fact">
|
||||
<dt>Default</dt>
|
||||
<dd>{{ formatDefault(plane.selectedOption) }}</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="plane.selectedOption.choices.length"
|
||||
class="doc-fact"
|
||||
>
|
||||
<dt>Choices</dt>
|
||||
<dd>{{ plane.selectedOption.choices.join(", ") }}</dd>
|
||||
</div>
|
||||
<div v-if="plane.selectedOption.reserved" class="doc-fact">
|
||||
<dt>State</dt>
|
||||
<dd>Managed by thePlug</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="doc-actions">
|
||||
<button
|
||||
class="button-icon"
|
||||
:disabled="plane.selectedOption.reserved"
|
||||
@click="plane.setOption(plane.selectedOption)"
|
||||
>
|
||||
<UiIcon name="plus" />
|
||||
<span>Add option</span>
|
||||
</button>
|
||||
<a
|
||||
class="button-link"
|
||||
v-if="plane.selectedOption.docs_url"
|
||||
:href="plane.selectedOption.docs_url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<UiIcon name="link" />
|
||||
Official docs
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProxyDocument } from "../types";
|
||||
import StatusPill from "./StatusPill.vue";
|
||||
|
||||
defineProps<{
|
||||
proxies: ProxyDocument[];
|
||||
selectedPort: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [port: number];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="rail panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<h2>Proxies</h2>
|
||||
</div>
|
||||
<span class="counter">{{ proxies.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!proxies.length" class="empty-state">
|
||||
<strong>No proxies yet</strong>
|
||||
<span>Create one to start.</span>
|
||||
</div>
|
||||
|
||||
<div class="proxy-list">
|
||||
<button
|
||||
v-for="proxy in proxies"
|
||||
:key="proxy.proxy_port"
|
||||
class="proxy-card"
|
||||
:class="{ selected: proxy.proxy_port === selectedPort }"
|
||||
@click="emit('select', proxy.proxy_port)"
|
||||
>
|
||||
<div class="proxy-card-head">
|
||||
<strong>{{ proxy.name }}</strong>
|
||||
<StatusPill :status="proxy.status" />
|
||||
</div>
|
||||
<div class="proxy-meta">
|
||||
<span>:{{ proxy.proxy_port }}</span>
|
||||
<span>{{ proxy.mode }} / {{ proxy.protocol }}</span>
|
||||
</div>
|
||||
<span class="proxy-route">
|
||||
{{ proxy.service.host }}:{{ proxy.service.port }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -1,77 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import HealthPanel from "./HealthPanel.vue";
|
||||
import OptionsPanel from "./OptionsPanel.vue";
|
||||
import RuntimePanel from "./RuntimePanel.vue";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="plane.wizardOpen" class="modal-backdrop" @click.self="plane.closeWizard">
|
||||
<section class="modal-shell">
|
||||
<header class="modal-head">
|
||||
<div>
|
||||
<h2>{{ plane.wizardTitle }}</h2>
|
||||
<p class="modal-step-copy">
|
||||
Step {{ plane.wizardStep + 1 }} of 3: {{ plane.wizardStepTitle }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-head-actions">
|
||||
<button class="primary button-icon" :disabled="!plane.canSaveProxy" @click="plane.saveProxy">
|
||||
<UiIcon name="save" />
|
||||
<span>Save & close</span>
|
||||
</button>
|
||||
<button class="ghost button-icon" @click="plane.closeWizard">
|
||||
<UiIcon name="close" />
|
||||
<span>Close</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="plane.wizardError" class="error modal-error">
|
||||
{{ plane.wizardError }}
|
||||
</p>
|
||||
|
||||
<div class="modal-stepper">
|
||||
<button
|
||||
v-for="(step, index) in ['Proxy route', 'Healthchecks', 'Custom mitmdump options']"
|
||||
:key="step"
|
||||
class="modal-step-chip"
|
||||
:class="{ active: plane.wizardStep === index, done: plane.wizardStep > index }"
|
||||
@click="plane.wizardStep = index"
|
||||
>
|
||||
<span>{{ index + 1 }}</span>
|
||||
<strong>{{ step }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<RuntimePanel v-if="plane.wizardStep === 0" embedded />
|
||||
<HealthPanel v-else-if="plane.wizardStep === 1" embedded />
|
||||
<OptionsPanel v-else embedded />
|
||||
</div>
|
||||
|
||||
<footer class="modal-actions">
|
||||
<button
|
||||
v-if="plane.canGoBackWizard"
|
||||
class="ghost button-icon"
|
||||
@click="plane.previousWizardStep"
|
||||
>
|
||||
<UiIcon name="back" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<div class="modal-action-spacer" />
|
||||
<button
|
||||
v-if="plane.canGoNextWizard"
|
||||
class="primary button-icon"
|
||||
@click="plane.nextWizardStep"
|
||||
>
|
||||
<UiIcon name="next" />
|
||||
<span>Next</span>
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,110 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
import EditorPane from "./EditorPane.vue";
|
||||
import HealthDetailsModal from "./HealthDetailsModal.vue";
|
||||
import HealthSummaryBar from "./HealthSummaryBar.vue";
|
||||
import LogsPanel from "./LogsPanel.vue";
|
||||
import ProxyWizardModal from "./ProxyWizardModal.vue";
|
||||
import ServiceDiscoveryPanel from "./ServiceDiscoveryPanel.vue";
|
||||
import StatusPill from "./StatusPill.vue";
|
||||
import TemplatesPanel from "./TemplatesPanel.vue";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workbench">
|
||||
<header class="workbench-head panel">
|
||||
<div class="workbench-copy">
|
||||
<h1>{{ plane.selectedProxy ? plane.draft.name : "Proxy workspace" }}</h1>
|
||||
<div v-if="plane.selectedProxy" class="route-row route-summary">
|
||||
<span class="route-token">{{ plane.routeState.proxySummary }}</span>
|
||||
<span class="route-target">{{ plane.routeState.upstreamSummary }}</span>
|
||||
<StatusPill
|
||||
:status="plane.selectedProxy.status"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="summary-copy">
|
||||
Create a proxy from autodiscovery or open one from the inventory.
|
||||
</p>
|
||||
<p v-if="plane.selectedProxy && plane.routeState.proxyPortReservedMessage" class="error compact">
|
||||
{{ plane.routeState.proxyPortReservedMessage }}
|
||||
</p>
|
||||
<p v-if="plane.selectedProxy?.last_error" class="error compact">
|
||||
{{ plane.selectedProxy.last_error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HealthSummaryBar />
|
||||
|
||||
<div class="action-row">
|
||||
<button
|
||||
v-if="plane.selectedProxy"
|
||||
class="ghost button-icon"
|
||||
@click="plane.openEditWizard()"
|
||||
:disabled="plane.loading"
|
||||
>
|
||||
<UiIcon name="settings" />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="plane.actionState.showStart"
|
||||
class="success button-icon"
|
||||
@click="plane.runtimeAction('start')"
|
||||
:disabled="!plane.actionState.canStart"
|
||||
>
|
||||
<UiIcon name="play" />
|
||||
<span>Start</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="plane.actionState.showStop"
|
||||
class="danger button-icon"
|
||||
@click="plane.runtimeAction('stop')"
|
||||
:disabled="!plane.actionState.canStop"
|
||||
>
|
||||
<UiIcon name="stop" />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="plane.actionState.showRestart"
|
||||
class="warning button-icon"
|
||||
@click="plane.runtimeAction('restart')"
|
||||
:disabled="!plane.actionState.canRestart"
|
||||
>
|
||||
<UiIcon name="restart" />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="plane.selectedProxy"
|
||||
class="danger button-icon"
|
||||
@click="plane.removeProxy"
|
||||
:disabled="!plane.actionState.canDelete"
|
||||
>
|
||||
<UiIcon name="trash" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="plane.workspaceError" class="error">{{ plane.workspaceError }}</p>
|
||||
|
||||
<ServiceDiscoveryPanel v-if="!plane.selectedProxy && !plane.wizardOpen" />
|
||||
|
||||
<div v-if="plane.selectedProxy" class="workspace-main-row">
|
||||
<div class="workspace-editor-pane">
|
||||
<EditorPane />
|
||||
</div>
|
||||
<div class="workspace-logs-pane">
|
||||
<LogsPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="plane.selectedProxy" class="workspace-templates-row">
|
||||
<TemplatesPanel />
|
||||
</div>
|
||||
|
||||
<ProxyWizardModal />
|
||||
<HealthDetailsModal />
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,101 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { modes, 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">
|
||||
<h2>Proxy route</h2>
|
||||
</div>
|
||||
|
||||
<div class="route-strip">
|
||||
<span class="route-chip">{{ plane.routeState.proxySummary }}</span>
|
||||
<span class="route-chip">{{ plane.routeState.upstreamSummary }}</span>
|
||||
</div>
|
||||
|
||||
<div class="route-overview-grid">
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Service</span>
|
||||
<strong>{{ plane.routeState.serviceLabel }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Mode</span>
|
||||
<strong>{{ plane.routeState.modeLabel }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="form-grid route-form-grid tight-grid single-row-grid">
|
||||
<label>
|
||||
Name
|
||||
<input v-model="plane.draft.name" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-grid tight-grid">
|
||||
<label>
|
||||
Proxy port
|
||||
<input
|
||||
v-model.number="plane.draft.proxy_port"
|
||||
:disabled="Boolean(plane.selectedProxy)"
|
||||
type="number"
|
||||
/>
|
||||
<small v-if="plane.selectedProxy">
|
||||
Port is fixed for existing proxies. Create a new proxy to change it.
|
||||
</small>
|
||||
<small v-if="plane.routeState.proxyPortReservedMessage" class="route-port-error">
|
||||
{{ plane.routeState.proxyPortReservedMessage }}
|
||||
</small>
|
||||
</label>
|
||||
<label>
|
||||
Upstream host
|
||||
<input v-model="plane.draft.service.host" />
|
||||
</label>
|
||||
<label>
|
||||
Upstream port
|
||||
<input v-model.number="plane.draft.service.port" type="number" />
|
||||
</label>
|
||||
<label>
|
||||
Listen host
|
||||
<input v-model="plane.draft.listen_host" />
|
||||
</label>
|
||||
<label>
|
||||
Mode
|
||||
<select v-model="plane.draft.mode">
|
||||
<option v-for="mode in modes" :key="mode">{{ mode }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Protocol
|
||||
<select v-model="plane.draft.protocol">
|
||||
<option v-for="protocol in protocols" :key="protocol">{{ protocol }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="route-mode-spec">
|
||||
Mode spec
|
||||
<input
|
||||
v-model="plane.draft.mode_spec"
|
||||
placeholder="optional raw mitmdump mode"
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
v-if="plane.routeState.takeoverMismatch"
|
||||
class="inline-grid advanced-inline-action"
|
||||
>
|
||||
<p class="muted small compact-copy">
|
||||
Proxy port differs from the original service port.
|
||||
</p>
|
||||
<button class="ghost button-icon" @click="plane.matchProxyPortToService">
|
||||
<UiIcon name="link" />
|
||||
<span>Use service port</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
import StatusPill from "./StatusPill.vue";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel stack-panel compact-panel">
|
||||
<div class="panel-title">
|
||||
<h2>Services</h2>
|
||||
<button class="ghost button-icon" :disabled="plane.servicesLoading" @click="plane.refreshServices">
|
||||
<UiIcon name="refresh" />
|
||||
<span>{{ plane.servicesLoading ? "Scanning" : "Refresh" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="inline-grid">
|
||||
<label>
|
||||
Scan root
|
||||
<input v-model="plane.scanRoot" placeholder="/root" />
|
||||
</label>
|
||||
<button class="primary button-icon" :disabled="plane.servicesLoading" @click="plane.refreshServices">
|
||||
<UiIcon name="refresh" />
|
||||
<span>Scan</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="plane.servicesLoading" class="muted small compact-copy">
|
||||
Scanning compose folders in {{ plane.scanRoot }}...
|
||||
</p>
|
||||
<p v-if="plane.discoveryError" class="error compact-copy">
|
||||
{{ plane.discoveryError }}
|
||||
</p>
|
||||
|
||||
<p v-else-if="!plane.serviceEntries.length" class="muted small compact-copy">
|
||||
No compose services detected in the scan root.
|
||||
</p>
|
||||
|
||||
<div v-else class="service-discovery-list">
|
||||
<article
|
||||
v-for="group in plane.serviceGroups"
|
||||
:key="group.id"
|
||||
class="service-group-card"
|
||||
>
|
||||
<div class="service-group-head">
|
||||
<div class="service-group-copy">
|
||||
<h3>{{ group.title }}</h3>
|
||||
<small class="service-folder">{{ group.folder }}</small>
|
||||
</div>
|
||||
<span class="muted small">
|
||||
{{ group.entries.length }} {{ group.entries.length === 1 ? "service" : "services" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="service-group-list">
|
||||
<article
|
||||
v-for="entry in group.entries"
|
||||
:key="entry.id"
|
||||
class="service-discovery-row"
|
||||
>
|
||||
<div class="service-discovery-main">
|
||||
<strong>{{ entry.service.name }}</strong>
|
||||
<span class="service-route-copy">
|
||||
{{ entry.listenHost }}:{{ entry.binding.published }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="service-discovery-actions">
|
||||
<template v-if="entry.proxy">
|
||||
<StatusPill :status="entry.proxy.status" />
|
||||
<button class="ghost button-icon" @click="plane.selectedPort = entry.proxy.proxy_port">
|
||||
<UiIcon name="link" />
|
||||
<span>Open proxy</span>
|
||||
</button>
|
||||
<button
|
||||
class="ghost button-icon"
|
||||
@click="plane.openEditWizard(entry.proxy)"
|
||||
>
|
||||
<UiIcon name="settings" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
class="primary button-icon"
|
||||
:disabled="plane.loading"
|
||||
@click="plane.openCreateWizard(entry.service, entry.binding)"
|
||||
>
|
||||
<UiIcon name="plus" />
|
||||
<span>Create proxy</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,9 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
status: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-pill" :class="status">{{ status }}</span>
|
||||
</template>
|
||||
@@ -1,55 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
code: string;
|
||||
}>();
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function highlightPython(source: string) {
|
||||
const lines = source.split("\n");
|
||||
return lines
|
||||
.map((line) => {
|
||||
const escaped = escapeHtml(line);
|
||||
const commentIndex = escaped.indexOf("#");
|
||||
const codePart = commentIndex >= 0 ? escaped.slice(0, commentIndex) : escaped;
|
||||
const commentPart = commentIndex >= 0 ? escaped.slice(commentIndex) : "";
|
||||
|
||||
let highlighted = codePart
|
||||
.replace(
|
||||
/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g,
|
||||
'<span class="py-string">$1</span>',
|
||||
)
|
||||
.replace(
|
||||
/\b(from|import|def|return|if|else|elif|for|in|while|try|except|finally|pass|continue|break|as|None|True|False|and|or|not)\b/g,
|
||||
'<span class="py-keyword">$1</span>',
|
||||
)
|
||||
.replace(
|
||||
/\b([A-Z_][A-Z0-9_]*)\b/g,
|
||||
'<span class="py-constant">$1</span>',
|
||||
)
|
||||
.replace(
|
||||
/\b(logging|flow|http|tcp|dns)\b/g,
|
||||
'<span class="py-name">$1</span>',
|
||||
);
|
||||
|
||||
if (commentPart) {
|
||||
highlighted += `<span class="py-comment">${commentPart}</span>`;
|
||||
}
|
||||
return highlighted;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const highlightedCode = computed(() => highlightPython(props.code));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<pre class="terminal template-detail-code template-code-preview"><code v-html="highlightedCode" /></pre>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user