BIG BIG BIG BIG BIG UPDATES
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
co-authored by
Copilot
Unverified
parent
46f7cd6a5f
commit
93ba8e9ac2
@@ -5,3 +5,12 @@
|
|||||||
.env
|
.env
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
**/__pycache__/
|
||||||
|
**/.pytest_cache/
|
||||||
|
**/.ruff_cache/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
backend/.venv/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|||||||
@@ -1,79 +1,84 @@
|
|||||||
# thePlug - plugger/proxy Barese
|
# thePlug
|
||||||
|
|
||||||
icona di una presa della spina idk
|
Wrapper web per gestire proxy `mitmdump` davanti ai servizi di una competizione
|
||||||
|
CTF attack/defense.
|
||||||
|
|
||||||
## Architettura
|
## Struttura
|
||||||
|
|
||||||
```
|
```text
|
||||||
FastAPI + MongoDB + Redis
|
backend/ FastAPI, MongoDB, Redis, mitmdump manager
|
||||||
|
frontend/ Vue 3, Vite, Monaco editor
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Setup (prod)
|
## Setup rapido
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -s https://git.reloia.uk/reloia/thePlug/raw/branch/master/setup.sh)
|
./setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup Dev
|
Lo script crea `backend/.env`, genera le credenziali mancanti e avvia lo stack
|
||||||
|
Docker completo:
|
||||||
|
|
||||||
imposta le variabili d'ambiente
|
```text
|
||||||
|
Frontend http://localhost:5173
|
||||||
|
Backend http://localhost:3671
|
||||||
|
```
|
||||||
|
|
||||||
|
Per installare anche il servizio systemd user:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
./setup.sh service
|
||||||
# modifica le variabili
|
|
||||||
nano .env
|
|
||||||
```
|
```
|
||||||
|
|
||||||
variabili auth richieste in `.env`:
|
## Dev backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
JWT_SECRET_KEY=change-this-secret
|
cp backend/.env.example backend/.env
|
||||||
JWT_ALGORITHM=HS256
|
docker compose --env-file backend/.env up -d mongodb redis
|
||||||
JWT_EXPIRE_MINUTES=30
|
npm run dev:backend
|
||||||
AUTH_USERNAME=admin
|
|
||||||
AUTH_PASSWORD=change-this-password
|
|
||||||
```
|
```
|
||||||
|
|
||||||
avvia i servizi e il server locale
|
Docs FastAPI in dev: `http://localhost:3671/docs`.
|
||||||
|
|
||||||
|
## Dev frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d mongodb redis
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Il dev server usa `/api` come proxy verso `http://127.0.0.1:3671`.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fastapi dev app/main.py
|
npm run test
|
||||||
```
|
```
|
||||||
|
|
||||||
accedi a FastAPI su: [FastAPI docs](http://localhost:3671/docs)
|
Equivalente a:
|
||||||
|
|
||||||
## Auth API
|
```bash
|
||||||
|
cd backend && uv run ruff check . && uv run pytest
|
||||||
|
cd frontend && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
Gli endpoint auth disponibili sono solo:
|
## API principali
|
||||||
|
|
||||||
- `POST /auth/login`
|
- `POST /auth/login`
|
||||||
- `POST /auth/logout`
|
- `POST /auth/logout`
|
||||||
|
- `GET /proxy`
|
||||||
|
- `POST /proxy`
|
||||||
|
- `POST /proxy/{port}/start`
|
||||||
|
- `POST /proxy/{port}/stop`
|
||||||
|
- `POST /proxy/{port}/restart`
|
||||||
|
- `GET /proxy/{port}/health`
|
||||||
|
- `GET /proxy/{port}/logs`
|
||||||
|
- `GET /proxy/mitmproxy/options`
|
||||||
|
- `GET /proxy/mitmproxy/examples`
|
||||||
|
|
||||||
Esempio login:
|
## Riferimenti
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3671/auth/login \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"username":"admin","password":"change-this-password"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
L'access token ottenuto va inviato come bearer token:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3671/proxy \
|
|
||||||
-H "Authorization: Bearer <ACCESS_TOKEN>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Esempio logout (revoca server-side del token):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:3671/auth/logout \
|
|
||||||
-H "Authorization: Bearer <ACCESS_TOKEN>"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentazione per Sviluppo
|
|
||||||
|
|
||||||
[MitMProxy examples](https://docs.mitmproxy.org/stable/addons/examples/)
|
|
||||||
|
|
||||||
|
- Mitmproxy addons: https://docs.mitmproxy.org/stable/addons/examples/
|
||||||
|
- Mitmproxy modes: https://docs.mitmproxy.org/stable/concepts/modes/
|
||||||
|
- Mitmproxy options: https://docs.mitmproxy.org/stable/concepts/options/
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
UI SCHIFOSA
|
||||||
|
andrebbe rifatto proprio il layout.
|
||||||
|
|
||||||
|
dovrebbe essere in ordine di priorità, l'editor non può stare così tanto sotto.
|
||||||
|
card come:
|
||||||
|
- Runtime mapping
|
||||||
|
- What start does
|
||||||
|
vanno eliminati.
|
||||||
|
|
||||||
|
di default quando si crea un reverse proxy, l'intenzione è quella di spostare la porta del servizio ad un'altra e mettere il proxy al posto della porta del servizio iniziale.
|
||||||
|
|
||||||
|
anche ```Python tools ready``` è inutile
|
||||||
|
|
||||||
|
Addon editor come titolo non mi piace
|
||||||
|
|
||||||
|
```
|
||||||
|
No proxies configured
|
||||||
|
Create a draft, save it, then start it when you want mitmdump to own the route.
|
||||||
|
```
|
||||||
|
troppa roba, riassumere.
|
||||||
|
|
||||||
|
Options card buggattissima lato UI, inoltre a questo punto va fatta una scrematura sulle opzioni sulle cose che possono servire veramente, inutile mettere troppe cose.
|
||||||
|
|
||||||
|
gli esempi vanno aggiornati con roba reale utile.
|
||||||
|
|
||||||
|
|
||||||
|
capire come testare davvero sulla mia macchina
|
||||||
|
|
||||||
|
cambiare text sulla pagina di login
|
||||||
|
|
||||||
|
cambiare dove e come viene mostrato alert quando c'è errore di autenticazione (loggati in precedenza con token scaduto.)
|
||||||
|
|
||||||
|
rendere lo script davvero funzionante
|
||||||
|
|
||||||
|
solo il backend non deve runnare dentro docker ma sulla macchina reale.
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
|
|
||||||
from core.security import require_authenticated_user
|
|
||||||
from models.proxy import ProxyCreateInput, ProxyUpdateInput
|
|
||||||
from repositories.proxy import (
|
|
||||||
create_proxy,
|
|
||||||
delete_proxy,
|
|
||||||
get_proxy_by_port,
|
|
||||||
update_proxy,
|
|
||||||
)
|
|
||||||
from repositories.proxy import (
|
|
||||||
list_proxies as list_proxy_documents,
|
|
||||||
)
|
|
||||||
from services.mitmproxy.validator import ProxyCodeValidationError, validate_proxy_code
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/proxy",
|
|
||||||
tags=["Proxy"],
|
|
||||||
dependencies=[Depends(require_authenticated_user)],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_code_or_raise(code: str) -> None:
|
|
||||||
try:
|
|
||||||
await validate_proxy_code(code)
|
|
||||||
except ProxyCodeValidationError as error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=error.to_http_detail(),
|
|
||||||
) from error
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
async def list_proxies_handler():
|
|
||||||
return await list_proxy_documents()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{proxy_port}")
|
|
||||||
async def get_proxy(proxy_port: int):
|
|
||||||
proxy = await get_proxy_by_port(proxy_port)
|
|
||||||
if proxy is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Proxy not found",
|
|
||||||
)
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_proxy_handler(payload: ProxyCreateInput):
|
|
||||||
existing_proxy = await get_proxy_by_port(payload.proxy_port)
|
|
||||||
if existing_proxy is not None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"Proxy already exists on port {payload.proxy_port}",
|
|
||||||
)
|
|
||||||
|
|
||||||
await _validate_code_or_raise(payload.code)
|
|
||||||
|
|
||||||
return await create_proxy(
|
|
||||||
proxy_port=payload.proxy_port,
|
|
||||||
name=payload.name,
|
|
||||||
service={"port": payload.service.port},
|
|
||||||
code=payload.code,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{proxy_port}")
|
|
||||||
async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput):
|
|
||||||
if payload.code is not None:
|
|
||||||
await _validate_code_or_raise(payload.code)
|
|
||||||
|
|
||||||
updated_proxy = await update_proxy(proxy_port, payload)
|
|
||||||
if updated_proxy is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Proxy not found",
|
|
||||||
)
|
|
||||||
return updated_proxy
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{proxy_port}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def remove_proxy(proxy_port: int):
|
|
||||||
deleted = await delete_proxy(proxy_port)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Proxy not found",
|
|
||||||
)
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from models.service import ServiceEndpointDocument
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyDocument(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
proxy_port: int = Field(..., description="Port exposed by the proxy")
|
|
||||||
name: str = Field(..., description="Human readable proxy name")
|
|
||||||
code: str = Field(..., description="Proxy source or identifier")
|
|
||||||
service: ServiceEndpointDocument = Field(
|
|
||||||
...,
|
|
||||||
description="Associated upstream service",
|
|
||||||
)
|
|
||||||
created_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Creation timestamp in UTC",
|
|
||||||
)
|
|
||||||
updated_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Last update timestamp in UTC",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyCreateInput(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
proxy_port: int = Field(..., description="Port exposed by the proxy")
|
|
||||||
name: str = Field(..., description="Human readable proxy name")
|
|
||||||
code: str = Field(..., description="Proxy source or identifier")
|
|
||||||
service: ServiceEndpointDocument = Field(
|
|
||||||
...,
|
|
||||||
description="Associated upstream service",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyUpdateInput(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
name: str | None = Field(default=None, description="Human readable proxy name")
|
|
||||||
code: str | None = Field(default=None, description="Proxy source or identifier")
|
|
||||||
service: ServiceEndpointDocument | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Associated upstream service",
|
|
||||||
)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceEndpointDocument(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
port: int = Field(..., description="Upstream service port")
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from integrations import docker
|
|
||||||
from integrations.docker import DockerContainer
|
|
||||||
from integrations.docker.ports import _get_random_port
|
|
||||||
|
|
||||||
|
|
||||||
async def create_proxy(
|
|
||||||
port: int,
|
|
||||||
service: DockerContainer,
|
|
||||||
code: str,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
"""
|
|
||||||
Utility function that will:
|
|
||||||
- run ``integrations/docker`` utilities to change the old port of original service to a random one
|
|
||||||
/- write ``code`` into ``/tmp/theplug/{random}.py``
|
|
||||||
- start a subprocess via asyncio
|
|
||||||
- return the proxy infos (pid, )
|
|
||||||
"""
|
|
||||||
path = Path(f"/tmp/theplug/{service['name']}.py")
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
path.write_text(code, encoding="utf-8")
|
|
||||||
|
|
||||||
# da qui bisogna:
|
|
||||||
# scegliere una porta random alla quale spostare il docker
|
|
||||||
# modificare il docker via integrations.docker.editor.change_port ( service.folder, service.name, port, random_nuova_port )
|
|
||||||
random_port = _get_random_port()
|
|
||||||
changed = await docker.change_port(
|
|
||||||
service["name"],
|
|
||||||
service["folder"],
|
|
||||||
port,
|
|
||||||
random_port,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"service": service["name"],
|
|
||||||
"previous_port": port,
|
|
||||||
"upstream_port": random_port,
|
|
||||||
"script_path": str(path),
|
|
||||||
"port_changed": changed,
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
API_PORT=3671
|
API_PORT=3671
|
||||||
|
FRONTEND_PORT=5173
|
||||||
MONGO_INITDB_ROOT_PASSWORD=pass# esegui 'openssl rand -hex 5' in un terminale e copia qui
|
MONGO_INITDB_ROOT_PASSWORD=pass# esegui 'openssl rand -hex 5' in un terminale e copia qui
|
||||||
MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin
|
MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin
|
||||||
MONGODB_DB_NAME=theplug
|
MONGODB_DB_NAME=theplug
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
FROM python:3.14-slim
|
FROM python:3.14-slim
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONPATH=/app/src
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -9,11 +10,15 @@ COPY pyproject.toml ./
|
|||||||
RUN pip install --no-cache-dir --upgrade pip && \
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
pip install --no-cache-dir \
|
pip install --no-cache-dir \
|
||||||
"authlib>=1.6.0" \
|
"authlib>=1.6.0" \
|
||||||
|
"basedpyright>=1.39.0" \
|
||||||
"fastapi[standard]>=0.135.3" \
|
"fastapi[standard]>=0.135.3" \
|
||||||
|
"mitmproxy>=12.2.2" \
|
||||||
"motor>=3.7.1" \
|
"motor>=3.7.1" \
|
||||||
"passlib[bcrypt]>=1.7.4" \
|
"pwdlib[argon2,bcrypt]>=0.3.0" \
|
||||||
|
"pyyaml>=6.0.3" \
|
||||||
"pydantic-settings>=2.13.1" \
|
"pydantic-settings>=2.13.1" \
|
||||||
"redis>=7.4.0"
|
"redis>=7.4.0" \
|
||||||
|
"ruff>=0.15.10"
|
||||||
|
|
||||||
COPY app ./src
|
COPY app ./src
|
||||||
|
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
||||||
|
|
||||||
from core.security import (
|
from core.security import (
|
||||||
AuthContext,
|
AuthContext,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
get_auth_context,
|
get_auth_context,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from models.auth import LoginInput, TokenResponse
|
from models.auth import LoginInput, TokenResponse
|
||||||
from repositories.auth import get_user_by_username, revoke_token
|
from repositories.auth import get_user_by_username, revoke_token
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from core.security import (
|
||||||
|
InvalidTokenError,
|
||||||
|
authenticate_token,
|
||||||
|
require_authenticated_user,
|
||||||
|
)
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
||||||
|
from services.editor import python_lsp
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/proxy/editor/python", tags=["Editor"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/session", dependencies=[Depends(require_authenticated_user)])
|
||||||
|
async def python_editor_session():
|
||||||
|
if python_lsp.lsp_binary() is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="basedpyright-langserver is not installed",
|
||||||
|
)
|
||||||
|
|
||||||
|
session = python_lsp.get_session()
|
||||||
|
return {
|
||||||
|
"root_uri": session.root_uri,
|
||||||
|
"file_uri": session.file_uri,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_editor_socket(websocket: WebSocket) -> None:
|
||||||
|
token = websocket.query_params.get("token")
|
||||||
|
if token is None:
|
||||||
|
authorization = websocket.headers.get("authorization", "")
|
||||||
|
scheme, _, value = authorization.partition(" ")
|
||||||
|
if scheme.lower() == "bearer" and value:
|
||||||
|
token = value
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
raise InvalidTokenError
|
||||||
|
|
||||||
|
await authenticate_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws")
|
||||||
|
async def python_editor_socket(websocket: WebSocket):
|
||||||
|
try:
|
||||||
|
await authenticate_editor_socket(websocket)
|
||||||
|
except InvalidTokenError:
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
|
||||||
|
if python_lsp.lsp_binary() is None:
|
||||||
|
await websocket.close(code=1011, reason="basedpyright unavailable")
|
||||||
|
return
|
||||||
|
|
||||||
|
session = python_lsp.get_session()
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await python_lsp.bridge(websocket, session)
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
except RuntimeError:
|
||||||
|
await websocket.close(code=1011, reason="language server failed")
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from core.security import require_authenticated_user
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from models.example import AddonExampleCreateInput
|
||||||
|
from models.proxy import ProxyCreateInput, ProxyUpdateInput
|
||||||
|
from pymongo.errors import DuplicateKeyError
|
||||||
|
from repositories.examples import (
|
||||||
|
create_custom_example,
|
||||||
|
delete_custom_example,
|
||||||
|
list_custom_examples,
|
||||||
|
)
|
||||||
|
from repositories.proxy import (
|
||||||
|
create_proxy,
|
||||||
|
delete_proxy,
|
||||||
|
get_proxy_by_port,
|
||||||
|
update_proxy,
|
||||||
|
update_proxy_runtime,
|
||||||
|
)
|
||||||
|
from repositories.proxy import (
|
||||||
|
list_proxies as list_proxy_documents,
|
||||||
|
)
|
||||||
|
from services.mitmproxy.examples import BUILTIN_EXAMPLES
|
||||||
|
from services.mitmproxy.health import check_proxy
|
||||||
|
from services.mitmproxy.manager import MitmdumpError, manager
|
||||||
|
from services.mitmproxy.options import (
|
||||||
|
MitmOptionError,
|
||||||
|
load_options,
|
||||||
|
public_options,
|
||||||
|
validate_options,
|
||||||
|
)
|
||||||
|
from 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",
|
||||||
|
}
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/proxy",
|
||||||
|
tags=["Proxy"],
|
||||||
|
dependencies=[Depends(require_authenticated_user)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_code_or_raise(code: str) -> None:
|
||||||
|
try:
|
||||||
|
await validate_proxy_code(code)
|
||||||
|
except ProxyCodeValidationError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=error.to_http_detail(),
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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("")
|
||||||
|
async def list_proxies_handler():
|
||||||
|
return await list_proxy_documents()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/services")
|
||||||
|
async def list_services(request: Request):
|
||||||
|
return getattr(request.app.state, "services", [])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mitmproxy/options")
|
||||||
|
async def list_mitmproxy_options():
|
||||||
|
return public_options(await load_options())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mitmproxy/examples")
|
||||||
|
async def list_mitmproxy_examples():
|
||||||
|
return [*BUILTIN_EXAMPLES, *await list_custom_examples()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mitmproxy/examples", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_mitmproxy_example(payload: AddonExampleCreateInput):
|
||||||
|
await _validate_code_or_raise(payload.code)
|
||||||
|
try:
|
||||||
|
return await create_custom_example(payload)
|
||||||
|
except DuplicateKeyError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Example already exists: {payload.id}",
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/mitmproxy/examples/{example_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
)
|
||||||
|
async def delete_mitmproxy_example(example_id: str):
|
||||||
|
if any(example.id == example_id for example in BUILTIN_EXAMPLES):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Built-in examples cannot be deleted",
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = await delete_custom_example(example_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Example not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{proxy_port}")
|
||||||
|
async def get_proxy(proxy_port: int):
|
||||||
|
return await get_proxy_or_404(proxy_port)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_proxy_handler(payload: ProxyCreateInput):
|
||||||
|
existing_proxy = await get_proxy_by_port(payload.proxy_port)
|
||||||
|
if existing_proxy is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Proxy already exists on port {payload.proxy_port}",
|
||||||
|
)
|
||||||
|
|
||||||
|
await _validate_code_or_raise(payload.code)
|
||||||
|
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}")
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
async def proxy_health(proxy_port: int):
|
||||||
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
|
return await check_proxy(proxy)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{proxy_port}/logs")
|
||||||
|
async def proxy_logs(proxy_port: int):
|
||||||
|
proxy = await get_proxy_or_404(proxy_port)
|
||||||
|
if proxy.log_path is None:
|
||||||
|
return {"proxy_port": proxy.proxy_port, "log": ""}
|
||||||
|
|
||||||
|
path = Path(proxy.log_path)
|
||||||
|
if not path.is_file():
|
||||||
|
return {"proxy_port": proxy.proxy_port, "log": ""}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"proxy_port": proxy.proxy_port,
|
||||||
|
"log": path.read_text(encoding="utf-8", errors="replace")[-8000:],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{proxy_port}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
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)
|
||||||
|
deleted = await delete_proxy(proxy_port)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Proxy not found",
|
||||||
|
)
|
||||||
@@ -4,7 +4,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=(".env", "backend/.env"),
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
@@ -10,6 +10,7 @@ class _MongoDB:
|
|||||||
raise RuntimeError("MongoDB client has not been initialized")
|
raise RuntimeError("MongoDB client has not been initialized")
|
||||||
return self.client[settings.MONGODB_DB_NAME]
|
return self.client[settings.MONGODB_DB_NAME]
|
||||||
|
|
||||||
|
|
||||||
mongodb = _MongoDB()
|
mongodb = _MongoDB()
|
||||||
|
|
||||||
|
|
||||||
@@ -5,13 +5,12 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from authlib.jose import jwt # type: ignore
|
from authlib.jose import jwt # type: ignore
|
||||||
from authlib.jose.errors import JoseError # type: ignore
|
from authlib.jose.errors import JoseError # type: ignore
|
||||||
|
from core.config import settings
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from models.auth import AccessTokenPayload, AuthUserDocument
|
||||||
from pwdlib import PasswordHash
|
from pwdlib import PasswordHash
|
||||||
from pwdlib.hashers.argon2 import Argon2Hasher
|
from pwdlib.hashers.argon2 import Argon2Hasher
|
||||||
|
|
||||||
from core.config import settings
|
|
||||||
from models.auth import AccessTokenPayload, AuthUserDocument
|
|
||||||
from repositories.auth import get_user_by_username, is_token_revoked
|
from repositories.auth import get_user_by_username, is_token_revoked
|
||||||
|
|
||||||
pwd_context = PasswordHash((Argon2Hasher(),))
|
pwd_context = PasswordHash((Argon2Hasher(),))
|
||||||
@@ -42,6 +41,8 @@ def hash_password(password: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, password_hash: str) -> bool:
|
def verify_password(plain_password: str, password_hash: str) -> bool:
|
||||||
|
if hasattr(pwd_context, "verify"):
|
||||||
|
return pwd_context.verify(plain_password, password_hash)
|
||||||
return pwd_context.check(plain_password, password_hash)
|
return pwd_context.check(plain_password, password_hash)
|
||||||
|
|
||||||
|
|
||||||
@@ -74,10 +75,26 @@ def decode_access_token(token: str) -> AccessTokenPayload:
|
|||||||
claims = jwt.decode(token, settings.JWT_SECRET_KEY)
|
claims = jwt.decode(token, settings.JWT_SECRET_KEY)
|
||||||
claims.validate()
|
claims.validate()
|
||||||
return AccessTokenPayload.model_validate(dict(claims))
|
return AccessTokenPayload.model_validate(dict(claims))
|
||||||
except (JoseError, ValueError, TypeError):
|
except JoseError, ValueError, TypeError:
|
||||||
raise InvalidTokenError from None
|
raise InvalidTokenError from None
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_token(token: str) -> AuthContext:
|
||||||
|
try:
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
except InvalidTokenError:
|
||||||
|
raise InvalidTokenError from None
|
||||||
|
|
||||||
|
if await is_token_revoked(payload.jti):
|
||||||
|
raise InvalidTokenError
|
||||||
|
|
||||||
|
user = await get_user_by_username(payload.sub)
|
||||||
|
if user is None:
|
||||||
|
raise InvalidTokenError
|
||||||
|
|
||||||
|
return AuthContext(token=token, user=user, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
async def get_auth_context(
|
async def get_auth_context(
|
||||||
credentials: Annotated[
|
credentials: Annotated[
|
||||||
HTTPAuthorizationCredentials | None,
|
HTTPAuthorizationCredentials | None,
|
||||||
@@ -87,21 +104,11 @@ async def get_auth_context(
|
|||||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||||
raise token_auth_error()
|
raise token_auth_error()
|
||||||
|
|
||||||
token = credentials.credentials
|
|
||||||
try:
|
try:
|
||||||
payload = decode_access_token(token)
|
return await authenticate_token(credentials.credentials)
|
||||||
except InvalidTokenError:
|
except InvalidTokenError:
|
||||||
raise token_auth_error() from None
|
raise token_auth_error() from None
|
||||||
|
|
||||||
if await is_token_revoked(payload.jti):
|
|
||||||
raise token_auth_error()
|
|
||||||
|
|
||||||
user = await get_user_by_username(payload.sub)
|
|
||||||
if user is None:
|
|
||||||
raise token_auth_error()
|
|
||||||
|
|
||||||
return AuthContext(token=token, user=user, payload=payload)
|
|
||||||
|
|
||||||
|
|
||||||
async def require_authenticated_user(
|
async def require_authenticated_user(
|
||||||
auth_context: Annotated[AuthContext, Depends(get_auth_context)],
|
auth_context: Annotated[AuthContext, Depends(get_auth_context)],
|
||||||
@@ -7,9 +7,9 @@ from integrations.docker.editor import change_port
|
|||||||
from integrations.docker.models import DockerContainer
|
from integrations.docker.models import DockerContainer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DockerContainer",
|
"DockerContainer",
|
||||||
"find_docker_compose",
|
"find_docker_compose",
|
||||||
"get_services",
|
"get_services",
|
||||||
"get_all_services_in_dir",
|
"get_all_services_in_dir",
|
||||||
"change_port",
|
"change_port",
|
||||||
]
|
]
|
||||||
@@ -2,7 +2,6 @@ import asyncio
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from integrations.docker.models import DockerContainer
|
from integrations.docker.models import DockerContainer
|
||||||
from integrations.docker.ports import _get_service_port
|
from integrations.docker.ports import _get_service_port
|
||||||
|
|
||||||
@@ -16,7 +15,9 @@ COMPOSE_NAMES = (
|
|||||||
|
|
||||||
def _get_visible_folders(directory: Path) -> list[Path]:
|
def _get_visible_folders(directory: Path) -> list[Path]:
|
||||||
return [
|
return [
|
||||||
p for p in Path(directory).iterdir() if p.is_dir() and not p.name.startswith(".")
|
p
|
||||||
|
for p in Path(directory).iterdir()
|
||||||
|
if p.is_dir() and not p.name.startswith(".")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -76,10 +77,7 @@ def get_all_services_in_dir(directory: str | Path) -> list[DockerContainer]:
|
|||||||
return services
|
return services
|
||||||
|
|
||||||
|
|
||||||
async def run_docker_command(
|
async def run_docker_command(file: Path, command: list[str]) -> dict | None:
|
||||||
file: Path,
|
|
||||||
command: list[str]
|
|
||||||
) -> dict | None:
|
|
||||||
if not command:
|
if not command:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -89,7 +87,7 @@ async def run_docker_command(
|
|||||||
*full_command,
|
*full_command,
|
||||||
cwd=str(file.parent),
|
cwd=str(file.parent),
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
stdout, stderr = await process.communicate()
|
stdout, stderr = await process.communicate()
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from integrations.docker.compose import find_docker_compose, run_docker_command
|
from integrations.docker.compose import find_docker_compose, run_docker_command
|
||||||
from integrations.docker.ports import _get_port_index, _replace_port_in_mapping
|
from integrations.docker.ports import _get_port_index, _replace_port_in_mapping
|
||||||
|
|
||||||
@@ -13,10 +12,10 @@ def _backup_path_for(compose_path: Path) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
async def change_port(
|
async def change_port(
|
||||||
service_name: str,
|
service_name: str,
|
||||||
folder: Path,
|
folder: Path,
|
||||||
port_from: int,
|
port_from: int,
|
||||||
port_to: int,
|
port_to: int,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
compose_data = find_docker_compose(folder)
|
compose_data = find_docker_compose(folder)
|
||||||
if compose_data is None:
|
if compose_data is None:
|
||||||
@@ -45,7 +44,9 @@ async def change_port(
|
|||||||
original_text = compose_path.read_text()
|
original_text = compose_path.read_text()
|
||||||
|
|
||||||
backup_path.write_text(compose_path.read_text())
|
backup_path.write_text(compose_path.read_text())
|
||||||
compose_path.write_text(yaml.dump(docker_compose, default_flow_style=False, sort_keys=False))
|
compose_path.write_text(
|
||||||
|
yaml.dump(docker_compose, default_flow_style=False, sort_keys=False)
|
||||||
|
)
|
||||||
|
|
||||||
result = await run_docker_command(
|
result = await run_docker_command(
|
||||||
compose_path,
|
compose_path,
|
||||||
@@ -2,16 +2,16 @@ import socket
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
|
||||||
def _get_random_port() -> int:
|
def get_random_port() -> int:
|
||||||
# os automatically assigns an available port when binding to port 0, so we can use that to find a free port
|
# Binding to port 0 asks the OS for an available ephemeral port.
|
||||||
try:
|
try:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
s.bind(('', 0))
|
s.bind(("", 0))
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
port = s.getsockname()[1]
|
port = s.getsockname()[1]
|
||||||
return port
|
return port
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Could not find an available port: {e}")
|
raise RuntimeError(f"Could not find an available port: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None:
|
def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None:
|
||||||
@@ -24,7 +24,7 @@ def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None:
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return int(published)
|
return int(published)
|
||||||
except (TypeError, ValueError):
|
except TypeError, ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
parts = str(mapping).split(":")
|
parts = str(mapping).split(":")
|
||||||
@@ -48,9 +48,9 @@ def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None:
|
|||||||
|
|
||||||
|
|
||||||
def _replace_port_in_mapping(
|
def _replace_port_in_mapping(
|
||||||
mapping: str | int | Mapping[str, object],
|
mapping: str | int | Mapping[str, object],
|
||||||
port_from: int,
|
port_from: int,
|
||||||
port_to: int,
|
port_to: int,
|
||||||
) -> str | dict[str, object]:
|
) -> str | dict[str, object]:
|
||||||
if isinstance(mapping, Mapping):
|
if isinstance(mapping, Mapping):
|
||||||
updated = dict(mapping)
|
updated = dict(mapping)
|
||||||
@@ -70,9 +70,11 @@ def _replace_port_in_mapping(
|
|||||||
return str(port_to)
|
return str(port_to)
|
||||||
|
|
||||||
|
|
||||||
def _get_port_index(mappings: list[str | int | Mapping[str, object]], port: int) -> int | None:
|
def _get_port_index(
|
||||||
|
mappings: list[str | int | Mapping[str, object]],
|
||||||
|
port: int,
|
||||||
|
) -> int | None:
|
||||||
for i, mapping in enumerate(mappings):
|
for i, mapping in enumerate(mappings):
|
||||||
if _get_service_port(mapping) == port:
|
if _get_service_port(mapping) == port:
|
||||||
return i
|
return i
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -3,16 +3,17 @@ import sys
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from api.auth.router import router as auth_router
|
from api.auth.router import router as auth_router
|
||||||
|
from api.editor.router import router as editor_router
|
||||||
from api.proxy.router import router as proxy_router
|
from api.proxy.router import router as proxy_router
|
||||||
from core.config import settings
|
from core.config import settings
|
||||||
from core.mongodb import close_mongodb, init_mongodb
|
from core.mongodb import close_mongodb, init_mongodb
|
||||||
from core.redis import close_redis, init_redis
|
from core.redis import close_redis, init_redis
|
||||||
from core.security import hash_password
|
from core.security import hash_password
|
||||||
|
from fastapi import FastAPI
|
||||||
from integrations.docker import get_all_services_in_dir
|
from integrations.docker import get_all_services_in_dir
|
||||||
from repositories.auth import ensure_auth_indexes, ensure_single_account
|
from repositories.auth import ensure_auth_indexes, ensure_single_account
|
||||||
|
from repositories.examples import ensure_example_indexes
|
||||||
from repositories.proxy import ensure_proxy_indexes
|
from repositories.proxy import ensure_proxy_indexes
|
||||||
|
|
||||||
|
|
||||||
@@ -26,15 +27,15 @@ async def lifespan(_: FastAPI):
|
|||||||
password_hash=hash_password(settings.AUTH_PASSWORD),
|
password_hash=hash_password(settings.AUTH_PASSWORD),
|
||||||
)
|
)
|
||||||
await ensure_proxy_indexes()
|
await ensure_proxy_indexes()
|
||||||
|
await ensure_example_indexes()
|
||||||
|
|
||||||
home_dir = str(Path.home())
|
home_dir = str(Path.home())
|
||||||
app.state.services = get_all_services_in_dir(home_dir) or []
|
app.state.services = get_all_services_in_dir(home_dir) or []
|
||||||
if not app.state.services:
|
if not app.state.services:
|
||||||
warning = (
|
warning = (
|
||||||
"\n" + "!" * 60 + "\n"
|
"\n" + "!" * 60 + "\n"
|
||||||
" WARNING: No services found in home directory!\n"
|
" WARNING: No services found in home directory!\n"
|
||||||
f" Scanned: {home_dir}\n"
|
f" Scanned: {home_dir}\n" + "!" * 60 + "\n"
|
||||||
+ "!" * 60 + "\n"
|
|
||||||
)
|
)
|
||||||
print(warning, file=sys.stderr)
|
print(warning, file=sys.stderr)
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ app = FastAPI(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
app.include_router(editor_router)
|
||||||
app.include_router(proxy_router)
|
app.include_router(proxy_router)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AddonExampleDocument(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
id: str = Field(..., description="Stable example identifier")
|
||||||
|
name: str = Field(..., description="Display name")
|
||||||
|
description: str = Field(default="", description="Short explanation")
|
||||||
|
category: str = Field(default="custom", description="Example category")
|
||||||
|
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")
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddonExampleCreateInput(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
id: str = Field(..., description="Stable example identifier")
|
||||||
|
name: str = Field(..., description="Display name")
|
||||||
|
description: str = Field(default="", description="Short explanation")
|
||||||
|
category: str = Field(default="custom", description="Example category")
|
||||||
|
code: str = Field(..., description="Addon source code")
|
||||||
|
source_url: str | None = Field(default=None, description="Reference URL")
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from models.service import ServiceEndpointDocument
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
timeout_seconds: float = Field(
|
||||||
|
default=2.0,
|
||||||
|
gt=0,
|
||||||
|
le=30,
|
||||||
|
description="Per-target health check timeout",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyDocument(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
proxy_port: int = Field(..., description="Port exposed by the proxy")
|
||||||
|
name: str = Field(..., description="Human readable proxy name")
|
||||||
|
code: str = Field(..., description="Proxy source or identifier")
|
||||||
|
mode: str = Field(default="reverse", description="mitmproxy proxy mode")
|
||||||
|
mode_spec: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
|
||||||
|
)
|
||||||
|
protocol: str = Field(default="http", description="mitmproxy reverse protocol")
|
||||||
|
listen_host: str = Field(default="0.0.0.0", description="Proxy listen host")
|
||||||
|
mitm_options: dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="mitmdump options passed through --set",
|
||||||
|
)
|
||||||
|
healthcheck: HealthCheckDocument = Field(default_factory=HealthCheckDocument)
|
||||||
|
status: str = Field(default="stopped", description="Runtime proxy status")
|
||||||
|
pid: int | None = Field(default=None, description="mitmdump process id")
|
||||||
|
script_path: str | None = Field(default=None, description="Generated addon path")
|
||||||
|
config_path: str | None = Field(default=None, description="Generated config path")
|
||||||
|
log_path: str | None = Field(default=None, description="mitmdump log path")
|
||||||
|
last_error: str | None = Field(default=None, description="Last runtime error")
|
||||||
|
service: ServiceEndpointDocument = Field(
|
||||||
|
...,
|
||||||
|
description="Associated upstream service",
|
||||||
|
)
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Creation timestamp in UTC",
|
||||||
|
)
|
||||||
|
updated_at: datetime | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Last update timestamp in UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyCreateInput(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
proxy_port: int = Field(..., description="Port exposed by the proxy")
|
||||||
|
name: str = Field(..., description="Human readable proxy name")
|
||||||
|
code: str = Field(..., description="Proxy source or identifier")
|
||||||
|
mode: str = Field(default="reverse", description="mitmproxy proxy mode")
|
||||||
|
mode_spec: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
|
||||||
|
)
|
||||||
|
protocol: str = Field(default="http", description="mitmproxy reverse protocol")
|
||||||
|
listen_host: str = Field(default="0.0.0.0", description="Proxy listen host")
|
||||||
|
mitm_options: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
healthcheck: HealthCheckDocument = Field(default_factory=HealthCheckDocument)
|
||||||
|
service: ServiceEndpointDocument = Field(
|
||||||
|
...,
|
||||||
|
description="Associated upstream service",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyUpdateInput(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, description="Human readable proxy name")
|
||||||
|
code: str | None = Field(default=None, description="Proxy source or identifier")
|
||||||
|
mode: str | None = Field(default=None, description="mitmproxy proxy mode")
|
||||||
|
mode_spec: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
|
||||||
|
)
|
||||||
|
protocol: str | None = Field(default=None, description="mitmproxy reverse protocol")
|
||||||
|
listen_host: str | None = Field(default=None, description="Proxy listen host")
|
||||||
|
mitm_options: dict[str, Any] | None = Field(default=None)
|
||||||
|
healthcheck: HealthCheckDocument | None = Field(default=None)
|
||||||
|
service: ServiceEndpointDocument | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Associated upstream service",
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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")
|
||||||
|
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",
|
||||||
|
)
|
||||||
@@ -2,10 +2,9 @@ from collections.abc import Mapping
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
|
||||||
|
|
||||||
from core.mongodb import get_database
|
from core.mongodb import get_database
|
||||||
from models.auth import AuthUserDocument
|
from models.auth import AuthUserDocument
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||||
|
|
||||||
USERS_COLLECTION_NAME = "auth_users"
|
USERS_COLLECTION_NAME = "auth_users"
|
||||||
REVOKED_COLLECTION_NAME = "revoked_tokens"
|
REVOKED_COLLECTION_NAME = "revoked_tokens"
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from core.mongodb import get_database
|
||||||
|
from models.example import AddonExampleCreateInput, AddonExampleDocument
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||||
|
|
||||||
|
COLLECTION_NAME = "addon_examples"
|
||||||
|
|
||||||
|
|
||||||
|
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) -> AddonExampleDocument | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
document = dict(raw)
|
||||||
|
document.pop("_id", None)
|
||||||
|
return AddonExampleDocument.model_validate(document)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_example_indexes() -> None:
|
||||||
|
examples = await collection()
|
||||||
|
await examples.create_index("id", unique=True, name="uq_addon_example_id")
|
||||||
|
|
||||||
|
|
||||||
|
async def list_custom_examples() -> list[AddonExampleDocument]:
|
||||||
|
examples = await collection()
|
||||||
|
cursor = examples.find({}).sort("name", 1)
|
||||||
|
|
||||||
|
result: list[AddonExampleDocument] = []
|
||||||
|
async for raw in cursor:
|
||||||
|
example = deserialize(raw)
|
||||||
|
if example is not None:
|
||||||
|
result.append(example)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def create_custom_example(
|
||||||
|
payload: AddonExampleCreateInput,
|
||||||
|
) -> AddonExampleDocument:
|
||||||
|
now = utcnow()
|
||||||
|
example = AddonExampleDocument(
|
||||||
|
id=payload.id,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
category=payload.category,
|
||||||
|
code=payload.code,
|
||||||
|
source_url=payload.source_url,
|
||||||
|
built_in=False,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
examples = await collection()
|
||||||
|
await examples.insert_one(example.model_dump(exclude_none=True))
|
||||||
|
return example
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_custom_example(example_id: str) -> bool:
|
||||||
|
examples = await collection()
|
||||||
|
result = await examples.delete_one({"id": example_id})
|
||||||
|
return result.deleted_count > 0
|
||||||
@@ -2,13 +2,11 @@ from collections.abc import Mapping
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from core.mongodb import get_database
|
||||||
|
from models.proxy import ProxyCreateInput, ProxyDocument, ProxyUpdateInput
|
||||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||||
from pymongo import ReturnDocument
|
from pymongo import ReturnDocument
|
||||||
|
|
||||||
from core.mongodb import get_database
|
|
||||||
from models.proxy import ProxyDocument, ProxyUpdateInput
|
|
||||||
from models.service import ServiceEndpointDocument
|
|
||||||
|
|
||||||
COLLECTION_NAME = "proxies"
|
COLLECTION_NAME = "proxies"
|
||||||
|
|
||||||
|
|
||||||
@@ -34,29 +32,19 @@ async def ensure_proxy_indexes() -> None:
|
|||||||
await collection.create_index("proxy_port", unique=True, name="uq_proxy_port")
|
await collection.create_index("proxy_port", unique=True, name="uq_proxy_port")
|
||||||
|
|
||||||
|
|
||||||
async def create_proxy(
|
async def create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
|
||||||
proxy_port: int,
|
|
||||||
name: str,
|
|
||||||
upstream_port: int,
|
|
||||||
code: str,
|
|
||||||
) -> ProxyDocument:
|
|
||||||
"""
|
|
||||||
Saves a Proxy to the database.
|
|
||||||
|
|
||||||
:param proxy_port: the port the proxy is listening on
|
|
||||||
:param name: the name of the service
|
|
||||||
:param upstream_port: the upstream port the proxy is forwarding to
|
|
||||||
:param code: the code of the service
|
|
||||||
:return:
|
|
||||||
"""
|
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
proxy = ProxyDocument(
|
proxy = ProxyDocument(
|
||||||
proxy_port=proxy_port,
|
proxy_port=payload.proxy_port,
|
||||||
name=name,
|
name=payload.name,
|
||||||
code=code,
|
code=payload.code,
|
||||||
service=ServiceEndpointDocument(
|
mode=payload.mode,
|
||||||
port=upstream_port,
|
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,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
@@ -103,6 +91,22 @@ async def update_proxy(
|
|||||||
return _deserialize_proxy(raw)
|
return _deserialize_proxy(raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_proxy_runtime(
|
||||||
|
proxy_port: int,
|
||||||
|
values: dict[str, object],
|
||||||
|
) -> ProxyDocument | None:
|
||||||
|
payload = dict(values)
|
||||||
|
payload["updated_at"] = _utcnow()
|
||||||
|
|
||||||
|
collection = await _collection()
|
||||||
|
raw = await collection.find_one_and_update(
|
||||||
|
{"proxy_port": proxy_port},
|
||||||
|
{"$set": payload},
|
||||||
|
return_document=ReturnDocument.AFTER,
|
||||||
|
)
|
||||||
|
return _deserialize_proxy(raw)
|
||||||
|
|
||||||
|
|
||||||
async def delete_proxy(proxy_port: int) -> bool:
|
async def delete_proxy(proxy_port: int) -> bool:
|
||||||
collection = await _collection()
|
collection = await _collection()
|
||||||
result = await collection.delete_one({"proxy_port": proxy_port})
|
result = await collection.delete_one({"proxy_port": proxy_port})
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WORKSPACE_ROOT = Path(tempfile.gettempdir()) / "theplug-lsp"
|
||||||
|
CONFIG_FILE = "pyrightconfig.json"
|
||||||
|
ENTRY_FILE = "proxy_addon.py"
|
||||||
|
|
||||||
|
STUB_FILES = {
|
||||||
|
"typings/mitmproxy/__init__.pyi": """from . import (
|
||||||
|
command,
|
||||||
|
ctx,
|
||||||
|
dns,
|
||||||
|
http,
|
||||||
|
tcp,
|
||||||
|
tls,
|
||||||
|
udp,
|
||||||
|
websocket,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"command",
|
||||||
|
"ctx",
|
||||||
|
"dns",
|
||||||
|
"http",
|
||||||
|
"tcp",
|
||||||
|
"tls",
|
||||||
|
"udp",
|
||||||
|
"websocket",
|
||||||
|
]
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/ctx.pyi": """class _Logger:
|
||||||
|
def debug(self, message: object) -> None: ...
|
||||||
|
def info(self, message: object) -> None: ...
|
||||||
|
def warn(self, message: object) -> None: ...
|
||||||
|
def error(self, message: object) -> None: ...
|
||||||
|
def alert(self, message: object) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class _Options:
|
||||||
|
def __getattr__(self, name: str) -> object: ...
|
||||||
|
|
||||||
|
|
||||||
|
log: _Logger
|
||||||
|
options: _Options
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/http.pyi": """from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class Headers(dict[str, str]):
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class Request:
|
||||||
|
host: str
|
||||||
|
path: str
|
||||||
|
headers: Headers
|
||||||
|
query: dict[str, str]
|
||||||
|
content: bytes
|
||||||
|
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
headers: Headers
|
||||||
|
status_code: int
|
||||||
|
content: bytes
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def make(
|
||||||
|
cls,
|
||||||
|
status_code: int,
|
||||||
|
content: bytes | str = b"",
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> "Response": ...
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketMessage:
|
||||||
|
content: bytes
|
||||||
|
from_client: bool
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketData:
|
||||||
|
messages: list[WebSocketMessage]
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPFlow:
|
||||||
|
request: Request
|
||||||
|
response: Response | None
|
||||||
|
websocket: WebSocketData | None
|
||||||
|
metadata: dict[str, Any]
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/tcp.pyi": """class TCPMessage:
|
||||||
|
content: bytes
|
||||||
|
from_client: bool
|
||||||
|
|
||||||
|
|
||||||
|
class TCPFlow:
|
||||||
|
messages: list[TCPMessage]
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/dns.pyi": """class Question:
|
||||||
|
name: str
|
||||||
|
type: int
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceRecord:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
type: int,
|
||||||
|
class_: int,
|
||||||
|
ttl: int,
|
||||||
|
data: bytes,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Message:
|
||||||
|
question: Question | None
|
||||||
|
|
||||||
|
def succeed(self, records: list[ResourceRecord]) -> "Message": ...
|
||||||
|
|
||||||
|
|
||||||
|
class _Types:
|
||||||
|
A: int
|
||||||
|
|
||||||
|
|
||||||
|
class _Classes:
|
||||||
|
IN: int
|
||||||
|
|
||||||
|
|
||||||
|
types: _Types
|
||||||
|
classes: _Classes
|
||||||
|
|
||||||
|
|
||||||
|
class DNSFlow:
|
||||||
|
request: Message
|
||||||
|
response: Message | None
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/command.pyi": """from collections.abc import Callable
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
F = TypeVar("F", bound=Callable[..., object])
|
||||||
|
|
||||||
|
|
||||||
|
def command(name: str) -> Callable[[F], F]: ...
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/websocket.pyi": """class WebSocketMessage:
|
||||||
|
content: bytes
|
||||||
|
from_client: bool
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/tls.pyi": """class ClientHelloData: ...
|
||||||
|
""",
|
||||||
|
"typings/mitmproxy/udp.pyi": """class UDPMessage:
|
||||||
|
content: bytes
|
||||||
|
from_client: bool
|
||||||
|
""",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LspFrameBuffer:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.buffer = bytearray()
|
||||||
|
|
||||||
|
def feed(self, chunk: bytes) -> list[str]:
|
||||||
|
self.buffer.extend(chunk)
|
||||||
|
messages: list[str] = []
|
||||||
|
while True:
|
||||||
|
header_end = self.buffer.find(b"\r\n\r\n")
|
||||||
|
if header_end < 0:
|
||||||
|
return messages
|
||||||
|
|
||||||
|
header = self.buffer[:header_end].decode("ascii", errors="ignore")
|
||||||
|
size = content_length(header)
|
||||||
|
if size is None:
|
||||||
|
del self.buffer[: header_end + 4]
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame_end = header_end + 4 + size
|
||||||
|
if len(self.buffer) < frame_end:
|
||||||
|
return messages
|
||||||
|
|
||||||
|
body = bytes(self.buffer[header_end + 4 : frame_end])
|
||||||
|
del self.buffer[:frame_end]
|
||||||
|
messages.append(body.decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PythonEditorSession:
|
||||||
|
root_path: Path
|
||||||
|
file_path: Path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def root_uri(self) -> str:
|
||||||
|
return self.root_path.as_uri()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_uri(self) -> str:
|
||||||
|
return self.file_path.as_uri()
|
||||||
|
|
||||||
|
|
||||||
|
def content_length(header: str) -> int | None:
|
||||||
|
for line in header.split("\r\n"):
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
name, value = line.split(":", 1)
|
||||||
|
if name.lower() == "content-length":
|
||||||
|
return int(value.strip())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def encode_message(message: str) -> bytes:
|
||||||
|
payload = message.encode("utf-8")
|
||||||
|
header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")
|
||||||
|
return header + payload
|
||||||
|
|
||||||
|
|
||||||
|
def lsp_binary() -> str | None:
|
||||||
|
return shutil.which("basedpyright-langserver")
|
||||||
|
|
||||||
|
|
||||||
|
def get_session() -> PythonEditorSession:
|
||||||
|
root_path = WORKSPACE_ROOT
|
||||||
|
file_path = root_path / ENTRY_FILE
|
||||||
|
|
||||||
|
root_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path.touch(exist_ok=True)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"include": [ENTRY_FILE],
|
||||||
|
"stubPath": "typings",
|
||||||
|
"typeCheckingMode": "basic",
|
||||||
|
"pythonVersion": "3.14",
|
||||||
|
"reportMissingTypeStubs": False,
|
||||||
|
"reportMissingImports": "warning",
|
||||||
|
"useLibraryCodeForTypes": True,
|
||||||
|
}
|
||||||
|
(root_path / CONFIG_FILE).write_text(
|
||||||
|
json.dumps(config, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
for relative_path, content in STUB_FILES.items():
|
||||||
|
path = root_path / relative_path
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
return PythonEditorSession(root_path=root_path, file_path=file_path)
|
||||||
|
|
||||||
|
|
||||||
|
async def open_process(session: PythonEditorSession) -> asyncio.subprocess.Process:
|
||||||
|
binary = lsp_binary()
|
||||||
|
if binary is None:
|
||||||
|
raise RuntimeError("basedpyright-langserver is not installed")
|
||||||
|
|
||||||
|
return await asyncio.create_subprocess_exec(
|
||||||
|
binary,
|
||||||
|
"--stdio",
|
||||||
|
cwd=session.root_path,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def forward_client(websocket, writer: asyncio.StreamWriter) -> None:
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive_text()
|
||||||
|
writer.write(encode_message(message))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
|
||||||
|
async def forward_server(reader: asyncio.StreamReader, websocket) -> None:
|
||||||
|
frames = LspFrameBuffer()
|
||||||
|
while True:
|
||||||
|
chunk = await reader.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
for message in frames.feed(chunk):
|
||||||
|
await websocket.send_text(message)
|
||||||
|
|
||||||
|
|
||||||
|
async def drain_errors(reader: asyncio.StreamReader) -> None:
|
||||||
|
while True:
|
||||||
|
chunk = await reader.read(4096)
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def bridge(websocket, session: PythonEditorSession) -> None:
|
||||||
|
process = await open_process(session)
|
||||||
|
stdin = process.stdin
|
||||||
|
stdout = process.stdout
|
||||||
|
stderr = process.stderr
|
||||||
|
|
||||||
|
if stdin is None or stdout is None or stderr is None:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
raise RuntimeError("basedpyright-langserver did not expose stdio pipes")
|
||||||
|
|
||||||
|
send_task = asyncio.create_task(forward_client(websocket, stdin))
|
||||||
|
receive_task = asyncio.create_task(forward_server(stdout, websocket))
|
||||||
|
error_task = asyncio.create_task(drain_errors(stderr))
|
||||||
|
|
||||||
|
try:
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
{send_task, receive_task, error_task},
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
for task in done:
|
||||||
|
task.result()
|
||||||
|
finally:
|
||||||
|
if process.returncode is None:
|
||||||
|
stdin.close()
|
||||||
|
await stdin.wait_closed()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=1)
|
||||||
|
except TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
from models.example import AddonExampleDocument
|
||||||
|
|
||||||
|
DOCS_EXAMPLES_URL = "https://docs.mitmproxy.org/stable/addons/examples/"
|
||||||
|
|
||||||
|
BUILTIN_EXAMPLES = [
|
||||||
|
AddonExampleDocument(
|
||||||
|
id="http-add-response-header",
|
||||||
|
name="HTTP add response header",
|
||||||
|
description=(
|
||||||
|
"Injects a fixed response header on every HTTP response so you can "
|
||||||
|
"verify that traffic is passing through the proxy response path."
|
||||||
|
),
|
||||||
|
category="http",
|
||||||
|
source_url=DOCS_EXAMPLES_URL,
|
||||||
|
built_in=True,
|
||||||
|
code="""from mitmproxy import http
|
||||||
|
|
||||||
|
|
||||||
|
def response(flow: http.HTTPFlow) -> None:
|
||||||
|
flow.response.headers["x-theplug"] = "enabled"
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
AddonExampleDocument(
|
||||||
|
id="http-modify-query",
|
||||||
|
name="HTTP modify query",
|
||||||
|
description=(
|
||||||
|
"Adds or overwrites a query parameter before each request is "
|
||||||
|
"forwarded upstream. Useful for forced debug or feature flags."
|
||||||
|
),
|
||||||
|
category="http",
|
||||||
|
source_url=DOCS_EXAMPLES_URL,
|
||||||
|
built_in=True,
|
||||||
|
code="""from mitmproxy import http
|
||||||
|
|
||||||
|
|
||||||
|
def request(flow: http.HTTPFlow) -> None:
|
||||||
|
flow.request.query["debug"] = "1"
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
AddonExampleDocument(
|
||||||
|
id="http-reply-from-proxy",
|
||||||
|
name="HTTP reply from proxy",
|
||||||
|
description=(
|
||||||
|
"Serves a local HTTP response for a selected path without touching "
|
||||||
|
"the upstream service. Useful for probes, maintenance, or stubs."
|
||||||
|
),
|
||||||
|
category="http",
|
||||||
|
source_url=DOCS_EXAMPLES_URL,
|
||||||
|
built_in=True,
|
||||||
|
code="""from mitmproxy import http
|
||||||
|
|
||||||
|
|
||||||
|
def request(flow: http.HTTPFlow) -> None:
|
||||||
|
if flow.request.path == "/healthz":
|
||||||
|
flow.response = http.Response.make(
|
||||||
|
200,
|
||||||
|
b"ok",
|
||||||
|
{"content-type": "text/plain"},
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
AddonExampleDocument(
|
||||||
|
id="websocket-rewrite",
|
||||||
|
name="WebSocket rewrite",
|
||||||
|
description=(
|
||||||
|
"Rewrites matching bytes inside live WebSocket frames to alter "
|
||||||
|
"runtime flags or payload markers in transit."
|
||||||
|
),
|
||||||
|
category="websocket",
|
||||||
|
source_url=DOCS_EXAMPLES_URL,
|
||||||
|
built_in=True,
|
||||||
|
code="""from mitmproxy import http
|
||||||
|
|
||||||
|
|
||||||
|
def websocket_message(flow: http.HTTPFlow) -> None:
|
||||||
|
if flow.websocket is None:
|
||||||
|
return
|
||||||
|
message = flow.websocket.messages[-1]
|
||||||
|
message.content = message.content.replace(b"debug=false", b"debug=true")
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
AddonExampleDocument(
|
||||||
|
id="tcp-log-messages",
|
||||||
|
name="TCP log messages",
|
||||||
|
description=(
|
||||||
|
"Logs raw TCP message direction and size for each frame so you can "
|
||||||
|
"observe binary traffic without altering it."
|
||||||
|
),
|
||||||
|
category="tcp",
|
||||||
|
source_url=DOCS_EXAMPLES_URL,
|
||||||
|
built_in=True,
|
||||||
|
code="""import logging
|
||||||
|
|
||||||
|
from mitmproxy import tcp
|
||||||
|
|
||||||
|
|
||||||
|
def tcp_message(flow: tcp.TCPFlow) -> None:
|
||||||
|
message = flow.messages[-1]
|
||||||
|
direction = "client" if message.from_client else "server"
|
||||||
|
logging.info("%s sent %d bytes", direction, len(message.content))
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
AddonExampleDocument(
|
||||||
|
id="dns-spoof-a-record",
|
||||||
|
name="DNS spoof A record",
|
||||||
|
description=(
|
||||||
|
"Overrides one DNS A record while forwarding every other query "
|
||||||
|
"unchanged. Useful for selective service redirection."
|
||||||
|
),
|
||||||
|
category="dns",
|
||||||
|
source_url=DOCS_EXAMPLES_URL,
|
||||||
|
built_in=True,
|
||||||
|
code="""import ipaddress
|
||||||
|
|
||||||
|
from mitmproxy import dns
|
||||||
|
|
||||||
|
|
||||||
|
def dns_request(flow: dns.DNSFlow) -> None:
|
||||||
|
question = flow.request.question
|
||||||
|
if question and question.name == "target.local" and question.type == dns.types.A:
|
||||||
|
flow.response = flow.request.succeed(
|
||||||
|
[
|
||||||
|
dns.ResourceRecord(
|
||||||
|
name="target.local",
|
||||||
|
type=dns.types.A,
|
||||||
|
class_=dns.classes.IN,
|
||||||
|
ttl=60,
|
||||||
|
data=ipaddress.ip_address("127.0.0.1").packed,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import asyncio
|
||||||
|
import ssl
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from models.proxy import HealthCheckDocument, ProxyDocument
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class Target:
|
||||||
|
name: str
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
protocol: str
|
||||||
|
|
||||||
|
|
||||||
|
async def check_proxy(proxy: ProxyDocument) -> dict[str, object]:
|
||||||
|
if not proxy.healthcheck.enabled:
|
||||||
|
return {"enabled": False, "overall": "disabled", "checks": []}
|
||||||
|
|
||||||
|
upstream_port = proxy.service.upstream_port or proxy.service.port
|
||||||
|
checks = await asyncio.gather(
|
||||||
|
check_target(
|
||||||
|
Target(
|
||||||
|
name="upstream",
|
||||||
|
host=proxy.service.host,
|
||||||
|
port=upstream_port,
|
||||||
|
protocol=health_protocol(proxy),
|
||||||
|
),
|
||||||
|
proxy.healthcheck,
|
||||||
|
),
|
||||||
|
check_target(
|
||||||
|
Target(
|
||||||
|
name="proxy",
|
||||||
|
host=listen_host_for_check(proxy.listen_host),
|
||||||
|
port=proxy.proxy_port,
|
||||||
|
protocol=health_protocol(proxy),
|
||||||
|
),
|
||||||
|
proxy.healthcheck,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
overall = "healthy" if all(check["healthy"] for check in checks) else "unhealthy"
|
||||||
|
return {"enabled": True, "overall": overall, "checks": checks}
|
||||||
|
|
||||||
|
|
||||||
|
def health_protocol(proxy: ProxyDocument) -> str:
|
||||||
|
return proxy.healthcheck.protocol or proxy.protocol
|
||||||
|
|
||||||
|
|
||||||
|
def listen_host_for_check(host: str) -> str:
|
||||||
|
if host in {"", "0.0.0.0", "::"}:
|
||||||
|
return "127.0.0.1"
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
async def check_target(
|
||||||
|
target: Target,
|
||||||
|
config: HealthCheckDocument,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
match target.protocol:
|
||||||
|
case "http":
|
||||||
|
return await check_http(target, config, use_tls=False)
|
||||||
|
case "https":
|
||||||
|
return await check_http(target, config, use_tls=True)
|
||||||
|
case "tcp":
|
||||||
|
return await check_tcp(target, config, use_tls=False)
|
||||||
|
case "tls":
|
||||||
|
return await check_tcp(target, config, use_tls=True)
|
||||||
|
case "udp" | "dtls" | "quic" | "dns" | "http3":
|
||||||
|
return await check_udp(target, config)
|
||||||
|
case _:
|
||||||
|
return result(target, False, f"Unsupported protocol: {target.protocol}")
|
||||||
|
except TimeoutError:
|
||||||
|
return result(target, False, "Timed out")
|
||||||
|
except OSError as error:
|
||||||
|
return result(target, False, str(error))
|
||||||
|
|
||||||
|
|
||||||
|
async def check_http(
|
||||||
|
target: Target,
|
||||||
|
config: HealthCheckDocument,
|
||||||
|
*,
|
||||||
|
use_tls: bool,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
reader, writer = await open_connection(target, config, use_tls=use_tls)
|
||||||
|
try:
|
||||||
|
path = config.path if config.path.startswith("/") else f"/{config.path}"
|
||||||
|
request = (
|
||||||
|
f"{config.method} {path} HTTP/1.1\r\n"
|
||||||
|
f"Host: {target.host}\r\n"
|
||||||
|
"Connection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
writer.write(request.encode("ascii"))
|
||||||
|
await writer.drain()
|
||||||
|
line = await asyncio.wait_for(reader.readline(), config.timeout_seconds)
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
parts = line.decode("iso-8859-1", errors="replace").split()
|
||||||
|
status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None
|
||||||
|
expected = config.expected_status
|
||||||
|
healthy = status == expected if expected is not None else status is not None
|
||||||
|
return result(target, healthy, None, {"status": status})
|
||||||
|
|
||||||
|
|
||||||
|
async def check_tcp(
|
||||||
|
target: Target,
|
||||||
|
config: HealthCheckDocument,
|
||||||
|
*,
|
||||||
|
use_tls: bool,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_reader, writer = await open_connection(target, config, use_tls=use_tls)
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
return result(target, True)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_udp(target: Target, config: HealthCheckDocument) -> dict[str, object]:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
transport, protocol = await loop.create_datagram_endpoint(
|
||||||
|
lambda: DatagramProbe(config.expect_response),
|
||||||
|
remote_addr=(target.host, target.port),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = config.udp_payload.encode()
|
||||||
|
transport.sendto(payload)
|
||||||
|
if not config.expect_response:
|
||||||
|
return result(target, True, None, {"mode": "send-only"})
|
||||||
|
data = await asyncio.wait_for(protocol.response, config.timeout_seconds)
|
||||||
|
return result(target, True, None, {"bytes": len(data)})
|
||||||
|
finally:
|
||||||
|
transport.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def open_connection(
|
||||||
|
target: Target,
|
||||||
|
config: HealthCheckDocument,
|
||||||
|
*,
|
||||||
|
use_tls: bool,
|
||||||
|
):
|
||||||
|
if use_tls and config.verify_tls:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
elif use_tls:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
else:
|
||||||
|
context = None
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(target.host, target.port, ssl=context),
|
||||||
|
config.timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatagramProbe(asyncio.DatagramProtocol):
|
||||||
|
def __init__(self, expect_response: bool):
|
||||||
|
self.response = asyncio.get_running_loop().create_future()
|
||||||
|
if not expect_response:
|
||||||
|
self.response.set_result(b"")
|
||||||
|
|
||||||
|
def datagram_received(self, data, _addr):
|
||||||
|
if not self.response.done():
|
||||||
|
self.response.set_result(data)
|
||||||
|
|
||||||
|
def error_received(self, exc):
|
||||||
|
if not self.response.done():
|
||||||
|
self.response.set_exception(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def result(
|
||||||
|
target: Target,
|
||||||
|
healthy: bool,
|
||||||
|
error: str | None = None,
|
||||||
|
details: dict[str, object] | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"target": target.name,
|
||||||
|
"host": target.host,
|
||||||
|
"port": target.port,
|
||||||
|
"protocol": target.protocol,
|
||||||
|
"healthy": healthy,
|
||||||
|
"error": error,
|
||||||
|
"details": details or {},
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from integrations import docker
|
||||||
|
from integrations.docker.ports import get_random_port
|
||||||
|
from models.proxy import ProxyDocument
|
||||||
|
from services.mitmproxy.options import MitmOptionError, validate_options
|
||||||
|
|
||||||
|
|
||||||
|
class MitmdumpError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MitmdumpManager:
|
||||||
|
def __init__(self, work_dir: str = "/tmp/theplug", command: str = "mitmdump"):
|
||||||
|
self.work_dir = Path(work_dir)
|
||||||
|
self.command = command
|
||||||
|
self.processes: dict[int, asyncio.subprocess.Process] = {}
|
||||||
|
self.locks: dict[int, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
async def start(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||||
|
async with self.lock_for(proxy.proxy_port):
|
||||||
|
return await self.start_unlocked(proxy)
|
||||||
|
|
||||||
|
async def stop(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||||
|
async with self.lock_for(proxy.proxy_port):
|
||||||
|
return await self.stop_unlocked(proxy)
|
||||||
|
|
||||||
|
async def start_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||||
|
if await self.status(proxy) == "running":
|
||||||
|
raise MitmdumpError("Proxy is already running")
|
||||||
|
|
||||||
|
command_path = shutil.which(self.command)
|
||||||
|
if command_path is None:
|
||||||
|
raise MitmdumpError("mitmdump executable not found in PATH")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await validate_options(proxy.mitm_options)
|
||||||
|
except MitmOptionError as error:
|
||||||
|
raise MitmdumpError(str(error)) from error
|
||||||
|
upstream_port = await self.prepare_upstream(proxy)
|
||||||
|
script_path = self.write_script(proxy)
|
||||||
|
config_path = self.write_config(proxy)
|
||||||
|
log_path = self.log_path(proxy)
|
||||||
|
command = self.build_command(
|
||||||
|
command_path,
|
||||||
|
proxy,
|
||||||
|
upstream_port,
|
||||||
|
script_path,
|
||||||
|
config_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = await self.spawn(command, log_path)
|
||||||
|
except Exception as error:
|
||||||
|
await self.restore_upstream(proxy, upstream_port)
|
||||||
|
raise MitmdumpError(str(error)) from error
|
||||||
|
|
||||||
|
failed = await self.exited_early(process)
|
||||||
|
if failed:
|
||||||
|
await self.restore_upstream(proxy, upstream_port)
|
||||||
|
reason = self.read_log_tail(log_path) or "mitmdump exited during startup"
|
||||||
|
raise MitmdumpError(reason)
|
||||||
|
|
||||||
|
self.processes[proxy.proxy_port] = process
|
||||||
|
return {
|
||||||
|
"status": "running",
|
||||||
|
"pid": process.pid,
|
||||||
|
"script_path": str(script_path),
|
||||||
|
"config_path": str(config_path),
|
||||||
|
"log_path": str(log_path),
|
||||||
|
"service.upstream_port": upstream_port,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def stop_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||||
|
process = self.processes.pop(proxy.proxy_port, None)
|
||||||
|
if process is not None and process.returncode is None:
|
||||||
|
await self.stop_process(process)
|
||||||
|
elif proxy.pid is not None:
|
||||||
|
await self.stop_pid(proxy.pid)
|
||||||
|
|
||||||
|
await self.restore_upstream(proxy, proxy.service.upstream_port)
|
||||||
|
return {
|
||||||
|
"status": "stopped",
|
||||||
|
"pid": None,
|
||||||
|
"service.upstream_port": None,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def status(self, proxy: ProxyDocument) -> str:
|
||||||
|
process = self.processes.get(proxy.proxy_port)
|
||||||
|
if process is not None:
|
||||||
|
if process.returncode is None:
|
||||||
|
return "running"
|
||||||
|
self.processes.pop(proxy.proxy_port, None)
|
||||||
|
return "stopped"
|
||||||
|
|
||||||
|
if proxy.pid is None:
|
||||||
|
return "stopped" if proxy.status == "running" else proxy.status
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.kill(proxy.pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return "stopped"
|
||||||
|
except PermissionError:
|
||||||
|
return "running"
|
||||||
|
return "running"
|
||||||
|
|
||||||
|
async def prepare_upstream(self, proxy: ProxyDocument) -> int | None:
|
||||||
|
if proxy.mode_spec is not None or proxy.mode != "reverse":
|
||||||
|
return None
|
||||||
|
if proxy.service.upstream_port is not None:
|
||||||
|
return proxy.service.upstream_port
|
||||||
|
|
||||||
|
if proxy.service.name is None or proxy.service.folder is None:
|
||||||
|
return proxy.service.port
|
||||||
|
|
||||||
|
upstream_port = get_random_port()
|
||||||
|
changed = await docker.change_port(
|
||||||
|
proxy.service.name,
|
||||||
|
Path(proxy.service.folder),
|
||||||
|
proxy.service.port,
|
||||||
|
upstream_port,
|
||||||
|
)
|
||||||
|
if not changed:
|
||||||
|
raise MitmdumpError("Unable to move Docker service port")
|
||||||
|
return upstream_port
|
||||||
|
|
||||||
|
async def restore_upstream(
|
||||||
|
self,
|
||||||
|
proxy: ProxyDocument,
|
||||||
|
upstream_port: int | None,
|
||||||
|
) -> None:
|
||||||
|
if upstream_port is None:
|
||||||
|
return
|
||||||
|
if proxy.service.name is None or proxy.service.folder is None:
|
||||||
|
return
|
||||||
|
if upstream_port == proxy.service.port:
|
||||||
|
return
|
||||||
|
|
||||||
|
await docker.change_port(
|
||||||
|
proxy.service.name,
|
||||||
|
Path(proxy.service.folder),
|
||||||
|
upstream_port,
|
||||||
|
proxy.service.port,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_command(
|
||||||
|
self,
|
||||||
|
command_path: str,
|
||||||
|
proxy: ProxyDocument,
|
||||||
|
upstream_port: int | None,
|
||||||
|
script_path: Path,
|
||||||
|
config_path: Path,
|
||||||
|
) -> list[str]:
|
||||||
|
command = [
|
||||||
|
command_path,
|
||||||
|
"--mode",
|
||||||
|
self.mode_for(proxy, upstream_port),
|
||||||
|
"--listen-host",
|
||||||
|
proxy.listen_host,
|
||||||
|
"--set",
|
||||||
|
f"confdir={config_path.parent}",
|
||||||
|
"-s",
|
||||||
|
str(script_path),
|
||||||
|
]
|
||||||
|
if self.needs_listen_port(proxy):
|
||||||
|
command.extend(["--listen-port", str(proxy.proxy_port)])
|
||||||
|
return command
|
||||||
|
|
||||||
|
def mode_for(self, proxy: ProxyDocument, upstream_port: int | None) -> str:
|
||||||
|
if proxy.mode_spec:
|
||||||
|
return proxy.mode_spec
|
||||||
|
|
||||||
|
match proxy.mode:
|
||||||
|
case "reverse":
|
||||||
|
port = upstream_port or proxy.service.port
|
||||||
|
return (
|
||||||
|
f"reverse:{proxy.protocol}://{proxy.service.host}:{port}"
|
||||||
|
f"@{proxy.proxy_port}"
|
||||||
|
)
|
||||||
|
case "upstream":
|
||||||
|
return (
|
||||||
|
f"upstream:{proxy.protocol}://{proxy.service.host}:"
|
||||||
|
f"{proxy.service.port}@{proxy.proxy_port}"
|
||||||
|
)
|
||||||
|
case "wireguard":
|
||||||
|
return f"wireguard@{proxy.proxy_port}"
|
||||||
|
case _:
|
||||||
|
return proxy.mode
|
||||||
|
|
||||||
|
def needs_listen_port(self, proxy: ProxyDocument) -> bool:
|
||||||
|
if proxy.mode_spec is not None:
|
||||||
|
return False
|
||||||
|
return proxy.mode in {"regular", "socks5", "dns", "transparent"}
|
||||||
|
|
||||||
|
def write_script(self, proxy: ProxyDocument) -> Path:
|
||||||
|
path = self.work_dir / "addons" / f"{proxy.proxy_port}.py"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(proxy.code, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def write_config(self, proxy: ProxyDocument) -> Path:
|
||||||
|
path = self.work_dir / "conf" / str(proxy.proxy_port) / "config.yaml"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
options = {"block_global": False, **proxy.mitm_options}
|
||||||
|
path.write_text(yaml.safe_dump(options, sort_keys=True), encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def log_path(self, proxy: ProxyDocument) -> Path:
|
||||||
|
path = self.work_dir / "logs" / f"{proxy.proxy_port}.log"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
async def spawn(self, command: list[str], log_path: Path):
|
||||||
|
with log_path.open("ab") as log_file:
|
||||||
|
return await asyncio.create_subprocess_exec(
|
||||||
|
*command,
|
||||||
|
stdout=log_file,
|
||||||
|
stderr=log_file,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop_process(self, process) -> None:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=5)
|
||||||
|
except TimeoutError:
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
async def exited_early(self, process) -> bool:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=0.5)
|
||||||
|
except TimeoutError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def stop_pid(self, pid: int) -> None:
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
|
||||||
|
def read_log_tail(self, path: Path, limit: int = 4000) -> str:
|
||||||
|
if not path.exists():
|
||||||
|
return ""
|
||||||
|
content = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
return content[-limit:].strip()
|
||||||
|
|
||||||
|
def lock_for(self, proxy_port: int) -> asyncio.Lock:
|
||||||
|
lock = self.locks.get(proxy_port)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
self.locks[proxy_port] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
manager = MitmdumpManager()
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
RESERVED_OPTIONS = {"mode", "listen_host", "listen_port", "scripts"}
|
||||||
|
OPTIONS_DOCS_URL = "https://docs.mitmproxy.org/stable/concepts/options/"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class MitmOption:
|
||||||
|
name: str
|
||||||
|
value_type: str
|
||||||
|
default: Any
|
||||||
|
description: str
|
||||||
|
choices: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class MitmOptionError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
catalog_cache: dict[str, MitmOption] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def load_options() -> dict[str, MitmOption]:
|
||||||
|
global catalog_cache
|
||||||
|
if catalog_cache is None:
|
||||||
|
catalog_cache = await read_options()
|
||||||
|
return catalog_cache
|
||||||
|
|
||||||
|
|
||||||
|
async def read_options(command: str = "mitmdump") -> dict[str, MitmOption]:
|
||||||
|
command_path = shutil.which(command)
|
||||||
|
if command_path is None:
|
||||||
|
raise MitmOptionError("mitmdump executable not found in PATH")
|
||||||
|
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
command_path,
|
||||||
|
"--options",
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await process.communicate()
|
||||||
|
if process.returncode != 0:
|
||||||
|
message = stderr.decode("utf-8", errors="replace").strip()
|
||||||
|
raise MitmOptionError(message or "Unable to load mitmdump options")
|
||||||
|
|
||||||
|
text = stdout.decode("utf-8", errors="replace")
|
||||||
|
defaults = yaml.safe_load(text) or {}
|
||||||
|
return parse_options(text, defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_options(text: str, defaults: dict[str, Any]) -> dict[str, MitmOption]:
|
||||||
|
comments: list[str] = []
|
||||||
|
options: dict[str, MitmOption] = {}
|
||||||
|
|
||||||
|
for line in text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped:
|
||||||
|
comments = []
|
||||||
|
continue
|
||||||
|
if stripped.startswith("#"):
|
||||||
|
comments.append(stripped[1:].strip())
|
||||||
|
continue
|
||||||
|
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*:", stripped):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = stripped.split(":", 1)[0]
|
||||||
|
description = " ".join(comments)
|
||||||
|
options[name] = MitmOption(
|
||||||
|
name=name,
|
||||||
|
value_type=parse_type(description),
|
||||||
|
default=defaults.get(name),
|
||||||
|
description=description,
|
||||||
|
choices=parse_choices(description),
|
||||||
|
)
|
||||||
|
comments = []
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def parse_type(description: str) -> str:
|
||||||
|
match = re.search(r"Type ([^.]+)\.", description)
|
||||||
|
if match is None:
|
||||||
|
return "unknown"
|
||||||
|
return match.group(1).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_choices(description: str) -> list[str]:
|
||||||
|
match = re.search(r"Valid values are ([^.]+)\.", description)
|
||||||
|
if match is None:
|
||||||
|
return []
|
||||||
|
return re.findall(r"'([^']+)'", match.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_options(options: dict[str, Any]) -> None:
|
||||||
|
catalog = await load_options()
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for name, value in options.items():
|
||||||
|
option = catalog.get(name)
|
||||||
|
if option is None:
|
||||||
|
errors.append(f"Unknown mitmdump option: {name}")
|
||||||
|
continue
|
||||||
|
if name in RESERVED_OPTIONS:
|
||||||
|
errors.append(f"Option is managed by thePlug and cannot be set: {name}")
|
||||||
|
continue
|
||||||
|
message = validate_value(option, value)
|
||||||
|
if message:
|
||||||
|
errors.append(f"{name}: {message}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise MitmOptionError("; ".join(errors))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_value(option: MitmOption, value: Any) -> str | None:
|
||||||
|
value_type = option.value_type
|
||||||
|
if option.choices and str(value) not in option.choices:
|
||||||
|
return f"expected one of {', '.join(option.choices)}"
|
||||||
|
if value is None:
|
||||||
|
if value_type.startswith("optional "):
|
||||||
|
return None
|
||||||
|
return "cannot be null"
|
||||||
|
if value_type == "bool" and not isinstance(value, bool):
|
||||||
|
return "expected bool"
|
||||||
|
if value_type == "int" and not isinstance(value, int):
|
||||||
|
return "expected int"
|
||||||
|
if value_type == "str" and not isinstance(value, str):
|
||||||
|
return "expected str"
|
||||||
|
if value_type == "sequence of str":
|
||||||
|
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
||||||
|
return "expected list[str]"
|
||||||
|
if value_type == "optional str" and not isinstance(value, str):
|
||||||
|
return "expected str or null"
|
||||||
|
if value_type == "optional int" and not isinstance(value, int):
|
||||||
|
return "expected int or null"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def public_options(options: dict[str, MitmOption]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": option.name,
|
||||||
|
"type": option.value_type,
|
||||||
|
"default": option.default,
|
||||||
|
"description": option.description,
|
||||||
|
"choices": option.choices,
|
||||||
|
"reserved": option.name in RESERVED_OPTIONS,
|
||||||
|
"docs_url": f"{OPTIONS_DOCS_URL}#{option.name}",
|
||||||
|
}
|
||||||
|
for option in options.values()
|
||||||
|
]
|
||||||
@@ -5,7 +5,7 @@ import shutil
|
|||||||
import tempfile
|
import tempfile
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
TARGET_RUFF_CODES = ("F821", "F822", "F823", "E999")
|
TARGET_RUFF_CODES = ("F821", "F822", "F823")
|
||||||
|
|
||||||
|
|
||||||
class ProxyCodeValidationError(Exception):
|
class ProxyCodeValidationError(Exception):
|
||||||
@@ -5,13 +5,16 @@ description = "Add your description here"
|
|||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"authlib>=1.6.0",
|
"authlib>=1.6.0",
|
||||||
|
"basedpyright>=1.39.0",
|
||||||
"fastapi[standard]>=0.135.3",
|
"fastapi[standard]>=0.135.3",
|
||||||
|
"mitmproxy>=12.2.2",
|
||||||
"motor>=3.7.1",
|
"motor>=3.7.1",
|
||||||
"pwdlib[argon2,bcrypt]>=0.3.0",
|
"pwdlib[argon2,bcrypt]>=0.3.0",
|
||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
"pydantic-settings>=2.13.1",
|
"pydantic-settings>=2.13.1",
|
||||||
"pyyaml>=6.0.3",
|
"pyyaml>=6.0.3",
|
||||||
"redis>=7.4.0",
|
"redis>=7.4.0",
|
||||||
|
"ruff>=0.15.10",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
@@ -33,5 +36,4 @@ addopts = "-q"
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
"ruff>=0.15.10",
|
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import pytest
|
||||||
|
from services.editor import python_lsp
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
|
||||||
|
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("api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp")
|
||||||
|
monkeypatch.setattr("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(
|
||||||
|
"api.editor.router.authenticate_editor_socket",
|
||||||
|
fake_authenticate,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp")
|
||||||
|
monkeypatch.setattr("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,8 +1,9 @@
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
from core.security import get_auth_context, require_authenticated_user
|
from core.security import get_auth_context, require_authenticated_user
|
||||||
from models.proxy import ProxyDocument
|
from models.proxy import ProxyCreateInput, ProxyDocument
|
||||||
from models.service import ServiceEndpointDocument
|
from models.service import ServiceEndpointDocument
|
||||||
|
from services.mitmproxy.examples import BUILTIN_EXAMPLES
|
||||||
from services.mitmproxy.validator import ProxyCodeValidationError
|
from services.mitmproxy.validator import ProxyCodeValidationError
|
||||||
|
|
||||||
proxy_router_module = importlib.import_module("api.proxy.router")
|
proxy_router_module = importlib.import_module("api.proxy.router")
|
||||||
@@ -101,22 +102,17 @@ def test_create_proxy_returns_created_document(client, monkeypatch: object):
|
|||||||
assert proxy_port == 4040
|
assert proxy_port == 4040
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def fake_create_proxy(
|
async def fake_create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
|
||||||
*,
|
captured["proxy_port"] = payload.proxy_port
|
||||||
proxy_port: int,
|
captured["name"] = payload.name
|
||||||
name: str,
|
captured["service"] = payload.service.model_dump(exclude_none=True)
|
||||||
service: dict[str, object],
|
captured["code"] = payload.code
|
||||||
code: str,
|
captured["protocol"] = payload.protocol
|
||||||
) -> ProxyDocument:
|
|
||||||
captured["proxy_port"] = proxy_port
|
|
||||||
captured["name"] = name
|
|
||||||
captured["service"] = service
|
|
||||||
captured["code"] = code
|
|
||||||
return make_proxy_document(
|
return make_proxy_document(
|
||||||
proxy_port=proxy_port,
|
proxy_port=payload.proxy_port,
|
||||||
name=name,
|
name=payload.name,
|
||||||
code=code,
|
code=payload.code,
|
||||||
port=int(service["port"]),
|
port=payload.service.port,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def fake_validate_proxy_code(code: str) -> None:
|
async def fake_validate_proxy_code(code: str) -> None:
|
||||||
@@ -149,8 +145,9 @@ def test_create_proxy_returns_created_document(client, monkeypatch: object):
|
|||||||
assert captured == {
|
assert captured == {
|
||||||
"proxy_port": 4040,
|
"proxy_port": 4040,
|
||||||
"name": "edge",
|
"name": "edge",
|
||||||
"service": {"port": 8181},
|
"service": {"host": "127.0.0.1", "port": 8181},
|
||||||
"code": "def proxy_handler():\n return 'ok'\n",
|
"code": "def proxy_handler():\n return 'ok'\n",
|
||||||
|
"protocol": "http",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -261,3 +258,203 @@ def test_update_proxy_returns_422_when_code_validation_fails(
|
|||||||
assert body["message"] == "Proxy code has invalid Python syntax"
|
assert body["message"] == "Proxy code has invalid Python syntax"
|
||||||
assert body["errors"][0]["code"] == "COMPILE"
|
assert body["errors"][0]["code"] == "COMPILE"
|
||||||
assert update_called is False
|
assert update_called is False
|
||||||
|
|
||||||
|
|
||||||
|
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_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_examples_returns_builtins_and_custom(client, monkeypatch):
|
||||||
|
async def fake_list_custom_examples():
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"list_custom_examples",
|
||||||
|
fake_list_custom_examples,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/proxy/mitmproxy/examples")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert len(body) == len(BUILTIN_EXAMPLES)
|
||||||
|
assert body[0]["built_in"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_mitmproxy_example_validates_and_persists(client, monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_validate_proxy_code(code: str) -> None:
|
||||||
|
captured["validated"] = code
|
||||||
|
|
||||||
|
async def fake_create_custom_example(payload):
|
||||||
|
captured["payload"] = payload
|
||||||
|
return {
|
||||||
|
"id": payload.id,
|
||||||
|
"name": payload.name,
|
||||||
|
"description": payload.description,
|
||||||
|
"category": payload.category,
|
||||||
|
"code": payload.code,
|
||||||
|
"source_url": payload.source_url,
|
||||||
|
"built_in": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"validate_proxy_code",
|
||||||
|
fake_validate_proxy_code,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
proxy_router_module,
|
||||||
|
"create_custom_example",
|
||||||
|
fake_create_custom_example,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/proxy/mitmproxy/examples",
|
||||||
|
json={
|
||||||
|
"id": "custom-header",
|
||||||
|
"name": "Custom header",
|
||||||
|
"description": "Adds a custom response header",
|
||||||
|
"category": "http",
|
||||||
|
"code": "def response(flow):\n pass\n",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.json()["id"] == "custom-header"
|
||||||
|
assert captured["validated"] == "def response(flow):\n pass\n"
|
||||||
@@ -41,12 +41,14 @@ def auth_context(fake_user):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app(fake_user, auth_context) -> FastAPI:
|
def app(fake_user, auth_context) -> FastAPI:
|
||||||
from api.auth.router import router as auth_router
|
from api.auth.router import router as auth_router
|
||||||
|
from api.editor.router import router as editor_router
|
||||||
from api.proxy.router import router as proxy_router
|
from api.proxy.router import router as proxy_router
|
||||||
from core.security import AuthContext, get_auth_context, require_authenticated_user
|
from core.security import AuthContext, get_auth_context, require_authenticated_user
|
||||||
from models.auth import AuthUserDocument
|
from models.auth import AuthUserDocument
|
||||||
|
|
||||||
app_instance = FastAPI()
|
app_instance = FastAPI()
|
||||||
app_instance.include_router(auth_router)
|
app_instance.include_router(auth_router)
|
||||||
|
app_instance.include_router(editor_router)
|
||||||
app_instance.include_router(proxy_router)
|
app_instance.include_router(proxy_router)
|
||||||
|
|
||||||
async def _override_current_user() -> AuthUserDocument:
|
async def _override_current_user() -> AuthUserDocument:
|
||||||
@@ -55,9 +57,9 @@ def app(fake_user, auth_context) -> FastAPI:
|
|||||||
async def _override_auth_context() -> AuthContext:
|
async def _override_auth_context() -> AuthContext:
|
||||||
return auth_context
|
return auth_context
|
||||||
|
|
||||||
app_instance.dependency_overrides[
|
app_instance.dependency_overrides[require_authenticated_user] = (
|
||||||
require_authenticated_user
|
_override_current_user
|
||||||
] = _override_current_user
|
)
|
||||||
app_instance.dependency_overrides[get_auth_context] = _override_auth_context
|
app_instance.dependency_overrides[get_auth_context] = _override_auth_context
|
||||||
return app_instance
|
return app_instance
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from models.proxy import ProxyDocument
|
||||||
|
from models.service import ServiceEndpointDocument
|
||||||
|
from 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 health["overall"] == "healthy"
|
||||||
|
assert [check["target"] for check in health["checks"]] == [
|
||||||
|
"upstream",
|
||||||
|
"proxy",
|
||||||
|
]
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
async def http_handler(reader, writer):
|
||||||
|
await reader.readline()
|
||||||
|
writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from models.proxy import ProxyDocument
|
||||||
|
from models.service import ServiceEndpointDocument
|
||||||
|
from services.mitmproxy.manager import 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_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"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import pytest
|
||||||
|
from services.mitmproxy.options import (
|
||||||
|
MitmOption,
|
||||||
|
parse_options,
|
||||||
|
public_options,
|
||||||
|
validate_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_options_extracts_types_and_choices():
|
||||||
|
text = """
|
||||||
|
# Enable feature. Type bool.
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Valid values are 'eager', 'lazy'.
|
||||||
|
strategy: eager
|
||||||
|
|
||||||
|
# Hosts. Type sequence of str.
|
||||||
|
hosts: []
|
||||||
|
"""
|
||||||
|
options = parse_options(
|
||||||
|
text,
|
||||||
|
{"enabled": False, "strategy": "eager", "hosts": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert options["enabled"].value_type == "bool"
|
||||||
|
assert options["strategy"].choices == ["eager", "lazy"]
|
||||||
|
assert options["hosts"].value_type == "sequence of str"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("option", "value", "message"),
|
||||||
|
[
|
||||||
|
(MitmOption("anticache", "bool", False, "", []), "true", "expected bool"),
|
||||||
|
(MitmOption("flow_detail", "int", 1, "", []), "1", "expected int"),
|
||||||
|
(MitmOption("allow_hosts", "sequence of str", [], "", []), "x", "list[str]"),
|
||||||
|
(MitmOption("strategy", "unknown", "eager", "", ["eager"]), "lazy", "one of"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_value_rejects_wrong_types(option, value, message):
|
||||||
|
assert message in validate_value(option, value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_options_include_docs_links():
|
||||||
|
options = {
|
||||||
|
"flow_detail": MitmOption("flow_detail", "int", 1, "Flow detail.", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
public = public_options(options)
|
||||||
|
|
||||||
|
assert public[0]["docs_url"].endswith("#flow_detail")
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from 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}']
|
||||||
Generated
+490
-4
@@ -2,6 +2,28 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aioquic"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "cryptography" },
|
||||||
|
{ name = "pylsqpack" },
|
||||||
|
{ name = "pyopenssl" },
|
||||||
|
{ name = "service-identity" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4b/1a/bf10b2c57c06c7452b685368cb1ac90565a6e686e84ec6f84465fb8f78f4/aioquic-1.2.0.tar.gz", hash = "sha256:f91263bb3f71948c5c8915b4d50ee370004f20a416f67fab3dcc90556c7e7199", size = 179891, upload-time = "2024-07-06T23:27:09.301Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/03/1c385739e504c70ab2a66a4bc0e7cd95cee084b374dcd4dc97896378400b/aioquic-1.2.0-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3e23964dfb04526ade6e66f5b7cd0c830421b8138303ab60ba6e204015e7cb0b", size = 1753473, upload-time = "2024-07-06T23:26:20.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/1f/4d1c40714db65be828e1a1e2cce7f8f4b252be67d89f2942f86a1951826c/aioquic-1.2.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:84d733332927b76218a3b246216104116f766f5a9b2308ec306cd017b3049660", size = 2083563, upload-time = "2024-07-06T23:26:24.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/48/56a8c9083d1deea4ccaf1cbf5a91a396b838b4a0f8650f4e9f45c7879a38/aioquic-1.2.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2466499759b31ea4f1d17f4aeb1f8d4297169e05e3c1216d618c9757f4dd740d", size = 2555697, upload-time = "2024-07-06T23:26:26.16Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/93/fa4c981a8a8a903648d4cd6e12c0fca7f44e3ef4ba15a8b99a26af05b868/aioquic-1.2.0-cp38-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd75015462ca5070a888110dc201f35a9f4c7459f9201b77adc3c06013611bb8", size = 2149089, upload-time = "2024-07-06T23:26:28.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/0f/4a280923313b831892caaa45348abea89e7dd2e4422a86699bb0e506b1dd/aioquic-1.2.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43ae3b11d43400a620ca0b4b4885d12b76a599c2cbddba755f74bebfa65fe587", size = 2205221, upload-time = "2024-07-06T23:26:30.682Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/6b/a6a1d1762ce06f13b68f524bb9c5f4d6ca7cda9b072d7e744626b89b77be/aioquic-1.2.0-cp38-abi3-win32.whl", hash = "sha256:910d8c91da86bba003d491d15deaeac3087d1b9d690b9edc1375905d8867b742", size = 1214037, upload-time = "2024-07-06T23:26:32.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/aa/e8a8a75c93dee0ab229df3c2d17f63cd44d0ad5ee8540e2ec42779ce3a39/aioquic-1.2.0-cp38-abi3-win_amd64.whl", hash = "sha256:e3dcfb941004333d477225a6689b55fc7f905af5ee6a556eb5083be0354e653a", size = 1530339, upload-time = "2024-07-06T23:26:34.753Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-doc"
|
name = "annotated-doc"
|
||||||
version = "0.0.4"
|
version = "0.0.4"
|
||||||
@@ -75,6 +97,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asgiref"
|
||||||
|
version = "3.11.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "attrs"
|
||||||
|
version = "26.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "authlib"
|
name = "authlib"
|
||||||
version = "1.6.9"
|
version = "1.6.9"
|
||||||
@@ -87,6 +127,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "basedpyright"
|
||||||
|
version = "1.39.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "nodejs-wheel-binaries" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/04/19/5a5b9b9197973da732638957be3a65cf514d2f5a4964eeedbf33b6c65bbd/basedpyright-1.39.3.tar.gz", hash = "sha256:2f794e6b5f4260fb89f614ca6cd23c6f305373bb6b50c4ed7794ff2ae647fb14", size = 25503187, upload-time = "2026-04-20T22:14:47.424Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/5c/f950c1239ad26f3bb453e665428a2cf1893995de725a5eb0b64a2520b366/basedpyright-1.39.3-py3-none-any.whl", hash = "sha256:aba760dc83307727554f936d6b4381caa14482f30dbc2173167710e217c1f7ab", size = 12419181, upload-time = "2026-04-20T22:14:51.975Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bcrypt"
|
name = "bcrypt"
|
||||||
version = "5.0.0"
|
version = "5.0.0"
|
||||||
@@ -138,6 +190,33 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blinker"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brotli"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.2.25"
|
version = "2026.2.25"
|
||||||
@@ -383,6 +462,23 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/71/4f/a5b3cfc3e1779c756c4fef790facef12d63088402a718b167b780d4f2ccc/fastar-0.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3152a80835ef11cbfaf153037dd694e0014f25d074792b9785421679acdbe179", size = 461150, upload-time = "2026-04-08T01:02:08.47Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/4f/a5b3cfc3e1779c756c4fef790facef12d63088402a718b167b780d4f2ccc/fastar-0.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3152a80835ef11cbfaf153037dd694e0014f25d074792b9785421679acdbe179", size = 461150, upload-time = "2026-04-08T01:02:08.47Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask"
|
||||||
|
version = "3.1.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "blinker" },
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "itsdangerous" },
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -392,6 +488,28 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "4.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "hpack" },
|
||||||
|
{ name = "hyperframe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hpack"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpcore"
|
name = "httpcore"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
@@ -435,6 +553,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyperframe"
|
||||||
|
version = "6.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.11"
|
version = "3.11"
|
||||||
@@ -453,6 +580,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itsdangerous"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jinja2"
|
name = "jinja2"
|
||||||
version = "3.1.6"
|
version = "3.1.6"
|
||||||
@@ -465,6 +601,27 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kaitaistruct"
|
||||||
|
version = "0.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/27/b8/ca7319556912f68832daa4b81425314857ec08dfccd8dbc8c0f65c992108/kaitaistruct-0.11.tar.gz", hash = "sha256:053ee764288e78b8e53acf748e9733268acbd579b8d82a427b1805453625d74b", size = 11519, upload-time = "2025-09-08T15:46:25.037Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/4a/cf14bf3b1f5ffb13c69cf5f0ea78031247790558ee88984a8bdd22fae60d/kaitaistruct-0.11-py2.py3-none-any.whl", hash = "sha256:5c6ce79177b4e193a577ecd359e26516d1d6d000a0bffd6e1010f2a46a62a561", size = 11372, upload-time = "2025-09-08T15:46:23.635Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ldap3"
|
||||||
|
version = "2.9.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyasn1" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/ac/96bd5464e3edbc61595d0d69989f5d9969ae411866427b2500a8e5b812c0/ldap3-2.9.1.tar.gz", hash = "sha256:f3e7fc4718e3f09dda568b57100095e0ce58633bcabbed8667ce3f8fbaa4229f", size = 398830, upload-time = "2021-07-18T06:34:21.786Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192, upload-time = "2021-07-18T06:34:12.905Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
@@ -516,6 +673,85 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mitmproxy"
|
||||||
|
version = "12.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "aioquic" },
|
||||||
|
{ name = "argon2-cffi" },
|
||||||
|
{ name = "asgiref" },
|
||||||
|
{ name = "bcrypt" },
|
||||||
|
{ name = "brotli" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "cryptography" },
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "h11" },
|
||||||
|
{ name = "h2" },
|
||||||
|
{ name = "hyperframe" },
|
||||||
|
{ name = "kaitaistruct" },
|
||||||
|
{ name = "ldap3" },
|
||||||
|
{ name = "mitmproxy-rs" },
|
||||||
|
{ name = "msgpack" },
|
||||||
|
{ name = "publicsuffix2" },
|
||||||
|
{ name = "pydivert", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "pyopenssl" },
|
||||||
|
{ name = "pyparsing" },
|
||||||
|
{ name = "pyperclip" },
|
||||||
|
{ name = "ruamel-yaml" },
|
||||||
|
{ name = "sortedcontainers" },
|
||||||
|
{ name = "tornado" },
|
||||||
|
{ name = "urwid" },
|
||||||
|
{ name = "wsproto" },
|
||||||
|
{ name = "zstandard" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/14/32b4c3a9c3380e0cf1bed1976951e70510c06f1ba7c0dc46f9e69e3965ae/mitmproxy-12.2.2-py3-none-any.whl", hash = "sha256:7b111ba3b83b34b0d9b653044685db7c3f5fbc63b39b8f06439642da83910713", size = 1651385, upload-time = "2026-04-12T21:49:54.312Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mitmproxy-linux"
|
||||||
|
version = "0.12.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2f/f2/8c776f9bf013752c4521fc8382efc7b55cb238cea69b7963200b4f8da293/mitmproxy_linux-0.12.9.tar.gz", hash = "sha256:94b10fee02aa42287739623cef921e1a53955005d45c9e2fa309ae9f0bf8d37d", size = 1299779, upload-time = "2026-01-30T14:54:13.898Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/6e/10a2fbcf564e18254293dc7118dc4ec72f3e5897509d7b4f804ab23df5cd/mitmproxy_linux-0.12.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4413e27c692f30036ad6d73432826e728ede026fac8e51651d0c545dd0177f2", size = 987838, upload-time = "2026-01-30T14:53:59.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/c5/2eeb523019b1ad84ec659fc41b007cbc90ac99e2451c4e7ba7a28d910b04/mitmproxy_linux-0.12.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee842865a05f69196004ddcb29d50af0602361d9d6acee04f370f7e01c3674e8", size = 1067258, upload-time = "2026-01-30T14:54:01.872Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mitmproxy-macos"
|
||||||
|
version = "0.12.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/71/d5899c5d1593403bccdd4b56306d03a200e14483318f86b882a144f79a32/mitmproxy_macos-0.12.9-py3-none-any.whl", hash = "sha256:20e024fbfeeecbdb4ee2a1e8361d18782146777fdc1e00dcfecd52c22a3219bf", size = 2569740, upload-time = "2026-01-30T14:54:03.379Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mitmproxy-rs"
|
||||||
|
version = "0.12.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mitmproxy-linux", marker = "sys_platform == 'linux'" },
|
||||||
|
{ name = "mitmproxy-macos", marker = "sys_platform == 'darwin'" },
|
||||||
|
{ name = "mitmproxy-windows", marker = "os_name == 'nt'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5d/5c/16a61303da76cd34aa6ddbd7ef6ac66d9ef8514c4d3a5b71831169d63236/mitmproxy_rs-0.12.9.tar.gz", hash = "sha256:c6ffc35c002c675cac534442d92d1cdebd66fafd63754ad33b92ae968ea6e449", size = 1334424, upload-time = "2026-01-30T14:54:15.043Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/78/dc9f4b4ef894709853407291ab281e478cb122b993633125b858eea523ba/mitmproxy_rs-0.12.9-cp312-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:afeb3a2da2bc26474e1a2febaea4432430c5fde890dfce33bc4c1e65e6baef1b", size = 7145620, upload-time = "2026-01-30T14:54:05.132Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/6f/1ebd9ca748bf62eb90657b41692c46716cff03aaf134260a249a2ae2d251/mitmproxy_rs-0.12.9-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245922663440330c4b5a36d0194ed559b1dbd5e38545db2eb947180ed12a5e92", size = 3084785, upload-time = "2026-01-30T14:54:06.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/af/fc2f2b30a6ade8646d276c4813f68b86d775696d467f12df32613d22c638/mitmproxy_rs-0.12.9-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fb9fb4aac9ecb82e2c3c5c439ef5e4961be7934d80ade5e9a99c0a944b8ea2f", size = 3252443, upload-time = "2026-01-30T14:54:08.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/20/b065c6a1eb27effec3368b03bdc842f6f611800ee5f990d994884286f160/mitmproxy_rs-0.12.9-cp312-abi3-win_amd64.whl", hash = "sha256:1fd716e87da8be3c62daa4325a5ff42bedd951fb8614c5f66caa94b7c21e2593", size = 3321769, upload-time = "2026-01-30T14:54:10.735Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mitmproxy-windows"
|
||||||
|
version = "0.12.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/83/2712af146c5f6a59a7f4658c02356b241c40ba19cb2b16db94235e95b699/mitmproxy_windows-0.12.9-py3-none-any.whl", hash = "sha256:fdec21fb66a5ba237d9106bfdc09d9428f315551bf4b41ba06b261e7beb56417", size = 464363, upload-time = "2026-01-30T14:54:12.531Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "motor"
|
name = "motor"
|
||||||
version = "3.7.1"
|
version = "3.7.1"
|
||||||
@@ -528,6 +764,48 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/01/9a/35e053d4f442addf751ed20e0e922476508ee580786546d699b0567c4c67/motor-3.7.1-py3-none-any.whl", hash = "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298", size = 74996, upload-time = "2025-05-14T18:56:31.665Z" },
|
{ url = "https://files.pythonhosted.org/packages/01/9a/35e053d4f442addf751ed20e0e922476508ee580786546d699b0567c4c67/motor-3.7.1-py3-none-any.whl", hash = "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298", size = 74996, upload-time = "2025-05-14T18:56:31.665Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "msgpack"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nodejs-wheel-binaries"
|
||||||
|
version = "24.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3f/70/a1e4f4d5986768ab90cc860b1cc3660fd2ded74ca175a900a5c29f839c7d/nodejs_wheel_binaries-24.15.0.tar.gz", hash = "sha256:b43f5c4f6e5768d8845b2ae4682eb703a19bf7aadc84187e2d903ed3a611c859", size = 8057, upload-time = "2026-04-19T15:48:16.899Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/66/54051d14853d6ab4fb85f8be9b042b530be653357fb9a19557498bc91ab7/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:a6232fa8b754220941f52388c8ead923f7c1c7fdf0ea0d98f657523bd9a81ef4", size = 55173485, upload-time = "2026-04-19T15:47:34.561Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/5f/66acada164da5ca10a0824db021aa7394ae18396c550cd9280e839a43126/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:001a6b62c69d9109c1738163cca00608dd2722e8663af59300054ea02610972d", size = 55348100, upload-time = "2026-04-19T15:47:40.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/2d/0cbd5ff40c9bb030ca1735d8f8793bd74f08a4cbd49100a1d19313ea57ab/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0fbc48765e60ed0ff30d43898dbf5cadbadf2e5f1e7f204afc2b01493b7ebce6", size = 59668206, upload-time = "2026-04-19T15:47:46.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/d5/91ac63951ec75927a486b83b8cafe650e360fa70ac01dc94adfb32b93b97/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:20ee0536809795da8a4942fc1ab4cbdebbcaaf29383eab67ba8874268fb00008", size = 60206736, upload-time = "2026-04-19T15:47:52.668Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/72/dc22776974d928869c0c30d23ee98ed7df254243c2df68f09f5963e8e8b8/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1fade6c214285e72472ca40a631e98ff36559671cd5eefc8bf009471d67f04b4", size = 61720456, upload-time = "2026-04-19T15:47:58.325Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/0a/34461b9050cb45ee371dccdefc622aef6351506ea2691b08fc761ca67150/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3984cb8d87766567aee67a49743227ab40ede6f47734ec990ff90e50b74e7740", size = 62326172, upload-time = "2026-04-19T15:48:04.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/17/09252bf35672dba926649d59dfe51443a0f6955ad13784e91131d5ec82a2/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_amd64.whl", hash = "sha256:a437601956b532dcb3082046e6978e622733f90edc0932cbb9adb3bb97a16501", size = 41543461, upload-time = "2026-04-19T15:48:09.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/7e/b649777d148e1e0c2ce349156603cdb12f7ed99921b95d93717393650193/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_arm64.whl", hash = "sha256:bdf4a431e08321a32efc604111c6f23941f87055d796a537e8c4110daecad23f", size = 39233248, upload-time = "2026-04-19T15:48:13.326Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.0"
|
version = "26.0"
|
||||||
@@ -546,6 +824,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "publicsuffix2"
|
||||||
|
version = "2.20191221"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/04/1759906c4c5b67b2903f546de234a824d4028ef24eb0b1122daa43376c20/publicsuffix2-2.20191221.tar.gz", hash = "sha256:00f8cc31aa8d0d5592a5ced19cccba7de428ebca985db26ac852d920ddd6fe7b", size = 99592, upload-time = "2019-12-21T11:30:44.863Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/16/053c2945c5e3aebeefb4ccd5c5e7639e38bc30ad1bdc7ce86c6d01707726/publicsuffix2-2.20191221-py2.py3-none-any.whl", hash = "sha256:786b5e36205b88758bd3518725ec8cfe7a8173f5269354641f581c6b80a99893", size = 89033, upload-time = "2019-12-21T11:30:41.744Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pwdlib"
|
name = "pwdlib"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -563,6 +850,27 @@ bcrypt = [
|
|||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyasn1"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyasn1-modules"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyasn1" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pycparser"
|
name = "pycparser"
|
||||||
version = "3.0"
|
version = "3.0"
|
||||||
@@ -658,6 +966,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydivert"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cf/71/2da9bcf742df3ab23f75f10fedca074951dd13a84bda8dea3077f68ae9a6/pydivert-2.1.0.tar.gz", hash = "sha256:f0e150f4ff591b78e35f514e319561dadff7f24a82186a171dd4d465483de5b4", size = 91057, upload-time = "2017-10-20T21:36:58.165Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/8f/86d7931c62013a5a7ebf4e1642a87d4a6050c0f570e714f61b0df1984c62/pydivert-2.1.0-py2.py3-none-any.whl", hash = "sha256:382db488e3c37c03ec9ec94e061a0b24334d78dbaeebb7d4e4d32ce4355d9da1", size = 104718, upload-time = "2017-10-20T21:36:56.726Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.20.0"
|
version = "2.20.0"
|
||||||
@@ -667,6 +984,25 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pylsqpack"
|
||||||
|
version = "0.3.24"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7c/a0/20b34e654b911a9abb736b242cc0a11912bc79ea3e911f139ea756e39ea2/pylsqpack-0.3.24.tar.gz", hash = "sha256:8ec455f44614228f89e38d40c1b1e37895620e20ec6b21e3b562fa8b79a23890", size = 677187, upload-time = "2026-03-29T15:42:40.136Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/88/71b79d334f67dd595fbed5f3a337e2aa997a96e452bb1b64120bccf5679d/pylsqpack-0.3.24-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:8edf48d0a023cd3629b2c4aaccac9b79a46d566c0f61e7416b5678228433763d", size = 162525, upload-time = "2026-03-29T15:42:25.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/96/f0a7625075394e93db42bd476abb7240ff1a474acd1ad404158baf68dc6a/pylsqpack-0.3.24-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e7d956dbc8f7d597b237b9157d0a16bc7c655a1b031239763c18dc8582aff8cc", size = 168643, upload-time = "2026-03-29T15:42:26.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/de/49ec59856ea41468ed879ec143fc429729e37e4860b2119959a2a66fb652/pylsqpack-0.3.24-cp310-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b6a8bb42127d5ece8d301a673c8205df25b73b69f8c46b9f0c3034588de1789a", size = 246930, upload-time = "2026-03-29T15:42:28.136Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/d3/3e748fa5317782bfe68a7eaf890524aee48281c59f07e9bdfd7774f158db/pylsqpack-0.3.24-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3f977d419c60c1d6c2240e6d7a52df820d37eb8c36b4057113bcd7859f53e2c", size = 249234, upload-time = "2026-03-29T15:42:29.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/5b/06f5e354ed882ce036ed65f2a393c98d0f6c71a23fa64b53251ddeb40a7b/pylsqpack-0.3.24-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6024854eb16d32803d4890fb90a73b9348c74b61c0770680aefaaa75f8456e8c", size = 250274, upload-time = "2026-03-29T15:42:31.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/0e/c95cae2817a5c272b7a3132376165aa16875efcccbbd3e6608f5082770cc/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54978a9879471596d84bbad5e67d727014048926bc5bb2dac0eb3701b48c5ac9", size = 246966, upload-time = "2026-03-29T15:42:32.035Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/fe/d5e84c3b4b2fa716df9e95aeb40d3bfb4de50c21cccccd66e194cfc084ac/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:caf63ddc2e581c764d17432893acce02c5c29ff879d77c2abf1e26aa4eeb831b", size = 246546, upload-time = "2026-03-29T15:42:33.105Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/f5/88e442ced83c0305f50f45bf521bbce3344ef0c29c3442f010086ff0c124/pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc5f146fd456b50b227858aed59faa0ff8445aa426e69bb4e50d46c487aab0", size = 248517, upload-time = "2026-03-29T15:42:34.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/c2/886348974bba20db2a80cf37e97203d7334223b3c1c1babe4159dd12626d/pylsqpack-0.3.24-cp310-abi3-win32.whl", hash = "sha256:8da12be7b35b7c9a8cf73a4c077f72e5022a311f80a401c79904213376f2d767", size = 153483, upload-time = "2026-03-29T15:42:35.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/22/adbce7adfb41b8f5f222195f7f4f5e58655aa3e83f525bc5f3882b07d6e8/pylsqpack-0.3.24-cp310-abi3-win_amd64.whl", hash = "sha256:c3e2327af25ee616ce4483a8748f0957cf017cbca82d58ed15efea68f70f94ff", size = 156145, upload-time = "2026-03-29T15:42:36.902Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/2e/6fb6d797ce88741a0e18984bbab69160abc0971a41f4478cab6c8255a8dc/pylsqpack-0.3.24-cp310-abi3-win_arm64.whl", hash = "sha256:23b4d8af48836893beac356c10ca268161953de5bf9ed691526a93f5c82433e9", size = 153424, upload-time = "2026-03-29T15:42:38.73Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pymongo"
|
name = "pymongo"
|
||||||
version = "4.16.0"
|
version = "4.16.0"
|
||||||
@@ -698,6 +1034,36 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/32/cd/ddc794cdc8500f6f28c119c624252fb6dfb19481c6d7ed150f13cf468a6d/pymongo-4.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6b2a20edb5452ac8daa395890eeb076c570790dfce6b7a44d788af74c2f8cf96", size = 1047725, upload-time = "2026-01-07T18:05:28.47Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/cd/ddc794cdc8500f6f28c119c624252fb6dfb19481c6d7ed150f13cf468a6d/pymongo-4.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6b2a20edb5452ac8daa395890eeb076c570790dfce6b7a44d788af74c2f8cf96", size = 1047725, upload-time = "2026-01-07T18:05:28.47Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyopenssl"
|
||||||
|
version = "25.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cryptography" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyparsing"
|
||||||
|
version = "3.3.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyperclip"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.3"
|
version = "9.0.3"
|
||||||
@@ -832,6 +1198,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruamel-yaml"
|
||||||
|
version = "0.19.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.10"
|
version = "0.15.10"
|
||||||
@@ -870,6 +1245,21 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c9/64/982e07b93219cb52e1cca5d272cb579e2f3eb001956c9e7a9a6d106c9473/sentry_sdk-2.57.0-py2.py3-none-any.whl", hash = "sha256:812c8bf5ff3d2f0e89c82f5ce80ab3a6423e102729c4706af7413fd1eb480585", size = 456489, upload-time = "2026-03-31T09:39:27.524Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/64/982e07b93219cb52e1cca5d272cb579e2f3eb001956c9e7a9a6d106c9473/sentry_sdk-2.57.0-py2.py3-none-any.whl", hash = "sha256:812c8bf5ff3d2f0e89c82f5ce80ab3a6423e102729c4706af7413fd1eb480585", size = 456489, upload-time = "2026-03-31T09:39:27.524Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "service-identity"
|
||||||
|
version = "24.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "attrs" },
|
||||||
|
{ name = "cryptography" },
|
||||||
|
{ name = "pyasn1" },
|
||||||
|
{ name = "pyasn1-modules" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/07/a5/dfc752b979067947261dbbf2543470c58efe735c3c1301dd870ef27830ee/service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09", size = 39245, upload-time = "2024-10-26T07:21:57.736Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shellingham"
|
name = "shellingham"
|
||||||
version = "1.5.4"
|
version = "1.5.4"
|
||||||
@@ -879,6 +1269,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sortedcontainers"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -897,37 +1296,56 @@ version = "0.1.0"
|
|||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "authlib" },
|
{ name = "authlib" },
|
||||||
|
{ name = "basedpyright" },
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
|
{ name = "mitmproxy" },
|
||||||
{ name = "motor" },
|
{ name = "motor" },
|
||||||
{ name = "pwdlib", extra = ["argon2", "bcrypt"] },
|
{ name = "pwdlib", extra = ["argon2", "bcrypt"] },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "ruff" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "authlib", specifier = ">=1.6.0" },
|
{ name = "authlib", specifier = ">=1.6.0" },
|
||||||
|
{ name = "basedpyright", specifier = ">=1.39.0" },
|
||||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" },
|
{ name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" },
|
||||||
|
{ name = "mitmproxy", specifier = ">=12.2.2" },
|
||||||
{ name = "motor", specifier = ">=3.7.1" },
|
{ name = "motor", specifier = ">=3.7.1" },
|
||||||
{ name = "pwdlib", extras = ["argon2", "bcrypt"], specifier = ">=0.3.0" },
|
{ name = "pwdlib", extras = ["argon2", "bcrypt"], specifier = ">=0.3.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.13.1" },
|
{ name = "pydantic-settings", specifier = ">=2.13.1" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||||
{ name = "redis", specifier = ">=7.4.0" },
|
{ name = "redis", specifier = ">=7.4.0" },
|
||||||
|
{ name = "ruff", specifier = ">=0.15.10" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [{ name = "pytest", specifier = ">=9.0.3" }]
|
||||||
{ name = "pytest", specifier = ">=9.0.3" },
|
|
||||||
{ name = "ruff", specifier = ">=0.15.10" },
|
[[package]]
|
||||||
|
name = "tornado"
|
||||||
|
version = "6.5.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -975,6 +1393,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urwid"
|
||||||
|
version = "3.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "wcwidth" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/59/67cd42db7c549c0c106d2b56d2d2ec1915c459e0a92722029efc5359e871/urwid-3.0.5.tar.gz", hash = "sha256:24be27ffafdb68c09cd95dc21b60ccfd02843320b25ce5feee1708b34fad5a23", size = 860746, upload-time = "2026-02-02T14:38:43.958Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/6a/efd8ad30ab250a4ad23dc7c07437b374921042cb35215e468d49977822f7/urwid-3.0.5-py3-none-any.whl", hash = "sha256:f188144261224fdfc9b56b4222869bd0eac90fd7895cf1e376129cdc7e13bc84", size = 296957, upload-time = "2026-02-02T14:38:42.044Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.44.0"
|
version = "0.44.0"
|
||||||
@@ -1053,6 +1483,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wcwidth"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "websockets"
|
name = "websockets"
|
||||||
version = "16.0"
|
version = "16.0"
|
||||||
@@ -1079,3 +1518,50 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "werkzeug"
|
||||||
|
version = "3.1.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wsproto"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstandard"
|
||||||
|
version = "0.25.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
|
||||||
|
]
|
||||||
+36
-6
@@ -1,18 +1,48 @@
|
|||||||
services:
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
container_name: theplug-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ./backend/.env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
MONGODB_URI: mongodb://${MONGO_USER:-theplug}:${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}@mongodb:27017/${MONGO_DB:-theplug}?authSource=admin
|
||||||
|
MONGODB_DB_NAME: ${MONGO_DB:-theplug}
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-3671}:3671"
|
||||||
|
depends_on:
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
container_name: theplug-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-5173}:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
mongodb:
|
mongodb:
|
||||||
image: mongo:8.2
|
image: mongo:8.2
|
||||||
container_name: theplug-mongodb
|
container_name: theplug-mongodb
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug}
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug}
|
||||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}
|
||||||
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
|
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
|
||||||
ports:
|
ports:
|
||||||
- "27017:27017"
|
- "27017:27017"
|
||||||
volumes:
|
volumes:
|
||||||
- mongodb_data:/data/db
|
- theplug_mongodb:/data/db
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "mongosh --quiet --username ${MONGO_USER:-theplug} --password ${MONGO_PASSWORD:-theplug_dev_password} --authenticationDatabase admin --eval \"db.adminCommand({ ping: 1 }).ok\" | grep 1"]
|
test: ["CMD-SHELL", "mongosh --quiet --username ${MONGO_USER:-theplug} --password ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password} --authenticationDatabase admin --eval \"db.adminCommand({ ping: 1 }).ok\" | grep 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -26,7 +56,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- theplug_redis:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -35,5 +65,5 @@ services:
|
|||||||
start_period: 5s
|
start_period: 5s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongodb_data:
|
theplug_mongodb:
|
||||||
redis_data:
|
theplug_redis:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
npm-debug.log
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.29-alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>thePlug</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:3671/;
|
||||||
|
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
+1670
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.2.7",
|
||||||
|
"vue-tsc": "^3.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, provide } from "vue";
|
||||||
|
|
||||||
|
import AppHeader from "./components/AppHeader.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.error"
|
||||||
|
@submit="plane.signIn"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main v-else class="app-shell">
|
||||||
|
<AppHeader
|
||||||
|
:loading="plane.loading"
|
||||||
|
@refresh="plane.refreshAll"
|
||||||
|
@create="plane.newProxy"
|
||||||
|
@logout="plane.signOut"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="app-body">
|
||||||
|
<ProxyList
|
||||||
|
:proxies="plane.proxies"
|
||||||
|
:selected-port="plane.selectedPort"
|
||||||
|
@select="plane.selectedPort = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProxyWorkbench />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type {
|
||||||
|
AddonExample,
|
||||||
|
DockerService,
|
||||||
|
HealthResult,
|
||||||
|
MitmOption,
|
||||||
|
PythonEditorSession,
|
||||||
|
ProxyDocument,
|
||||||
|
ProxyDraft,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "theplug.token";
|
||||||
|
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||||
|
|
||||||
|
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;
|
||||||
|
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||||
|
}
|
||||||
|
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: () => request<DockerService[]>("/proxy/services"),
|
||||||
|
options: () => request<MitmOption[]>("/proxy/mitmproxy/options"),
|
||||||
|
examples: () => request<AddonExample[]>("/proxy/mitmproxy/examples"),
|
||||||
|
editorSession: () =>
|
||||||
|
request<PythonEditorSession>("/proxy/editor/python/session"),
|
||||||
|
createExample: (payload: AddonExample) =>
|
||||||
|
request<AddonExample>("/proxy/mitmproxy/examples", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
deleteExample: (id: string) =>
|
||||||
|
request<void>(`/proxy/mitmproxy/examples/${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`),
|
||||||
|
logs: (port: number) => request<{ log: string }>(`/proxy/${port}/logs`),
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AppLogo from "./AppLogo.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
loading: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
refresh: [];
|
||||||
|
create: [];
|
||||||
|
logout: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="brand">
|
||||||
|
<AppLogo />
|
||||||
|
<div>
|
||||||
|
<strong>thePlug</strong>
|
||||||
|
<small>proxy control plane</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-meta">
|
||||||
|
<span class="team">mitmdump</span>
|
||||||
|
<span v-if="loading" class="muted">applying changes</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="ghost" :disabled="loading" @click="emit('refresh')">Refresh</button>
|
||||||
|
<button class="primary" @click="emit('create')">New</button>
|
||||||
|
<button class="ghost" @click="emit('logout')">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg class="logo" viewBox="0 0 64 64" role="img" aria-label="thePlug logo">
|
||||||
|
<rect x="10" y="10" width="44" height="44" rx="10" />
|
||||||
|
<path d="M26 19v10M38 19v10" />
|
||||||
|
<path d="M22 29h20v6a10 10 0 0 1-10 10 10 10 0 0 1-10-10v-6z" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<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 status = ref("Connecting Python tools...");
|
||||||
|
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%",
|
||||||
|
color: "#edf2f7",
|
||||||
|
backgroundColor: "#0b0f12",
|
||||||
|
fontSize: "14px",
|
||||||
|
},
|
||||||
|
".cm-scroller": {
|
||||||
|
fontFamily: "IBM Plex Mono, JetBrains Mono, ui-monospace, monospace",
|
||||||
|
lineHeight: "1.6",
|
||||||
|
},
|
||||||
|
".cm-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.then(
|
||||||
|
() => {
|
||||||
|
status.value = "Python tools ready";
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
status.value = "Python tools unavailable";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
editor = new EditorView({
|
||||||
|
doc: model.value,
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
python(),
|
||||||
|
editorTheme,
|
||||||
|
keymap.of([{ key: "Mod-s", run: emitSave }]),
|
||||||
|
EditorView.lineWrapping,
|
||||||
|
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;
|
||||||
|
status.value = "Python tools unavailable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="code-editor-wrap">
|
||||||
|
<div class="code-editor-status">
|
||||||
|
<span>{{ status }}</span>
|
||||||
|
<span v-if="error" class="field-error">{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
<div ref="host" class="code-editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { defineAsyncComponent } from "vue";
|
||||||
|
|
||||||
|
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||||
|
|
||||||
|
const CodeEditor = defineAsyncComponent(() => import("./CodeEditor.vue"));
|
||||||
|
const plane = useControlPlaneContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="editor-pane panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Code</p>
|
||||||
|
<h2>Addon editor</h2>
|
||||||
|
</div>
|
||||||
|
<div class="editor-meta">
|
||||||
|
<span>{{ plane.codeLineCount }} lines</span>
|
||||||
|
<span>{{ plane.draft.mode }} / {{ plane.draft.protocol }}</span>
|
||||||
|
<button class="ghost" @click="plane.toggleEditor(false)">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-shell">
|
||||||
|
<CodeEditor v-model="plane.draft.code" @save="plane.saveProxy" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||||
|
|
||||||
|
const plane = useControlPlaneContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel stack-panel tool-panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Templates</p>
|
||||||
|
<h2>Examples</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline-grid">
|
||||||
|
<input
|
||||||
|
v-model="plane.customExampleName"
|
||||||
|
placeholder="Save current code as..."
|
||||||
|
/>
|
||||||
|
<button @click="plane.saveCurrentAsExample">Save</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="example-shell">
|
||||||
|
<div class="example-browser">
|
||||||
|
<input v-model="plane.exampleSearch" placeholder="Search examples" />
|
||||||
|
|
||||||
|
<div class="example-index">
|
||||||
|
<button
|
||||||
|
v-for="example in plane.filteredExamples"
|
||||||
|
:key="example.id"
|
||||||
|
class="example-entry"
|
||||||
|
:class="{ selected: plane.selectedExample?.id === example.id }"
|
||||||
|
@click="plane.selectExample(example.id)"
|
||||||
|
>
|
||||||
|
<strong>{{ example.name }}</strong>
|
||||||
|
<small>{{ example.category }}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article v-if="plane.selectedExample" class="example-preview">
|
||||||
|
<div class="option-doc-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Selected</p>
|
||||||
|
<h3>{{ plane.selectedExample.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="template-meta">
|
||||||
|
<span class="type-chip">{{ plane.selectedExample.category }}</span>
|
||||||
|
<span class="type-chip">
|
||||||
|
{{ plane.selectedExample.built_in ? "built-in" : "custom" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="doc-copy">{{ plane.selectedExample.description }}</p>
|
||||||
|
|
||||||
|
<div class="doc-actions">
|
||||||
|
<button @click="plane.applyExample(plane.selectedExample)">Use template</button>
|
||||||
|
<a
|
||||||
|
v-if="plane.selectedExample.source_url"
|
||||||
|
:href="plane.selectedExample.source_url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Source
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
v-if="!plane.selectedExample.built_in"
|
||||||
|
class="ghost"
|
||||||
|
@click="plane.removeExample(plane.selectedExample.id)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { protocols, useControlPlaneContext } from "../composables/useControlPlane";
|
||||||
|
|
||||||
|
const plane = useControlPlaneContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel stack-panel compact-panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Health</p>
|
||||||
|
<h2>Checks</h2>
|
||||||
|
</div>
|
||||||
|
<button :disabled="!plane.selectedProxy" @click="plane.checkHealth">
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="compact-details">
|
||||||
|
<summary>Advanced check settings</summary>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div v-if="plane.health" class="health-box" :class="plane.health.overall">
|
||||||
|
<strong>{{ plane.health.overall }}</strong>
|
||||||
|
<div class="health-list">
|
||||||
|
<div v-for="check in plane.health.checks" :key="check.target" class="health-row">
|
||||||
|
<span>{{ check.target }}</span>
|
||||||
|
<span>{{ check.protocol }}</span>
|
||||||
|
<span>{{ check.healthy ? "ok" : check.error }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AppLogo from "./AppLogo.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
loading: boolean;
|
||||||
|
error: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const username = defineModel<string>("username", { required: true });
|
||||||
|
const password = defineModel<string>("password", { required: true });
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="login-screen">
|
||||||
|
<section class="login-card">
|
||||||
|
<div class="login-brand">
|
||||||
|
<AppLogo />
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">thePlug</p>
|
||||||
|
<h1>Proxy control plane</h1>
|
||||||
|
<p class="muted">Manage routes, runtime state, and mitmdump addons from one console.</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">Login</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||||
|
|
||||||
|
const plane = useControlPlaneContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel stack-panel tool-panel logs-panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Output</p>
|
||||||
|
<h2>Logs</h2>
|
||||||
|
</div>
|
||||||
|
<button :disabled="!plane.selectedProxy" @click="plane.loadLogs">Load</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre class="terminal">{{ plane.logs || "No logs loaded." }}</pre>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MitmOption } from "../types";
|
||||||
|
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||||
|
|
||||||
|
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="panel stack-panel tool-panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Mitmdump</p>
|
||||||
|
<h2>Options</h2>
|
||||||
|
</div>
|
||||||
|
<span class="counter">{{ plane.optionCount }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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" @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">
|
||||||
|
<input
|
||||||
|
v-model="plane.optionSearch"
|
||||||
|
placeholder="Search options"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div 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>
|
||||||
|
|
||||||
|
<article v-if="plane.selectedOption" class="option-doc">
|
||||||
|
<div class="option-doc-head">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Reference</p>
|
||||||
|
<h3>{{ plane.selectedOption.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<span class="type-chip">{{ plane.selectedOption.type }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="doc-copy">{{ plane.selectedOption.description }}</p>
|
||||||
|
|
||||||
|
<div class="doc-facts">
|
||||||
|
<span>Default: {{ formatDefault(plane.selectedOption) }}</span>
|
||||||
|
<span v-if="plane.selectedOption.choices.length">
|
||||||
|
Choices: {{ plane.selectedOption.choices.join(", ") }}
|
||||||
|
</span>
|
||||||
|
<span v-if="plane.selectedOption.reserved">Managed by thePlug</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="doc-actions">
|
||||||
|
<button
|
||||||
|
:disabled="plane.selectedOption.reserved"
|
||||||
|
@click="plane.setOption(plane.selectedOption)"
|
||||||
|
>
|
||||||
|
Add option
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
v-if="plane.selectedOption.docs_url"
|
||||||
|
:href="plane.selectedOption.docs_url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Official docs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<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>
|
||||||
|
<p class="eyebrow">Inventory</p>
|
||||||
|
<h2>Proxies</h2>
|
||||||
|
</div>
|
||||||
|
<span class="counter">{{ proxies.length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!proxies.length" class="empty-state">
|
||||||
|
<strong>No proxies configured</strong>
|
||||||
|
<span>Create a draft, save it, then start it when you want mitmdump to own the route.</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>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||||
|
import EditorPane from "./EditorPane.vue";
|
||||||
|
import ExamplesPanel from "./ExamplesPanel.vue";
|
||||||
|
import HealthPanel from "./HealthPanel.vue";
|
||||||
|
import LogsPanel from "./LogsPanel.vue";
|
||||||
|
import OptionsPanel from "./OptionsPanel.vue";
|
||||||
|
import RuntimePanel from "./RuntimePanel.vue";
|
||||||
|
import ServicePanel from "./ServicePanel.vue";
|
||||||
|
import StatusPill from "./StatusPill.vue";
|
||||||
|
|
||||||
|
const plane = useControlPlaneContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="workbench">
|
||||||
|
<header class="workbench-head panel">
|
||||||
|
<div class="workbench-copy">
|
||||||
|
<p class="eyebrow">Workspace</p>
|
||||||
|
<h1>{{ plane.draft.name || "Untitled proxy" }}</h1>
|
||||||
|
<div class="route-row">
|
||||||
|
<span class="route-token">{{ plane.routeState.configuredListen }}</span>
|
||||||
|
<span class="route-arrow">to</span>
|
||||||
|
<span class="route-target">{{ plane.routeState.configuredTarget }}</span>
|
||||||
|
<StatusPill
|
||||||
|
v-if="plane.selectedProxy"
|
||||||
|
:status="plane.selectedProxy.status"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="summary-copy">{{ plane.routeState.startNotice }}</p>
|
||||||
|
<p v-if="plane.selectedProxy?.last_error" class="error compact">
|
||||||
|
{{ plane.selectedProxy.last_error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-row">
|
||||||
|
<button @click="plane.saveProxy" :disabled="plane.loading">Save</button>
|
||||||
|
<button
|
||||||
|
@click="plane.runtimeAction('start')"
|
||||||
|
:disabled="!plane.selectedProxy || plane.loading"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="plane.runtimeAction('stop')"
|
||||||
|
:disabled="!plane.selectedProxy || plane.loading"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="plane.runtimeAction('restart')"
|
||||||
|
:disabled="!plane.selectedProxy || plane.loading"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button class="ghost" @click="plane.toggleEditor()">
|
||||||
|
{{ plane.editorOpen ? "Hide editor" : "Open editor" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="danger"
|
||||||
|
@click="plane.removeProxy"
|
||||||
|
:disabled="!plane.selectedProxy || plane.loading"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="plane.error" class="error">{{ plane.error }}</p>
|
||||||
|
<p v-if="plane.notice" class="notice">{{ plane.notice }}</p>
|
||||||
|
|
||||||
|
<section class="summary-grid">
|
||||||
|
<article class="panel summary-panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Route</p>
|
||||||
|
<h2>Runtime mapping</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-grid">
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Listen</span>
|
||||||
|
<strong>{{ plane.routeState.configuredListen }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Configured upstream</span>
|
||||||
|
<strong>{{ plane.routeState.configuredTarget }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Live upstream</span>
|
||||||
|
<strong>{{ plane.routeState.liveTarget }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Mode</span>
|
||||||
|
<strong>{{ plane.routeState.modeLabel }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p class="inline-note">{{ plane.routeState.liveNotice }}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel summary-panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Behaviour</p>
|
||||||
|
<h2>What start does</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-lines">
|
||||||
|
<span>Service source: {{ plane.routeState.serviceLabel }}</span>
|
||||||
|
<span>Save never remaps upstream ports.</span>
|
||||||
|
<span>Start is the only action that applies runtime routing.</span>
|
||||||
|
<span v-if="plane.routeState.remapped">
|
||||||
|
This proxy is currently holding the original public service port.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="workbench-grid">
|
||||||
|
<div class="overview-stack">
|
||||||
|
<RuntimePanel />
|
||||||
|
<ServicePanel />
|
||||||
|
<HealthPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="utility-panel">
|
||||||
|
<div class="tool-switcher">
|
||||||
|
<button
|
||||||
|
:class="{ active: plane.toolView === 'options' }"
|
||||||
|
@click="plane.setToolView('options')"
|
||||||
|
>
|
||||||
|
Options
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: plane.toolView === 'examples' }"
|
||||||
|
@click="plane.setToolView('examples')"
|
||||||
|
>
|
||||||
|
Examples
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: plane.toolView === 'logs' }"
|
||||||
|
@click="plane.setToolView('logs')"
|
||||||
|
>
|
||||||
|
Logs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OptionsPanel v-if="plane.toolView === 'options'" />
|
||||||
|
<ExamplesPanel v-else-if="plane.toolView === 'examples'" />
|
||||||
|
<LogsPanel v-else />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditorPane v-if="plane.editorOpen" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { modes, protocols, useControlPlaneContext } from "../composables/useControlPlane";
|
||||||
|
|
||||||
|
const plane = useControlPlaneContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel stack-panel compact-panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Runtime</p>
|
||||||
|
<h2>Proxy settings</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid tight-grid">
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input v-model="plane.draft.name" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Proxy port
|
||||||
|
<input v-model.number="plane.draft.proxy_port" type="number" />
|
||||||
|
</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>
|
||||||
|
Listen host
|
||||||
|
<input v-model="plane.draft.listen_host" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Mode spec
|
||||||
|
<input
|
||||||
|
v-model="plane.draft.mode_spec"
|
||||||
|
placeholder="optional raw mitmproxy mode"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="inline-note">
|
||||||
|
Save stores this configuration. Start launches mitmdump with the selected mode.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||||
|
|
||||||
|
const plane = useControlPlaneContext();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel stack-panel compact-panel">
|
||||||
|
<div class="panel-title">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Upstream</p>
|
||||||
|
<h2>Service route</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-grid">
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Configured target</span>
|
||||||
|
<strong>{{ plane.routeState.configuredTarget }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card">
|
||||||
|
<span class="metric-label">Live upstream</span>
|
||||||
|
<strong>{{ plane.routeState.liveTarget }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid tight-grid">
|
||||||
|
<label>
|
||||||
|
Host
|
||||||
|
<input v-model="plane.draft.service.host" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Port
|
||||||
|
<input v-model.number="plane.draft.service.port" type="number" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="inline-note">{{ plane.routeState.liveNotice }}</p>
|
||||||
|
|
||||||
|
<div v-if="plane.services.length" class="service-grid">
|
||||||
|
<div
|
||||||
|
v-for="service in plane.services"
|
||||||
|
:key="service.name + service.folder"
|
||||||
|
class="service-chip"
|
||||||
|
>
|
||||||
|
<strong>{{ service.name }}</strong>
|
||||||
|
<small>{{ service.folder }}</small>
|
||||||
|
<div class="port-row">
|
||||||
|
<button
|
||||||
|
v-for="port in service.ports"
|
||||||
|
:key="port"
|
||||||
|
class="port-chip"
|
||||||
|
:class="{
|
||||||
|
active:
|
||||||
|
plane.draft.service.name === service.name &&
|
||||||
|
plane.draft.service.folder === service.folder &&
|
||||||
|
plane.draft.service.port === port,
|
||||||
|
}"
|
||||||
|
@click="plane.applyService(service, port)"
|
||||||
|
>
|
||||||
|
{{ port }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="muted small">No Docker services detected by the backend.</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
status: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="status-pill" :class="status">{{ status }}</span>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
import {
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
proxyRefs,
|
||||||
|
reactive,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
type InjectionKey,
|
||||||
|
} from "vue";
|
||||||
|
|
||||||
|
import { api, getToken, login, logout } from "../api";
|
||||||
|
import type {
|
||||||
|
AddonExample,
|
||||||
|
DockerService,
|
||||||
|
HealthResult,
|
||||||
|
MitmOption,
|
||||||
|
ProxyDocument,
|
||||||
|
ProxyDraft,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const editorStorageKey = "theplug.editor.open";
|
||||||
|
const toolStorageKey = "theplug.tool.view";
|
||||||
|
|
||||||
|
export const modes = [
|
||||||
|
"reverse",
|
||||||
|
"regular",
|
||||||
|
"socks5",
|
||||||
|
"upstream",
|
||||||
|
"wireguard",
|
||||||
|
"transparent",
|
||||||
|
"local",
|
||||||
|
"tun",
|
||||||
|
"dns",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const protocols = [
|
||||||
|
"http",
|
||||||
|
"https",
|
||||||
|
"tcp",
|
||||||
|
"tls",
|
||||||
|
"udp",
|
||||||
|
"dtls",
|
||||||
|
"dns",
|
||||||
|
"http3",
|
||||||
|
"quic",
|
||||||
|
];
|
||||||
|
|
||||||
|
export type RuntimeAction = "start" | "stop" | "restart";
|
||||||
|
export type ToolView = "options" | "examples" | "logs";
|
||||||
|
|
||||||
|
export function createDefaultDraft(port = 8080, code?: string): ProxyDraft {
|
||||||
|
return {
|
||||||
|
proxy_port: port,
|
||||||
|
name: "new-proxy",
|
||||||
|
code:
|
||||||
|
code ??
|
||||||
|
`from mitmproxy import http
|
||||||
|
|
||||||
|
|
||||||
|
def request(flow: http.HTTPFlow) -> None:
|
||||||
|
pass
|
||||||
|
`,
|
||||||
|
mode: "reverse",
|
||||||
|
mode_spec: null,
|
||||||
|
protocol: "http",
|
||||||
|
listen_host: "0.0.0.0",
|
||||||
|
mitm_options: {
|
||||||
|
flow_detail: 1,
|
||||||
|
termlog_verbosity: "info",
|
||||||
|
},
|
||||||
|
healthcheck: {
|
||||||
|
enabled: true,
|
||||||
|
protocol: null,
|
||||||
|
path: "/",
|
||||||
|
method: "GET",
|
||||||
|
expected_status: null,
|
||||||
|
udp_payload: "",
|
||||||
|
expect_response: true,
|
||||||
|
verify_tls: false,
|
||||||
|
timeout_seconds: 2,
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 9000,
|
||||||
|
name: null,
|
||||||
|
folder: null,
|
||||||
|
upstream_port: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSession(key: string, fallback: string) {
|
||||||
|
if (typeof window === "undefined") return fallback;
|
||||||
|
return window.sessionStorage.getItem(key) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSession(key: string, value: string) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.sessionStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useControlPlane() {
|
||||||
|
const token = ref(getToken());
|
||||||
|
const username = ref("admin");
|
||||||
|
const password = ref("");
|
||||||
|
const error = ref("");
|
||||||
|
const notice = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const proxies = ref<ProxyDocument[]>([]);
|
||||||
|
const services = ref<DockerService[]>([]);
|
||||||
|
const options = ref<MitmOption[]>([]);
|
||||||
|
const examples = ref<AddonExample[]>([]);
|
||||||
|
const selectedPort = ref<number | null>(null);
|
||||||
|
const health = ref<HealthResult | null>(null);
|
||||||
|
const logs = ref("");
|
||||||
|
const optionSearch = ref("");
|
||||||
|
const exampleSearch = ref("");
|
||||||
|
const customExampleName = ref("");
|
||||||
|
const editorOpen = ref(readSession(editorStorageKey, "0") === "1");
|
||||||
|
const toolView = ref<ToolView>(
|
||||||
|
readSession(toolStorageKey, "options") as ToolView,
|
||||||
|
);
|
||||||
|
const selectedOptionName = ref("");
|
||||||
|
const selectedExampleId = ref("");
|
||||||
|
const draft = reactive<ProxyDraft>(createDefaultDraft());
|
||||||
|
|
||||||
|
const selectedProxy = computed(() =>
|
||||||
|
proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
const routeState = computed(() => {
|
||||||
|
const livePort =
|
||||||
|
selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null;
|
||||||
|
const dockerManaged = Boolean(draft.service.name && draft.service.folder);
|
||||||
|
const reverseManaged = draft.mode === "reverse" && !draft.mode_spec;
|
||||||
|
const remapped = livePort !== null && livePort !== draft.service.port;
|
||||||
|
const configuredTarget = `${draft.service.host}:${draft.service.port}`;
|
||||||
|
const liveTarget = `${draft.service.host}:${livePort ?? draft.service.port}`;
|
||||||
|
|
||||||
|
let startNotice = "Save stores configuration only. Start applies runtime routing.";
|
||||||
|
if (reverseManaged && dockerManaged) {
|
||||||
|
startNotice =
|
||||||
|
`Starting this reverse proxy will move the service from :${draft.service.port} ` +
|
||||||
|
`to a temporary upstream port and bind mitmdump on :${draft.proxy_port}.`;
|
||||||
|
} else if (draft.mode_spec) {
|
||||||
|
startNotice =
|
||||||
|
"This proxy uses a raw mode specification. Start will follow the custom mitmdump mode exactly as configured.";
|
||||||
|
} else if (draft.mode !== "reverse") {
|
||||||
|
startNotice =
|
||||||
|
"This mode does not use Docker port remapping. Start only launches the configured mitmdump listener.";
|
||||||
|
}
|
||||||
|
|
||||||
|
let liveNotice = "No runtime remap is active.";
|
||||||
|
if (remapped) {
|
||||||
|
liveNotice = `Live upstream is currently ${liveTarget}. Stop restores :${draft.service.port}.`;
|
||||||
|
} else if (reverseManaged && dockerManaged) {
|
||||||
|
liveNotice = "The original service port stays unchanged until Start is executed.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
configuredListen: `${draft.listen_host}:${draft.proxy_port}`,
|
||||||
|
configuredTarget,
|
||||||
|
liveTarget,
|
||||||
|
modeLabel: draft.mode_spec ?? `${draft.mode} / ${draft.protocol}`,
|
||||||
|
serviceLabel: draft.service.name ?? "manual upstream",
|
||||||
|
dockerManaged,
|
||||||
|
reverseManaged,
|
||||||
|
remapped,
|
||||||
|
livePort,
|
||||||
|
startNotice,
|
||||||
|
liveNotice,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredOptions = computed(() => {
|
||||||
|
const query = optionSearch.value.toLowerCase().trim();
|
||||||
|
const available = options.value.filter((option) => !option.reserved);
|
||||||
|
const filtered = available
|
||||||
|
.filter((option) => {
|
||||||
|
if (!query) return true;
|
||||||
|
return `${option.name} ${option.description}`.toLowerCase().includes(query);
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return filtered.slice(0, query ? 80 : 36);
|
||||||
|
});
|
||||||
|
|
||||||
|
const optionCount = computed(
|
||||||
|
() => options.value.filter((option) => !option.reserved).length,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedOption = computed(
|
||||||
|
() =>
|
||||||
|
filteredOptions.value.find((option) => option.name === selectedOptionName.value) ??
|
||||||
|
filteredOptions.value[0] ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredExamples = computed(() => {
|
||||||
|
const query = exampleSearch.value.toLowerCase().trim();
|
||||||
|
const filtered = examples.value.filter((example) => {
|
||||||
|
if (!query) return true;
|
||||||
|
return `${example.name} ${example.category} ${example.description}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query);
|
||||||
|
});
|
||||||
|
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return query ? sorted : sorted.slice(0, 24);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedExample = computed(
|
||||||
|
() =>
|
||||||
|
filteredExamples.value.find((example) => example.id === selectedExampleId.value) ??
|
||||||
|
filteredExamples.value[0] ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeOptions = computed(() =>
|
||||||
|
Object.entries(draft.mitm_options)
|
||||||
|
.map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value: Array.isArray(value) ? value.join(", ") : String(value ?? ""),
|
||||||
|
option: options.value.find((item) => item.name === name),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const codeLineCount = computed(() => draft.code.split("\n").length);
|
||||||
|
|
||||||
|
watch(selectedProxy, (proxy) => {
|
||||||
|
if (!proxy) return;
|
||||||
|
Object.assign(draft, structuredClone(proxy));
|
||||||
|
health.value = null;
|
||||||
|
logs.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(editorOpen, (value) => {
|
||||||
|
writeSession(editorStorageKey, value ? "1" : "0");
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(toolView, (value) => {
|
||||||
|
writeSession(toolStorageKey, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(filteredOptions, (items) => {
|
||||||
|
if (!items.some((item) => item.name === selectedOptionName.value)) {
|
||||||
|
selectedOptionName.value = items[0]?.name ?? "";
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(filteredExamples, (items) => {
|
||||||
|
if (!items.some((item) => item.id === selectedExampleId.value)) {
|
||||||
|
selectedExampleId.value = items[0]?.id ?? "";
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
async function signIn() {
|
||||||
|
await run(async () => {
|
||||||
|
await login(username.value, password.value);
|
||||||
|
token.value = getToken();
|
||||||
|
password.value = "";
|
||||||
|
await refreshAll();
|
||||||
|
}, "Logged in");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
await logout();
|
||||||
|
token.value = null;
|
||||||
|
proxies.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await run(async () => {
|
||||||
|
const [proxyList, serviceList, optionList, exampleList] = await Promise.all([
|
||||||
|
api.proxies(),
|
||||||
|
api.services(),
|
||||||
|
api.options(),
|
||||||
|
api.examples(),
|
||||||
|
]);
|
||||||
|
proxies.value = proxyList;
|
||||||
|
services.value = serviceList;
|
||||||
|
options.value = optionList;
|
||||||
|
examples.value = exampleList;
|
||||||
|
if (!selectedPort.value && proxyList[0]) {
|
||||||
|
selectedPort.value = proxyList[0].proxy_port;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function newProxy() {
|
||||||
|
selectedPort.value = null;
|
||||||
|
health.value = null;
|
||||||
|
logs.value = "";
|
||||||
|
Object.assign(
|
||||||
|
draft,
|
||||||
|
createDefaultDraft(nextPort(), examples.value[0]?.code ?? draft.code),
|
||||||
|
);
|
||||||
|
notice.value = "New proxy draft created. Save stores configuration only.";
|
||||||
|
toolView.value = "options";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProxy() {
|
||||||
|
await run(async () => {
|
||||||
|
const payload = cleanDraft();
|
||||||
|
const saved = selectedProxy.value
|
||||||
|
? await api.updateProxy(selectedProxy.value.proxy_port, payload)
|
||||||
|
: await api.createProxy(payload);
|
||||||
|
await refreshAll();
|
||||||
|
selectedPort.value = saved.proxy_port;
|
||||||
|
}, "Proxy saved");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProxy() {
|
||||||
|
const proxy = selectedProxy.value;
|
||||||
|
if (!proxy) return;
|
||||||
|
await run(async () => {
|
||||||
|
await api.deleteProxy(proxy.proxy_port);
|
||||||
|
selectedPort.value = null;
|
||||||
|
await refreshAll();
|
||||||
|
}, "Proxy deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runtimeAction(kind: RuntimeAction) {
|
||||||
|
const proxy = selectedProxy.value;
|
||||||
|
if (!proxy) return;
|
||||||
|
await run(async () => {
|
||||||
|
const port = proxy.proxy_port;
|
||||||
|
const updated =
|
||||||
|
kind === "start"
|
||||||
|
? await api.start(port)
|
||||||
|
: kind === "stop"
|
||||||
|
? await api.stop(port)
|
||||||
|
: await api.restart(port);
|
||||||
|
replaceProxy(updated);
|
||||||
|
selectedPort.value = updated.proxy_port;
|
||||||
|
}, `Proxy ${kind} requested`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkHealth() {
|
||||||
|
const proxy = selectedProxy.value;
|
||||||
|
if (!proxy) return;
|
||||||
|
await run(async () => {
|
||||||
|
health.value = await api.health(proxy.proxy_port);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
const proxy = selectedProxy.value;
|
||||||
|
if (!proxy) return;
|
||||||
|
await run(async () => {
|
||||||
|
logs.value = (await api.logs(proxy.proxy_port)).log;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCurrentAsExample() {
|
||||||
|
const name = customExampleName.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
error.value = "Custom example name is required";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id =
|
||||||
|
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") ||
|
||||||
|
`example-${Date.now()}`;
|
||||||
|
await run(async () => {
|
||||||
|
await api.createExample({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description: `Captured from ${draft.mode} ${draft.protocol} proxy ${draft.name}.`,
|
||||||
|
category: "custom",
|
||||||
|
code: draft.code,
|
||||||
|
source_url: null,
|
||||||
|
built_in: false,
|
||||||
|
});
|
||||||
|
examples.value = await api.examples();
|
||||||
|
customExampleName.value = "";
|
||||||
|
selectedExampleId.value = id;
|
||||||
|
}, "Custom example saved");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeExample(id: string) {
|
||||||
|
await run(async () => {
|
||||||
|
await api.deleteExample(id);
|
||||||
|
examples.value = await api.examples();
|
||||||
|
}, "Custom example deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyService(service: DockerService, port: number) {
|
||||||
|
draft.service.name = service.name;
|
||||||
|
draft.service.folder = service.folder;
|
||||||
|
draft.service.port = port;
|
||||||
|
draft.service.host = "127.0.0.1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyExample(example: AddonExample) {
|
||||||
|
selectedExampleId.value = example.id;
|
||||||
|
draft.code = example.code;
|
||||||
|
notice.value = editorOpen.value
|
||||||
|
? `Loaded example: ${example.name}`
|
||||||
|
: `Loaded example: ${example.name}. Open the editor to review or modify the code.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectExample(exampleId: string) {
|
||||||
|
selectedExampleId.value = exampleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOption(option: MitmOption) {
|
||||||
|
selectedOptionName.value = option.name;
|
||||||
|
if (!(option.name in draft.mitm_options)) {
|
||||||
|
draft.mitm_options[option.name] = defaultFor(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectOption(optionName: string) {
|
||||||
|
selectedOptionName.value = optionName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOption(name: string) {
|
||||||
|
delete draft.mitm_options[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOption(name: string, value: string, option?: MitmOption) {
|
||||||
|
if (option?.type === "bool") {
|
||||||
|
draft.mitm_options[name] = value === "true";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (option?.type === "int" || option?.type === "optional int") {
|
||||||
|
draft.mitm_options[name] = value === "" ? null : Number(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (option?.type === "sequence of str") {
|
||||||
|
draft.mitm_options[name] = value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draft.mitm_options[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEditor(force?: boolean) {
|
||||||
|
editorOpen.value = typeof force === "boolean" ? force : !editorOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setToolView(view: ToolView) {
|
||||||
|
toolView.value = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanDraft(): ProxyDraft {
|
||||||
|
return JSON.parse(
|
||||||
|
JSON.stringify({
|
||||||
|
proxy_port: draft.proxy_port,
|
||||||
|
name: draft.name,
|
||||||
|
code: draft.code,
|
||||||
|
mode: draft.mode,
|
||||||
|
mode_spec: draft.mode_spec || null,
|
||||||
|
protocol: draft.protocol,
|
||||||
|
listen_host: draft.listen_host,
|
||||||
|
mitm_options: draft.mitm_options,
|
||||||
|
healthcheck: {
|
||||||
|
...draft.healthcheck,
|
||||||
|
expected_status: draft.healthcheck.expected_status || null,
|
||||||
|
protocol: draft.healthcheck.protocol || null,
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
host: draft.service.host,
|
||||||
|
port: draft.service.port,
|
||||||
|
name: draft.service.name || null,
|
||||||
|
folder: draft.service.folder || null,
|
||||||
|
upstream_port: draft.service.upstream_port || null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
) as ProxyDraft;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceProxy(proxy: ProxyDocument) {
|
||||||
|
const index = proxies.value.findIndex(
|
||||||
|
(item) => item.proxy_port === proxy.proxy_port,
|
||||||
|
);
|
||||||
|
if (index === -1) proxies.value.push(proxy);
|
||||||
|
else proxies.value[index] = proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPort() {
|
||||||
|
const used = new Set(proxies.value.map((proxy) => proxy.proxy_port));
|
||||||
|
let port = 8080;
|
||||||
|
while (used.has(port)) port += 1;
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultFor(option: MitmOption) {
|
||||||
|
if (option.default !== null && option.default !== undefined) return option.default;
|
||||||
|
if (option.type === "bool") return false;
|
||||||
|
if (option.type === "int" || option.type === "optional int") return 0;
|
||||||
|
if (option.type === "sequence of str") return [];
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(task: () => Promise<void>, success?: string) {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
notice.value = "";
|
||||||
|
try {
|
||||||
|
await task();
|
||||||
|
if (success) notice.value = success;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyRefs({
|
||||||
|
activeOptions,
|
||||||
|
applyExample,
|
||||||
|
applyService,
|
||||||
|
checkHealth,
|
||||||
|
codeLineCount,
|
||||||
|
customExampleName,
|
||||||
|
draft,
|
||||||
|
editorOpen,
|
||||||
|
error,
|
||||||
|
exampleSearch,
|
||||||
|
filteredExamples,
|
||||||
|
filteredOptions,
|
||||||
|
health,
|
||||||
|
loadLogs,
|
||||||
|
loading,
|
||||||
|
logs,
|
||||||
|
newProxy,
|
||||||
|
notice,
|
||||||
|
optionCount,
|
||||||
|
optionSearch,
|
||||||
|
password,
|
||||||
|
proxies,
|
||||||
|
refreshAll,
|
||||||
|
removeExample,
|
||||||
|
removeOption,
|
||||||
|
removeProxy,
|
||||||
|
routeState,
|
||||||
|
runtimeAction,
|
||||||
|
saveCurrentAsExample,
|
||||||
|
saveProxy,
|
||||||
|
selectExample,
|
||||||
|
selectedExample,
|
||||||
|
selectedOption,
|
||||||
|
selectedPort,
|
||||||
|
selectedProxy,
|
||||||
|
selectOption,
|
||||||
|
services,
|
||||||
|
setOption,
|
||||||
|
setToolView,
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
toggleEditor,
|
||||||
|
token,
|
||||||
|
toolView,
|
||||||
|
updateOption,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ControlPlane = ReturnType<typeof useControlPlane>;
|
||||||
|
|
||||||
|
export const controlPlaneKey: InjectionKey<ControlPlane> = Symbol("control-plane");
|
||||||
|
|
||||||
|
export function useControlPlaneContext() {
|
||||||
|
const context = inject(controlPlaneKey);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("Control plane context is not available");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
LSPClient,
|
||||||
|
languageServerExtensions,
|
||||||
|
type Transport,
|
||||||
|
} from "@codemirror/lsp-client";
|
||||||
|
|
||||||
|
import { buildSocketUrl } from "../api";
|
||||||
|
import type { PythonEditorSession } from "../types";
|
||||||
|
|
||||||
|
export type PythonLspConnection = {
|
||||||
|
client: LSPClient;
|
||||||
|
socket: WebSocket;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function connectPythonLsp(
|
||||||
|
session: PythonEditorSession,
|
||||||
|
): Promise<PythonLspConnection> {
|
||||||
|
const url = buildSocketUrl("/proxy/editor/python/ws");
|
||||||
|
const socket = new WebSocket(url);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let handlers: Array<(value: string) => void> = [];
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
const value =
|
||||||
|
typeof event.data === "string" ? event.data : String(event.data ?? "");
|
||||||
|
for (const handler of handlers) handler(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
const transport: Transport = {
|
||||||
|
send(message) {
|
||||||
|
socket.send(message);
|
||||||
|
},
|
||||||
|
subscribe(handler) {
|
||||||
|
handlers.push(handler);
|
||||||
|
},
|
||||||
|
unsubscribe(handler) {
|
||||||
|
handlers = handlers.filter((value) => value !== handler);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new LSPClient({
|
||||||
|
rootUri: session.root_uri,
|
||||||
|
extensions: languageServerExtensions(),
|
||||||
|
}).connect(transport);
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
resolve({ client, socket });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("error", () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
reject(new Error("Unable to connect to the Python language server"));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", (event) => {
|
||||||
|
if (settled || socket.readyState !== WebSocket.CLOSED) return;
|
||||||
|
settled = true;
|
||||||
|
reject(new Error(event.reason || "Python language server connection closed"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*?worker" {
|
||||||
|
const workerConstructor: {
|
||||||
|
new (): Worker;
|
||||||
|
};
|
||||||
|
export default workerConstructor;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
createApp(App).mount("#app");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
|||||||
|
export type ServiceEndpoint = {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
name?: string | null;
|
||||||
|
folder?: string | null;
|
||||||
|
upstream_port?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HealthCheck = {
|
||||||
|
enabled: boolean;
|
||||||
|
protocol?: string | null;
|
||||||
|
path: string;
|
||||||
|
method: string;
|
||||||
|
expected_status?: number | null;
|
||||||
|
udp_payload: string;
|
||||||
|
expect_response: boolean;
|
||||||
|
verify_tls: boolean;
|
||||||
|
timeout_seconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProxyDocument = {
|
||||||
|
proxy_port: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
mode: string;
|
||||||
|
mode_spec?: string | null;
|
||||||
|
protocol: string;
|
||||||
|
listen_host: string;
|
||||||
|
mitm_options: Record<string, unknown>;
|
||||||
|
healthcheck: HealthCheck;
|
||||||
|
status: string;
|
||||||
|
pid?: number | null;
|
||||||
|
script_path?: string | null;
|
||||||
|
config_path?: string | null;
|
||||||
|
log_path?: string | null;
|
||||||
|
last_error?: string | null;
|
||||||
|
service: ServiceEndpoint;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DockerService = {
|
||||||
|
name: string;
|
||||||
|
ports: number[];
|
||||||
|
folder: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MitmOption = {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
default: unknown;
|
||||||
|
description: string;
|
||||||
|
choices: string[];
|
||||||
|
reserved: boolean;
|
||||||
|
docs_url?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddonExample = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
code: string;
|
||||||
|
source_url?: string | null;
|
||||||
|
built_in: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HealthResult = {
|
||||||
|
enabled: boolean;
|
||||||
|
overall: string;
|
||||||
|
checks: Array<{
|
||||||
|
target: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
protocol: string;
|
||||||
|
healthy: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PythonEditorSession = {
|
||||||
|
root_uri: string;
|
||||||
|
file_uri: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProxyDraft = Omit<
|
||||||
|
ProxyDocument,
|
||||||
|
"status" | "pid" | "script_path" | "config_path" | "log_path" | "last_error"
|
||||||
|
>;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
build: {
|
||||||
|
chunkSizeWarningLimit: 3000,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://127.0.0.1:3671",
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "theplug",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev:backend": "cd backend && PYTHONPATH=app uv run fastapi dev app/main.py",
|
||||||
|
"dev:frontend": "cd frontend && npm run dev",
|
||||||
|
"test:backend": "cd backend && uv run pytest",
|
||||||
|
"lint:backend": "cd backend && uv run ruff check .",
|
||||||
|
"format:backend": "cd backend && uv run ruff format .",
|
||||||
|
"build:frontend": "cd frontend && npm run build",
|
||||||
|
"test": "npm run lint:backend && npm run test:backend && npm run build:frontend",
|
||||||
|
"compose:up": "./setup.sh",
|
||||||
|
"compose:down": "docker compose --env-file backend/.env -f docker-compose.yml down"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,121 +6,81 @@ GREEN='\033[0;32m'
|
|||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||||
die() { echo -e "${RED}[✗] FATAL:${NC} $*" >&2; exit 1; }
|
die() { echo -e "${RED}[x] FATAL:${NC} $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ENV_FILE="$ROOT_DIR/backend/.env"
|
||||||
|
|
||||||
|
compose() {
|
||||||
|
docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.yml" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_env() {
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "backend/.env not found, creating it from backend/.env.example"
|
||||||
|
cp "$ROOT_DIR/backend/.env.example" "$ENV_FILE"
|
||||||
|
|
||||||
|
local mongo_pass jwt_secret auth_pass
|
||||||
|
mongo_pass=$(openssl rand -hex 8)
|
||||||
|
jwt_secret=$(openssl rand -hex 20)
|
||||||
|
auth_pass=$(openssl rand -hex 10)
|
||||||
|
|
||||||
|
sed -i "s|^MONGO_INITDB_ROOT_PASSWORD=.*|MONGO_INITDB_ROOT_PASSWORD=$mongo_pass|" "$ENV_FILE"
|
||||||
|
sed -i "s|^MONGODB_URI=.*|MONGODB_URI=mongodb://theplug:$mongo_pass@localhost:27017/theplug?authSource=admin|" "$ENV_FILE"
|
||||||
|
sed -i "s|^JWT_SECRET_KEY=.*|JWT_SECRET_KEY=$jwt_secret|" "$ENV_FILE"
|
||||||
|
sed -i "s|^AUTH_PASSWORD=.*|AUTH_PASSWORD=$auth_pass|" "$ENV_FILE"
|
||||||
|
|
||||||
|
info "Generated credentials in $ENV_FILE"
|
||||||
|
info "AUTH_USERNAME: admin"
|
||||||
|
info "AUTH_PASSWORD: $auth_pass"
|
||||||
|
}
|
||||||
|
|
||||||
install_service() {
|
install_service() {
|
||||||
local uv_bin
|
|
||||||
uv_bin=$(command -v uv)
|
|
||||||
local api_port
|
|
||||||
api_port=$(grep -E '^API_PORT=' .env 2>/dev/null | cut -d '=' -f2)
|
|
||||||
api_port=${api_port:-3671}
|
|
||||||
|
|
||||||
mkdir -p ~/.config/systemd/user
|
mkdir -p ~/.config/systemd/user
|
||||||
cat > ~/.config/systemd/user/theplug.service <<EOF
|
cat > ~/.config/systemd/user/theplug.service <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=thePlug service
|
Description=thePlug stack
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
WorkingDirectory=/srv/theplug
|
Type=oneshot
|
||||||
EnvironmentFile=/srv/theplug/.env
|
RemainAfterExit=yes
|
||||||
Environment=PYTHONPATH=app
|
WorkingDirectory=$ROOT_DIR
|
||||||
ExecStartPre=/usr/bin/docker compose -f /srv/theplug/compose.yml up -d redis mongodb
|
ExecStart=/usr/bin/docker compose --env-file $ENV_FILE -f $ROOT_DIR/docker-compose.yml up -d
|
||||||
ExecStart=$uv_bin run uvicorn app.main:app --host 0.0.0.0 --port $api_port
|
ExecStop=/usr/bin/docker compose --env-file $ENV_FILE -f $ROOT_DIR/docker-compose.yml down
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
EOF
|
EOF
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable theplug
|
systemctl --user enable theplug
|
||||||
info "systemd user service installed (port $api_port)"
|
info "systemd user service installed"
|
||||||
}
|
}
|
||||||
|
|
||||||
start_services() {
|
start_stack() {
|
||||||
info "Starting Redis and MongoDB..."
|
ensure_env
|
||||||
docker compose up -d redis mongodb || die "docker compose failed — are Redis/MongoDB defined in compose.yml?"
|
info "Building and starting backend, frontend, MongoDB and Redis..."
|
||||||
info "Starting thePlug via systemctl..."
|
compose up -d --build
|
||||||
|
info "Backend: http://localhost:$(grep -E '^API_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 3671)"
|
||||||
|
info "Frontend: http://localhost:$(grep -E '^FRONTEND_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 5173)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "service" ]]; then
|
||||||
|
ensure_env
|
||||||
|
install_service
|
||||||
systemctl --user start theplug
|
systemctl --user start theplug
|
||||||
warn "Use 'systemctl --user status theplug' to check / 'journalctl --user -u theplug -f' to follow logs"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 1. ensure we're in thePlug ────────────────────────────────────────────────
|
|
||||||
if [[ -d "/srv/theplug" ]]; then
|
|
||||||
info "thePlug già presente — avvio diretto..."
|
|
||||||
cd /srv/theplug
|
|
||||||
start_services
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd /srv
|
if [[ -d "/srv/theplug" && "$ROOT_DIR" != "/srv/theplug" ]]; then
|
||||||
git clone https://git.reloia.uk/reloia/thePlug theplug || die "git clone failed"
|
info "thePlug found in /srv/theplug, switching there"
|
||||||
cd theplug
|
cd /srv/theplug
|
||||||
chmod +x ./setup.sh
|
exec ./setup.sh "$@"
|
||||||
PLUG_DIR="$(pwd)"
|
|
||||||
|
|
||||||
if [[ ! -L /usr/bin/theplug ]]; then
|
|
||||||
info "Creating symlink /usr/bin/theplug..."
|
|
||||||
sudo ln -sf "$PLUG_DIR/setup.sh" /usr/bin/theplug
|
|
||||||
sudo chmod +x /usr/bin/theplug
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2 env
|
start_stack
|
||||||
if [[ ! -f .env ]]; then
|
|
||||||
warn ".env not found — copyinh from .env.example with random stuff..."
|
|
||||||
cp .env.example .env || die ".env.example not found (impossibile)"
|
|
||||||
|
|
||||||
MONGO_PASS=$(openssl rand -hex 8)
|
|
||||||
JWT_SECRET=$(openssl rand -hex 20)
|
|
||||||
AUTH_PASS=$(openssl rand -hex 10)
|
|
||||||
|
|
||||||
sed -i "s|^MONGO_INITDB_ROOT_PASSWORD=.*|MONGO_INITDB_ROOT_PASSWORD=$MONGO_PASS|" .env
|
|
||||||
sed -i "s|^MONGODB_URI=.*|MONGODB_URI=mongodb://theplug:$MONGO_PASS@localhost:27017/theplug?authSource=admin|" .env
|
|
||||||
sed -i "s|^JWT_SECRET_KEY=.*|JWT_SECRET_KEY=$JWT_SECRET|" .env
|
|
||||||
sed -i "s|^AUTH_PASSWORD=.*|AUTH_PASSWORD=$AUTH_PASS|" .env
|
|
||||||
|
|
||||||
info "Resetting docker containers..."
|
|
||||||
docker compose down -v 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 3. spin up dependencies ───────────────────────────────────────────────────
|
|
||||||
info "Starting Redis and MongoDB..."
|
|
||||||
docker compose up -d redis mongodb || die "docker compose failed — are Redis/MongoDB defined in compose.yml?"
|
|
||||||
|
|
||||||
# ── 4. ensure uv is available ─────────────────────────────────────────────────
|
|
||||||
if ! command -v uv &>/dev/null; then
|
|
||||||
warn "uv not found — installing..."
|
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh || die "uv installation failed"
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
command -v uv &>/dev/null || die "uv installed but still not in PATH — open a new shell and retry"
|
|
||||||
info "uv installed: $(uv --version)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 5. venv + deps ────────────────────────────────────────────────────────────
|
|
||||||
info "Pinning Python 3.14..."
|
|
||||||
uv python pin 3.14
|
|
||||||
|
|
||||||
info "Creating venv..."
|
|
||||||
uv venv
|
|
||||||
|
|
||||||
info "Syncing dependencies..."
|
|
||||||
uv sync
|
|
||||||
|
|
||||||
# ── 6. install service + start ────────────────────────────────────────────────
|
|
||||||
install_service
|
|
||||||
systemctl --user start theplug
|
|
||||||
|
|
||||||
echo -e ""
|
|
||||||
info "Automatically generated Secrets:"
|
|
||||||
info " MONGO_PASS: $MONGO_PASS"
|
|
||||||
info " JWT_SECRET: $JWT_SECRET"
|
|
||||||
info " AUTH_PASS: $AUTH_PASS - questa è importante"
|
|
||||||
warn "Save them — these won't be showed again (non è vero basta che leggi $PLUG_DIR/.env)"
|
|
||||||
echo -e ""
|
|
||||||
|
|
||||||
info "Installed thePlug in $PLUG_DIR"
|
|
||||||
warn "Use 'systemctl --user status theplug' to check / 'journalctl --user -u theplug -f' to follow logs"
|
|
||||||
echo -e ""
|
|
||||||
|
|||||||
Reference in New Issue
Block a user