Compare commits
1
Commits
@@ -0,0 +1,5 @@
|
|||||||
|
API_PORT=3671
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD=pass
|
||||||
|
MONGODB_URL=mongodb://theplug:pass@mongodb:27017/theplug?authSource=admin
|
||||||
|
MONGODB_DB_NAME=theplug
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: Build and Push to Registry
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.reloia.uk
|
||||||
|
username: reloia
|
||||||
|
password: ${{ secrets.LOGIN_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and Push (Shell)
|
||||||
|
run: |
|
||||||
|
IMAGE_NAME="git.reloia.uk/reloia/theplug:latest"
|
||||||
|
|
||||||
|
echo "Building $IMAGE_NAME..."
|
||||||
|
docker build -t "$IMAGE_NAME" .
|
||||||
|
|
||||||
|
echo "Pushing $IMAGE_NAME..."
|
||||||
|
docker push "$IMAGE_NAME"
|
||||||
|
|
||||||
-15
@@ -1,17 +1,2 @@
|
|||||||
.idea
|
.idea
|
||||||
.venv/
|
|
||||||
.ruff_cache/
|
|
||||||
.vscode/
|
|
||||||
.env
|
.env
|
||||||
__pycache__/
|
|
||||||
.pytest_cache/
|
|
||||||
**/__pycache__/
|
|
||||||
**/.pytest_cache/
|
|
||||||
**/.ruff_cache/
|
|
||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
*.tsbuildinfo
|
|
||||||
backend/.venv/
|
|
||||||
frontend/node_modules/
|
|
||||||
frontend/dist/
|
|
||||||
playground/
|
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.14-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip install --no-cache-dir fastapi uvicorn
|
||||||
|
|
||||||
|
COPY app ./src
|
||||||
|
|
||||||
|
EXPOSE 3671
|
||||||
|
|
||||||
|
CMD ["fastapi", "run", "src/main.py", "--host", "0.0.0.0", "--port", "3671"]
|
||||||
@@ -1,167 +1,34 @@
|
|||||||
# thePlug
|
# thePlug - plugger/proxy Barese
|
||||||
|
|
||||||
Web UI per gestire proxy `mitmdump` davanti a servizi esposti via Docker
|
icona di una presa della spina idk
|
||||||
Compose.
|
|
||||||
|
|
||||||
## Struttura
|
## Architettura
|
||||||
|
|
||||||
```text
|
```
|
||||||
backend/ FastAPI, MongoDB, mitmdump manager
|
FastAPI + MongoDB + Redis
|
||||||
frontend/ Vue 3, Vite, CodeMirror + Python LSP
|
|
||||||
playground/ challenge/service compose locali
|
|
||||||
templates/ template proxy YAML file-backed
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup iniziale
|
### Setup Dev
|
||||||
|
|
||||||
|
imposta le variabili d'ambiente
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./setup.sh
|
cp .env.example .env
|
||||||
|
# modifica le variabili
|
||||||
|
nano .env
|
||||||
```
|
```
|
||||||
|
|
||||||
`setup.sh` fa bootstrap della macchina/progetto:
|
avvia i servizi e il server locale
|
||||||
- installa `uv` se manca
|
|
||||||
- installa Python `3.14` via `uv`
|
|
||||||
- crea `backend/.env` se manca
|
|
||||||
- installa dipendenze backend e frontend
|
|
||||||
- builda il frontend
|
|
||||||
- prepara MongoDB e l'immagine Docker del frontend
|
|
||||||
|
|
||||||
## Runtime reale / vulnbox
|
|
||||||
|
|
||||||
Questi sono i comandi pensati per l’ambiente reale di gara.
|
|
||||||
|
|
||||||
### MongoDB
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./run.sh mongo up
|
docker compose up -d mongodb redis
|
||||||
./run.sh mongo status
|
|
||||||
./run.sh mongo logs
|
|
||||||
./run.sh mongo down
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./run.sh backend
|
fastapi dev app/main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Docker
|
accedi a FastAPI su: [FastAPI docs](http://localhost:3671/docs)
|
||||||
|
|
||||||
```bash
|
## Documentazione per Sviluppo
|
||||||
./run.sh frontend up
|
|
||||||
./run.sh frontend status
|
|
||||||
./run.sh frontend logs
|
|
||||||
./run.sh frontend down
|
|
||||||
```
|
|
||||||
|
|
||||||
Il frontend in container fa proxy API verso il backend in esecuzione sulla
|
[MitMProxy examples](https://docs.mitmproxy.org/stable/addons/examples/)
|
||||||
macchina host.
|
|
||||||
|
|
||||||
## Workflow development
|
|
||||||
|
|
||||||
Questi comandi sono pensati per sviluppo locale, test e playground.
|
|
||||||
|
|
||||||
### MongoDB
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./dev.sh mongo up
|
|
||||||
./dev.sh mongo status
|
|
||||||
./dev.sh mongo logs
|
|
||||||
./dev.sh mongo down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backend dev
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./dev.sh backend
|
|
||||||
```
|
|
||||||
|
|
||||||
Docs FastAPI in dev: `http://localhost:3671/docs`.
|
|
||||||
|
|
||||||
### Frontend dev
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./dev.sh frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
Questo avvia Vite normalmente sulla macchina host.
|
|
||||||
|
|
||||||
### Playground / servizi locali
|
|
||||||
|
|
||||||
Per vedere i challenge/service disponibili:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./dev.sh services list
|
|
||||||
```
|
|
||||||
|
|
||||||
Per avviarli o fermarli:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./dev.sh services up GabibbiTowers
|
|
||||||
./dev.sh services down GabibbiTowers
|
|
||||||
|
|
||||||
./dev.sh services up all
|
|
||||||
./dev.sh services status all
|
|
||||||
./dev.sh services down all
|
|
||||||
```
|
|
||||||
|
|
||||||
`dev.sh` scopre automaticamente i compose da `playground/*/docker-compose.yml`.
|
|
||||||
|
|
||||||
## Flussi consigliati
|
|
||||||
|
|
||||||
Runtime reale:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./setup.sh
|
|
||||||
./run.sh mongo up
|
|
||||||
./run.sh backend
|
|
||||||
./run.sh frontend up
|
|
||||||
```
|
|
||||||
|
|
||||||
Sviluppo locale:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./setup.sh
|
|
||||||
./dev.sh mongo up
|
|
||||||
./dev.sh backend
|
|
||||||
./dev.sh frontend
|
|
||||||
./dev.sh services up <challenge>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test
|
|
||||||
|
|
||||||
Backend:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
uv run ruff check .
|
|
||||||
uv run pytest
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## API principali
|
|
||||||
|
|
||||||
- `POST /auth/login`
|
|
||||||
- `POST /auth/logout`
|
|
||||||
- `GET /proxy`
|
|
||||||
- `POST /proxy`
|
|
||||||
- `POST /proxy/{port}/start`
|
|
||||||
- `POST /proxy/{port}/stop`
|
|
||||||
- `POST /proxy/{port}/restart`
|
|
||||||
- `GET /proxy/{port}/health`
|
|
||||||
- `GET /proxy/{port}/logs`
|
|
||||||
- `WS /proxy/{port}/logs/ws`
|
|
||||||
- `GET /proxy/mitmproxy/options`
|
|
||||||
- `GET /proxy/mitmproxy/templates`
|
|
||||||
|
|
||||||
## Riferimenti
|
|
||||||
|
|
||||||
- Mitmproxy addons: https://docs.mitmproxy.org/stable/addons/examples/
|
|
||||||
- Mitmproxy modes: https://docs.mitmproxy.org/stable/concepts/modes/
|
|
||||||
- Mitmproxy options: https://docs.mitmproxy.org/stable/concepts/options/
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
- fixare i test di unità, di integrazione e aggiungere test reali di benchmark.
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from models.proxy import ProxyCreateInput, ProxyUpdateInput
|
||||||
|
from utils.proxy.manager import (
|
||||||
|
create_proxy,
|
||||||
|
delete_proxy,
|
||||||
|
get_proxy_by_port,
|
||||||
|
list_proxies as list_proxy_documents,
|
||||||
|
update_proxy,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/proxy",
|
||||||
|
tags=["Proxy"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return await create_proxy(
|
||||||
|
proxy_port=payload.proxy_port,
|
||||||
|
name=payload.name,
|
||||||
|
service={"address": payload.service.address, "port": payload.service.port},
|
||||||
|
code=payload.code,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{proxy_port}/")
|
||||||
|
async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput):
|
||||||
|
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")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore")
|
||||||
|
|
||||||
|
MONGODB_URI: str
|
||||||
|
MONGODB_DB_NAME: str
|
||||||
|
REDIS_URL: str
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||||
|
|
||||||
from app.core.config import settings
|
from core.config import settings
|
||||||
|
|
||||||
|
|
||||||
class _MongoDB:
|
class _MongoDB:
|
||||||
client: AsyncIOMotorClient[dict[str, Any]] | None = None
|
client: AsyncIOMotorClient | None = None
|
||||||
|
|
||||||
def get_database(self) -> AsyncIOMotorDatabase[dict[str, Any]]:
|
def get_database(self) -> AsyncIOMotorDatabase:
|
||||||
if self.client is None:
|
if self.client is None:
|
||||||
raise RuntimeError("MongoDB client has not been initialized")
|
raise RuntimeError("MongoDB client has not been initialized")
|
||||||
return self.client[settings.MONGODB_DB_NAME]
|
return self.client[settings.MONGODB_DB_NAME]
|
||||||
|
|
||||||
|
|
||||||
mongodb = _MongoDB()
|
mongodb = _MongoDB()
|
||||||
|
|
||||||
|
|
||||||
@@ -28,8 +25,7 @@ async def close_mongodb() -> None:
|
|||||||
if client is not None:
|
if client is not None:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
async def get_database() -> AsyncIOMotorDatabase:
|
||||||
async def get_database() -> AsyncIOMotorDatabase[dict[str, Any]]:
|
|
||||||
return mongodb.get_database()
|
return mongodb.get_database()
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
_redis_pool: aioredis.Redis | None = None
|
||||||
|
|
||||||
|
async def init_redis() -> None:
|
||||||
|
global _redis_pool
|
||||||
|
_redis_pool = aioredis.from_url(
|
||||||
|
settings.REDIS_URL, encoding="utf-8", decode_responses=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_redis() -> None:
|
||||||
|
if _redis_pool:
|
||||||
|
await _redis_pool.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_redis() -> aioredis.Redis:
|
||||||
|
if _redis_pool is None:
|
||||||
|
raise RuntimeError("Redis pool has not been initialized")
|
||||||
|
return _redis_pool
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from api.proxy.router import router as proxy_router
|
||||||
|
from core.mongodb import close_mongodb, init_mongodb
|
||||||
|
from core.redis import close_redis, init_redis
|
||||||
|
from utils.proxy.manager import ensure_proxy_indexes
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
await init_mongodb()
|
||||||
|
await init_redis()
|
||||||
|
await ensure_proxy_indexes()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await close_redis()
|
||||||
|
await close_mongodb()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="thePlugAPI",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(proxy_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {"Hello": "World!"}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from models.service import ServiceEndpointDocument
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceEndpointDocument(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
address: str = Field(..., description="Upstream service address")
|
||||||
|
port: int = Field(..., description="Upstream service port")
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorCollection
|
||||||
|
from pymongo import ReturnDocument
|
||||||
|
|
||||||
|
from core.mongodb import get_database
|
||||||
|
from models.proxy import ProxyDocument, ProxyUpdateInput
|
||||||
|
from models.service import ServiceEndpointDocument
|
||||||
|
from utils.proxy.types import ServiceConfig
|
||||||
|
|
||||||
|
COLLECTION_NAME = "proxies"
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
async def _collection() -> AsyncIOMotorCollection[dict[str, Any]]:
|
||||||
|
database = await get_database()
|
||||||
|
return database[COLLECTION_NAME]
|
||||||
|
|
||||||
|
|
||||||
|
def _deserialize_proxy(raw: Mapping[str, Any] | None) -> ProxyDocument | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
document = dict(raw)
|
||||||
|
document.pop("_id", None)
|
||||||
|
return ProxyDocument.model_validate(document)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_proxy_indexes() -> None:
|
||||||
|
collection = await _collection()
|
||||||
|
await collection.create_index("proxy_port", unique=True, name="uq_proxy_port")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_proxy(
|
||||||
|
proxy_port: int,
|
||||||
|
name: str,
|
||||||
|
service: ServiceConfig,
|
||||||
|
code: str,
|
||||||
|
) -> ProxyDocument:
|
||||||
|
now = _utcnow()
|
||||||
|
proxy = ProxyDocument(
|
||||||
|
proxy_port=proxy_port,
|
||||||
|
name=name,
|
||||||
|
code=code,
|
||||||
|
service=ServiceEndpointDocument(address=service["address"], port=service["port"]),
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
collection = await _collection()
|
||||||
|
await collection.insert_one(proxy.model_dump(exclude_none=True))
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
|
||||||
|
async def get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
||||||
|
collection = await _collection()
|
||||||
|
raw = await collection.find_one({"proxy_port": proxy_port})
|
||||||
|
return _deserialize_proxy(raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_proxies() -> list[ProxyDocument]:
|
||||||
|
collection = await _collection()
|
||||||
|
cursor = collection.find({}).sort("proxy_port", 1)
|
||||||
|
|
||||||
|
proxies: list[ProxyDocument] = []
|
||||||
|
async for raw in cursor:
|
||||||
|
proxy = _deserialize_proxy(raw)
|
||||||
|
if proxy is not None:
|
||||||
|
proxies.append(proxy)
|
||||||
|
return proxies
|
||||||
|
|
||||||
|
|
||||||
|
async def update_proxy(proxy_port: int, updates: ProxyUpdateInput) -> ProxyDocument | None:
|
||||||
|
payload = updates.model_dump(exclude_none=True)
|
||||||
|
if not payload:
|
||||||
|
return await get_proxy_by_port(proxy_port)
|
||||||
|
|
||||||
|
payload["updated_at"] = _utcnow()
|
||||||
|
|
||||||
|
collection = await _collection()
|
||||||
|
raw = await collection.find_one_and_update(
|
||||||
|
{"proxy_port": proxy_port},
|
||||||
|
{"$set": payload},
|
||||||
|
return_document=ReturnDocument.AFTER,
|
||||||
|
)
|
||||||
|
return _deserialize_proxy(raw)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_proxy(proxy_port: int) -> bool:
|
||||||
|
collection = await _collection()
|
||||||
|
result = await collection.delete_one({"proxy_port": proxy_port})
|
||||||
|
return result.deleted_count > 0
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceConfig(TypedDict):
|
||||||
|
address: str
|
||||||
|
port: int
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
API_PORT=3671
|
|
||||||
FRONTEND_PORT=5173
|
|
||||||
# Use a random value in real environments.
|
|
||||||
MONGO_INITDB_ROOT_PASSWORD=pass
|
|
||||||
MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin
|
|
||||||
MONGODB_DB_NAME=theplug
|
|
||||||
# Use a random value in real environments.
|
|
||||||
JWT_SECRET_KEY=change-me
|
|
||||||
JWT_ALGORITHM=HS256
|
|
||||||
JWT_EXPIRE_MINUTES=720
|
|
||||||
AUTH_USERNAME=admin
|
|
||||||
# Use a random value in real environments.
|
|
||||||
AUTH_PASSWORD=change-me
|
|
||||||
# Optional: override the directory whose immediate child folders are scanned
|
|
||||||
# for docker-compose files. Defaults to the current user's home directory.
|
|
||||||
SERVICE_SCAN_ROOT=
|
|
||||||
HEALTHCHECK_INTERVAL_SECONDS=15
|
|
||||||
HEALTH_HISTORY_TTL_SECONDS=86400
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from app.api.auth.router import router
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
||||||
|
|
||||||
from app.core.security import (
|
|
||||||
AuthContext,
|
|
||||||
create_access_token,
|
|
||||||
get_auth_context,
|
|
||||||
verify_password,
|
|
||||||
)
|
|
||||||
from app.models.auth import LoginInput, TokenResponse
|
|
||||||
from app.repositories.auth import get_user_by_username, revoke_token
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/auth",
|
|
||||||
tags=["Auth"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
|
||||||
async def login_handler(payload: LoginInput):
|
|
||||||
user = await get_user_by_username(payload.username)
|
|
||||||
if user is None or not verify_password(payload.password, user.password_hash):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid username or password",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
token = create_access_token(subject=user.username)
|
|
||||||
return TokenResponse(access_token=token)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def logout_handler(
|
|
||||||
auth_context: Annotated[AuthContext, Depends(get_auth_context)],
|
|
||||||
) -> Response:
|
|
||||||
expires_at = datetime.fromtimestamp(auth_context.payload.exp, tz=UTC)
|
|
||||||
if expires_at > datetime.now(UTC):
|
|
||||||
await revoke_token(jti=auth_context.payload.jti, expires_at=expires_at)
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
|
||||||
from starlette.websockets import WebSocketDisconnect
|
|
||||||
|
|
||||||
from app.core.security import (
|
|
||||||
InvalidTokenError,
|
|
||||||
authenticate_token,
|
|
||||||
require_authenticated_user,
|
|
||||||
)
|
|
||||||
from app.services.editor import python_lsp
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/proxy/editor/python", tags=["Editor"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/session", dependencies=[Depends(require_authenticated_user)])
|
|
||||||
async def python_editor_session():
|
|
||||||
if python_lsp.lsp_binary() is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="basedpyright-langserver is not installed",
|
|
||||||
)
|
|
||||||
|
|
||||||
session = python_lsp.get_session()
|
|
||||||
return {
|
|
||||||
"root_uri": session.root_uri,
|
|
||||||
"file_uri": session.file_uri,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def authenticate_editor_socket(websocket: WebSocket) -> None:
|
|
||||||
token = websocket.query_params.get("token")
|
|
||||||
if token is None:
|
|
||||||
authorization = websocket.headers.get("authorization", "")
|
|
||||||
scheme, _, value = authorization.partition(" ")
|
|
||||||
if scheme.lower() == "bearer" and value:
|
|
||||||
token = value
|
|
||||||
|
|
||||||
if not token:
|
|
||||||
raise InvalidTokenError
|
|
||||||
|
|
||||||
await authenticate_token(token)
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws")
|
|
||||||
async def python_editor_socket(websocket: WebSocket):
|
|
||||||
try:
|
|
||||||
await authenticate_editor_socket(websocket)
|
|
||||||
except InvalidTokenError:
|
|
||||||
await websocket.close(code=4401)
|
|
||||||
return
|
|
||||||
|
|
||||||
if python_lsp.lsp_binary() is None:
|
|
||||||
await websocket.close(code=1011, reason="basedpyright unavailable")
|
|
||||||
return
|
|
||||||
|
|
||||||
session = python_lsp.get_session()
|
|
||||||
await websocket.accept()
|
|
||||||
|
|
||||||
try:
|
|
||||||
await python_lsp.bridge(websocket, session)
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
return
|
|
||||||
except RuntimeError:
|
|
||||||
await websocket.close(code=1011, reason="language server failed")
|
|
||||||
@@ -1,504 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
|
|
||||||
from pymongo.errors import DuplicateKeyError
|
|
||||||
from starlette.websockets import WebSocketDisconnect
|
|
||||||
|
|
||||||
from app.api.proxy.socket_utils import (
|
|
||||||
authenticate_proxy_socket,
|
|
||||||
proxy_log_path,
|
|
||||||
read_log_snapshot,
|
|
||||||
serialize_health_snapshot,
|
|
||||||
snapshot_log_state,
|
|
||||||
stream_proxy_logs,
|
|
||||||
watch_socket_disconnect,
|
|
||||||
)
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.security import InvalidTokenError, require_authenticated_user
|
|
||||||
from app.integrations.docker import get_all_services_in_dir
|
|
||||||
from app.integrations.docker.discovery import service_scan_root
|
|
||||||
from app.models.health import HealthHistoryResponse
|
|
||||||
from app.models.proxy import ProxyCreateInput, ProxyUpdateInput
|
|
||||||
from app.models.template import ProxyTemplateCreateInput
|
|
||||||
from app.repositories.health import (
|
|
||||||
delete_health_history,
|
|
||||||
get_latest_health_sample,
|
|
||||||
list_health_history,
|
|
||||||
record_health_sample,
|
|
||||||
)
|
|
||||||
from app.repositories.proxy import (
|
|
||||||
create_proxy,
|
|
||||||
delete_proxy,
|
|
||||||
get_proxy_by_port,
|
|
||||||
update_proxy,
|
|
||||||
update_proxy_runtime,
|
|
||||||
)
|
|
||||||
from app.repositories.proxy import (
|
|
||||||
list_proxies as list_proxy_documents,
|
|
||||||
)
|
|
||||||
from app.repositories.templates import (
|
|
||||||
create_custom_template,
|
|
||||||
delete_custom_template,
|
|
||||||
list_custom_templates,
|
|
||||||
)
|
|
||||||
from app.services.mitmproxy.health import check_proxy
|
|
||||||
from app.services.mitmproxy.health_monitor import health_monitor
|
|
||||||
from app.services.mitmproxy.manager import MitmdumpError, manager
|
|
||||||
from app.services.mitmproxy.options import (
|
|
||||||
MitmOptionError,
|
|
||||||
load_options,
|
|
||||||
public_options,
|
|
||||||
validate_options,
|
|
||||||
)
|
|
||||||
from app.services.mitmproxy.runtime_validation import (
|
|
||||||
ReservedProxyPortError,
|
|
||||||
backend_port,
|
|
||||||
ensure_proxy_port_not_reserved,
|
|
||||||
)
|
|
||||||
from app.services.mitmproxy.templates import file_template_ids, list_file_templates
|
|
||||||
from app.services.mitmproxy.validator import (
|
|
||||||
ProxyCodeValidationError,
|
|
||||||
validate_proxy_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
SUPPORTED_MODES = {
|
|
||||||
"regular",
|
|
||||||
"local",
|
|
||||||
"wireguard",
|
|
||||||
"reverse",
|
|
||||||
"transparent",
|
|
||||||
"tun",
|
|
||||||
"upstream",
|
|
||||||
"socks5",
|
|
||||||
"dns",
|
|
||||||
}
|
|
||||||
SUPPORTED_PROTOCOLS = {
|
|
||||||
"http",
|
|
||||||
"https",
|
|
||||||
"tcp",
|
|
||||||
"tls",
|
|
||||||
"udp",
|
|
||||||
"dtls",
|
|
||||||
"dns",
|
|
||||||
"http3",
|
|
||||||
"quic",
|
|
||||||
}
|
|
||||||
DEFAULT_HEALTH_HISTORY_LIMIT = 40
|
|
||||||
MAX_HEALTH_HISTORY_LIMIT = 200
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/proxy",
|
|
||||||
tags=["Proxy"],
|
|
||||||
)
|
|
||||||
auth_dependencies = [Depends(require_authenticated_user)]
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_code_or_raise(code: str) -> None:
|
|
||||||
try:
|
|
||||||
await validate_proxy_code(code)
|
|
||||||
except ProxyCodeValidationError as error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=error.to_http_detail(),
|
|
||||||
) from error
|
|
||||||
|
|
||||||
|
|
||||||
def validate_mode(mode: str) -> None:
|
|
||||||
if mode not in SUPPORTED_MODES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=f"Unsupported proxy mode: {mode}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_mode_spec(mode_spec: str | None) -> None:
|
|
||||||
if mode_spec is None:
|
|
||||||
return
|
|
||||||
mode = mode_spec.split(":", 1)[0].split("@", 1)[0]
|
|
||||||
validate_mode(mode)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_protocol(protocol: str) -> None:
|
|
||||||
if protocol not in SUPPORTED_PROTOCOLS:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=f"Unsupported reverse proxy protocol: {protocol}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_mitm_options(options: dict[str, object]) -> None:
|
|
||||||
try:
|
|
||||||
await validate_options(options)
|
|
||||||
except MitmOptionError as error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=str(error),
|
|
||||||
) from error
|
|
||||||
|
|
||||||
|
|
||||||
def validate_proxy_port(proxy_port: int) -> None:
|
|
||||||
try:
|
|
||||||
ensure_proxy_port_not_reserved(proxy_port)
|
|
||||||
except ReservedProxyPortError as error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=str(error),
|
|
||||||
) from error
|
|
||||||
|
|
||||||
|
|
||||||
async def get_proxy_or_404(proxy_port: int):
|
|
||||||
proxy = await get_proxy_by_port(proxy_port)
|
|
||||||
if proxy is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Proxy not found",
|
|
||||||
)
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", dependencies=auth_dependencies)
|
|
||||||
async def list_proxies_handler():
|
|
||||||
return await list_proxy_documents()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/services", dependencies=auth_dependencies)
|
|
||||||
async def list_services(scan_root: str | None = None):
|
|
||||||
_ = settings
|
|
||||||
root = Path(scan_root).expanduser() if scan_root else service_scan_root()
|
|
||||||
services = get_all_services_in_dir(root)
|
|
||||||
return {
|
|
||||||
"scan_root": str(root),
|
|
||||||
"services": services,
|
|
||||||
"backend_port": backend_port(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/mitmproxy/options", dependencies=auth_dependencies)
|
|
||||||
async def list_mitmproxy_options():
|
|
||||||
return public_options(await load_options())
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/mitmproxy/templates", dependencies=auth_dependencies)
|
|
||||||
async def list_mitmproxy_templates():
|
|
||||||
return [*list_file_templates(), *await list_custom_templates()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/mitmproxy/templates",
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
dependencies=auth_dependencies,
|
|
||||||
)
|
|
||||||
async def create_mitmproxy_template(payload: ProxyTemplateCreateInput):
|
|
||||||
await _validate_code_or_raise(payload.code)
|
|
||||||
if payload.id in file_template_ids():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"Template already exists: {payload.id}",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
return await create_custom_template(payload)
|
|
||||||
except DuplicateKeyError as error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"Template already exists: {payload.id}",
|
|
||||||
) from error
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/mitmproxy/templates/{template_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=auth_dependencies,
|
|
||||||
)
|
|
||||||
async def delete_mitmproxy_template(template_id: str):
|
|
||||||
if template_id in file_template_ids():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Built-in templates cannot be deleted",
|
|
||||||
)
|
|
||||||
|
|
||||||
deleted = await delete_custom_template(template_id)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Template not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{proxy_port}", dependencies=auth_dependencies)
|
|
||||||
async def get_proxy(proxy_port: int):
|
|
||||||
return await get_proxy_or_404(proxy_port)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=status.HTTP_201_CREATED, dependencies=auth_dependencies)
|
|
||||||
async def create_proxy_handler(payload: ProxyCreateInput):
|
|
||||||
validate_proxy_port(payload.proxy_port)
|
|
||||||
existing_proxy = await get_proxy_by_port(payload.proxy_port)
|
|
||||||
if existing_proxy is not None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"Proxy already exists on port {payload.proxy_port}",
|
|
||||||
)
|
|
||||||
|
|
||||||
await _validate_code_or_raise(payload.code)
|
|
||||||
validate_mode(payload.mode)
|
|
||||||
validate_mode_spec(payload.mode_spec)
|
|
||||||
validate_protocol(payload.protocol)
|
|
||||||
await validate_mitm_options(payload.mitm_options)
|
|
||||||
|
|
||||||
return await create_proxy(payload)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{proxy_port}", dependencies=auth_dependencies)
|
|
||||||
async def edit_proxy(proxy_port: int, payload: ProxyUpdateInput):
|
|
||||||
if payload.code is not None:
|
|
||||||
await _validate_code_or_raise(payload.code)
|
|
||||||
if payload.mode is not None:
|
|
||||||
validate_mode(payload.mode)
|
|
||||||
if "mode_spec" in payload.model_fields_set:
|
|
||||||
validate_mode_spec(payload.mode_spec)
|
|
||||||
if payload.protocol is not None:
|
|
||||||
validate_protocol(payload.protocol)
|
|
||||||
if payload.mitm_options is not None:
|
|
||||||
await validate_mitm_options(payload.mitm_options)
|
|
||||||
|
|
||||||
updated_proxy = await update_proxy(proxy_port, payload)
|
|
||||||
if updated_proxy is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Proxy not found",
|
|
||||||
)
|
|
||||||
return updated_proxy
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{proxy_port}/start", dependencies=auth_dependencies)
|
|
||||||
async def start_proxy(proxy_port: int):
|
|
||||||
proxy = await get_proxy_or_404(proxy_port)
|
|
||||||
|
|
||||||
try:
|
|
||||||
runtime = await manager.start(proxy)
|
|
||||||
except MitmdumpError as error:
|
|
||||||
updated = await update_proxy_runtime(
|
|
||||||
proxy_port,
|
|
||||||
{"status": "error", "last_error": str(error)},
|
|
||||||
)
|
|
||||||
return updated or proxy
|
|
||||||
|
|
||||||
return await update_proxy_runtime(proxy_port, runtime)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{proxy_port}/stop", dependencies=auth_dependencies)
|
|
||||||
async def stop_proxy(proxy_port: int):
|
|
||||||
proxy = await get_proxy_or_404(proxy_port)
|
|
||||||
runtime = await manager.stop(proxy)
|
|
||||||
return await update_proxy_runtime(proxy_port, runtime)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{proxy_port}/restart", dependencies=auth_dependencies)
|
|
||||||
async def restart_proxy(proxy_port: int):
|
|
||||||
proxy = await get_proxy_or_404(proxy_port)
|
|
||||||
stopped = await manager.stop(proxy)
|
|
||||||
proxy = await update_proxy_runtime(proxy_port, stopped) or proxy
|
|
||||||
try:
|
|
||||||
runtime = await manager.start(proxy)
|
|
||||||
except MitmdumpError as error:
|
|
||||||
updated = await update_proxy_runtime(
|
|
||||||
proxy_port,
|
|
||||||
{"status": "error", "last_error": str(error)},
|
|
||||||
)
|
|
||||||
return updated or proxy
|
|
||||||
return await update_proxy_runtime(proxy_port, runtime)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{proxy_port}/status", dependencies=auth_dependencies)
|
|
||||||
async def proxy_status(proxy_port: int):
|
|
||||||
proxy = await get_proxy_or_404(proxy_port)
|
|
||||||
runtime_status = await manager.status(proxy)
|
|
||||||
if runtime_status != proxy.status:
|
|
||||||
values: dict[str, object] = {"status": runtime_status}
|
|
||||||
if runtime_status != "running":
|
|
||||||
values["pid"] = None
|
|
||||||
proxy = await update_proxy_runtime(proxy_port, values) or proxy
|
|
||||||
return {"proxy_port": proxy.proxy_port, "status": runtime_status, "pid": proxy.pid}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{proxy_port}/health", dependencies=auth_dependencies)
|
|
||||||
async def proxy_health(proxy_port: int):
|
|
||||||
proxy = await get_proxy_or_404(proxy_port)
|
|
||||||
snapshot = await check_proxy(proxy)
|
|
||||||
if proxy.healthcheck.enabled:
|
|
||||||
await record_health_sample(snapshot)
|
|
||||||
await health_monitor.publish(proxy.proxy_port, snapshot)
|
|
||||||
return serialize_health_snapshot(snapshot)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{proxy_port}/health/history", dependencies=auth_dependencies)
|
|
||||||
async def proxy_health_history(
|
|
||||||
proxy_port: int,
|
|
||||||
limit: int = DEFAULT_HEALTH_HISTORY_LIMIT,
|
|
||||||
):
|
|
||||||
proxy = await get_proxy_or_404(proxy_port)
|
|
||||||
limit = max(1, min(limit, MAX_HEALTH_HISTORY_LIMIT))
|
|
||||||
latest = await get_latest_health_sample(proxy.proxy_port)
|
|
||||||
history = await list_health_history(proxy.proxy_port, limit=limit)
|
|
||||||
response = HealthHistoryResponse(
|
|
||||||
proxy_port=proxy.proxy_port,
|
|
||||||
latest=latest,
|
|
||||||
history=history,
|
|
||||||
)
|
|
||||||
return response.model_dump(mode="json")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{proxy_port}/logs", dependencies=auth_dependencies)
|
|
||||||
async def proxy_logs(proxy_port: int):
|
|
||||||
proxy = await get_proxy_or_404(proxy_port)
|
|
||||||
path = proxy_log_path(proxy)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"proxy_port": proxy.proxy_port,
|
|
||||||
"log": await read_log_snapshot(path),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/{proxy_port}/logs/ws")
|
|
||||||
async def proxy_logs_socket(websocket: WebSocket, proxy_port: int):
|
|
||||||
disconnect_task: asyncio.Task[None] | None = None
|
|
||||||
stream_task: asyncio.Task[None] | None = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
await authenticate_proxy_socket(websocket)
|
|
||||||
except InvalidTokenError:
|
|
||||||
await websocket.close(code=4401)
|
|
||||||
return
|
|
||||||
|
|
||||||
proxy = await get_proxy_by_port(proxy_port)
|
|
||||||
if proxy is None:
|
|
||||||
await websocket.close(code=4404)
|
|
||||||
return
|
|
||||||
|
|
||||||
path = proxy_log_path(proxy)
|
|
||||||
initial_snapshot, offset, marker = await snapshot_log_state(path)
|
|
||||||
await websocket.accept()
|
|
||||||
await websocket.send_json({"type": "snapshot", "log": initial_snapshot})
|
|
||||||
|
|
||||||
disconnect_task = asyncio.create_task(watch_socket_disconnect(websocket))
|
|
||||||
stream_task = asyncio.create_task(
|
|
||||||
stream_proxy_logs(websocket, path, offset=offset, marker=marker)
|
|
||||||
)
|
|
||||||
|
|
||||||
done, pending = await asyncio.wait(
|
|
||||||
{disconnect_task, stream_task},
|
|
||||||
return_when=asyncio.FIRST_EXCEPTION,
|
|
||||||
)
|
|
||||||
|
|
||||||
for task in pending:
|
|
||||||
task.cancel()
|
|
||||||
if pending:
|
|
||||||
await asyncio.gather(*pending, return_exceptions=True)
|
|
||||||
|
|
||||||
for task in done:
|
|
||||||
try:
|
|
||||||
task.result()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
return
|
|
||||||
except RuntimeError:
|
|
||||||
await websocket.close(code=1011, reason="log stream failed")
|
|
||||||
return
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return
|
|
||||||
finally:
|
|
||||||
for task in (disconnect_task, stream_task):
|
|
||||||
if task is None or task.done():
|
|
||||||
continue
|
|
||||||
task.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/{proxy_port}/health/ws")
|
|
||||||
async def proxy_health_socket(websocket: WebSocket, proxy_port: int):
|
|
||||||
disconnect_task: asyncio.Task[None] | None = None
|
|
||||||
queue_task: asyncio.Task[None] | None = None
|
|
||||||
queue = None
|
|
||||||
|
|
||||||
async def forward_health() -> None:
|
|
||||||
assert queue is not None
|
|
||||||
while True:
|
|
||||||
snapshot = await queue.get()
|
|
||||||
await websocket.send_json(serialize_health_snapshot(snapshot))
|
|
||||||
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
await authenticate_proxy_socket(websocket)
|
|
||||||
except InvalidTokenError:
|
|
||||||
await websocket.close(code=4401)
|
|
||||||
return
|
|
||||||
|
|
||||||
proxy = await get_proxy_by_port(proxy_port)
|
|
||||||
if proxy is None:
|
|
||||||
await websocket.close(code=4404)
|
|
||||||
return
|
|
||||||
|
|
||||||
await websocket.accept()
|
|
||||||
latest = await get_latest_health_sample(proxy.proxy_port)
|
|
||||||
if latest is None:
|
|
||||||
latest = await check_proxy(proxy)
|
|
||||||
await websocket.send_json(serialize_health_snapshot(latest))
|
|
||||||
|
|
||||||
queue = health_monitor.subscribe(proxy.proxy_port)
|
|
||||||
disconnect_task = asyncio.create_task(watch_socket_disconnect(websocket))
|
|
||||||
queue_task = asyncio.create_task(forward_health())
|
|
||||||
|
|
||||||
done, pending = await asyncio.wait(
|
|
||||||
{disconnect_task, queue_task},
|
|
||||||
return_when=asyncio.FIRST_EXCEPTION,
|
|
||||||
)
|
|
||||||
|
|
||||||
for task in pending:
|
|
||||||
task.cancel()
|
|
||||||
if pending:
|
|
||||||
await asyncio.gather(*pending, return_exceptions=True)
|
|
||||||
|
|
||||||
for task in done:
|
|
||||||
try:
|
|
||||||
task.result()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
return
|
|
||||||
except RuntimeError:
|
|
||||||
await websocket.close(code=1011, reason="health stream failed")
|
|
||||||
return
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return
|
|
||||||
finally:
|
|
||||||
if queue is not None:
|
|
||||||
health_monitor.unsubscribe(proxy_port, queue)
|
|
||||||
for task in (disconnect_task, queue_task):
|
|
||||||
if task is None or task.done():
|
|
||||||
continue
|
|
||||||
task.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{proxy_port}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=auth_dependencies,
|
|
||||||
)
|
|
||||||
async def remove_proxy(proxy_port: int):
|
|
||||||
proxy = await get_proxy_by_port(proxy_port)
|
|
||||||
if proxy is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Proxy not found",
|
|
||||||
)
|
|
||||||
await manager.stop(proxy)
|
|
||||||
manager.cleanup_runtime_artifacts(proxy)
|
|
||||||
await delete_health_history(proxy.proxy_port)
|
|
||||||
deleted = await delete_proxy(proxy_port)
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Proxy not found",
|
|
||||||
)
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import WebSocket
|
|
||||||
|
|
||||||
from app.core.security import InvalidTokenError, authenticate_token
|
|
||||||
from app.models.health import HealthSnapshot
|
|
||||||
from app.services.mitmproxy.manager import manager
|
|
||||||
|
|
||||||
|
|
||||||
async def authenticate_proxy_socket(websocket: WebSocket) -> None:
|
|
||||||
token = websocket.query_params.get("token")
|
|
||||||
if token is None:
|
|
||||||
authorization = websocket.headers.get("authorization", "")
|
|
||||||
scheme, _, value = authorization.partition(" ")
|
|
||||||
if scheme.lower() == "bearer" and value:
|
|
||||||
token = value
|
|
||||||
|
|
||||||
if not token:
|
|
||||||
raise InvalidTokenError
|
|
||||||
|
|
||||||
await authenticate_token(token)
|
|
||||||
|
|
||||||
|
|
||||||
def proxy_log_path(proxy) -> Path:
|
|
||||||
if proxy.log_path:
|
|
||||||
return Path(proxy.log_path)
|
|
||||||
return manager.log_path_for(proxy)
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_health_snapshot(
|
|
||||||
snapshot: HealthSnapshot | None,
|
|
||||||
) -> dict[str, object] | None:
|
|
||||||
if snapshot is None:
|
|
||||||
return None
|
|
||||||
return snapshot.model_dump(mode="json")
|
|
||||||
|
|
||||||
|
|
||||||
async def read_log_snapshot(path: Path, limit: int = 8000) -> str:
|
|
||||||
snapshot, _offset, _marker = await snapshot_log_state(path, limit=limit)
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
|
|
||||||
async def snapshot_log_state(
|
|
||||||
path: Path,
|
|
||||||
limit: int = 8000,
|
|
||||||
marker_width: int = 64,
|
|
||||||
) -> tuple[str, int, bytes]:
|
|
||||||
if not await asyncio.to_thread(path.is_file):
|
|
||||||
return "", 0, b""
|
|
||||||
|
|
||||||
data = await asyncio.to_thread(path.read_bytes)
|
|
||||||
size = len(data)
|
|
||||||
marker = data[max(0, size - marker_width) : size]
|
|
||||||
snapshot = data[-limit:].decode("utf-8", errors="replace")
|
|
||||||
return snapshot, size, marker
|
|
||||||
|
|
||||||
|
|
||||||
def _read_log_marker_bytes(path: Path, offset: int, width: int) -> bytes:
|
|
||||||
if offset <= 0 or not path.is_file():
|
|
||||||
return b""
|
|
||||||
|
|
||||||
start = max(0, offset - width)
|
|
||||||
with path.open("rb") as handle:
|
|
||||||
handle.seek(start)
|
|
||||||
return handle.read(offset - start)
|
|
||||||
|
|
||||||
|
|
||||||
async def read_log_marker(path: Path, offset: int, width: int = 64) -> bytes:
|
|
||||||
return await asyncio.to_thread(_read_log_marker_bytes, path, offset, width)
|
|
||||||
|
|
||||||
|
|
||||||
async def watch_socket_disconnect(websocket: WebSocket) -> None:
|
|
||||||
while True:
|
|
||||||
await websocket.receive_text()
|
|
||||||
|
|
||||||
|
|
||||||
async def stream_proxy_logs(
|
|
||||||
websocket: WebSocket,
|
|
||||||
path: Path,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
marker: bytes,
|
|
||||||
) -> None:
|
|
||||||
while True:
|
|
||||||
if not await asyncio.to_thread(path.exists):
|
|
||||||
await asyncio.sleep(0.4)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
size = await asyncio.to_thread(lambda: path.stat().st_size)
|
|
||||||
except OSError:
|
|
||||||
await asyncio.sleep(0.4)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if marker and size >= offset and await read_log_marker(path, offset) != marker:
|
|
||||||
snapshot, offset, marker = await snapshot_log_state(path)
|
|
||||||
await websocket.send_json({"type": "snapshot", "log": snapshot})
|
|
||||||
await asyncio.sleep(0.4)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if size < offset:
|
|
||||||
snapshot, offset, marker = await snapshot_log_state(path)
|
|
||||||
await websocket.send_json({"type": "snapshot", "log": snapshot})
|
|
||||||
await asyncio.sleep(0.4)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if size > offset:
|
|
||||||
|
|
||||||
def read_log_chunk(
|
|
||||||
chunk_offset: int = offset,
|
|
||||||
chunk_size: int = size,
|
|
||||||
) -> str:
|
|
||||||
with path.open("rb") as handle:
|
|
||||||
handle.seek(chunk_offset)
|
|
||||||
return handle.read(chunk_size - chunk_offset).decode(
|
|
||||||
"utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
|
|
||||||
chunk = await asyncio.to_thread(read_log_chunk)
|
|
||||||
offset = size
|
|
||||||
marker = await read_log_marker(path, offset)
|
|
||||||
if chunk:
|
|
||||||
await websocket.send_json({"type": "append", "log": chunk})
|
|
||||||
|
|
||||||
await asyncio.sleep(0.4)
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from pydantic import Field
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=(".env", "backend/.env"),
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
case_sensitive=False,
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
MONGODB_URI: str
|
|
||||||
MONGODB_DB_NAME: str
|
|
||||||
JWT_SECRET_KEY: str
|
|
||||||
JWT_ALGORITHM: str = "HS256"
|
|
||||||
JWT_EXPIRE_MINUTES: int = Field(default=720, gt=0)
|
|
||||||
AUTH_USERNAME: str
|
|
||||||
AUTH_PASSWORD: str
|
|
||||||
API_PORT: int = Field(default=3671, ge=1, le=65535)
|
|
||||||
SERVICE_SCAN_ROOT: str | None = None
|
|
||||||
HEALTHCHECK_INTERVAL_SECONDS: int = Field(default=15, ge=5, le=300)
|
|
||||||
HEALTH_HISTORY_TTL_SECONDS: int = Field(default=86400, ge=300)
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings() # pyright: ignore[reportCallIssue]
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from typing import Annotated
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from authlib.jose import jwt # type: ignore
|
|
||||||
from authlib.jose.errors import JoseError # type: ignore
|
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
from pwdlib import PasswordHash
|
|
||||||
from pwdlib.hashers.argon2 import Argon2Hasher
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.models.auth import AccessTokenPayload, AuthUserDocument
|
|
||||||
from app.repositories.auth import get_user_by_username, is_token_revoked
|
|
||||||
|
|
||||||
pwd_context = PasswordHash((Argon2Hasher(),))
|
|
||||||
bearer_scheme = HTTPBearer(auto_error=False)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class AuthContext:
|
|
||||||
token: str
|
|
||||||
user: AuthUserDocument
|
|
||||||
payload: AccessTokenPayload
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidTokenError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def token_auth_error() -> HTTPException:
|
|
||||||
return HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Could not validate credentials",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
|
||||||
return pwd_context.hash(password)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, password_hash: str) -> bool:
|
|
||||||
return pwd_context.verify(plain_password, password_hash)
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(*, subject: str) -> str:
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
expires_delta = timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
|
|
||||||
expires_at = int((now + expires_delta).timestamp())
|
|
||||||
|
|
||||||
header = {
|
|
||||||
"alg": settings.JWT_ALGORITHM,
|
|
||||||
"typ": "JWT",
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"sub": subject,
|
|
||||||
"jti": str(uuid4()),
|
|
||||||
"exp": expires_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
token = jwt.encode(header, payload, settings.JWT_SECRET_KEY)
|
|
||||||
|
|
||||||
if isinstance(token, bytes):
|
|
||||||
return token.decode("utf-8")
|
|
||||||
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def decode_access_token(token: str) -> AccessTokenPayload:
|
|
||||||
try:
|
|
||||||
claims = jwt.decode(token, settings.JWT_SECRET_KEY)
|
|
||||||
claims.validate()
|
|
||||||
return AccessTokenPayload.model_validate(dict(claims))
|
|
||||||
except JoseError, ValueError, TypeError:
|
|
||||||
raise InvalidTokenError from None
|
|
||||||
|
|
||||||
|
|
||||||
async def authenticate_token(token: str) -> AuthContext:
|
|
||||||
try:
|
|
||||||
payload = decode_access_token(token)
|
|
||||||
except InvalidTokenError:
|
|
||||||
raise InvalidTokenError from None
|
|
||||||
|
|
||||||
if await is_token_revoked(payload.jti):
|
|
||||||
raise InvalidTokenError
|
|
||||||
|
|
||||||
user = await get_user_by_username(payload.sub)
|
|
||||||
if user is None:
|
|
||||||
raise InvalidTokenError
|
|
||||||
|
|
||||||
return AuthContext(token=token, user=user, payload=payload)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_auth_context(
|
|
||||||
credentials: Annotated[
|
|
||||||
HTTPAuthorizationCredentials | None,
|
|
||||||
Depends(bearer_scheme),
|
|
||||||
],
|
|
||||||
) -> AuthContext:
|
|
||||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
|
||||||
raise token_auth_error()
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await authenticate_token(credentials.credentials)
|
|
||||||
except InvalidTokenError:
|
|
||||||
raise token_auth_error() from None
|
|
||||||
|
|
||||||
|
|
||||||
async def require_authenticated_user(
|
|
||||||
auth_context: Annotated[AuthContext, Depends(get_auth_context)],
|
|
||||||
) -> AuthUserDocument:
|
|
||||||
return auth_context.user
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
from app.integrations.docker.compose import (
|
|
||||||
find_docker_compose,
|
|
||||||
get_all_services_in_dir,
|
|
||||||
get_services,
|
|
||||||
)
|
|
||||||
from app.integrations.docker.editor import change_port
|
|
||||||
from app.integrations.docker.models import DockerContainer
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"DockerContainer",
|
|
||||||
"find_docker_compose",
|
|
||||||
"get_services",
|
|
||||||
"get_all_services_in_dir",
|
|
||||||
"change_port",
|
|
||||||
]
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from app.integrations.docker.models import DockerContainer, DockerPortBinding
|
|
||||||
from app.integrations.docker.ports import _get_service_binding
|
|
||||||
|
|
||||||
COMPOSE_NAMES = (
|
|
||||||
"compose.yml",
|
|
||||||
"compose.yaml",
|
|
||||||
"docker-compose.yml",
|
|
||||||
"docker-compose.yaml",
|
|
||||||
)
|
|
||||||
|
|
||||||
type ComposeDocument = dict[str, object]
|
|
||||||
|
|
||||||
|
|
||||||
def _get_visible_folders(directory: Path) -> list[Path]:
|
|
||||||
try:
|
|
||||||
entries = list(Path(directory).iterdir())
|
|
||||||
except OSError:
|
|
||||||
return []
|
|
||||||
|
|
||||||
folders: list[Path] = []
|
|
||||||
for entry in entries:
|
|
||||||
if entry.name.startswith("."):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if entry.is_dir():
|
|
||||||
folders.append(entry)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return folders
|
|
||||||
|
|
||||||
|
|
||||||
def find_docker_compose(path: Path) -> tuple[ComposeDocument, Path] | None:
|
|
||||||
try:
|
|
||||||
if not path or not path.is_dir():
|
|
||||||
return None
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for name in COMPOSE_NAMES:
|
|
||||||
file = path / name
|
|
||||||
try:
|
|
||||||
if file.is_file():
|
|
||||||
content = yaml.safe_load(file.read_text()) or {}
|
|
||||||
return content, file
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_services(folder: Path) -> list[DockerContainer] | None:
|
|
||||||
services: list[DockerContainer] = []
|
|
||||||
compose_data = find_docker_compose(folder)
|
|
||||||
if compose_data is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
docker_compose, _ = compose_data
|
|
||||||
compose_services = docker_compose.get("services") or {}
|
|
||||||
if not isinstance(compose_services, dict) or not compose_services:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for service_name, config in compose_services.items():
|
|
||||||
mappings = config.get("ports") if isinstance(config, dict) else None
|
|
||||||
if not isinstance(mappings, list) or not mappings:
|
|
||||||
continue
|
|
||||||
|
|
||||||
bindings: list[DockerPortBinding] = [
|
|
||||||
{"published": published, "host": host}
|
|
||||||
for published, host in (
|
|
||||||
_get_service_binding(mapping) for mapping in mappings
|
|
||||||
)
|
|
||||||
if published is not None
|
|
||||||
]
|
|
||||||
if not bindings:
|
|
||||||
continue
|
|
||||||
|
|
||||||
services.append(
|
|
||||||
{
|
|
||||||
"name": service_name,
|
|
||||||
"ports": [binding["published"] for binding in bindings],
|
|
||||||
"bindings": bindings,
|
|
||||||
"folder": folder,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return services
|
|
||||||
|
|
||||||
|
|
||||||
def get_all_services_in_dir(
|
|
||||||
directory: str | Path,
|
|
||||||
) -> list[DockerContainer]:
|
|
||||||
path = Path(directory)
|
|
||||||
try:
|
|
||||||
if not path.is_dir():
|
|
||||||
return []
|
|
||||||
except OSError:
|
|
||||||
return []
|
|
||||||
|
|
||||||
services: list[DockerContainer] = []
|
|
||||||
for sub_folder in _get_visible_folders(path):
|
|
||||||
sub_services = get_services(sub_folder)
|
|
||||||
if sub_services:
|
|
||||||
services.extend(sub_services)
|
|
||||||
|
|
||||||
return services
|
|
||||||
|
|
||||||
|
|
||||||
async def run_docker_command(
|
|
||||||
file: Path,
|
|
||||||
command: list[str],
|
|
||||||
) -> dict[str, str] | None:
|
|
||||||
if not command:
|
|
||||||
return None
|
|
||||||
|
|
||||||
full_command = ["docker", "compose", "-f", f"{file}", *command]
|
|
||||||
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
*full_command,
|
|
||||||
cwd=str(file.parent),
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
stdout, stderr = await process.communicate()
|
|
||||||
|
|
||||||
if process.returncode == 0:
|
|
||||||
return {"status": "success", "output": stdout.decode()}
|
|
||||||
else:
|
|
||||||
return {"status": "error", "reason": stderr.decode()}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
|
|
||||||
def service_scan_root() -> Path:
|
|
||||||
configured = settings.SERVICE_SCAN_ROOT
|
|
||||||
if configured:
|
|
||||||
return Path(configured).expanduser()
|
|
||||||
return Path.home()
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from app.integrations.docker.compose import (
|
|
||||||
ComposeDocument,
|
|
||||||
find_docker_compose,
|
|
||||||
run_docker_command,
|
|
||||||
)
|
|
||||||
from app.integrations.docker.ports import _get_port_index, _replace_port_in_mapping
|
|
||||||
|
|
||||||
|
|
||||||
def _render_compose(document: ComposeDocument) -> str:
|
|
||||||
return yaml.dump(document, default_flow_style=False, sort_keys=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _write_compose(path: Path, content: str) -> None:
|
|
||||||
path.write_text(content, encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _read_compose_text(path: Path) -> str:
|
|
||||||
return path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
async def change_port(
|
|
||||||
service_name: str,
|
|
||||||
folder: Path,
|
|
||||||
port_from: int,
|
|
||||||
port_to: int,
|
|
||||||
host_to: str | None = None,
|
|
||||||
rewrite_host: bool = False,
|
|
||||||
) -> bool:
|
|
||||||
compose_data = find_docker_compose(folder)
|
|
||||||
if compose_data is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
docker_compose, compose_path = compose_data
|
|
||||||
compose_services = docker_compose.get("services") or {}
|
|
||||||
if not isinstance(compose_services, dict) or not compose_services:
|
|
||||||
return False
|
|
||||||
|
|
||||||
service_config = compose_services.get(service_name)
|
|
||||||
if not isinstance(service_config, dict):
|
|
||||||
return False
|
|
||||||
|
|
||||||
mappings = service_config.get("ports")
|
|
||||||
if not isinstance(mappings, list):
|
|
||||||
return False
|
|
||||||
|
|
||||||
idx = _get_port_index(mappings, port_from)
|
|
||||||
if idx is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
mappings[idx] = _replace_port_in_mapping(
|
|
||||||
mappings[idx],
|
|
||||||
port_from,
|
|
||||||
port_to,
|
|
||||||
host_to=host_to,
|
|
||||||
rewrite_host=rewrite_host,
|
|
||||||
)
|
|
||||||
|
|
||||||
original_text = await asyncio.to_thread(_read_compose_text, compose_path)
|
|
||||||
updated_text = _render_compose(docker_compose)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(_write_compose, compose_path, updated_text)
|
|
||||||
result = await run_docker_command(
|
|
||||||
compose_path,
|
|
||||||
["up", "-d", service_name],
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(_write_compose, compose_path, original_text)
|
|
||||||
except OSError as restore_error:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Failed to restore original compose file: {compose_path}"
|
|
||||||
) from restore_error
|
|
||||||
return False
|
|
||||||
|
|
||||||
if result is None or result.get("status") != "success":
|
|
||||||
await asyncio.to_thread(_write_compose, compose_path, original_text)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
|
|
||||||
class DockerPortBinding(TypedDict):
|
|
||||||
published: int
|
|
||||||
host: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class DockerContainer(TypedDict):
|
|
||||||
name: str
|
|
||||||
ports: list[int]
|
|
||||||
bindings: list[DockerPortBinding]
|
|
||||||
folder: Path
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import socket
|
|
||||||
from collections.abc import Mapping
|
|
||||||
|
|
||||||
|
|
||||||
def get_random_port() -> int:
|
|
||||||
# Binding to port 0 asks the OS for an available ephemeral port.
|
|
||||||
try:
|
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
||||||
s.bind(("", 0))
|
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
port = s.getsockname()[1]
|
|
||||||
return port
|
|
||||||
except Exception as e:
|
|
||||||
raise RuntimeError(f"Could not find an available port: {e}") from e
|
|
||||||
|
|
||||||
|
|
||||||
def _get_service_binding(
|
|
||||||
mapping: str | int | Mapping[str, object],
|
|
||||||
) -> tuple[int | None, str | None]:
|
|
||||||
if isinstance(mapping, int):
|
|
||||||
return mapping, None
|
|
||||||
|
|
||||||
if isinstance(mapping, Mapping):
|
|
||||||
published = mapping.get("published")
|
|
||||||
if published is None:
|
|
||||||
return None, None
|
|
||||||
if not isinstance(published, (str, int)):
|
|
||||||
return None, None
|
|
||||||
try:
|
|
||||||
port = int(published)
|
|
||||||
except ValueError:
|
|
||||||
return None, None
|
|
||||||
host = mapping.get("host_ip")
|
|
||||||
return port, str(host) if host not in {None, ""} else None
|
|
||||||
|
|
||||||
parts = str(mapping).split(":")
|
|
||||||
match parts:
|
|
||||||
case [""]:
|
|
||||||
return None, None
|
|
||||||
case [port] | [port, _]:
|
|
||||||
try:
|
|
||||||
return int(port), None
|
|
||||||
except ValueError:
|
|
||||||
return None, None
|
|
||||||
case [host, port, _]:
|
|
||||||
try:
|
|
||||||
return int(port), host or None
|
|
||||||
except ValueError:
|
|
||||||
return None, None
|
|
||||||
case _:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None:
|
|
||||||
port, _host = _get_service_binding(mapping)
|
|
||||||
return port
|
|
||||||
|
|
||||||
|
|
||||||
def _replace_port_in_mapping(
|
|
||||||
mapping: str | int | Mapping[str, object],
|
|
||||||
port_from: int,
|
|
||||||
port_to: int,
|
|
||||||
host_to: str | None = None,
|
|
||||||
rewrite_host: bool = False,
|
|
||||||
) -> str | dict[str, object]:
|
|
||||||
if isinstance(mapping, Mapping):
|
|
||||||
updated = dict(mapping)
|
|
||||||
if _get_service_port(updated) == port_from:
|
|
||||||
updated["published"] = str(port_to)
|
|
||||||
if rewrite_host:
|
|
||||||
if host_to is None:
|
|
||||||
updated.pop("host_ip", None)
|
|
||||||
else:
|
|
||||||
updated["host_ip"] = host_to
|
|
||||||
return updated
|
|
||||||
|
|
||||||
parts = str(mapping).split(":")
|
|
||||||
match parts:
|
|
||||||
case [_]:
|
|
||||||
return str(port_to)
|
|
||||||
case [_, container_port]:
|
|
||||||
if rewrite_host and host_to:
|
|
||||||
return f"{host_to}:{port_to}:{container_port}"
|
|
||||||
return f"{port_to}:{container_port}"
|
|
||||||
case [ip, _, container_port]:
|
|
||||||
if rewrite_host:
|
|
||||||
if host_to:
|
|
||||||
return f"{host_to}:{port_to}:{container_port}"
|
|
||||||
return f"{port_to}:{container_port}"
|
|
||||||
return f"{ip}:{port_to}:{container_port}"
|
|
||||||
case _:
|
|
||||||
return str(port_to)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_port_index(
|
|
||||||
mappings: list[str | int | Mapping[str, object]],
|
|
||||||
port: int,
|
|
||||||
) -> int | None:
|
|
||||||
for i, mapping in enumerate(mappings):
|
|
||||||
if _get_service_port(mapping) == port:
|
|
||||||
return i
|
|
||||||
return None
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from app.api.auth.router import router as auth_router
|
|
||||||
from app.api.editor.router import router as editor_router
|
|
||||||
from app.api.proxy.router import router as proxy_router
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.mongodb import close_mongodb, init_mongodb
|
|
||||||
from app.core.security import hash_password
|
|
||||||
from app.integrations.docker import get_all_services_in_dir
|
|
||||||
from app.integrations.docker.discovery import service_scan_root
|
|
||||||
from app.repositories.auth import ensure_auth_indexes, ensure_single_account
|
|
||||||
from app.repositories.health import ensure_health_indexes
|
|
||||||
from app.repositories.proxy import ensure_proxy_indexes
|
|
||||||
from app.repositories.templates import ensure_template_indexes
|
|
||||||
from app.services.mitmproxy.health_monitor import health_monitor
|
|
||||||
from app.services.mitmproxy.recovery import reconcile_runtime
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(_: FastAPI):
|
|
||||||
await init_mongodb()
|
|
||||||
await ensure_auth_indexes()
|
|
||||||
await ensure_single_account(
|
|
||||||
username=settings.AUTH_USERNAME,
|
|
||||||
password_hash=hash_password(settings.AUTH_PASSWORD),
|
|
||||||
)
|
|
||||||
await ensure_proxy_indexes()
|
|
||||||
await ensure_template_indexes()
|
|
||||||
await ensure_health_indexes()
|
|
||||||
recovered = await reconcile_runtime()
|
|
||||||
if recovered:
|
|
||||||
recovered_count = len(recovered)
|
|
||||||
error_count = sum(
|
|
||||||
1
|
|
||||||
for _proxy_port, values in recovered
|
|
||||||
if values.get("status") == "error"
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
f"[thePlug] Reconciled {recovered_count} persisted proxy runtimes"
|
|
||||||
f" ({error_count} with errors).",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
scan_root = service_scan_root()
|
|
||||||
services = get_all_services_in_dir(scan_root) or []
|
|
||||||
if not services:
|
|
||||||
warning = (
|
|
||||||
"\n" + "!" * 60 + "\n"
|
|
||||||
" WARNING: No services found in scan root!\n"
|
|
||||||
f" Scanned: {scan_root}\n" + "!" * 60 + "\n"
|
|
||||||
)
|
|
||||||
print(warning, file=sys.stderr)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await health_monitor.start()
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
await health_monitor.stop()
|
|
||||||
await close_mongodb()
|
|
||||||
|
|
||||||
|
|
||||||
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title="thePlugAPI",
|
|
||||||
lifespan=lifespan,
|
|
||||||
docs_url="/docs" if DEBUG else None,
|
|
||||||
redoc_url="/redoc" if DEBUG else None,
|
|
||||||
openapi_url="/openapi.json" if DEBUG else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(auth_router)
|
|
||||||
app.include_router(editor_router)
|
|
||||||
app.include_router(proxy_router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
|
||||||
async def root():
|
|
||||||
return {"Hello": "World!"}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class AuthUserDocument(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
username: str = Field(
|
|
||||||
...,
|
|
||||||
description="Unique username for the single account",
|
|
||||||
)
|
|
||||||
password_hash: str = Field(..., description="Hashed password")
|
|
||||||
created_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Creation timestamp in UTC",
|
|
||||||
)
|
|
||||||
updated_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Last update timestamp in UTC",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LoginInput(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
username: str = Field(..., description="Username")
|
|
||||||
password: str = Field(..., description="Plain text password")
|
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
access_token: str = Field(..., description="JWT access token")
|
|
||||||
token_type: str = Field(default="bearer", description="Token type")
|
|
||||||
|
|
||||||
|
|
||||||
class AccessTokenPayload(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
sub: str = Field(..., description="Subject username")
|
|
||||||
jti: str = Field(..., description="Unique token identifier")
|
|
||||||
exp: int = Field(..., description="Token expiration as unix timestamp")
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class HealthCheckEntry(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
target: str = Field(..., description="Logical target name")
|
|
||||||
host: str = Field(..., description="Resolved host checked at runtime")
|
|
||||||
port: int = Field(..., description="Resolved port checked at runtime")
|
|
||||||
protocol: str = Field(..., description="Protocol used for the check")
|
|
||||||
healthy: bool = Field(..., description="Whether the check succeeded")
|
|
||||||
error: str | None = Field(default=None, description="Optional failure reason")
|
|
||||||
duration_ms: float | None = Field(
|
|
||||||
default=None,
|
|
||||||
description=(
|
|
||||||
"Measured response latency in milliseconds when benchmarking "
|
|
||||||
"is enabled"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
details: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class HealthSnapshot(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
proxy_port: int = Field(..., description="Proxy port this snapshot belongs to")
|
|
||||||
timestamp: datetime = Field(..., description="When the snapshot was produced")
|
|
||||||
enabled: bool = Field(..., description="Whether healthchecks are enabled")
|
|
||||||
overall: str = Field(..., description="Overall health status")
|
|
||||||
checks: list[HealthCheckEntry] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class HealthHistoryResponse(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
proxy_port: int
|
|
||||||
latest: HealthSnapshot | None = None
|
|
||||||
history: list[HealthSnapshot] = Field(default_factory=list)
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
from app.models.service import ServiceEndpointDocument
|
|
||||||
|
|
||||||
|
|
||||||
class HealthCheckDocument(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
enabled: bool = Field(default=True, description="Enable active health checks")
|
|
||||||
protocol: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Override protocol used by the health check",
|
|
||||||
)
|
|
||||||
path: str = Field(default="/", description="HTTP path to check")
|
|
||||||
method: str = Field(default="GET", description="HTTP method to use")
|
|
||||||
expected_status: int | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Expected HTTP status code",
|
|
||||||
)
|
|
||||||
udp_payload: str = Field(default="", description="UDP payload to send")
|
|
||||||
expect_response: bool = Field(
|
|
||||||
default=True,
|
|
||||||
description="Wait for a response when the protocol supports it",
|
|
||||||
)
|
|
||||||
verify_tls: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Verify TLS certificates during health checks",
|
|
||||||
)
|
|
||||||
benchmark_enabled: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Measure response latency during health checks when supported",
|
|
||||||
)
|
|
||||||
timeout_seconds: float = Field(
|
|
||||||
default=2.0,
|
|
||||||
gt=0,
|
|
||||||
le=30,
|
|
||||||
description="Per-target health check timeout",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyDocument(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
proxy_port: int = Field(..., description="Port exposed by the proxy")
|
|
||||||
name: str = Field(..., description="Human readable proxy name")
|
|
||||||
code: str = Field(..., description="Proxy source or identifier")
|
|
||||||
mode: str = Field(default="reverse", description="mitmproxy proxy mode")
|
|
||||||
mode_spec: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
|
|
||||||
)
|
|
||||||
protocol: str = Field(default="http", description="mitmproxy reverse protocol")
|
|
||||||
listen_host: str = Field(default="0.0.0.0", description="Proxy listen host")
|
|
||||||
mitm_options: dict[str, Any] = Field(
|
|
||||||
default_factory=dict,
|
|
||||||
description="mitmdump options passed through --set",
|
|
||||||
)
|
|
||||||
healthcheck: HealthCheckDocument = Field(default_factory=HealthCheckDocument)
|
|
||||||
status: str = Field(default="stopped", description="Runtime proxy status")
|
|
||||||
pid: int | None = Field(default=None, description="mitmdump process id")
|
|
||||||
script_path: str | None = Field(default=None, description="Generated addon path")
|
|
||||||
config_path: str | None = Field(default=None, description="Generated config path")
|
|
||||||
log_path: str | None = Field(default=None, description="mitmdump log path")
|
|
||||||
last_error: str | None = Field(default=None, description="Last runtime error")
|
|
||||||
service: ServiceEndpointDocument = Field(
|
|
||||||
...,
|
|
||||||
description="Associated upstream service",
|
|
||||||
)
|
|
||||||
created_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Creation timestamp in UTC",
|
|
||||||
)
|
|
||||||
updated_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Last update timestamp in UTC",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyCreateInput(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
proxy_port: int = Field(..., description="Port exposed by the proxy")
|
|
||||||
name: str = Field(..., description="Human readable proxy name")
|
|
||||||
code: str = Field(..., description="Proxy source or identifier")
|
|
||||||
mode: str = Field(default="reverse", description="mitmproxy proxy mode")
|
|
||||||
mode_spec: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
|
|
||||||
)
|
|
||||||
protocol: str = Field(default="http", description="mitmproxy reverse protocol")
|
|
||||||
listen_host: str = Field(default="0.0.0.0", description="Proxy listen host")
|
|
||||||
mitm_options: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
healthcheck: HealthCheckDocument = Field(default_factory=HealthCheckDocument)
|
|
||||||
service: ServiceEndpointDocument = Field(
|
|
||||||
...,
|
|
||||||
description="Associated upstream service",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyUpdateInput(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
name: str | None = Field(default=None, description="Human readable proxy name")
|
|
||||||
code: str | None = Field(default=None, description="Proxy source or identifier")
|
|
||||||
mode: str | None = Field(default=None, description="mitmproxy proxy mode")
|
|
||||||
mode_spec: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Raw mitmproxy mode specification. Overrides mode/protocol target.",
|
|
||||||
)
|
|
||||||
protocol: str | None = Field(default=None, description="mitmproxy reverse protocol")
|
|
||||||
listen_host: str | None = Field(default=None, description="Proxy listen host")
|
|
||||||
mitm_options: dict[str, Any] | None = Field(default=None)
|
|
||||||
healthcheck: HealthCheckDocument | None = Field(default=None)
|
|
||||||
service: ServiceEndpointDocument | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Associated upstream service",
|
|
||||||
)
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceEndpointDocument(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
host: str = Field(default="127.0.0.1", description="Upstream service host")
|
|
||||||
port: int = Field(..., description="Original upstream service port")
|
|
||||||
bind_host: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Original bind host exposed by Docker Compose",
|
|
||||||
)
|
|
||||||
name: str | None = Field(default=None, description="Docker Compose service name")
|
|
||||||
folder: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Docker Compose project folder",
|
|
||||||
)
|
|
||||||
upstream_port: int | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Current port used by mitmdump as upstream",
|
|
||||||
)
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyTemplateDocument(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
id: str = Field(..., description="Stable template identifier")
|
|
||||||
name: str = Field(..., description="Display name")
|
|
||||||
description: str = Field(default="", description="Short explanation")
|
|
||||||
category: str = Field(default="custom", description="Template category")
|
|
||||||
protocol: str = Field(default="custom", description="Primary proxy protocol")
|
|
||||||
family: str = Field(default="custom", description="Template family")
|
|
||||||
tags: list[str] = Field(default_factory=list, description="Filter tags")
|
|
||||||
action: str = Field(default="custom", description="Primary template action")
|
|
||||||
summary: str = Field(default="", description="Short operational summary")
|
|
||||||
behavior: str = Field(default="", description="Runtime behavior description")
|
|
||||||
code: str = Field(..., description="Addon source code")
|
|
||||||
source_url: str | None = Field(default=None, description="Reference URL")
|
|
||||||
built_in: bool = Field(default=False, description="Provided by thePlug")
|
|
||||||
origin: str = Field(default="custom", description="Template source")
|
|
||||||
created_at: datetime | None = None
|
|
||||||
updated_at: datetime | None = None
|
|
||||||
source_path: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Filesystem source path",
|
|
||||||
exclude=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyTemplateCreateInput(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
id: str = Field(..., description="Stable template identifier")
|
|
||||||
name: str = Field(..., description="Display name")
|
|
||||||
description: str = Field(default="", description="Short explanation")
|
|
||||||
category: str = Field(default="custom", description="Template category")
|
|
||||||
protocol: str = Field(default="custom", description="Primary proxy protocol")
|
|
||||||
family: str = Field(default="custom", description="Template family")
|
|
||||||
tags: list[str] = Field(default_factory=list, description="Filter tags")
|
|
||||||
action: str = Field(default="custom", description="Primary template action")
|
|
||||||
summary: str = Field(default="", description="Short operational summary")
|
|
||||||
behavior: str = Field(default="", description="Runtime behavior description")
|
|
||||||
code: str = Field(..., description="Addon source code")
|
|
||||||
source_url: str | None = Field(default=None, description="Reference URL")
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
from collections.abc import Mapping
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
|
||||||
|
|
||||||
from app.core.mongodb import get_database
|
|
||||||
from app.models.auth import AuthUserDocument
|
|
||||||
|
|
||||||
USERS_COLLECTION_NAME = "auth_users"
|
|
||||||
REVOKED_COLLECTION_NAME = "revoked_tokens"
|
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
|
||||||
return datetime.now(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
async def users_collection() -> AsyncIOMotorCollection[dict[str, Any]]:
|
|
||||||
database = await get_database()
|
|
||||||
return database[USERS_COLLECTION_NAME]
|
|
||||||
|
|
||||||
|
|
||||||
async def revoked_tokens_collection() -> AsyncIOMotorCollection[dict[str, Any]]:
|
|
||||||
database = await get_database()
|
|
||||||
return database[REVOKED_COLLECTION_NAME]
|
|
||||||
|
|
||||||
|
|
||||||
def deserialize_user(raw: Mapping[str, Any] | None) -> AuthUserDocument | None:
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
document = dict(raw)
|
|
||||||
document.pop("_id", None)
|
|
||||||
return AuthUserDocument.model_validate(document)
|
|
||||||
|
|
||||||
|
|
||||||
async def ensure_auth_indexes() -> None:
|
|
||||||
users = await users_collection()
|
|
||||||
revoked = await revoked_tokens_collection()
|
|
||||||
|
|
||||||
await users.create_index("username", unique=True, name="uq_auth_username")
|
|
||||||
await revoked.create_index("jti", unique=True, name="uq_revoked_jti")
|
|
||||||
await revoked.create_index(
|
|
||||||
"expires_at",
|
|
||||||
expireAfterSeconds=0,
|
|
||||||
name="ttl_revoked_tokens",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def ensure_single_account(*, username: str, password_hash: str) -> None:
|
|
||||||
users = await users_collection()
|
|
||||||
now = utcnow()
|
|
||||||
|
|
||||||
current = await users.find_one({}, sort=[("created_at", 1)])
|
|
||||||
if current is None:
|
|
||||||
await users.insert_one(
|
|
||||||
{
|
|
||||||
"username": username,
|
|
||||||
"password_hash": password_hash,
|
|
||||||
"created_at": now,
|
|
||||||
"updated_at": now,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
current_id = current["_id"]
|
|
||||||
await users.update_one(
|
|
||||||
{"_id": current_id},
|
|
||||||
{
|
|
||||||
"$set": {
|
|
||||||
"username": username,
|
|
||||||
"password_hash": password_hash,
|
|
||||||
"updated_at": now,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await users.delete_many({"_id": {"$ne": current_id}})
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_username(username: str) -> AuthUserDocument | None:
|
|
||||||
users = await users_collection()
|
|
||||||
raw = await users.find_one({"username": username})
|
|
||||||
return deserialize_user(raw)
|
|
||||||
|
|
||||||
|
|
||||||
async def revoke_token(*, jti: str, expires_at: datetime) -> None:
|
|
||||||
revoked = await revoked_tokens_collection()
|
|
||||||
await revoked.update_one(
|
|
||||||
{"jti": jti},
|
|
||||||
{
|
|
||||||
"$set": {
|
|
||||||
"jti": jti,
|
|
||||||
"expires_at": expires_at,
|
|
||||||
"revoked_at": utcnow(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
upsert=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def is_token_revoked(jti: str) -> bool:
|
|
||||||
revoked = await revoked_tokens_collection()
|
|
||||||
raw = await revoked.find_one({"jti": jti}, projection={"_id": 1})
|
|
||||||
return raw is not None
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.mongodb import get_database
|
|
||||||
from app.models.health import HealthSnapshot
|
|
||||||
|
|
||||||
COLLECTION_NAME = "health_history"
|
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
|
||||||
return datetime.now(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
async def collection() -> AsyncIOMotorCollection[dict[str, Any]]:
|
|
||||||
database = await get_database()
|
|
||||||
return database[COLLECTION_NAME]
|
|
||||||
|
|
||||||
|
|
||||||
async def ensure_health_indexes() -> None:
|
|
||||||
samples = await collection()
|
|
||||||
await samples.create_index(
|
|
||||||
[("proxy_port", 1), ("timestamp", -1)],
|
|
||||||
name="ix_health_proxy_port_timestamp",
|
|
||||||
)
|
|
||||||
await samples.create_index(
|
|
||||||
"expires_at",
|
|
||||||
expireAfterSeconds=0,
|
|
||||||
name="ttl_health_history",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def deserialize_health_sample(raw: dict[str, Any] | None) -> HealthSnapshot | None:
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
document = dict(raw)
|
|
||||||
document.pop("_id", None)
|
|
||||||
document.pop("expires_at", None)
|
|
||||||
return HealthSnapshot.model_validate(document)
|
|
||||||
|
|
||||||
|
|
||||||
async def record_health_sample(snapshot: HealthSnapshot) -> HealthSnapshot:
|
|
||||||
samples = await collection()
|
|
||||||
document = snapshot.model_dump(mode="python")
|
|
||||||
document["expires_at"] = document["timestamp"] + timedelta(
|
|
||||||
seconds=settings.HEALTH_HISTORY_TTL_SECONDS,
|
|
||||||
)
|
|
||||||
await samples.insert_one(document)
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
|
|
||||||
async def get_latest_health_sample(proxy_port: int) -> HealthSnapshot | None:
|
|
||||||
samples = await collection()
|
|
||||||
raw = await samples.find_one(
|
|
||||||
{"proxy_port": proxy_port},
|
|
||||||
sort=[("timestamp", -1)],
|
|
||||||
)
|
|
||||||
return deserialize_health_sample(raw)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_health_history(
|
|
||||||
proxy_port: int,
|
|
||||||
limit: int = 40,
|
|
||||||
) -> list[HealthSnapshot]:
|
|
||||||
samples = await collection()
|
|
||||||
cursor = samples.find({"proxy_port": proxy_port}).sort("timestamp", -1).limit(limit)
|
|
||||||
history: list[HealthSnapshot] = []
|
|
||||||
async for raw in cursor:
|
|
||||||
snapshot = deserialize_health_sample(raw)
|
|
||||||
if snapshot is not None:
|
|
||||||
history.append(snapshot)
|
|
||||||
history.reverse()
|
|
||||||
return history
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_health_history(proxy_port: int) -> None:
|
|
||||||
samples = await collection()
|
|
||||||
await samples.delete_many({"proxy_port": proxy_port})
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
from collections.abc import Mapping
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
|
||||||
from pymongo import ReturnDocument
|
|
||||||
|
|
||||||
from app.core.mongodb import get_database
|
|
||||||
from app.models.proxy import ProxyCreateInput, ProxyDocument, ProxyUpdateInput
|
|
||||||
|
|
||||||
COLLECTION_NAME = "proxies"
|
|
||||||
|
|
||||||
|
|
||||||
def _utcnow() -> datetime:
|
|
||||||
return datetime.now(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
async def _collection() -> AsyncIOMotorCollection[dict[str, Any]]:
|
|
||||||
database = await get_database()
|
|
||||||
return database[COLLECTION_NAME]
|
|
||||||
|
|
||||||
|
|
||||||
def _deserialize_proxy(raw: Mapping[str, Any] | None) -> ProxyDocument | None:
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
document = dict(raw)
|
|
||||||
document.pop("_id", None)
|
|
||||||
return ProxyDocument.model_validate(document)
|
|
||||||
|
|
||||||
|
|
||||||
async def ensure_proxy_indexes() -> None:
|
|
||||||
collection = await _collection()
|
|
||||||
await collection.create_index("proxy_port", unique=True, name="uq_proxy_port")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
|
|
||||||
now = _utcnow()
|
|
||||||
proxy = ProxyDocument(
|
|
||||||
proxy_port=payload.proxy_port,
|
|
||||||
name=payload.name,
|
|
||||||
code=payload.code,
|
|
||||||
mode=payload.mode,
|
|
||||||
mode_spec=payload.mode_spec,
|
|
||||||
protocol=payload.protocol,
|
|
||||||
listen_host=payload.listen_host,
|
|
||||||
mitm_options=payload.mitm_options,
|
|
||||||
healthcheck=payload.healthcheck,
|
|
||||||
service=payload.service,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
|
|
||||||
collection = await _collection()
|
|
||||||
await collection.insert_one(proxy.model_dump(exclude_none=True))
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
|
|
||||||
async def get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
collection = await _collection()
|
|
||||||
raw = await collection.find_one({"proxy_port": proxy_port})
|
|
||||||
return _deserialize_proxy(raw)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_proxies() -> list[ProxyDocument]:
|
|
||||||
collection = await _collection()
|
|
||||||
cursor = collection.find({}).sort("proxy_port", 1)
|
|
||||||
|
|
||||||
proxies: list[ProxyDocument] = []
|
|
||||||
async for raw in cursor:
|
|
||||||
proxy = _deserialize_proxy(raw)
|
|
||||||
if proxy is not None:
|
|
||||||
proxies.append(proxy)
|
|
||||||
return proxies
|
|
||||||
|
|
||||||
|
|
||||||
async def update_proxy(
|
|
||||||
proxy_port: int,
|
|
||||||
updates: ProxyUpdateInput,
|
|
||||||
) -> ProxyDocument | None:
|
|
||||||
payload = updates.model_dump(exclude_none=True)
|
|
||||||
if not payload:
|
|
||||||
return await get_proxy_by_port(proxy_port)
|
|
||||||
|
|
||||||
payload["updated_at"] = _utcnow()
|
|
||||||
|
|
||||||
collection = await _collection()
|
|
||||||
raw = await collection.find_one_and_update(
|
|
||||||
{"proxy_port": proxy_port},
|
|
||||||
{"$set": payload},
|
|
||||||
return_document=ReturnDocument.AFTER,
|
|
||||||
)
|
|
||||||
return _deserialize_proxy(raw)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_proxy_runtime(
|
|
||||||
proxy_port: int,
|
|
||||||
values: dict[str, object],
|
|
||||||
) -> ProxyDocument | None:
|
|
||||||
payload = dict(values)
|
|
||||||
payload["updated_at"] = _utcnow()
|
|
||||||
|
|
||||||
collection = await _collection()
|
|
||||||
raw = await collection.find_one_and_update(
|
|
||||||
{"proxy_port": proxy_port},
|
|
||||||
{"$set": payload},
|
|
||||||
return_document=ReturnDocument.AFTER,
|
|
||||||
)
|
|
||||||
return _deserialize_proxy(raw)
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_proxy(proxy_port: int) -> bool:
|
|
||||||
collection = await _collection()
|
|
||||||
result = await collection.delete_one({"proxy_port": proxy_port})
|
|
||||||
return result.deleted_count > 0
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
from collections.abc import Mapping
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from motor.motor_asyncio import AsyncIOMotorCollection
|
|
||||||
|
|
||||||
from app.core.mongodb import get_database
|
|
||||||
from app.models.template import ProxyTemplateCreateInput, ProxyTemplateDocument
|
|
||||||
|
|
||||||
COLLECTION_NAME = "addon_templates"
|
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
|
||||||
return datetime.now(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
async def collection() -> AsyncIOMotorCollection[dict[str, Any]]:
|
|
||||||
database = await get_database()
|
|
||||||
return database[COLLECTION_NAME]
|
|
||||||
|
|
||||||
|
|
||||||
def deserialize(raw: Mapping[str, Any] | None) -> ProxyTemplateDocument | None:
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
document = dict(raw)
|
|
||||||
document.pop("_id", None)
|
|
||||||
return ProxyTemplateDocument.model_validate(document)
|
|
||||||
|
|
||||||
|
|
||||||
async def ensure_template_indexes() -> None:
|
|
||||||
templates = await collection()
|
|
||||||
await templates.create_index("id", unique=True, name="uq_addon_template_id")
|
|
||||||
|
|
||||||
|
|
||||||
async def list_custom_templates() -> list[ProxyTemplateDocument]:
|
|
||||||
templates = await collection()
|
|
||||||
cursor = templates.find({}).sort("name", 1)
|
|
||||||
|
|
||||||
result: list[ProxyTemplateDocument] = []
|
|
||||||
async for raw in cursor:
|
|
||||||
template = deserialize(raw)
|
|
||||||
if template is not None:
|
|
||||||
result.append(template)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def create_custom_template(
|
|
||||||
payload: ProxyTemplateCreateInput,
|
|
||||||
) -> ProxyTemplateDocument:
|
|
||||||
now = utcnow()
|
|
||||||
template = ProxyTemplateDocument(
|
|
||||||
id=payload.id,
|
|
||||||
name=payload.name,
|
|
||||||
description=payload.description,
|
|
||||||
category=payload.category,
|
|
||||||
protocol=payload.protocol,
|
|
||||||
family=payload.family,
|
|
||||||
tags=payload.tags,
|
|
||||||
action=payload.action,
|
|
||||||
summary=payload.summary,
|
|
||||||
behavior=payload.behavior,
|
|
||||||
code=payload.code,
|
|
||||||
source_url=payload.source_url,
|
|
||||||
built_in=False,
|
|
||||||
origin="custom",
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
|
|
||||||
templates = await collection()
|
|
||||||
await templates.insert_one(template.model_dump(exclude_none=True))
|
|
||||||
return template
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_custom_template(template_id: str) -> bool:
|
|
||||||
templates = await collection()
|
|
||||||
result = await templates.delete_one({"id": template_id})
|
|
||||||
return result.deleted_count > 0
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
WORKSPACE_ROOT = Path(tempfile.gettempdir()) / "theplug-lsp"
|
|
||||||
CONFIG_FILE = "pyrightconfig.json"
|
|
||||||
ENTRY_FILE = "proxy_addon.py"
|
|
||||||
|
|
||||||
STUB_FILES = {
|
|
||||||
"typings/mitmproxy/__init__.pyi": """from . import (
|
|
||||||
command,
|
|
||||||
ctx,
|
|
||||||
dns,
|
|
||||||
http,
|
|
||||||
tcp,
|
|
||||||
tls,
|
|
||||||
udp,
|
|
||||||
websocket,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"command",
|
|
||||||
"ctx",
|
|
||||||
"dns",
|
|
||||||
"http",
|
|
||||||
"tcp",
|
|
||||||
"tls",
|
|
||||||
"udp",
|
|
||||||
"websocket",
|
|
||||||
]
|
|
||||||
""",
|
|
||||||
"typings/mitmproxy/ctx.pyi": """class _Logger:
|
|
||||||
def debug(self, message: object) -> None: ...
|
|
||||||
def info(self, message: object) -> None: ...
|
|
||||||
def warn(self, message: object) -> None: ...
|
|
||||||
def error(self, message: object) -> None: ...
|
|
||||||
def alert(self, message: object) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class _Options:
|
|
||||||
def __getattr__(self, name: str) -> object: ...
|
|
||||||
|
|
||||||
|
|
||||||
log: _Logger
|
|
||||||
options: _Options
|
|
||||||
""",
|
|
||||||
"typings/mitmproxy/http.pyi": """from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
class Headers(dict[str, str]):
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class Request:
|
|
||||||
host: str
|
|
||||||
path: str
|
|
||||||
headers: Headers
|
|
||||||
query: dict[str, str]
|
|
||||||
content: bytes
|
|
||||||
|
|
||||||
|
|
||||||
class Response:
|
|
||||||
headers: Headers
|
|
||||||
status_code: int
|
|
||||||
content: bytes
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def make(
|
|
||||||
cls,
|
|
||||||
status_code: int,
|
|
||||||
content: bytes | str = b"",
|
|
||||||
headers: dict[str, str] | None = None,
|
|
||||||
) -> "Response": ...
|
|
||||||
|
|
||||||
|
|
||||||
class WebSocketMessage:
|
|
||||||
content: bytes
|
|
||||||
from_client: bool
|
|
||||||
|
|
||||||
|
|
||||||
class WebSocketData:
|
|
||||||
messages: list[WebSocketMessage]
|
|
||||||
|
|
||||||
|
|
||||||
class HTTPFlow:
|
|
||||||
request: Request
|
|
||||||
response: Response | None
|
|
||||||
websocket: WebSocketData | None
|
|
||||||
metadata: dict[str, Any]
|
|
||||||
""",
|
|
||||||
"typings/mitmproxy/tcp.pyi": """class TCPMessage:
|
|
||||||
content: bytes
|
|
||||||
from_client: bool
|
|
||||||
|
|
||||||
|
|
||||||
class TCPFlow:
|
|
||||||
messages: list[TCPMessage]
|
|
||||||
""",
|
|
||||||
"typings/mitmproxy/dns.pyi": """class Question:
|
|
||||||
name: str
|
|
||||||
type: int
|
|
||||||
|
|
||||||
|
|
||||||
class ResourceRecord:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
type: int,
|
|
||||||
class_: int,
|
|
||||||
ttl: int,
|
|
||||||
data: bytes,
|
|
||||||
) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class Message:
|
|
||||||
question: Question | None
|
|
||||||
|
|
||||||
def succeed(self, records: list[ResourceRecord]) -> "Message": ...
|
|
||||||
|
|
||||||
|
|
||||||
class _Types:
|
|
||||||
A: int
|
|
||||||
|
|
||||||
|
|
||||||
class _Classes:
|
|
||||||
IN: int
|
|
||||||
|
|
||||||
|
|
||||||
types: _Types
|
|
||||||
classes: _Classes
|
|
||||||
|
|
||||||
|
|
||||||
class DNSFlow:
|
|
||||||
request: Message
|
|
||||||
response: Message | None
|
|
||||||
""",
|
|
||||||
"typings/mitmproxy/command.pyi": """from collections.abc import Callable
|
|
||||||
from typing import TypeVar
|
|
||||||
|
|
||||||
F = TypeVar("F", bound=Callable[..., object])
|
|
||||||
|
|
||||||
|
|
||||||
def command(name: str) -> Callable[[F], F]: ...
|
|
||||||
""",
|
|
||||||
"typings/mitmproxy/websocket.pyi": """class WebSocketMessage:
|
|
||||||
content: bytes
|
|
||||||
from_client: bool
|
|
||||||
""",
|
|
||||||
"typings/mitmproxy/tls.pyi": """class ClientHelloData: ...
|
|
||||||
""",
|
|
||||||
"typings/mitmproxy/udp.pyi": """class UDPMessage:
|
|
||||||
content: bytes
|
|
||||||
from_client: bool
|
|
||||||
""",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class LspFrameBuffer:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.buffer = bytearray()
|
|
||||||
|
|
||||||
def feed(self, chunk: bytes) -> list[str]:
|
|
||||||
self.buffer.extend(chunk)
|
|
||||||
messages: list[str] = []
|
|
||||||
while True:
|
|
||||||
header_end = self.buffer.find(b"\r\n\r\n")
|
|
||||||
if header_end < 0:
|
|
||||||
return messages
|
|
||||||
|
|
||||||
header = self.buffer[:header_end].decode("ascii", errors="ignore")
|
|
||||||
size = content_length(header)
|
|
||||||
if size is None:
|
|
||||||
del self.buffer[: header_end + 4]
|
|
||||||
continue
|
|
||||||
|
|
||||||
frame_end = header_end + 4 + size
|
|
||||||
if len(self.buffer) < frame_end:
|
|
||||||
return messages
|
|
||||||
|
|
||||||
body = bytes(self.buffer[header_end + 4 : frame_end])
|
|
||||||
del self.buffer[:frame_end]
|
|
||||||
messages.append(body.decode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class PythonEditorSession:
|
|
||||||
root_path: Path
|
|
||||||
file_path: Path
|
|
||||||
|
|
||||||
@property
|
|
||||||
def root_uri(self) -> str:
|
|
||||||
return self.root_path.as_uri()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def file_uri(self) -> str:
|
|
||||||
return self.file_path.as_uri()
|
|
||||||
|
|
||||||
|
|
||||||
def content_length(header: str) -> int | None:
|
|
||||||
for line in header.split("\r\n"):
|
|
||||||
if ":" not in line:
|
|
||||||
continue
|
|
||||||
name, value = line.split(":", 1)
|
|
||||||
if name.lower() == "content-length":
|
|
||||||
return int(value.strip())
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def encode_message(message: str) -> bytes:
|
|
||||||
payload = message.encode("utf-8")
|
|
||||||
header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")
|
|
||||||
return header + payload
|
|
||||||
|
|
||||||
|
|
||||||
def lsp_binary() -> str | None:
|
|
||||||
return shutil.which("basedpyright-langserver")
|
|
||||||
|
|
||||||
|
|
||||||
def get_session() -> PythonEditorSession:
|
|
||||||
root_path = WORKSPACE_ROOT
|
|
||||||
file_path = root_path / ENTRY_FILE
|
|
||||||
|
|
||||||
root_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
file_path.touch(exist_ok=True)
|
|
||||||
|
|
||||||
config = {
|
|
||||||
"include": [ENTRY_FILE],
|
|
||||||
"stubPath": "typings",
|
|
||||||
"typeCheckingMode": "basic",
|
|
||||||
"pythonVersion": "3.14",
|
|
||||||
"reportMissingTypeStubs": False,
|
|
||||||
"reportMissingImports": "warning",
|
|
||||||
"useLibraryCodeForTypes": True,
|
|
||||||
}
|
|
||||||
(root_path / CONFIG_FILE).write_text(
|
|
||||||
json.dumps(config, indent=2),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
for relative_path, content in STUB_FILES.items():
|
|
||||||
path = root_path / relative_path
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(content, encoding="utf-8")
|
|
||||||
|
|
||||||
return PythonEditorSession(root_path=root_path, file_path=file_path)
|
|
||||||
|
|
||||||
|
|
||||||
async def open_process(session: PythonEditorSession) -> asyncio.subprocess.Process:
|
|
||||||
binary = lsp_binary()
|
|
||||||
if binary is None:
|
|
||||||
raise RuntimeError("basedpyright-langserver is not installed")
|
|
||||||
|
|
||||||
return await asyncio.create_subprocess_exec(
|
|
||||||
binary,
|
|
||||||
"--stdio",
|
|
||||||
cwd=session.root_path,
|
|
||||||
stdin=asyncio.subprocess.PIPE,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def forward_client(websocket, writer: asyncio.StreamWriter) -> None:
|
|
||||||
while True:
|
|
||||||
message = await websocket.receive_text()
|
|
||||||
writer.write(encode_message(message))
|
|
||||||
await writer.drain()
|
|
||||||
|
|
||||||
|
|
||||||
async def forward_server(reader: asyncio.StreamReader, websocket) -> None:
|
|
||||||
frames = LspFrameBuffer()
|
|
||||||
while True:
|
|
||||||
chunk = await reader.read(65536)
|
|
||||||
if not chunk:
|
|
||||||
return
|
|
||||||
for message in frames.feed(chunk):
|
|
||||||
await websocket.send_text(message)
|
|
||||||
|
|
||||||
|
|
||||||
async def drain_errors(reader: asyncio.StreamReader) -> None:
|
|
||||||
while True:
|
|
||||||
chunk = await reader.read(4096)
|
|
||||||
if not chunk:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
async def bridge(websocket, session: PythonEditorSession) -> None:
|
|
||||||
process = await open_process(session)
|
|
||||||
stdin = process.stdin
|
|
||||||
stdout = process.stdout
|
|
||||||
stderr = process.stderr
|
|
||||||
|
|
||||||
if stdin is None or stdout is None or stderr is None:
|
|
||||||
process.kill()
|
|
||||||
await process.wait()
|
|
||||||
raise RuntimeError("basedpyright-langserver did not expose stdio pipes")
|
|
||||||
|
|
||||||
send_task = asyncio.create_task(forward_client(websocket, stdin))
|
|
||||||
receive_task = asyncio.create_task(forward_server(stdout, websocket))
|
|
||||||
error_task = asyncio.create_task(drain_errors(stderr))
|
|
||||||
|
|
||||||
try:
|
|
||||||
done, pending = await asyncio.wait(
|
|
||||||
{send_task, receive_task, error_task},
|
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
for task in pending:
|
|
||||||
task.cancel()
|
|
||||||
for task in done:
|
|
||||||
task.result()
|
|
||||||
finally:
|
|
||||||
if process.returncode is None:
|
|
||||||
stdin.close()
|
|
||||||
await stdin.wait_closed()
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(process.wait(), timeout=1)
|
|
||||||
except TimeoutError:
|
|
||||||
process.kill()
|
|
||||||
await process.wait()
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import ssl
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
from app.models.health import HealthCheckEntry, HealthSnapshot
|
|
||||||
from app.models.proxy import HealthCheckDocument, ProxyDocument
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class Target:
|
|
||||||
name: str
|
|
||||||
host: str
|
|
||||||
port: int
|
|
||||||
protocol: str
|
|
||||||
|
|
||||||
|
|
||||||
async def check_proxy(proxy: ProxyDocument) -> HealthSnapshot:
|
|
||||||
timestamp = datetime.now(UTC)
|
|
||||||
if not proxy.healthcheck.enabled:
|
|
||||||
return HealthSnapshot(
|
|
||||||
proxy_port=proxy.proxy_port,
|
|
||||||
timestamp=timestamp,
|
|
||||||
enabled=False,
|
|
||||||
overall="disabled",
|
|
||||||
checks=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
upstream_port = proxy.service.upstream_port or proxy.service.port
|
|
||||||
checks = list(
|
|
||||||
await asyncio.gather(
|
|
||||||
check_target(
|
|
||||||
Target(
|
|
||||||
name="upstream",
|
|
||||||
host=proxy.service.host,
|
|
||||||
port=upstream_port,
|
|
||||||
protocol=health_protocol(proxy),
|
|
||||||
),
|
|
||||||
proxy.healthcheck,
|
|
||||||
),
|
|
||||||
check_target(
|
|
||||||
Target(
|
|
||||||
name="proxy",
|
|
||||||
host=listen_host_for_check(proxy.listen_host),
|
|
||||||
port=proxy.proxy_port,
|
|
||||||
protocol=health_protocol(proxy),
|
|
||||||
),
|
|
||||||
proxy.healthcheck,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
overall = "healthy" if all(check.healthy for check in checks) else "unhealthy"
|
|
||||||
return HealthSnapshot(
|
|
||||||
proxy_port=proxy.proxy_port,
|
|
||||||
timestamp=timestamp,
|
|
||||||
enabled=True,
|
|
||||||
overall=overall,
|
|
||||||
checks=checks,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def health_protocol(proxy: ProxyDocument) -> str:
|
|
||||||
return proxy.healthcheck.protocol or proxy.protocol
|
|
||||||
|
|
||||||
|
|
||||||
def listen_host_for_check(host: str) -> str:
|
|
||||||
if host in {"", "0.0.0.0", "::"}:
|
|
||||||
return "127.0.0.1"
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
async def check_target(
|
|
||||||
target: Target,
|
|
||||||
config: HealthCheckDocument,
|
|
||||||
) -> HealthCheckEntry:
|
|
||||||
try:
|
|
||||||
match target.protocol:
|
|
||||||
case "http":
|
|
||||||
return await check_http(target, config, use_tls=False)
|
|
||||||
case "https":
|
|
||||||
return await check_http(target, config, use_tls=True)
|
|
||||||
case "tcp":
|
|
||||||
return await check_tcp(target, config, use_tls=False)
|
|
||||||
case "tls":
|
|
||||||
return await check_tcp(target, config, use_tls=True)
|
|
||||||
case "udp" | "dtls" | "quic" | "dns" | "http3":
|
|
||||||
return await check_udp(target, config)
|
|
||||||
case _:
|
|
||||||
return result(target, False, f"Unsupported protocol: {target.protocol}")
|
|
||||||
except TimeoutError:
|
|
||||||
return result(target, False, "Timed out")
|
|
||||||
except OSError as error:
|
|
||||||
return result(target, False, str(error))
|
|
||||||
|
|
||||||
|
|
||||||
async def check_http(
|
|
||||||
target: Target,
|
|
||||||
config: HealthCheckDocument,
|
|
||||||
*,
|
|
||||||
use_tls: bool,
|
|
||||||
) -> HealthCheckEntry:
|
|
||||||
started = time.perf_counter()
|
|
||||||
reader, writer = await open_connection(target, config, use_tls=use_tls)
|
|
||||||
try:
|
|
||||||
path = config.path if config.path.startswith("/") else f"/{config.path}"
|
|
||||||
request = (
|
|
||||||
f"{config.method} {path} HTTP/1.1\r\n"
|
|
||||||
f"Host: {target.host}\r\n"
|
|
||||||
"Connection: close\r\n\r\n"
|
|
||||||
)
|
|
||||||
writer.write(request.encode("ascii"))
|
|
||||||
await writer.drain()
|
|
||||||
line = await asyncio.wait_for(reader.readline(), config.timeout_seconds)
|
|
||||||
finally:
|
|
||||||
writer.close()
|
|
||||||
await writer.wait_closed()
|
|
||||||
|
|
||||||
parts = line.decode("iso-8859-1", errors="replace").split()
|
|
||||||
status = int(parts[1]) if len(parts) >= 2 and parts[1].isdigit() else None
|
|
||||||
expected = config.expected_status
|
|
||||||
healthy = status == expected if expected is not None else status is not None
|
|
||||||
return result(
|
|
||||||
target,
|
|
||||||
healthy,
|
|
||||||
None,
|
|
||||||
{"status": status},
|
|
||||||
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def check_tcp(
|
|
||||||
target: Target,
|
|
||||||
config: HealthCheckDocument,
|
|
||||||
*,
|
|
||||||
use_tls: bool,
|
|
||||||
) -> HealthCheckEntry:
|
|
||||||
started = time.perf_counter()
|
|
||||||
_reader, writer = await open_connection(target, config, use_tls=use_tls)
|
|
||||||
writer.close()
|
|
||||||
await writer.wait_closed()
|
|
||||||
return result(
|
|
||||||
target,
|
|
||||||
True,
|
|
||||||
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def check_udp(target: Target, config: HealthCheckDocument) -> HealthCheckEntry:
|
|
||||||
started = time.perf_counter()
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
transport, protocol = await loop.create_datagram_endpoint(
|
|
||||||
lambda: DatagramProbe(config.expect_response),
|
|
||||||
remote_addr=(target.host, target.port),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
payload = config.udp_payload.encode()
|
|
||||||
transport.sendto(payload)
|
|
||||||
if not config.expect_response:
|
|
||||||
return result(target, True, None, {"mode": "send-only"})
|
|
||||||
data = await asyncio.wait_for(protocol.response, config.timeout_seconds)
|
|
||||||
return result(
|
|
||||||
target,
|
|
||||||
True,
|
|
||||||
None,
|
|
||||||
{"bytes": len(data)},
|
|
||||||
benchmark_ms=elapsed_ms(started) if config.benchmark_enabled else None,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
transport.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def open_connection(
|
|
||||||
target: Target,
|
|
||||||
config: HealthCheckDocument,
|
|
||||||
*,
|
|
||||||
use_tls: bool,
|
|
||||||
):
|
|
||||||
if use_tls and config.verify_tls:
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
elif use_tls:
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
context.check_hostname = False
|
|
||||||
context.verify_mode = ssl.CERT_NONE
|
|
||||||
else:
|
|
||||||
context = None
|
|
||||||
return await asyncio.wait_for(
|
|
||||||
asyncio.open_connection(target.host, target.port, ssl=context),
|
|
||||||
config.timeout_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DatagramProbe(asyncio.DatagramProtocol):
|
|
||||||
def __init__(self, expect_response: bool):
|
|
||||||
self.response = asyncio.get_running_loop().create_future()
|
|
||||||
if not expect_response:
|
|
||||||
self.response.set_result(b"")
|
|
||||||
|
|
||||||
def datagram_received(self, data, addr):
|
|
||||||
if not self.response.done():
|
|
||||||
self.response.set_result(data)
|
|
||||||
|
|
||||||
def error_received(self, exc):
|
|
||||||
if not self.response.done():
|
|
||||||
self.response.set_exception(exc)
|
|
||||||
|
|
||||||
|
|
||||||
def result(
|
|
||||||
target: Target,
|
|
||||||
healthy: bool,
|
|
||||||
error: str | None = None,
|
|
||||||
details: dict[str, object] | None = None,
|
|
||||||
benchmark_ms: float | None = None,
|
|
||||||
) -> HealthCheckEntry:
|
|
||||||
return HealthCheckEntry(
|
|
||||||
target=target.name,
|
|
||||||
host=target.host,
|
|
||||||
port=target.port,
|
|
||||||
protocol=target.protocol,
|
|
||||||
healthy=healthy,
|
|
||||||
error=error,
|
|
||||||
duration_ms=benchmark_ms,
|
|
||||||
details=details or {},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def elapsed_ms(started: float) -> float:
|
|
||||||
return round((time.perf_counter() - started) * 1000, 2)
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.models.health import HealthSnapshot
|
|
||||||
from app.models.proxy import ProxyDocument
|
|
||||||
from app.repositories.health import record_health_sample
|
|
||||||
from app.repositories.proxy import list_proxies
|
|
||||||
from app.services.mitmproxy.health import check_proxy
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class HealthMonitor:
|
|
||||||
def __init__(self):
|
|
||||||
self.task: asyncio.Task[None] | None = None
|
|
||||||
self.subscribers: dict[int, set[asyncio.Queue[HealthSnapshot]]] = {}
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
if self.task is None or self.task.done():
|
|
||||||
self.task = asyncio.create_task(self.run())
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
if self.task is None:
|
|
||||||
return
|
|
||||||
self.task.cancel()
|
|
||||||
await asyncio.gather(self.task, return_exceptions=True)
|
|
||||||
self.task = None
|
|
||||||
|
|
||||||
async def run(self) -> None:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
next_cycle = loop.time()
|
|
||||||
while True:
|
|
||||||
delay = next_cycle - loop.time()
|
|
||||||
if delay > 0:
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
|
|
||||||
cycle_started = loop.time()
|
|
||||||
try:
|
|
||||||
await self.run_cycle()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Health monitor cycle failed")
|
|
||||||
next_cycle = loop.time() + min(
|
|
||||||
1.0,
|
|
||||||
float(settings.HEALTHCHECK_INTERVAL_SECONDS),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
next_cycle = cycle_started + settings.HEALTHCHECK_INTERVAL_SECONDS
|
|
||||||
|
|
||||||
async def run_cycle(self) -> None:
|
|
||||||
proxies = [
|
|
||||||
proxy
|
|
||||||
for proxy in await list_proxies()
|
|
||||||
if proxy.status == "running" and proxy.healthcheck.enabled
|
|
||||||
]
|
|
||||||
async with asyncio.TaskGroup() as group:
|
|
||||||
for proxy in proxies:
|
|
||||||
group.create_task(self.run_check(proxy))
|
|
||||||
|
|
||||||
async def run_check(self, proxy: ProxyDocument) -> None:
|
|
||||||
try:
|
|
||||||
snapshot = await check_proxy(proxy)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Health check failed for proxy %s",
|
|
||||||
proxy.proxy_port,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
await record_health_sample(snapshot)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Health snapshot persistence failed for proxy %s",
|
|
||||||
proxy.proxy_port,
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.publish(proxy.proxy_port, snapshot)
|
|
||||||
|
|
||||||
async def publish(self, proxy_port: int, snapshot: HealthSnapshot) -> None:
|
|
||||||
for queue in list(self.subscribers.get(proxy_port, set())):
|
|
||||||
try:
|
|
||||||
queue.put_nowait(snapshot)
|
|
||||||
except asyncio.QueueFull:
|
|
||||||
logger.debug(
|
|
||||||
"Dropped health update for slow subscriber on proxy %s",
|
|
||||||
proxy_port,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
def subscribe(self, proxy_port: int) -> asyncio.Queue[HealthSnapshot]:
|
|
||||||
queue: asyncio.Queue[HealthSnapshot] = asyncio.Queue(maxsize=8)
|
|
||||||
listeners = self.subscribers.setdefault(proxy_port, set())
|
|
||||||
listeners.add(queue)
|
|
||||||
return queue
|
|
||||||
|
|
||||||
def unsubscribe(
|
|
||||||
self,
|
|
||||||
proxy_port: int,
|
|
||||||
queue: asyncio.Queue[HealthSnapshot],
|
|
||||||
) -> None:
|
|
||||||
listeners = self.subscribers.get(proxy_port)
|
|
||||||
if listeners is None:
|
|
||||||
return
|
|
||||||
listeners.discard(queue)
|
|
||||||
if not listeners:
|
|
||||||
self.subscribers.pop(proxy_port, None)
|
|
||||||
|
|
||||||
|
|
||||||
health_monitor = HealthMonitor()
|
|
||||||
@@ -1,443 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import signal
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.integrations.docker import change_port
|
|
||||||
from app.integrations.docker.ports import get_random_port
|
|
||||||
from app.models.proxy import ProxyDocument
|
|
||||||
from app.services.mitmproxy.options import MitmOptionError, validate_options
|
|
||||||
from app.services.mitmproxy.runtime_files import (
|
|
||||||
conf_dir_for,
|
|
||||||
config_path_for,
|
|
||||||
ensure_log_path,
|
|
||||||
log_path_for,
|
|
||||||
read_cmdline,
|
|
||||||
read_log_tail,
|
|
||||||
remove_dir,
|
|
||||||
remove_file,
|
|
||||||
script_path_for,
|
|
||||||
write_config,
|
|
||||||
write_script,
|
|
||||||
)
|
|
||||||
from app.services.mitmproxy.runtime_validation import (
|
|
||||||
ReservedProxyPortError,
|
|
||||||
ensure_proxy_port_not_reserved,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MitmdumpError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class MitmdumpManager:
|
|
||||||
def __init__(self, work_dir: str = "/tmp/theplug", command: str = "mitmdump"):
|
|
||||||
self.work_dir = Path(work_dir)
|
|
||||||
self.command = command
|
|
||||||
self.processes: dict[int, asyncio.subprocess.Process] = {}
|
|
||||||
self.attached_pids: dict[int, int] = {}
|
|
||||||
self.locks: dict[int, asyncio.Lock] = {}
|
|
||||||
self.upstream_port_attempts = 8
|
|
||||||
|
|
||||||
async def start(self, proxy: ProxyDocument) -> dict[str, object]:
|
|
||||||
async with self.lock_for(proxy.proxy_port):
|
|
||||||
return await self.start_unlocked(proxy)
|
|
||||||
|
|
||||||
async def stop(self, proxy: ProxyDocument) -> dict[str, object]:
|
|
||||||
async with self.lock_for(proxy.proxy_port):
|
|
||||||
return await self.stop_unlocked(proxy)
|
|
||||||
|
|
||||||
def cleanup_runtime_artifacts(self, proxy: ProxyDocument) -> None:
|
|
||||||
script_path = (
|
|
||||||
Path(proxy.script_path)
|
|
||||||
if proxy.script_path
|
|
||||||
else self.script_path_for(proxy)
|
|
||||||
)
|
|
||||||
log_path = Path(proxy.log_path) if proxy.log_path else self.log_path_for(proxy)
|
|
||||||
config_path = (
|
|
||||||
Path(proxy.config_path)
|
|
||||||
if proxy.config_path
|
|
||||||
else self.config_path_for(proxy)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.remove_file(script_path)
|
|
||||||
self.remove_file(log_path)
|
|
||||||
self.remove_file(config_path)
|
|
||||||
self.remove_dir(self.conf_dir_for(proxy))
|
|
||||||
|
|
||||||
async def start_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
|
|
||||||
try:
|
|
||||||
ensure_proxy_port_not_reserved(proxy.proxy_port)
|
|
||||||
except ReservedProxyPortError as error:
|
|
||||||
raise MitmdumpError(str(error)) from error
|
|
||||||
|
|
||||||
if await self.status(proxy) == "running":
|
|
||||||
raise MitmdumpError("Proxy is already running")
|
|
||||||
|
|
||||||
command_path = shutil.which(self.command)
|
|
||||||
if command_path is None:
|
|
||||||
raise MitmdumpError("mitmdump executable not found in PATH")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await validate_options(proxy.mitm_options)
|
|
||||||
except MitmOptionError as error:
|
|
||||||
raise MitmdumpError(str(error)) from error
|
|
||||||
upstream_port = await self.prepare_upstream(proxy)
|
|
||||||
script_path = await self.write_script(proxy)
|
|
||||||
config_path = await self.write_config(proxy)
|
|
||||||
log_path = await self.log_path(proxy)
|
|
||||||
command = self.build_command(
|
|
||||||
command_path,
|
|
||||||
proxy,
|
|
||||||
upstream_port,
|
|
||||||
script_path,
|
|
||||||
config_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
process = await self.spawn(command, log_path)
|
|
||||||
except Exception as error:
|
|
||||||
await self.restore_upstream(proxy, upstream_port)
|
|
||||||
raise MitmdumpError(str(error)) from error
|
|
||||||
|
|
||||||
failed = await self.exited_early(process)
|
|
||||||
if failed:
|
|
||||||
await self.restore_upstream(proxy, upstream_port)
|
|
||||||
reason = (
|
|
||||||
await self.read_log_tail(log_path)
|
|
||||||
or "mitmdump exited during startup"
|
|
||||||
)
|
|
||||||
raise MitmdumpError(reason)
|
|
||||||
|
|
||||||
self.processes[proxy.proxy_port] = process
|
|
||||||
self.attached_pids.pop(proxy.proxy_port, None)
|
|
||||||
return {
|
|
||||||
"status": "running",
|
|
||||||
"pid": process.pid,
|
|
||||||
"script_path": str(script_path),
|
|
||||||
"config_path": str(config_path),
|
|
||||||
"log_path": str(log_path),
|
|
||||||
"service.upstream_port": upstream_port,
|
|
||||||
"last_error": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def stop_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
|
|
||||||
process = self.processes.pop(proxy.proxy_port, None)
|
|
||||||
self.attached_pids.pop(proxy.proxy_port, None)
|
|
||||||
if process is not None and process.returncode is None:
|
|
||||||
await self.stop_process(process)
|
|
||||||
elif proxy.pid is not None:
|
|
||||||
await self.stop_pid(proxy.pid)
|
|
||||||
|
|
||||||
await self.restore_upstream(proxy, proxy.service.upstream_port)
|
|
||||||
return {
|
|
||||||
"status": "stopped",
|
|
||||||
"pid": None,
|
|
||||||
"service.upstream_port": None,
|
|
||||||
"last_error": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def status(self, proxy: ProxyDocument) -> str:
|
|
||||||
process = self.processes.get(proxy.proxy_port)
|
|
||||||
if process is not None:
|
|
||||||
if process.returncode is None:
|
|
||||||
return "running"
|
|
||||||
self.processes.pop(proxy.proxy_port, None)
|
|
||||||
self.attached_pids.pop(proxy.proxy_port, None)
|
|
||||||
return "stopped"
|
|
||||||
|
|
||||||
pid = self.attached_pids.get(proxy.proxy_port, proxy.pid)
|
|
||||||
if pid is None:
|
|
||||||
return "stopped" if proxy.status == "running" else proxy.status
|
|
||||||
|
|
||||||
if not self.is_process_alive(pid):
|
|
||||||
self.attached_pids.pop(proxy.proxy_port, None)
|
|
||||||
return "stopped"
|
|
||||||
if not self.matches_proxy_process(proxy, pid):
|
|
||||||
self.attached_pids.pop(proxy.proxy_port, None)
|
|
||||||
return "stopped"
|
|
||||||
return "running"
|
|
||||||
|
|
||||||
async def reconcile(self, proxy: ProxyDocument) -> dict[str, object] | None:
|
|
||||||
if not self.needs_recovery(proxy):
|
|
||||||
return None
|
|
||||||
|
|
||||||
if proxy.pid is not None and self.is_process_alive(proxy.pid):
|
|
||||||
if self.matches_proxy_process(proxy, proxy.pid):
|
|
||||||
self.reattach(proxy)
|
|
||||||
return {
|
|
||||||
"status": "running",
|
|
||||||
"pid": proxy.pid,
|
|
||||||
"script_path": str(self.script_path_for(proxy)),
|
|
||||||
"config_path": str(self.config_path_for(proxy)),
|
|
||||||
"log_path": str(self.log_path_for(proxy)),
|
|
||||||
"last_error": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
self.attached_pids.pop(proxy.proxy_port, None)
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"pid": None,
|
|
||||||
"last_error": (
|
|
||||||
f"Found a live pid {proxy.pid}, but its command does not match "
|
|
||||||
"the expected mitmdump runtime for this proxy."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.needs_upstream_restore(proxy):
|
|
||||||
restored = await self.restore_upstream(proxy, proxy.service.upstream_port)
|
|
||||||
if not restored:
|
|
||||||
return {
|
|
||||||
"status": "error",
|
|
||||||
"pid": None,
|
|
||||||
"last_error": (
|
|
||||||
"Proxy process is gone and the upstream service port "
|
|
||||||
"could not be restored automatically."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"status": "stopped",
|
|
||||||
"pid": None,
|
|
||||||
"service.upstream_port": None,
|
|
||||||
"last_error": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
if proxy.status == "running" or proxy.pid is not None:
|
|
||||||
return {"status": "stopped", "pid": None, "last_error": None}
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def prepare_upstream(self, proxy: ProxyDocument) -> int | None:
|
|
||||||
if proxy.mode_spec is not None or proxy.mode != "reverse":
|
|
||||||
return None
|
|
||||||
if proxy.service.upstream_port is not None:
|
|
||||||
return proxy.service.upstream_port
|
|
||||||
|
|
||||||
if proxy.service.name is None or proxy.service.folder is None:
|
|
||||||
return proxy.service.port
|
|
||||||
|
|
||||||
attempted_ports: set[int] = {proxy.proxy_port, proxy.service.port}
|
|
||||||
for _attempt in range(self.upstream_port_attempts):
|
|
||||||
upstream_port = self.pick_upstream_port(attempted_ports)
|
|
||||||
attempted_ports.add(upstream_port)
|
|
||||||
changed = await change_port(
|
|
||||||
proxy.service.name,
|
|
||||||
Path(proxy.service.folder),
|
|
||||||
proxy.service.port,
|
|
||||||
upstream_port,
|
|
||||||
host_to="127.0.0.1",
|
|
||||||
rewrite_host=True,
|
|
||||||
)
|
|
||||||
if changed:
|
|
||||||
return upstream_port
|
|
||||||
|
|
||||||
raise MitmdumpError("Unable to move Docker service port after retries")
|
|
||||||
|
|
||||||
async def restore_upstream(
|
|
||||||
self,
|
|
||||||
proxy: ProxyDocument,
|
|
||||||
upstream_port: int | None,
|
|
||||||
) -> bool:
|
|
||||||
if upstream_port is None:
|
|
||||||
return True
|
|
||||||
if proxy.service.name is None or proxy.service.folder is None:
|
|
||||||
return True
|
|
||||||
if upstream_port == proxy.service.port:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return await change_port(
|
|
||||||
proxy.service.name,
|
|
||||||
Path(proxy.service.folder),
|
|
||||||
upstream_port,
|
|
||||||
proxy.service.port,
|
|
||||||
host_to=proxy.service.bind_host,
|
|
||||||
rewrite_host=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def build_command(
|
|
||||||
self,
|
|
||||||
command_path: str,
|
|
||||||
proxy: ProxyDocument,
|
|
||||||
upstream_port: int | None,
|
|
||||||
script_path: Path,
|
|
||||||
config_path: Path,
|
|
||||||
) -> list[str]:
|
|
||||||
command = [
|
|
||||||
command_path,
|
|
||||||
"--mode",
|
|
||||||
self.mode_for(proxy, upstream_port),
|
|
||||||
"--listen-host",
|
|
||||||
proxy.listen_host,
|
|
||||||
"--set",
|
|
||||||
f"confdir={config_path.parent}",
|
|
||||||
"-s",
|
|
||||||
str(script_path),
|
|
||||||
]
|
|
||||||
if self.needs_listen_port(proxy):
|
|
||||||
command.extend(["--listen-port", str(proxy.proxy_port)])
|
|
||||||
return command
|
|
||||||
|
|
||||||
def mode_for(self, proxy: ProxyDocument, upstream_port: int | None) -> str:
|
|
||||||
if proxy.mode_spec:
|
|
||||||
return proxy.mode_spec
|
|
||||||
|
|
||||||
match proxy.mode:
|
|
||||||
case "reverse":
|
|
||||||
port = upstream_port or proxy.service.port
|
|
||||||
return (
|
|
||||||
f"reverse:{proxy.protocol}://{proxy.service.host}:{port}"
|
|
||||||
f"@{proxy.proxy_port}"
|
|
||||||
)
|
|
||||||
case "upstream":
|
|
||||||
return (
|
|
||||||
f"upstream:{proxy.protocol}://{proxy.service.host}:"
|
|
||||||
f"{proxy.service.port}@{proxy.proxy_port}"
|
|
||||||
)
|
|
||||||
case "wireguard":
|
|
||||||
return f"wireguard@{proxy.proxy_port}"
|
|
||||||
case _:
|
|
||||||
return proxy.mode
|
|
||||||
|
|
||||||
def needs_listen_port(self, proxy: ProxyDocument) -> bool:
|
|
||||||
if proxy.mode_spec is not None:
|
|
||||||
return False
|
|
||||||
return proxy.mode in {"regular", "socks5", "dns", "transparent"}
|
|
||||||
|
|
||||||
def pick_upstream_port(self, excluded_ports: set[int]) -> int:
|
|
||||||
while True:
|
|
||||||
candidate = get_random_port()
|
|
||||||
if candidate not in excluded_ports:
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
async def write_script(self, proxy: ProxyDocument) -> Path:
|
|
||||||
return await write_script(self.work_dir, proxy)
|
|
||||||
|
|
||||||
async def write_config(self, proxy: ProxyDocument) -> Path:
|
|
||||||
return await write_config(self.work_dir, proxy)
|
|
||||||
|
|
||||||
async def log_path(self, proxy: ProxyDocument) -> Path:
|
|
||||||
return await ensure_log_path(self.work_dir, proxy)
|
|
||||||
|
|
||||||
async def spawn(self, command: list[str], log_path: Path):
|
|
||||||
with log_path.open("ab") as log_file:
|
|
||||||
return await asyncio.create_subprocess_exec(
|
|
||||||
*command,
|
|
||||||
stdout=log_file,
|
|
||||||
stderr=log_file,
|
|
||||||
start_new_session=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def stop_process(self, process) -> None:
|
|
||||||
await self.stop_pid(process.pid)
|
|
||||||
|
|
||||||
async def exited_early(self, process) -> bool:
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(process.wait(), timeout=0.5)
|
|
||||||
except TimeoutError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def stop_pid(self, pid: int) -> None:
|
|
||||||
try:
|
|
||||||
pgid = os.getpgid(pid)
|
|
||||||
except ProcessLookupError:
|
|
||||||
return
|
|
||||||
except PermissionError:
|
|
||||||
pgid = pid
|
|
||||||
|
|
||||||
try:
|
|
||||||
os.killpg(pgid, signal.SIGTERM)
|
|
||||||
except ProcessLookupError:
|
|
||||||
return
|
|
||||||
except PermissionError:
|
|
||||||
try:
|
|
||||||
os.kill(pid, signal.SIGTERM)
|
|
||||||
except ProcessLookupError:
|
|
||||||
return
|
|
||||||
|
|
||||||
for _ in range(10):
|
|
||||||
await asyncio.sleep(0.2)
|
|
||||||
if not self.is_process_alive(pid):
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
os.killpg(pgid, signal.SIGKILL)
|
|
||||||
except ProcessLookupError:
|
|
||||||
pass
|
|
||||||
except PermissionError:
|
|
||||||
try:
|
|
||||||
os.kill(pid, signal.SIGKILL)
|
|
||||||
except ProcessLookupError:
|
|
||||||
return
|
|
||||||
|
|
||||||
def reattach(self, proxy: ProxyDocument) -> None:
|
|
||||||
if proxy.pid is not None:
|
|
||||||
self.attached_pids[proxy.proxy_port] = proxy.pid
|
|
||||||
|
|
||||||
def needs_recovery(self, proxy: ProxyDocument) -> bool:
|
|
||||||
return (
|
|
||||||
proxy.status == "running"
|
|
||||||
or proxy.pid is not None
|
|
||||||
or self.needs_upstream_restore(proxy)
|
|
||||||
)
|
|
||||||
|
|
||||||
def needs_upstream_restore(self, proxy: ProxyDocument) -> bool:
|
|
||||||
upstream_port = proxy.service.upstream_port
|
|
||||||
return upstream_port is not None and upstream_port != proxy.service.port
|
|
||||||
|
|
||||||
def is_process_alive(self, pid: int) -> bool:
|
|
||||||
try:
|
|
||||||
os.kill(pid, 0)
|
|
||||||
except ProcessLookupError:
|
|
||||||
return False
|
|
||||||
except PermissionError:
|
|
||||||
return True
|
|
||||||
return True
|
|
||||||
|
|
||||||
def matches_proxy_process(self, proxy: ProxyDocument, pid: int) -> bool:
|
|
||||||
cmdline = self.read_cmdline(pid)
|
|
||||||
if not cmdline:
|
|
||||||
return False
|
|
||||||
|
|
||||||
expected_command = Path(self.command).name
|
|
||||||
if not any(Path(part).name == expected_command for part in cmdline):
|
|
||||||
return False
|
|
||||||
|
|
||||||
expected_script = str(self.script_path_for(proxy))
|
|
||||||
expected_confdir = f"confdir={self.conf_dir_for(proxy)}"
|
|
||||||
return expected_script in cmdline and expected_confdir in cmdline
|
|
||||||
|
|
||||||
def read_cmdline(self, pid: int) -> list[str]:
|
|
||||||
return read_cmdline(pid)
|
|
||||||
|
|
||||||
def script_path_for(self, proxy: ProxyDocument) -> Path:
|
|
||||||
return script_path_for(self.work_dir, proxy)
|
|
||||||
|
|
||||||
def conf_dir_for(self, proxy: ProxyDocument) -> Path:
|
|
||||||
return conf_dir_for(self.work_dir, proxy)
|
|
||||||
|
|
||||||
def config_path_for(self, proxy: ProxyDocument) -> Path:
|
|
||||||
return config_path_for(self.work_dir, proxy)
|
|
||||||
|
|
||||||
def log_path_for(self, proxy: ProxyDocument) -> Path:
|
|
||||||
return log_path_for(self.work_dir, proxy)
|
|
||||||
|
|
||||||
async def read_log_tail(self, path: Path, limit: int = 4000) -> str:
|
|
||||||
return await read_log_tail(path, limit=limit)
|
|
||||||
|
|
||||||
def remove_file(self, path: Path) -> None:
|
|
||||||
remove_file(path)
|
|
||||||
|
|
||||||
def remove_dir(self, path: Path) -> None:
|
|
||||||
remove_dir(path)
|
|
||||||
|
|
||||||
def lock_for(self, proxy_port: int) -> asyncio.Lock:
|
|
||||||
lock = self.locks.get(proxy_port)
|
|
||||||
if lock is None:
|
|
||||||
lock = asyncio.Lock()
|
|
||||||
self.locks[proxy_port] = lock
|
|
||||||
return lock
|
|
||||||
|
|
||||||
|
|
||||||
manager = MitmdumpManager()
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
RESERVED_OPTIONS = {"mode", "listen_host", "listen_port", "scripts"}
|
|
||||||
OPTIONS_DOCS_URL = "https://docs.mitmproxy.org/stable/concepts/options/"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class MitmOption:
|
|
||||||
name: str
|
|
||||||
value_type: str
|
|
||||||
default: Any
|
|
||||||
description: str
|
|
||||||
choices: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class MitmOptionError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
catalog_cache: dict[str, MitmOption] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
async def load_options() -> dict[str, MitmOption]:
|
|
||||||
global catalog_cache
|
|
||||||
if catalog_cache is None:
|
|
||||||
catalog_cache = await read_options()
|
|
||||||
return catalog_cache
|
|
||||||
|
|
||||||
|
|
||||||
async def read_options(command: str = "mitmdump") -> dict[str, MitmOption]:
|
|
||||||
command_path = shutil.which(command)
|
|
||||||
if command_path is None:
|
|
||||||
raise MitmOptionError("mitmdump executable not found in PATH")
|
|
||||||
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
command_path,
|
|
||||||
"--options",
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
stdout, stderr = await process.communicate()
|
|
||||||
if process.returncode != 0:
|
|
||||||
message = stderr.decode("utf-8", errors="replace").strip()
|
|
||||||
raise MitmOptionError(message or "Unable to load mitmdump options")
|
|
||||||
|
|
||||||
text = stdout.decode("utf-8", errors="replace")
|
|
||||||
defaults = yaml.safe_load(text) or {}
|
|
||||||
return parse_options(text, defaults)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_options(text: str, defaults: dict[str, Any]) -> dict[str, MitmOption]:
|
|
||||||
comments: list[str] = []
|
|
||||||
options: dict[str, MitmOption] = {}
|
|
||||||
|
|
||||||
for line in text.splitlines():
|
|
||||||
stripped = line.strip()
|
|
||||||
if not stripped:
|
|
||||||
comments = []
|
|
||||||
continue
|
|
||||||
if stripped.startswith("#"):
|
|
||||||
comments.append(stripped[1:].strip())
|
|
||||||
continue
|
|
||||||
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*:", stripped):
|
|
||||||
continue
|
|
||||||
|
|
||||||
name = stripped.split(":", 1)[0]
|
|
||||||
description = " ".join(comments)
|
|
||||||
options[name] = MitmOption(
|
|
||||||
name=name,
|
|
||||||
value_type=parse_type(description),
|
|
||||||
default=defaults.get(name),
|
|
||||||
description=description,
|
|
||||||
choices=parse_choices(description),
|
|
||||||
)
|
|
||||||
comments = []
|
|
||||||
|
|
||||||
return options
|
|
||||||
|
|
||||||
|
|
||||||
def parse_type(description: str) -> str:
|
|
||||||
match = re.search(r"Type ([^.]+)\.", description)
|
|
||||||
if match is None:
|
|
||||||
return "unknown"
|
|
||||||
return match.group(1).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_choices(description: str) -> list[str]:
|
|
||||||
match = re.search(r"Valid values are ([^.]+)\.", description)
|
|
||||||
if match is None:
|
|
||||||
return []
|
|
||||||
return re.findall(r"'([^']+)'", match.group(1))
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_options(options: dict[str, Any]) -> None:
|
|
||||||
catalog = await load_options()
|
|
||||||
errors: list[str] = []
|
|
||||||
|
|
||||||
for name, value in options.items():
|
|
||||||
option = catalog.get(name)
|
|
||||||
if option is None:
|
|
||||||
errors.append(f"Unknown mitmdump option: {name}")
|
|
||||||
continue
|
|
||||||
if name in RESERVED_OPTIONS:
|
|
||||||
errors.append(f"Option is managed by thePlug and cannot be set: {name}")
|
|
||||||
continue
|
|
||||||
message = validate_value(option, value)
|
|
||||||
if message:
|
|
||||||
errors.append(f"{name}: {message}")
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
raise MitmOptionError("; ".join(errors))
|
|
||||||
|
|
||||||
|
|
||||||
def validate_value(option: MitmOption, value: Any) -> str | None:
|
|
||||||
value_type = option.value_type
|
|
||||||
if option.choices and str(value) not in option.choices:
|
|
||||||
return f"expected one of {', '.join(option.choices)}"
|
|
||||||
if value is None:
|
|
||||||
if value_type.startswith("optional "):
|
|
||||||
return None
|
|
||||||
return "cannot be null"
|
|
||||||
if value_type == "bool" and not isinstance(value, bool):
|
|
||||||
return "expected bool"
|
|
||||||
if value_type == "int" and not isinstance(value, int):
|
|
||||||
return "expected int"
|
|
||||||
if value_type == "str" and not isinstance(value, str):
|
|
||||||
return "expected str"
|
|
||||||
if value_type == "sequence of str":
|
|
||||||
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
||||||
return "expected list[str]"
|
|
||||||
if value_type == "optional str" and not isinstance(value, str):
|
|
||||||
return "expected str or null"
|
|
||||||
if value_type == "optional int" and not isinstance(value, int):
|
|
||||||
return "expected int or null"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def public_options(options: dict[str, MitmOption]) -> list[dict[str, Any]]:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"name": option.name,
|
|
||||||
"type": option.value_type,
|
|
||||||
"default": option.default,
|
|
||||||
"description": option.description,
|
|
||||||
"choices": option.choices,
|
|
||||||
"reserved": option.name in RESERVED_OPTIONS,
|
|
||||||
"docs_url": f"{OPTIONS_DOCS_URL}#{option.name}",
|
|
||||||
}
|
|
||||||
for option in options.values()
|
|
||||||
]
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from app.repositories.proxy import list_proxies, update_proxy_runtime
|
|
||||||
from app.services.mitmproxy.manager import manager
|
|
||||||
|
|
||||||
|
|
||||||
async def reconcile_runtime() -> list[tuple[int, dict[str, object]]]:
|
|
||||||
changes: list[tuple[int, dict[str, object]]] = []
|
|
||||||
|
|
||||||
for proxy in await list_proxies():
|
|
||||||
updates = await manager.reconcile(proxy)
|
|
||||||
if updates is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
await update_proxy_runtime(proxy.proxy_port, updates)
|
|
||||||
changes.append((proxy.proxy_port, updates))
|
|
||||||
|
|
||||||
return changes
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from app.models.proxy import ProxyDocument
|
|
||||||
|
|
||||||
|
|
||||||
def script_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
|
||||||
return work_dir / "addons" / f"{proxy.proxy_port}.py"
|
|
||||||
|
|
||||||
|
|
||||||
def conf_dir_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
|
||||||
return work_dir / "conf" / str(proxy.proxy_port)
|
|
||||||
|
|
||||||
|
|
||||||
def config_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
|
||||||
return conf_dir_for(work_dir, proxy) / "config.yaml"
|
|
||||||
|
|
||||||
|
|
||||||
def log_path_for(work_dir: Path, proxy: ProxyDocument) -> Path:
|
|
||||||
return work_dir / "logs" / f"{proxy.proxy_port}.log"
|
|
||||||
|
|
||||||
|
|
||||||
async def write_script(work_dir: Path, proxy: ProxyDocument) -> Path:
|
|
||||||
path = script_path_for(work_dir, proxy)
|
|
||||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
|
||||||
await asyncio.to_thread(path.write_text, proxy.code, encoding="utf-8")
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
async def write_config(work_dir: Path, proxy: ProxyDocument) -> Path:
|
|
||||||
path = config_path_for(work_dir, proxy)
|
|
||||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
|
||||||
options = {"block_global": False, **proxy.mitm_options}
|
|
||||||
config_text = yaml.safe_dump(options, sort_keys=True)
|
|
||||||
await asyncio.to_thread(path.write_text, config_text, encoding="utf-8")
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
async def ensure_log_path(work_dir: Path, proxy: ProxyDocument) -> Path:
|
|
||||||
path = log_path_for(work_dir, proxy)
|
|
||||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
async def read_log_tail(path: Path, limit: int = 4000) -> str:
|
|
||||||
if not await asyncio.to_thread(path.exists):
|
|
||||||
return ""
|
|
||||||
content = await asyncio.to_thread(
|
|
||||||
path.read_text,
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace",
|
|
||||||
)
|
|
||||||
return content[-limit:].strip()
|
|
||||||
|
|
||||||
|
|
||||||
def remove_file(path: Path) -> None:
|
|
||||||
try:
|
|
||||||
path.unlink(missing_ok=True)
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def remove_dir(path: Path) -> None:
|
|
||||||
try:
|
|
||||||
shutil.rmtree(path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return
|
|
||||||
except OSError:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def read_cmdline(pid: int) -> list[str]:
|
|
||||||
path = Path(f"/proc/{pid}/cmdline")
|
|
||||||
try:
|
|
||||||
raw = path.read_bytes()
|
|
||||||
except OSError:
|
|
||||||
return []
|
|
||||||
decoded = raw.decode("utf-8", errors="replace")
|
|
||||||
return [part for part in decoded.split("\x00") if part]
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
|
|
||||||
class ReservedProxyPortError(ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def backend_port() -> int:
|
|
||||||
return settings.API_PORT
|
|
||||||
|
|
||||||
|
|
||||||
def reserved_proxy_port_message(proxy_port: int) -> str:
|
|
||||||
return (
|
|
||||||
f"Proxy port {proxy_port} is reserved for the backend API "
|
|
||||||
"and cannot be used by mitmdump."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_proxy_port_not_reserved(proxy_port: int) -> None:
|
|
||||||
if proxy_port == backend_port():
|
|
||||||
raise ReservedProxyPortError(reserved_proxy_port_message(proxy_port))
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from collections import defaultdict
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from app.models.template import ProxyTemplateDocument
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
SUPPORTED_FILE_TEMPLATE_PROTOCOLS = frozenset({"http", "tcp"})
|
|
||||||
TEMPLATES_DIR = Path(__file__).resolve().parents[4] / "templates"
|
|
||||||
|
|
||||||
|
|
||||||
def template_directory() -> Path:
|
|
||||||
return TEMPLATES_DIR
|
|
||||||
|
|
||||||
|
|
||||||
def template_files(directory: Path | None = None) -> list[Path]:
|
|
||||||
root = directory or template_directory()
|
|
||||||
if not root.exists():
|
|
||||||
return []
|
|
||||||
return sorted(
|
|
||||||
path
|
|
||||||
for path in root.iterdir()
|
|
||||||
if path.is_file() and path.suffix.lower() in {".yaml", ".yml"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _default_category(protocol: str, family: str) -> str:
|
|
||||||
return protocol if protocol in SUPPORTED_FILE_TEMPLATE_PROTOCOLS else family
|
|
||||||
|
|
||||||
|
|
||||||
def _string_value(value: object, default: str = "") -> str:
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_template_payload(
|
|
||||||
raw: Mapping[str, object],
|
|
||||||
source: Path,
|
|
||||||
) -> tuple[dict[str, object], str]:
|
|
||||||
protocol = _string_value(raw.get("protocol")).strip().lower()
|
|
||||||
if protocol not in SUPPORTED_FILE_TEMPLATE_PROTOCOLS:
|
|
||||||
expected = sorted(SUPPORTED_FILE_TEMPLATE_PROTOCOLS)
|
|
||||||
raise ValueError(
|
|
||||||
f"unsupported protocol {protocol!r}; expected one of {expected}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if "id" not in raw or "name" not in raw or "code" not in raw:
|
|
||||||
raise ValueError("missing one of required fields: id, name, code")
|
|
||||||
|
|
||||||
description = _string_value(
|
|
||||||
raw.get("description") or raw.get("summary") or raw.get("behavior")
|
|
||||||
)
|
|
||||||
family = _string_value(raw.get("family") or protocol)
|
|
||||||
source_url = raw.get("source_url")
|
|
||||||
tags = raw.get("tags")
|
|
||||||
normalized_tags = [str(tag) for tag in tags] if isinstance(tags, list) else []
|
|
||||||
|
|
||||||
payload: dict[str, object] = {
|
|
||||||
"id": _string_value(raw["id"]).strip(),
|
|
||||||
"name": _string_value(raw["name"]).strip(),
|
|
||||||
"description": description,
|
|
||||||
"category": _string_value(
|
|
||||||
raw.get("category") or _default_category(protocol, family)
|
|
||||||
),
|
|
||||||
"protocol": protocol,
|
|
||||||
"family": family,
|
|
||||||
"tags": normalized_tags,
|
|
||||||
"action": _string_value(raw.get("action") or "custom"),
|
|
||||||
"summary": _string_value(raw.get("summary") or description),
|
|
||||||
"behavior": _string_value(raw.get("behavior") or description),
|
|
||||||
"code": _string_value(raw["code"]),
|
|
||||||
"source_url": _string_value(source_url) if source_url is not None else None,
|
|
||||||
"built_in": True,
|
|
||||||
"origin": "file",
|
|
||||||
}
|
|
||||||
return payload, str(source)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_template_from_file(path: Path) -> ProxyTemplateDocument | None:
|
|
||||||
try:
|
|
||||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, yaml.YAMLError) as error:
|
|
||||||
logger.warning("Skipping template file %s: %s", path, error)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
logger.warning("Skipping template file %s: expected mapping root", path)
|
|
||||||
return None
|
|
||||||
normalized_raw = {str(key): value for key, value in raw.items()}
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload, source_path = _normalize_template_payload(normalized_raw, path)
|
|
||||||
document = ProxyTemplateDocument.model_validate(payload)
|
|
||||||
except (ValidationError, ValueError) as error:
|
|
||||||
logger.warning("Skipping template file %s: %s", path, error)
|
|
||||||
return None
|
|
||||||
|
|
||||||
document.source_path = source_path
|
|
||||||
return document
|
|
||||||
|
|
||||||
|
|
||||||
def list_file_templates(directory: Path | None = None) -> list[ProxyTemplateDocument]:
|
|
||||||
by_id: dict[str, list[ProxyTemplateDocument]] = defaultdict(list)
|
|
||||||
for path in template_files(directory):
|
|
||||||
template = _load_template_from_file(path)
|
|
||||||
if template is None:
|
|
||||||
continue
|
|
||||||
by_id[template.id].append(template)
|
|
||||||
|
|
||||||
deduplicated: list[ProxyTemplateDocument] = []
|
|
||||||
duplicate_template_message = (
|
|
||||||
"Skipping template id %s from %s: duplicate file-backed template id"
|
|
||||||
)
|
|
||||||
for template_id, templates in by_id.items():
|
|
||||||
if len(templates) > 1:
|
|
||||||
for template in templates:
|
|
||||||
logger.warning(
|
|
||||||
duplicate_template_message,
|
|
||||||
template_id,
|
|
||||||
template.source_path or "<unknown>",
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
deduplicated.append(templates[0])
|
|
||||||
|
|
||||||
return sorted(deduplicated, key=lambda template: template.name.lower())
|
|
||||||
|
|
||||||
|
|
||||||
def file_template_ids(directory: Path | None = None) -> set[str]:
|
|
||||||
return {template.id for template in list_file_templates(directory)}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
TARGET_RUFF_CODES = ("F821", "F822", "F823")
|
|
||||||
|
|
||||||
|
|
||||||
class ProxyCodeValidationError(Exception):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
message: str,
|
|
||||||
*,
|
|
||||||
errors: list[dict[str, Any]] | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.message = message
|
|
||||||
self.errors = errors or []
|
|
||||||
|
|
||||||
def to_http_detail(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"message": self.message,
|
|
||||||
"errors": self.errors,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_python_syntax(code: str) -> None:
|
|
||||||
try:
|
|
||||||
compile(code, "<proxy_code>", "exec")
|
|
||||||
except SyntaxError as error:
|
|
||||||
line = error.lineno if error.lineno is not None else 0
|
|
||||||
column = error.offset if error.offset is not None else 0
|
|
||||||
detail = {
|
|
||||||
"code": "COMPILE",
|
|
||||||
"message": error.msg,
|
|
||||||
"location": {"row": line, "column": column},
|
|
||||||
}
|
|
||||||
raise ProxyCodeValidationError(
|
|
||||||
"Proxy code has invalid Python syntax",
|
|
||||||
errors=[detail],
|
|
||||||
) from error
|
|
||||||
|
|
||||||
|
|
||||||
def _get_ruff_executable() -> list[str]:
|
|
||||||
ruff_path = shutil.which("ruff")
|
|
||||||
if ruff_path:
|
|
||||||
return [ruff_path]
|
|
||||||
|
|
||||||
uv_path = shutil.which("uv")
|
|
||||||
if uv_path:
|
|
||||||
return [uv_path, "run", "ruff"]
|
|
||||||
|
|
||||||
raise ProxyCodeValidationError("Unable to run ruff: executable not found")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_ruff_output(output: str) -> list[dict[str, Any]]:
|
|
||||||
if not output.strip():
|
|
||||||
return []
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = json.loads(output)
|
|
||||||
except json.JSONDecodeError as error:
|
|
||||||
raise ProxyCodeValidationError("Unable to parse ruff JSON output") from error
|
|
||||||
|
|
||||||
if not isinstance(parsed, list):
|
|
||||||
raise ProxyCodeValidationError("Unexpected ruff output format")
|
|
||||||
|
|
||||||
errors: list[dict[str, Any]] = []
|
|
||||||
for item in parsed:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
code = item.get("code")
|
|
||||||
if code not in TARGET_RUFF_CODES:
|
|
||||||
continue
|
|
||||||
|
|
||||||
message = item.get("message")
|
|
||||||
raw_location = item.get("location")
|
|
||||||
location = raw_location if isinstance(raw_location, dict) else {}
|
|
||||||
row = location.get("row", 0)
|
|
||||||
column = location.get("column", 0)
|
|
||||||
|
|
||||||
errors.append(
|
|
||||||
{
|
|
||||||
"code": code,
|
|
||||||
"message": str(message) if message is not None else "",
|
|
||||||
"location": {"row": row, "column": column},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return errors
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_ruff_check(code: str) -> list[dict[str, Any]]:
|
|
||||||
command = _get_ruff_executable()
|
|
||||||
|
|
||||||
file_path = ""
|
|
||||||
try:
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode="w",
|
|
||||||
suffix=".py",
|
|
||||||
encoding="utf-8",
|
|
||||||
delete=False,
|
|
||||||
) as handle:
|
|
||||||
handle.write(code)
|
|
||||||
file_path = handle.name
|
|
||||||
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
*command,
|
|
||||||
"check",
|
|
||||||
"--output-format=json",
|
|
||||||
"--select",
|
|
||||||
",".join(TARGET_RUFF_CODES),
|
|
||||||
file_path,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
stdout, stderr = await process.communicate()
|
|
||||||
finally:
|
|
||||||
if file_path:
|
|
||||||
try:
|
|
||||||
os.unlink(file_path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
stdout_text = stdout.decode("utf-8", errors="replace")
|
|
||||||
stderr_text = stderr.decode("utf-8", errors="replace")
|
|
||||||
|
|
||||||
if process.returncode not in (0, 1):
|
|
||||||
raise ProxyCodeValidationError(
|
|
||||||
"Ruff check failed to execute",
|
|
||||||
errors=[
|
|
||||||
{
|
|
||||||
"code": "RUFF",
|
|
||||||
"message": stderr_text.strip() or "Unknown ruff failure",
|
|
||||||
"location": {"row": 0, "column": 0},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
if stderr_text.strip() and process.returncode == 0:
|
|
||||||
raise ProxyCodeValidationError(
|
|
||||||
"Ruff emitted unexpected stderr output",
|
|
||||||
errors=[
|
|
||||||
{
|
|
||||||
"code": "RUFF",
|
|
||||||
"message": stderr_text.strip(),
|
|
||||||
"location": {"row": 0, "column": 0},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
return _parse_ruff_output(stdout_text)
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_proxy_code(code: str) -> None:
|
|
||||||
_validate_python_syntax(code)
|
|
||||||
errors = await _run_ruff_check(code)
|
|
||||||
if errors:
|
|
||||||
raise ProxyCodeValidationError(
|
|
||||||
"Proxy code contains ruff violations",
|
|
||||||
errors=errors,
|
|
||||||
)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
[project]
|
|
||||||
name = "theplug"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Add your description here"
|
|
||||||
requires-python = ">=3.14"
|
|
||||||
dependencies = [
|
|
||||||
"authlib>=1.6.0",
|
|
||||||
"basedpyright>=1.39.0",
|
|
||||||
"fastapi[standard]>=0.135.3",
|
|
||||||
"locust>=2.43.4",
|
|
||||||
"mitmproxy>=12.2.2",
|
|
||||||
"motor>=3.7.1",
|
|
||||||
"pwdlib[argon2,bcrypt]>=0.3.0",
|
|
||||||
"pydantic>=2.12.5",
|
|
||||||
"pydantic-settings>=2.13.1",
|
|
||||||
"pyyaml>=6.0.3",
|
|
||||||
"ruff>=0.15.10",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.ruff]
|
|
||||||
target-version = "py314"
|
|
||||||
line-length = 88
|
|
||||||
|
|
||||||
[tool.ruff.lint]
|
|
||||||
select = ["E", "F", "I", "B", "UP"]
|
|
||||||
|
|
||||||
[tool.ruff.format]
|
|
||||||
quote-style = "double"
|
|
||||||
indent-style = "space"
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
testpaths = ["tests"]
|
|
||||||
pythonpath = ["."]
|
|
||||||
addopts = "-q"
|
|
||||||
markers = [
|
|
||||||
"unit: fast isolated tests",
|
|
||||||
"integration: tests using real app wiring and persistence",
|
|
||||||
"api: HTTP API behavior tests",
|
|
||||||
"websocket: websocket behavior tests",
|
|
||||||
"slow: slower-running tests",
|
|
||||||
"requires_docker: tests that require Docker or containerized dependencies",
|
|
||||||
"perf: performance and benchmark scenarios",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependency-groups]
|
|
||||||
dev = [
|
|
||||||
"pytest>=9.0.3",
|
|
||||||
]
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import importlib
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
|
|
||||||
from app.core.security import AuthContext, get_auth_context
|
|
||||||
from app.models.auth import AccessTokenPayload, AuthUserDocument
|
|
||||||
|
|
||||||
auth_router_module = importlib.import_module("app.api.auth.router")
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_success_returns_bearer_token(client, monkeypatch: object):
|
|
||||||
user = AuthUserDocument(username="admin", password_hash="stored-hash")
|
|
||||||
|
|
||||||
async def fake_get_user_by_username(username: str) -> AuthUserDocument | None:
|
|
||||||
assert username == "admin"
|
|
||||||
return user
|
|
||||||
|
|
||||||
def fake_verify_password(plain_password: str, password_hash: str) -> bool:
|
|
||||||
return plain_password == "secret" and password_hash == "stored-hash"
|
|
||||||
|
|
||||||
def fake_create_access_token(*, subject: str) -> str:
|
|
||||||
assert subject == "admin"
|
|
||||||
return "token-123"
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
auth_router_module,
|
|
||||||
"get_user_by_username",
|
|
||||||
fake_get_user_by_username,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(auth_router_module, "verify_password", fake_verify_password)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
auth_router_module,
|
|
||||||
"create_access_token",
|
|
||||||
fake_create_access_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/auth/login",
|
|
||||||
json={"username": "admin", "password": "secret"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"access_token": "token-123", "token_type": "bearer"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_invalid_credentials_returns_401(client, monkeypatch: object):
|
|
||||||
async def fake_get_user_by_username(_: str) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
auth_router_module,
|
|
||||||
"get_user_by_username",
|
|
||||||
fake_get_user_by_username,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/auth/login",
|
|
||||||
json={"username": "admin", "password": "wrong"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.json()["detail"] == "Invalid username or password"
|
|
||||||
assert response.headers["www-authenticate"] == "Bearer"
|
|
||||||
|
|
||||||
|
|
||||||
def test_logout_revokes_active_token(client, monkeypatch: object):
|
|
||||||
captured: dict[str, object] = {}
|
|
||||||
|
|
||||||
async def fake_revoke_token(*, jti: str, expires_at: datetime) -> None:
|
|
||||||
captured["jti"] = jti
|
|
||||||
captured["expires_at"] = expires_at
|
|
||||||
|
|
||||||
monkeypatch.setattr(auth_router_module, "revoke_token", fake_revoke_token)
|
|
||||||
|
|
||||||
response = client.post("/auth/logout")
|
|
||||||
|
|
||||||
assert response.status_code == 204
|
|
||||||
assert captured["jti"] == "test-jti"
|
|
||||||
assert isinstance(captured["expires_at"], datetime)
|
|
||||||
|
|
||||||
|
|
||||||
def test_logout_skips_revocation_when_token_is_expired(
|
|
||||||
client,
|
|
||||||
fake_user: AuthUserDocument,
|
|
||||||
monkeypatch: object,
|
|
||||||
):
|
|
||||||
state = {"called": False}
|
|
||||||
|
|
||||||
async def fake_revoke_token(*, jti: str, expires_at: datetime) -> None:
|
|
||||||
state["called"] = True
|
|
||||||
|
|
||||||
monkeypatch.setattr(auth_router_module, "revoke_token", fake_revoke_token)
|
|
||||||
|
|
||||||
expired_payload = AccessTokenPayload(
|
|
||||||
sub=fake_user.username,
|
|
||||||
jti="expired-jti",
|
|
||||||
exp=int((datetime.now(UTC) - timedelta(minutes=1)).timestamp()),
|
|
||||||
)
|
|
||||||
expired_context = AuthContext(
|
|
||||||
token="expired-token",
|
|
||||||
user=fake_user,
|
|
||||||
payload=expired_payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def override_auth_context() -> AuthContext:
|
|
||||||
return expired_context
|
|
||||||
|
|
||||||
client.app.dependency_overrides[get_auth_context] = override_auth_context
|
|
||||||
|
|
||||||
response = client.post("/auth/logout")
|
|
||||||
|
|
||||||
assert response.status_code == 204
|
|
||||||
assert state["called"] is False
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from starlette.websockets import WebSocketDisconnect
|
|
||||||
|
|
||||||
from app.services.editor import python_lsp
|
|
||||||
|
|
||||||
|
|
||||||
def test_python_editor_session(client, monkeypatch):
|
|
||||||
session = python_lsp.PythonEditorSession(
|
|
||||||
root_path=python_lsp.WORKSPACE_ROOT,
|
|
||||||
file_path=python_lsp.WORKSPACE_ROOT / python_lsp.ENTRY_FILE,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp"
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.api.editor.router.python_lsp.get_session", lambda: session
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/proxy/editor/python/session")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {
|
|
||||||
"root_uri": session.root_uri,
|
|
||||||
"file_uri": session.file_uri,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_python_editor_socket_rejects_missing_token(client):
|
|
||||||
with pytest.raises(WebSocketDisconnect) as error:
|
|
||||||
with client.websocket_connect("/proxy/editor/python/ws"):
|
|
||||||
pass
|
|
||||||
|
|
||||||
assert error.value.code == 4401
|
|
||||||
|
|
||||||
|
|
||||||
def test_python_editor_socket_accepts_token(client, monkeypatch):
|
|
||||||
accepted = {"value": False}
|
|
||||||
|
|
||||||
async def fake_authenticate(_: object) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fake_bridge(websocket, session) -> None:
|
|
||||||
accepted["value"] = True
|
|
||||||
await websocket.send_text(session.file_uri)
|
|
||||||
await websocket.close()
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.api.editor.router.authenticate_editor_socket",
|
|
||||||
fake_authenticate,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.api.editor.router.python_lsp.lsp_binary", lambda: "/bin/lsp"
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("app.api.editor.router.python_lsp.bridge", fake_bridge)
|
|
||||||
|
|
||||||
with client.websocket_connect("/proxy/editor/python/ws?token=test-token") as socket:
|
|
||||||
assert socket.receive_text().endswith("/proxy_addon.py")
|
|
||||||
|
|
||||||
assert accepted["value"] is True
|
|
||||||
@@ -1,980 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import importlib
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from starlette.websockets import WebSocketDisconnect
|
|
||||||
|
|
||||||
from app.core.security import get_auth_context, require_authenticated_user
|
|
||||||
from app.models.health import HealthSnapshot
|
|
||||||
from app.models.proxy import ProxyCreateInput, ProxyDocument
|
|
||||||
from app.models.service import ServiceEndpointDocument
|
|
||||||
from app.services.mitmproxy.templates import list_file_templates
|
|
||||||
from app.services.mitmproxy.validator import ProxyCodeValidationError
|
|
||||||
|
|
||||||
proxy_router_module = importlib.import_module("app.api.proxy.router")
|
|
||||||
|
|
||||||
|
|
||||||
def make_proxy_document(
|
|
||||||
*,
|
|
||||||
proxy_port: int,
|
|
||||||
name: str = "svc",
|
|
||||||
code: str = "proxy-code",
|
|
||||||
port: int = 9000,
|
|
||||||
log_path: str | None = None,
|
|
||||||
) -> ProxyDocument:
|
|
||||||
return ProxyDocument(
|
|
||||||
proxy_port=proxy_port,
|
|
||||||
name=name,
|
|
||||||
code=code,
|
|
||||||
service=ServiceEndpointDocument(port=port),
|
|
||||||
log_path=log_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_health_snapshot(
|
|
||||||
proxy_port: int,
|
|
||||||
*,
|
|
||||||
overall: str = "healthy",
|
|
||||||
timestamp: datetime | None = None,
|
|
||||||
) -> HealthSnapshot:
|
|
||||||
return HealthSnapshot(
|
|
||||||
proxy_port=proxy_port,
|
|
||||||
timestamp=timestamp or datetime.now(UTC),
|
|
||||||
enabled=True,
|
|
||||||
overall=overall,
|
|
||||||
checks=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_requires_auth_without_dependency_overrides(client):
|
|
||||||
client.app.dependency_overrides.pop(require_authenticated_user, None)
|
|
||||||
client.app.dependency_overrides.pop(get_auth_context, None)
|
|
||||||
|
|
||||||
response = client.get("/proxy")
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.json()["detail"] == "Could not validate credentials"
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_proxies_returns_documents(client, monkeypatch: object):
|
|
||||||
docs = [
|
|
||||||
make_proxy_document(proxy_port=8080, name="api"),
|
|
||||||
make_proxy_document(proxy_port=8081, name="worker"),
|
|
||||||
]
|
|
||||||
|
|
||||||
async def fake_list_proxies() -> list[ProxyDocument]:
|
|
||||||
return docs
|
|
||||||
|
|
||||||
monkeypatch.setattr(proxy_router_module, "list_proxy_documents", fake_list_proxies)
|
|
||||||
|
|
||||||
response = client.get("/proxy")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
body = response.json()
|
|
||||||
assert [item["proxy_port"] for item in body] == [8080, 8081]
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_services_uses_query_scan_root(client, monkeypatch):
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def fake_get_all_services_in_dir(root):
|
|
||||||
captured["root"] = str(root)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"name": "echo",
|
|
||||||
"ports": [9081],
|
|
||||||
"bindings": [],
|
|
||||||
"folder": "/root/play",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_all_services_in_dir",
|
|
||||||
fake_get_all_services_in_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/proxy/services", params={"scan_root": "/root"})
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {
|
|
||||||
"backend_port": 3671,
|
|
||||||
"scan_root": "/root",
|
|
||||||
"services": [
|
|
||||||
{"name": "echo", "ports": [9081], "bindings": [], "folder": "/root/play"}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
assert captured["root"] == "/root"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_proxy_returns_404_when_not_found(client, monkeypatch: object):
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/proxy/9999")
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert response.json()["detail"] == "Proxy not found"
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_proxy_returns_409_on_port_conflict(client, monkeypatch: object):
|
|
||||||
existing = make_proxy_document(proxy_port=4088)
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 4088
|
|
||||||
return existing
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/proxy",
|
|
||||||
json={
|
|
||||||
"proxy_port": 4088,
|
|
||||||
"name": "new",
|
|
||||||
"code": "abc",
|
|
||||||
"service": {"port": 8080},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 409
|
|
||||||
assert response.json()["detail"] == "Proxy already exists on port 4088"
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_proxy_rejects_backend_api_port(client, monkeypatch: object):
|
|
||||||
create_called = False
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 3671
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fake_create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
|
|
||||||
nonlocal create_called
|
|
||||||
create_called = True
|
|
||||||
return make_proxy_document(proxy_port=payload.proxy_port)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/proxy",
|
|
||||||
json={
|
|
||||||
"proxy_port": 3671,
|
|
||||||
"name": "backend-clash",
|
|
||||||
"code": "abc",
|
|
||||||
"service": {"port": 8080},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 422
|
|
||||||
assert response.json()["detail"] == (
|
|
||||||
"Proxy port 3671 is reserved for the backend API "
|
|
||||||
"and cannot be used by mitmdump."
|
|
||||||
)
|
|
||||||
assert create_called is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_proxy_returns_created_document(client, monkeypatch: object):
|
|
||||||
captured: dict[str, object] = {}
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fake_create_proxy(payload: ProxyCreateInput) -> ProxyDocument:
|
|
||||||
captured["proxy_port"] = payload.proxy_port
|
|
||||||
captured["name"] = payload.name
|
|
||||||
captured["service"] = payload.service.model_dump(exclude_none=True)
|
|
||||||
captured["code"] = payload.code
|
|
||||||
captured["protocol"] = payload.protocol
|
|
||||||
return make_proxy_document(
|
|
||||||
proxy_port=payload.proxy_port,
|
|
||||||
name=payload.name,
|
|
||||||
code=payload.code,
|
|
||||||
port=payload.service.port,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def fake_validate_proxy_code(code: str) -> None:
|
|
||||||
assert code == "def proxy_handler():\n return 'ok'\n"
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"validate_proxy_code",
|
|
||||||
fake_validate_proxy_code,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/proxy",
|
|
||||||
json={
|
|
||||||
"proxy_port": 4040,
|
|
||||||
"name": "edge",
|
|
||||||
"code": "def proxy_handler():\n return 'ok'\n",
|
|
||||||
"service": {"port": 8181},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
assert response.json()["proxy_port"] == 4040
|
|
||||||
assert captured == {
|
|
||||||
"proxy_port": 4040,
|
|
||||||
"name": "edge",
|
|
||||||
"service": {"host": "127.0.0.1", "port": 8181},
|
|
||||||
"code": "def proxy_handler():\n return 'ok'\n",
|
|
||||||
"protocol": "http",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_proxy_returns_422_when_code_validation_fails(
|
|
||||||
client,
|
|
||||||
monkeypatch: object,
|
|
||||||
):
|
|
||||||
create_called = False
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 4000
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fake_validate_proxy_code(code: str) -> None:
|
|
||||||
assert code == "invalid-code"
|
|
||||||
raise ProxyCodeValidationError(
|
|
||||||
"Proxy code contains ruff violations",
|
|
||||||
errors=[
|
|
||||||
{
|
|
||||||
"code": "F821",
|
|
||||||
"message": "Undefined name `missing_symbol`",
|
|
||||||
"location": {"row": 1, "column": 1},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def fake_create_proxy(
|
|
||||||
*,
|
|
||||||
proxy_port: int,
|
|
||||||
name: str,
|
|
||||||
service: dict[str, object],
|
|
||||||
code: str,
|
|
||||||
) -> ProxyDocument:
|
|
||||||
nonlocal create_called
|
|
||||||
create_called = True
|
|
||||||
return make_proxy_document(proxy_port=proxy_port, name=name, code=code)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"validate_proxy_code",
|
|
||||||
fake_validate_proxy_code,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(proxy_router_module, "create_proxy", fake_create_proxy)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/proxy",
|
|
||||||
json={
|
|
||||||
"proxy_port": 4000,
|
|
||||||
"name": "broken",
|
|
||||||
"code": "invalid-code",
|
|
||||||
"service": {"port": 9000},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 422
|
|
||||||
body = response.json()["detail"]
|
|
||||||
assert body["message"] == "Proxy code contains ruff violations"
|
|
||||||
assert body["errors"][0]["code"] == "F821"
|
|
||||||
assert create_called is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_proxy_returns_422_when_code_validation_fails(
|
|
||||||
client,
|
|
||||||
monkeypatch: object,
|
|
||||||
):
|
|
||||||
update_called = False
|
|
||||||
|
|
||||||
async def fake_validate_proxy_code(code: str) -> None:
|
|
||||||
assert code == "invalid-code"
|
|
||||||
raise ProxyCodeValidationError(
|
|
||||||
"Proxy code has invalid Python syntax",
|
|
||||||
errors=[
|
|
||||||
{
|
|
||||||
"code": "COMPILE",
|
|
||||||
"message": "invalid syntax",
|
|
||||||
"location": {"row": 1, "column": 1},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def fake_update_proxy(
|
|
||||||
proxy_port: int,
|
|
||||||
payload: object,
|
|
||||||
) -> ProxyDocument | None:
|
|
||||||
nonlocal update_called
|
|
||||||
update_called = True
|
|
||||||
return make_proxy_document(proxy_port=proxy_port)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"validate_proxy_code",
|
|
||||||
fake_validate_proxy_code,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(proxy_router_module, "update_proxy", fake_update_proxy)
|
|
||||||
|
|
||||||
response = client.put(
|
|
||||||
"/proxy/8123",
|
|
||||||
json={"code": "invalid-code"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 422
|
|
||||||
body = response.json()["detail"]
|
|
||||||
assert body["message"] == "Proxy code has invalid Python syntax"
|
|
||||||
assert body["errors"][0]["code"] == "COMPILE"
|
|
||||||
assert update_called is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_proxy_persists_code_changes(client, monkeypatch: object):
|
|
||||||
captured: dict[str, object] = {}
|
|
||||||
updated_code = "def response(flow):\n flow.response.text = 'ok'\n"
|
|
||||||
|
|
||||||
async def fake_validate_proxy_code(code: str) -> None:
|
|
||||||
captured["validated"] = code
|
|
||||||
|
|
||||||
async def fake_update_proxy(
|
|
||||||
proxy_port: int,
|
|
||||||
payload: object,
|
|
||||||
) -> ProxyDocument | None:
|
|
||||||
captured["proxy_port"] = proxy_port
|
|
||||||
captured["code"] = payload.code
|
|
||||||
return make_proxy_document(
|
|
||||||
proxy_port=proxy_port,
|
|
||||||
code=payload.code,
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"validate_proxy_code",
|
|
||||||
fake_validate_proxy_code,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(proxy_router_module, "update_proxy", fake_update_proxy)
|
|
||||||
|
|
||||||
response = client.put(
|
|
||||||
"/proxy/8123",
|
|
||||||
json={"code": updated_code},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["code"] == updated_code
|
|
||||||
assert captured == {
|
|
||||||
"validated": updated_code,
|
|
||||||
"proxy_port": 8123,
|
|
||||||
"code": updated_code,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_proxy_updates_runtime_state(client, monkeypatch: object):
|
|
||||||
proxy = make_proxy_document(proxy_port=4040)
|
|
||||||
captured: dict[str, object] = {}
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
class FakeManager:
|
|
||||||
async def start(self, document: ProxyDocument) -> dict[str, object]:
|
|
||||||
assert document.proxy_port == 4040
|
|
||||||
return {
|
|
||||||
"status": "running",
|
|
||||||
"pid": 1234,
|
|
||||||
"script_path": "/tmp/theplug/addons/4040.py",
|
|
||||||
"log_path": "/tmp/theplug/logs/4040.log",
|
|
||||||
"service.upstream_port": 9000,
|
|
||||||
"last_error": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def fake_update_proxy_runtime(
|
|
||||||
proxy_port: int,
|
|
||||||
values: dict[str, object],
|
|
||||||
) -> ProxyDocument:
|
|
||||||
captured["proxy_port"] = proxy_port
|
|
||||||
captured["values"] = values
|
|
||||||
return proxy.model_copy(update={"status": "running", "pid": 1234})
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"update_proxy_runtime",
|
|
||||||
fake_update_proxy_runtime,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.post("/proxy/4040/start")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["status"] == "running"
|
|
||||||
assert captured["proxy_port"] == 4040
|
|
||||||
assert captured["values"]["pid"] == 1234
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_proxy_records_runtime_error(client, monkeypatch: object):
|
|
||||||
proxy = make_proxy_document(proxy_port=4040)
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
class FakeManager:
|
|
||||||
async def start(self, document: ProxyDocument) -> dict[str, object]:
|
|
||||||
assert document.proxy_port == 4040
|
|
||||||
raise proxy_router_module.MitmdumpError("mitmdump executable not found")
|
|
||||||
|
|
||||||
async def fake_update_proxy_runtime(
|
|
||||||
proxy_port: int,
|
|
||||||
values: dict[str, object],
|
|
||||||
) -> ProxyDocument:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
assert values == {
|
|
||||||
"status": "error",
|
|
||||||
"last_error": "mitmdump executable not found",
|
|
||||||
}
|
|
||||||
return proxy.model_copy(
|
|
||||||
update={"status": "error", "last_error": values["last_error"]}
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"update_proxy_runtime",
|
|
||||||
fake_update_proxy_runtime,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.post("/proxy/4040/start")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["status"] == "error"
|
|
||||||
assert response.json()["last_error"] == "mitmdump executable not found"
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_logs_socket_rejects_missing_token(client):
|
|
||||||
with pytest.raises(WebSocketDisconnect) as error:
|
|
||||||
with client.websocket_connect("/proxy/4040/logs/ws"):
|
|
||||||
pass
|
|
||||||
|
|
||||||
assert error.value.code == 4401
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_logs_socket_streams_snapshot_and_append(client, monkeypatch, tmp_path):
|
|
||||||
log_path = tmp_path / "proxy.log"
|
|
||||||
log_path.write_text("line one\n", encoding="utf-8")
|
|
||||||
proxy = make_proxy_document(proxy_port=4040, log_path=str(log_path))
|
|
||||||
|
|
||||||
async def fake_authenticate(_: object) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"authenticate_proxy_socket",
|
|
||||||
fake_authenticate,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
|
|
||||||
with client.websocket_connect("/proxy/4040/logs/ws?token=test-token") as socket:
|
|
||||||
snapshot = socket.receive_json()
|
|
||||||
assert snapshot == {"type": "snapshot", "log": "line one\n"}
|
|
||||||
|
|
||||||
with log_path.open("a", encoding="utf-8") as handle:
|
|
||||||
handle.write("line two\n")
|
|
||||||
handle.flush()
|
|
||||||
|
|
||||||
appended = socket.receive_json()
|
|
||||||
assert appended == {"type": "append", "log": "line two\n"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_logs_socket_resets_snapshot_after_log_rotation(
|
|
||||||
client,
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path,
|
|
||||||
):
|
|
||||||
log_path = tmp_path / "proxy.log"
|
|
||||||
log_path.write_text("line one\n", encoding="utf-8")
|
|
||||||
proxy = make_proxy_document(proxy_port=4040, log_path=str(log_path))
|
|
||||||
|
|
||||||
async def fake_authenticate(_: object) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"authenticate_proxy_socket",
|
|
||||||
fake_authenticate,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
|
|
||||||
with client.websocket_connect("/proxy/4040/logs/ws?token=test-token") as socket:
|
|
||||||
snapshot = socket.receive_json()
|
|
||||||
assert snapshot == {"type": "snapshot", "log": "line one\n"}
|
|
||||||
|
|
||||||
log_path.write_text("fresh start\n", encoding="utf-8")
|
|
||||||
|
|
||||||
rotated = socket.receive_json()
|
|
||||||
assert rotated == {"type": "snapshot", "log": "fresh start\n"}
|
|
||||||
|
|
||||||
with log_path.open("a", encoding="utf-8") as handle:
|
|
||||||
handle.write("line two\n")
|
|
||||||
handle.flush()
|
|
||||||
|
|
||||||
appended = socket.receive_json()
|
|
||||||
assert appended == {"type": "append", "log": "line two\n"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_remove_proxy_stops_runtime_and_cleans_artifacts(client, monkeypatch: object):
|
|
||||||
proxy = make_proxy_document(
|
|
||||||
proxy_port=4040,
|
|
||||||
log_path="/tmp/theplug/logs/4040.log",
|
|
||||||
).model_copy(
|
|
||||||
update={
|
|
||||||
"script_path": "/tmp/theplug/addons/4040.py",
|
|
||||||
"config_path": "/tmp/theplug/conf/4040/config.yaml",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
captured: dict[str, object] = {}
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
class FakeManager:
|
|
||||||
async def stop(self, document: ProxyDocument) -> dict[str, object]:
|
|
||||||
captured["stopped"] = document.proxy_port
|
|
||||||
return {
|
|
||||||
"status": "stopped",
|
|
||||||
"pid": None,
|
|
||||||
"service.upstream_port": None,
|
|
||||||
"last_error": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
def cleanup_runtime_artifacts(self, document: ProxyDocument) -> None:
|
|
||||||
captured["cleaned"] = {
|
|
||||||
"proxy_port": document.proxy_port,
|
|
||||||
"script_path": document.script_path,
|
|
||||||
"config_path": document.config_path,
|
|
||||||
"log_path": document.log_path,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def fake_delete_proxy(proxy_port: int) -> bool:
|
|
||||||
captured["deleted"] = proxy_port
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def fake_delete_health_history(proxy_port: int) -> None:
|
|
||||||
captured["history_deleted"] = proxy_port
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"delete_health_history",
|
|
||||||
fake_delete_health_history,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(proxy_router_module, "delete_proxy", fake_delete_proxy)
|
|
||||||
|
|
||||||
response = client.delete("/proxy/4040")
|
|
||||||
|
|
||||||
assert response.status_code == 204
|
|
||||||
assert captured == {
|
|
||||||
"stopped": 4040,
|
|
||||||
"cleaned": {
|
|
||||||
"proxy_port": 4040,
|
|
||||||
"script_path": "/tmp/theplug/addons/4040.py",
|
|
||||||
"config_path": "/tmp/theplug/conf/4040/config.yaml",
|
|
||||||
"log_path": "/tmp/theplug/logs/4040.log",
|
|
||||||
},
|
|
||||||
"deleted": 4040,
|
|
||||||
"history_deleted": 4040,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_health_runs_immediate_check_and_publishes(client, monkeypatch: object):
|
|
||||||
proxy = make_proxy_document(proxy_port=4040)
|
|
||||||
captured: dict[str, object] = {}
|
|
||||||
snapshot = make_health_snapshot(4040)
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
async def fake_check_proxy(document: ProxyDocument):
|
|
||||||
captured["checked"] = document.proxy_port
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
async def fake_record_health_sample(document):
|
|
||||||
captured["recorded"] = document
|
|
||||||
return document
|
|
||||||
|
|
||||||
class FakeHealthMonitor:
|
|
||||||
async def publish(self, proxy_port: int, document: HealthSnapshot) -> None:
|
|
||||||
captured["published"] = (proxy_port, document)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(proxy_router_module, "check_proxy", fake_check_proxy)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"record_health_sample",
|
|
||||||
fake_record_health_sample,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(proxy_router_module, "health_monitor", FakeHealthMonitor())
|
|
||||||
|
|
||||||
response = client.get("/proxy/4040/health")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["overall"] == "healthy"
|
|
||||||
assert captured["checked"] == 4040
|
|
||||||
assert captured["recorded"] == snapshot
|
|
||||||
assert captured["published"] == (4040, snapshot)
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_health_history_returns_latest_and_history(client, monkeypatch: object):
|
|
||||||
proxy = make_proxy_document(proxy_port=4040)
|
|
||||||
timestamp = datetime.now(UTC)
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
async def fake_get_latest_health_sample(proxy_port: int):
|
|
||||||
assert proxy_port == 4040
|
|
||||||
return make_health_snapshot(4040, timestamp=timestamp)
|
|
||||||
|
|
||||||
async def fake_list_health_history(proxy_port: int, limit: int = 40):
|
|
||||||
assert proxy_port == 4040
|
|
||||||
assert limit == 40
|
|
||||||
return [make_health_snapshot(4040, timestamp=timestamp)]
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_latest_health_sample",
|
|
||||||
fake_get_latest_health_sample,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"list_health_history",
|
|
||||||
fake_list_health_history,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/proxy/4040/health/history")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
body = response.json()
|
|
||||||
assert body["proxy_port"] == 4040
|
|
||||||
assert body["latest"]["overall"] == "healthy"
|
|
||||||
assert len(body["history"]) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_health_history_clamps_requested_limit(client, monkeypatch: object):
|
|
||||||
proxy = make_proxy_document(proxy_port=4040)
|
|
||||||
captured: dict[str, int] = {}
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
async def fake_get_latest_health_sample(_proxy_port: int) -> HealthSnapshot | None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fake_list_health_history(proxy_port: int, limit: int = 40):
|
|
||||||
assert proxy_port == 4040
|
|
||||||
captured["limit"] = limit
|
|
||||||
return []
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_latest_health_sample",
|
|
||||||
fake_get_latest_health_sample,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"list_health_history",
|
|
||||||
fake_list_health_history,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/proxy/4040/health/history", params={"limit": 9999})
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert captured["limit"] == 200
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_health_socket_streams_latest_and_updates(client, monkeypatch: object):
|
|
||||||
proxy = make_proxy_document(proxy_port=4040)
|
|
||||||
timestamp = datetime.now(UTC)
|
|
||||||
follow_up = make_health_snapshot(4040, overall="unhealthy")
|
|
||||||
captured: dict[str, object] = {}
|
|
||||||
|
|
||||||
async def fake_authenticate(_: object) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
async def fake_get_latest_health_sample(proxy_port: int):
|
|
||||||
assert proxy_port == 4040
|
|
||||||
return make_health_snapshot(4040, timestamp=timestamp)
|
|
||||||
|
|
||||||
class FakeHealthMonitor:
|
|
||||||
def subscribe(self, proxy_port: int):
|
|
||||||
assert proxy_port == 4040
|
|
||||||
queue = asyncio.Queue()
|
|
||||||
queue.put_nowait(follow_up)
|
|
||||||
return queue
|
|
||||||
|
|
||||||
def unsubscribe(self, proxy_port: int, _queue) -> None:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
captured["unsubscribed"] = True
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"authenticate_proxy_socket",
|
|
||||||
fake_authenticate,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_latest_health_sample",
|
|
||||||
fake_get_latest_health_sample,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(proxy_router_module, "health_monitor", FakeHealthMonitor())
|
|
||||||
|
|
||||||
with client.websocket_connect("/proxy/4040/health/ws?token=test-token") as socket:
|
|
||||||
first = socket.receive_json()
|
|
||||||
assert first["overall"] == "healthy"
|
|
||||||
second = socket.receive_json()
|
|
||||||
assert second["overall"] == "unhealthy"
|
|
||||||
|
|
||||||
assert captured["unsubscribed"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_stop_proxy_updates_runtime_state(client, monkeypatch: object):
|
|
||||||
proxy = make_proxy_document(proxy_port=4040).model_copy(
|
|
||||||
update={"status": "running", "pid": 1234}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def fake_get_proxy_by_port(proxy_port: int) -> ProxyDocument | None:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
class FakeManager:
|
|
||||||
async def stop(self, document: ProxyDocument) -> dict[str, object]:
|
|
||||||
assert document.pid == 1234
|
|
||||||
return {
|
|
||||||
"status": "stopped",
|
|
||||||
"pid": None,
|
|
||||||
"service.upstream_port": None,
|
|
||||||
"last_error": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def fake_update_proxy_runtime(
|
|
||||||
proxy_port: int,
|
|
||||||
values: dict[str, object],
|
|
||||||
) -> ProxyDocument:
|
|
||||||
assert proxy_port == 4040
|
|
||||||
assert values["status"] == "stopped"
|
|
||||||
return proxy.model_copy(update={"status": "stopped", "pid": None})
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"get_proxy_by_port",
|
|
||||||
fake_get_proxy_by_port,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(proxy_router_module, "manager", FakeManager())
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"update_proxy_runtime",
|
|
||||||
fake_update_proxy_runtime,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.post("/proxy/4040/stop")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["status"] == "stopped"
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_mitmproxy_templates_returns_builtins_and_custom(client, monkeypatch):
|
|
||||||
file_templates = list_file_templates()
|
|
||||||
|
|
||||||
async def fake_list_custom_templates():
|
|
||||||
return []
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"list_custom_templates",
|
|
||||||
fake_list_custom_templates,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"list_file_templates",
|
|
||||||
lambda: file_templates,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get("/proxy/mitmproxy/templates")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
body = response.json()
|
|
||||||
assert len(body) == len(file_templates)
|
|
||||||
assert body[0]["built_in"] is True
|
|
||||||
assert body[0]["origin"] == "file"
|
|
||||||
assert body[0]["protocol"] in {"http", "tcp"}
|
|
||||||
assert isinstance(body[0]["tags"], list)
|
|
||||||
assert body[0]["action"] in {"block", "rewrite"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_mitmproxy_template_validates_and_persists(client, monkeypatch):
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_validate_proxy_code(code: str) -> None:
|
|
||||||
captured["validated"] = code
|
|
||||||
|
|
||||||
async def fake_create_custom_template(payload):
|
|
||||||
captured["payload"] = payload
|
|
||||||
return {
|
|
||||||
"id": payload.id,
|
|
||||||
"name": payload.name,
|
|
||||||
"description": payload.description,
|
|
||||||
"category": payload.category,
|
|
||||||
"protocol": payload.protocol,
|
|
||||||
"family": payload.family,
|
|
||||||
"tags": payload.tags,
|
|
||||||
"action": payload.action,
|
|
||||||
"summary": payload.summary,
|
|
||||||
"behavior": payload.behavior,
|
|
||||||
"code": payload.code,
|
|
||||||
"source_url": payload.source_url,
|
|
||||||
"built_in": False,
|
|
||||||
"origin": "custom",
|
|
||||||
}
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"validate_proxy_code",
|
|
||||||
fake_validate_proxy_code,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"create_custom_template",
|
|
||||||
fake_create_custom_template,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/proxy/mitmproxy/templates",
|
|
||||||
json={
|
|
||||||
"id": "custom-header",
|
|
||||||
"name": "Custom header",
|
|
||||||
"description": "Adds a custom response header",
|
|
||||||
"category": "http",
|
|
||||||
"protocol": "http",
|
|
||||||
"family": "custom",
|
|
||||||
"tags": ["observe"],
|
|
||||||
"action": "custom",
|
|
||||||
"summary": "Saved custom template.",
|
|
||||||
"behavior": "Applies the current script as-is.",
|
|
||||||
"code": "def response(flow):\n pass\n",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
assert response.json()["id"] == "custom-header"
|
|
||||||
assert response.json()["protocol"] == "http"
|
|
||||||
assert response.json()["origin"] == "custom"
|
|
||||||
assert captured["validated"] == "def response(flow):\n pass\n"
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_mitmproxy_template_rejects_file_backed_id(client, monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
proxy_router_module,
|
|
||||||
"file_template_ids",
|
|
||||||
lambda: {"http-block-path-traversal"},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/proxy/mitmproxy/templates",
|
|
||||||
json={
|
|
||||||
"id": "http-block-path-traversal",
|
|
||||||
"name": "Custom header",
|
|
||||||
"description": "Adds a custom response header",
|
|
||||||
"category": "http",
|
|
||||||
"protocol": "http",
|
|
||||||
"family": "custom",
|
|
||||||
"tags": [],
|
|
||||||
"action": "custom",
|
|
||||||
"summary": "Saved custom template.",
|
|
||||||
"behavior": "Applies the current script as-is.",
|
|
||||||
"code": "def response(flow):\n pass\n",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 409
|
|
||||||
assert "Template already exists" in response.json()["detail"]
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import os
|
|
||||||
from collections.abc import Iterator
|
|
||||||
from datetime import UTC, datetime, timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from motor.motor_asyncio import AsyncIOMotorClient
|
|
||||||
|
|
||||||
os.environ.setdefault(
|
|
||||||
"MONGODB_URI",
|
|
||||||
"mongodb://theplug:pass@localhost:27017/theplug?authSource=admin",
|
|
||||||
)
|
|
||||||
os.environ.setdefault("MONGODB_DB_NAME", "theplug")
|
|
||||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key")
|
|
||||||
os.environ.setdefault("JWT_ALGORITHM", "HS256")
|
|
||||||
os.environ.setdefault("JWT_EXPIRE_MINUTES", "720")
|
|
||||||
os.environ.setdefault("AUTH_USERNAME", "admin")
|
|
||||||
os.environ.setdefault("AUTH_PASSWORD", "admin")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_user():
|
|
||||||
from app.models.auth import AuthUserDocument
|
|
||||||
|
|
||||||
return AuthUserDocument(
|
|
||||||
username="admin",
|
|
||||||
password_hash="hashed-password",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def auth_context(fake_user):
|
|
||||||
from app.core.security import AuthContext
|
|
||||||
from app.models.auth import AccessTokenPayload
|
|
||||||
|
|
||||||
exp = int((datetime.now(UTC) + timedelta(minutes=720)).timestamp())
|
|
||||||
payload = AccessTokenPayload(sub=fake_user.username, jti="test-jti", exp=exp)
|
|
||||||
return AuthContext(token="test-token", user=fake_user, payload=payload)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def app(fake_user, auth_context) -> FastAPI:
|
|
||||||
from app.api.auth.router import router as auth_router
|
|
||||||
from app.api.editor.router import router as editor_router
|
|
||||||
from app.api.proxy.router import router as proxy_router
|
|
||||||
from app.core.security import (
|
|
||||||
AuthContext,
|
|
||||||
get_auth_context,
|
|
||||||
require_authenticated_user,
|
|
||||||
)
|
|
||||||
from app.models.auth import AuthUserDocument
|
|
||||||
|
|
||||||
app_instance = FastAPI()
|
|
||||||
app_instance.include_router(auth_router)
|
|
||||||
app_instance.include_router(editor_router)
|
|
||||||
app_instance.include_router(proxy_router)
|
|
||||||
|
|
||||||
async def _override_current_user() -> AuthUserDocument:
|
|
||||||
return fake_user
|
|
||||||
|
|
||||||
async def _override_auth_context() -> AuthContext:
|
|
||||||
return auth_context
|
|
||||||
|
|
||||||
app_instance.dependency_overrides[require_authenticated_user] = (
|
|
||||||
_override_current_user
|
|
||||||
)
|
|
||||||
app_instance.dependency_overrides[get_auth_context] = _override_auth_context
|
|
||||||
return app_instance
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(app: FastAPI):
|
|
||||||
with TestClient(app) as test_client:
|
|
||||||
yield test_client
|
|
||||||
|
|
||||||
|
|
||||||
async def _mongo_ping(uri: str) -> str | None:
|
|
||||||
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=500)
|
|
||||||
try:
|
|
||||||
await client.admin.command("ping")
|
|
||||||
except Exception as error: # pragma: no cover - exercised via skip path
|
|
||||||
return str(error)
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _drop_database(uri: str, database_name: str) -> None:
|
|
||||||
client = AsyncIOMotorClient(uri, serverSelectionTimeoutMS=500)
|
|
||||||
try:
|
|
||||||
await client.drop_database(database_name)
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def integration_runtime(tmp_path: Path) -> Iterator[dict[str, str]]:
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.services.mitmproxy.manager import manager
|
|
||||||
|
|
||||||
mongodb_uri = settings.MONGODB_URI
|
|
||||||
ping_error = asyncio.run(_mongo_ping(mongodb_uri))
|
|
||||||
if ping_error is not None:
|
|
||||||
pytest.skip(f"MongoDB is not available for integration tests: {ping_error}")
|
|
||||||
|
|
||||||
db_name = f"theplug_test_{uuid4().hex}"
|
|
||||||
scan_root = tmp_path / "scan-root"
|
|
||||||
runtime_root = tmp_path / "runtime"
|
|
||||||
scan_root.mkdir(parents=True, exist_ok=True)
|
|
||||||
runtime_root.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
original_db_name = settings.MONGODB_DB_NAME
|
|
||||||
original_scan_root = settings.SERVICE_SCAN_ROOT
|
|
||||||
original_auth_username = settings.AUTH_USERNAME
|
|
||||||
original_auth_password = settings.AUTH_PASSWORD
|
|
||||||
original_manager_work_dir = manager.work_dir
|
|
||||||
|
|
||||||
settings.MONGODB_DB_NAME = db_name
|
|
||||||
settings.SERVICE_SCAN_ROOT = str(scan_root)
|
|
||||||
settings.AUTH_USERNAME = "integration-admin"
|
|
||||||
settings.AUTH_PASSWORD = "integration-admin"
|
|
||||||
manager.work_dir = runtime_root
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield {
|
|
||||||
"db_name": db_name,
|
|
||||||
"scan_root": str(scan_root),
|
|
||||||
"runtime_root": str(runtime_root),
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
manager.work_dir = original_manager_work_dir
|
|
||||||
manager.processes.clear()
|
|
||||||
manager.attached_pids.clear()
|
|
||||||
settings.MONGODB_DB_NAME = original_db_name
|
|
||||||
settings.SERVICE_SCAN_ROOT = original_scan_root
|
|
||||||
settings.AUTH_USERNAME = original_auth_username
|
|
||||||
settings.AUTH_PASSWORD = original_auth_password
|
|
||||||
asyncio.run(_drop_database(mongodb_uri, db_name))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def real_app(integration_runtime) -> FastAPI:
|
|
||||||
from app.main import app as real_app_instance
|
|
||||||
|
|
||||||
real_app_instance.dependency_overrides.clear()
|
|
||||||
return real_app_instance
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def real_client(real_app: FastAPI):
|
|
||||||
with TestClient(real_app) as test_client:
|
|
||||||
yield test_client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def auth_token(real_client: TestClient) -> str:
|
|
||||||
response = real_client.post(
|
|
||||||
"/auth/login",
|
|
||||||
json={"username": "integration-admin", "password": "integration-admin"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
return response.json()["access_token"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def auth_headers(auth_token: str) -> dict[str, str]:
|
|
||||||
return {"Authorization": f"Bearer {auth_token}"}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
pytestmark = [pytest.mark.integration, pytest.mark.api]
|
|
||||||
|
|
||||||
|
|
||||||
def test_login_logout_revokes_real_token(real_client):
|
|
||||||
login_response = real_client.post(
|
|
||||||
"/auth/login",
|
|
||||||
json={"username": "integration-admin", "password": "integration-admin"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert login_response.status_code == 200
|
|
||||||
token = login_response.json()["access_token"]
|
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
list_response = real_client.get("/proxy", headers=headers)
|
|
||||||
assert list_response.status_code == 200
|
|
||||||
assert list_response.json() == []
|
|
||||||
|
|
||||||
logout_response = real_client.post("/auth/logout", headers=headers)
|
|
||||||
assert logout_response.status_code == 204
|
|
||||||
|
|
||||||
revoked_response = real_client.get("/proxy", headers=headers)
|
|
||||||
assert revoked_response.status_code == 401
|
|
||||||
assert revoked_response.json()["detail"] == "Could not validate credentials"
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
pytestmark = [pytest.mark.integration, pytest.mark.api]
|
|
||||||
|
|
||||||
VALID_PROXY_CODE = """def request(flow):
|
|
||||||
return None
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxy_crud_round_trip_uses_real_app_and_mongo(real_client, auth_headers):
|
|
||||||
create_response = real_client.post(
|
|
||||||
"/proxy",
|
|
||||||
headers=auth_headers,
|
|
||||||
json={
|
|
||||||
"proxy_port": 49081,
|
|
||||||
"name": "integration-echo",
|
|
||||||
"code": VALID_PROXY_CODE,
|
|
||||||
"protocol": "http",
|
|
||||||
"service": {
|
|
||||||
"host": "127.0.0.1",
|
|
||||||
"port": 49082,
|
|
||||||
},
|
|
||||||
"healthcheck": {
|
|
||||||
"enabled": True,
|
|
||||||
"path": "/healthz",
|
|
||||||
"benchmark_enabled": True,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert create_response.status_code == 201
|
|
||||||
created = create_response.json()
|
|
||||||
assert created["proxy_port"] == 49081
|
|
||||||
assert created["name"] == "integration-echo"
|
|
||||||
assert created["healthcheck"]["benchmark_enabled"] is True
|
|
||||||
|
|
||||||
list_response = real_client.get("/proxy", headers=auth_headers)
|
|
||||||
assert list_response.status_code == 200
|
|
||||||
assert [item["proxy_port"] for item in list_response.json()] == [49081]
|
|
||||||
|
|
||||||
get_response = real_client.get("/proxy/49081", headers=auth_headers)
|
|
||||||
assert get_response.status_code == 200
|
|
||||||
assert get_response.json()["service"]["port"] == 49082
|
|
||||||
|
|
||||||
update_response = real_client.put(
|
|
||||||
"/proxy/49081",
|
|
||||||
headers=auth_headers,
|
|
||||||
json={
|
|
||||||
"name": "integration-echo-updated",
|
|
||||||
"code": VALID_PROXY_CODE.replace("None", '"updated"'),
|
|
||||||
"healthcheck": {
|
|
||||||
"enabled": True,
|
|
||||||
"path": "/ready",
|
|
||||||
"benchmark_enabled": False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert update_response.status_code == 200
|
|
||||||
updated = update_response.json()
|
|
||||||
assert updated["name"] == "integration-echo-updated"
|
|
||||||
assert updated["code"].endswith('return "updated"\n')
|
|
||||||
assert updated["healthcheck"]["path"] == "/ready"
|
|
||||||
assert updated["healthcheck"]["benchmark_enabled"] is False
|
|
||||||
|
|
||||||
history_response = real_client.get(
|
|
||||||
"/proxy/49081/health/history",
|
|
||||||
headers=auth_headers,
|
|
||||||
)
|
|
||||||
assert history_response.status_code == 200
|
|
||||||
history_payload = history_response.json()
|
|
||||||
assert history_payload["proxy_port"] == 49081
|
|
||||||
assert history_payload["latest"] is None
|
|
||||||
assert history_payload["history"] == []
|
|
||||||
|
|
||||||
delete_response = real_client.delete("/proxy/49081", headers=auth_headers)
|
|
||||||
assert delete_response.status_code == 204
|
|
||||||
|
|
||||||
missing_response = real_client.get("/proxy/49081", headers=auth_headers)
|
|
||||||
assert missing_response.status_code == 404
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.integrations.docker.compose import get_all_services_in_dir
|
|
||||||
from app.integrations.docker.editor import change_port
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_all_services_in_dir_scans_immediate_children(tmp_path: Path):
|
|
||||||
root = tmp_path / "workspace"
|
|
||||||
project = root / "playground"
|
|
||||||
project.mkdir(parents=True)
|
|
||||||
(project / "docker-compose.yml").write_text(
|
|
||||||
"""
|
|
||||||
services:
|
|
||||||
echo:
|
|
||||||
image: hashicorp/http-echo:1.0
|
|
||||||
ports:
|
|
||||||
- "9081:5678"
|
|
||||||
""".strip(),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
services = get_all_services_in_dir(root)
|
|
||||||
|
|
||||||
assert services == [
|
|
||||||
{
|
|
||||||
"name": "echo",
|
|
||||||
"ports": [9081],
|
|
||||||
"bindings": [{"published": 9081, "host": None}],
|
|
||||||
"folder": project,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_all_services_in_dir_skips_nested_grandchildren(tmp_path: Path):
|
|
||||||
root = tmp_path / "workspace"
|
|
||||||
nested = root / "group" / "playground"
|
|
||||||
nested.mkdir(parents=True)
|
|
||||||
(nested / "docker-compose.yml").write_text(
|
|
||||||
"""
|
|
||||||
services:
|
|
||||||
echo:
|
|
||||||
image: hashicorp/http-echo:1.0
|
|
||||||
ports:
|
|
||||||
- "9081:5678"
|
|
||||||
""".strip(),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
services = get_all_services_in_dir(root)
|
|
||||||
|
|
||||||
assert services == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_all_services_in_dir_returns_empty_when_root_is_inaccessible(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch,
|
|
||||||
):
|
|
||||||
root = tmp_path / "workspace"
|
|
||||||
root.mkdir()
|
|
||||||
original_iterdir = Path.iterdir
|
|
||||||
|
|
||||||
def fake_iterdir(self: Path):
|
|
||||||
if self == root:
|
|
||||||
raise PermissionError("denied")
|
|
||||||
return original_iterdir(self)
|
|
||||||
|
|
||||||
monkeypatch.setattr(Path, "iterdir", fake_iterdir)
|
|
||||||
|
|
||||||
assert get_all_services_in_dir(root) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_all_services_in_dir_skips_inaccessible_child_folder(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch,
|
|
||||||
):
|
|
||||||
root = tmp_path / "workspace"
|
|
||||||
allowed = root / "allowed"
|
|
||||||
blocked = root / "blocked"
|
|
||||||
allowed.mkdir(parents=True)
|
|
||||||
blocked.mkdir(parents=True)
|
|
||||||
(allowed / "docker-compose.yml").write_text(
|
|
||||||
"""
|
|
||||||
services:
|
|
||||||
echo:
|
|
||||||
image: hashicorp/http-echo:1.0
|
|
||||||
ports:
|
|
||||||
- "9081:5678"
|
|
||||||
""".strip(),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
original_is_dir = Path.is_dir
|
|
||||||
|
|
||||||
def fake_is_dir(self: Path):
|
|
||||||
if self == blocked:
|
|
||||||
raise PermissionError("denied")
|
|
||||||
return original_is_dir(self)
|
|
||||||
|
|
||||||
monkeypatch.setattr(Path, "is_dir", fake_is_dir)
|
|
||||||
|
|
||||||
services = get_all_services_in_dir(root)
|
|
||||||
|
|
||||||
assert services == [
|
|
||||||
{
|
|
||||||
"name": "echo",
|
|
||||||
"ports": [9081],
|
|
||||||
"bindings": [{"published": 9081, "host": None}],
|
|
||||||
"folder": allowed,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_change_port_restores_original_compose_when_reload_fails(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch,
|
|
||||||
):
|
|
||||||
folder = tmp_path / "workspace"
|
|
||||||
folder.mkdir()
|
|
||||||
compose_path = folder / "docker-compose.yml"
|
|
||||||
original = """
|
|
||||||
services:
|
|
||||||
echo:
|
|
||||||
image: hashicorp/http-echo:1.0
|
|
||||||
ports:
|
|
||||||
- "9081:5678"
|
|
||||||
""".strip()
|
|
||||||
compose_path.write_text(original, encoding="utf-8")
|
|
||||||
|
|
||||||
async def fake_run_docker_command(
|
|
||||||
_file: Path,
|
|
||||||
_command: list[str],
|
|
||||||
) -> dict | None:
|
|
||||||
return {"status": "error", "reason": "boom"}
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.integrations.docker.editor.run_docker_command",
|
|
||||||
fake_run_docker_command,
|
|
||||||
)
|
|
||||||
|
|
||||||
changed = asyncio.run(change_port("echo", folder, 9081, 54001))
|
|
||||||
|
|
||||||
assert changed is False
|
|
||||||
assert compose_path.read_text(encoding="utf-8") == original
|
|
||||||
assert (folder / "docker-compose.bak.yml").exists() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_change_port_restores_original_compose_when_reload_raises(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch,
|
|
||||||
):
|
|
||||||
folder = tmp_path / "workspace"
|
|
||||||
folder.mkdir()
|
|
||||||
compose_path = folder / "docker-compose.yml"
|
|
||||||
original = """
|
|
||||||
services:
|
|
||||||
echo:
|
|
||||||
image: hashicorp/http-echo:1.0
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:54001:5678"
|
|
||||||
""".strip()
|
|
||||||
compose_path.write_text(original, encoding="utf-8")
|
|
||||||
|
|
||||||
async def fake_run_docker_command(
|
|
||||||
_file: Path,
|
|
||||||
_command: list[str],
|
|
||||||
) -> dict | None:
|
|
||||||
raise RuntimeError("docker unavailable")
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.integrations.docker.editor.run_docker_command",
|
|
||||||
fake_run_docker_command,
|
|
||||||
)
|
|
||||||
|
|
||||||
changed = asyncio.run(
|
|
||||||
change_port(
|
|
||||||
"echo",
|
|
||||||
folder,
|
|
||||||
54001,
|
|
||||||
9081,
|
|
||||||
host_to=None,
|
|
||||||
rewrite_host=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert changed is False
|
|
||||||
assert compose_path.read_text(encoding="utf-8") == original
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from app.integrations.docker.ports import _get_service_binding, _replace_port_in_mapping
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_service_binding_includes_loopback_hosts():
|
|
||||||
assert _get_service_binding("127.0.0.1:8080:80") == (8080, "127.0.0.1")
|
|
||||||
assert _get_service_binding("localhost:9081:5678") == (9081, "localhost")
|
|
||||||
|
|
||||||
|
|
||||||
def test_replace_port_in_mapping_can_force_loopback_host():
|
|
||||||
updated = _replace_port_in_mapping(
|
|
||||||
"9081:5678",
|
|
||||||
9081,
|
|
||||||
54001,
|
|
||||||
host_to="127.0.0.1",
|
|
||||||
rewrite_host=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert updated == "127.0.0.1:54001:5678"
|
|
||||||
|
|
||||||
|
|
||||||
def test_replace_port_in_mapping_can_restore_unspecified_host():
|
|
||||||
restored = _replace_port_in_mapping(
|
|
||||||
"127.0.0.1:54001:5678",
|
|
||||||
54001,
|
|
||||||
9081,
|
|
||||||
host_to=None,
|
|
||||||
rewrite_host=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert restored == "9081:5678"
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
from app.models.health import HealthSnapshot
|
|
||||||
from app.models.proxy import ProxyDocument
|
|
||||||
from app.models.service import ServiceEndpointDocument
|
|
||||||
from app.services.mitmproxy.health import check_proxy
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_proxy_reports_upstream_and_proxy_healthy():
|
|
||||||
async def run():
|
|
||||||
upstream = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
|
||||||
proxy = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
|
||||||
try:
|
|
||||||
upstream_port = upstream.sockets[0].getsockname()[1]
|
|
||||||
proxy_port = proxy.sockets[0].getsockname()[1]
|
|
||||||
document = ProxyDocument(
|
|
||||||
proxy_port=proxy_port,
|
|
||||||
name="health",
|
|
||||||
code="",
|
|
||||||
service=ServiceEndpointDocument(port=upstream_port),
|
|
||||||
)
|
|
||||||
|
|
||||||
health = await check_proxy(document)
|
|
||||||
finally:
|
|
||||||
upstream.close()
|
|
||||||
proxy.close()
|
|
||||||
await upstream.wait_closed()
|
|
||||||
await proxy.wait_closed()
|
|
||||||
|
|
||||||
assert isinstance(health, HealthSnapshot)
|
|
||||||
assert health.overall == "healthy"
|
|
||||||
assert [check.target for check in health.checks] == [
|
|
||||||
"upstream",
|
|
||||||
"proxy",
|
|
||||||
]
|
|
||||||
assert all(check.duration_ms is None for check in health.checks)
|
|
||||||
|
|
||||||
asyncio.run(run())
|
|
||||||
|
|
||||||
|
|
||||||
def test_check_proxy_reports_benchmark_latency_when_enabled():
|
|
||||||
async def run():
|
|
||||||
upstream = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
|
||||||
proxy = await asyncio.start_server(http_handler, "127.0.0.1", 0)
|
|
||||||
try:
|
|
||||||
upstream_port = upstream.sockets[0].getsockname()[1]
|
|
||||||
proxy_port = proxy.sockets[0].getsockname()[1]
|
|
||||||
document = ProxyDocument(
|
|
||||||
proxy_port=proxy_port,
|
|
||||||
name="health",
|
|
||||||
code="",
|
|
||||||
healthcheck={"benchmark_enabled": True},
|
|
||||||
service=ServiceEndpointDocument(port=upstream_port),
|
|
||||||
)
|
|
||||||
|
|
||||||
health = await check_proxy(document)
|
|
||||||
finally:
|
|
||||||
upstream.close()
|
|
||||||
proxy.close()
|
|
||||||
await upstream.wait_closed()
|
|
||||||
await proxy.wait_closed()
|
|
||||||
|
|
||||||
assert isinstance(health, HealthSnapshot)
|
|
||||||
assert all(
|
|
||||||
check.duration_ms is not None and check.duration_ms >= 0
|
|
||||||
for check in health.checks
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.run(run())
|
|
||||||
|
|
||||||
|
|
||||||
async def http_handler(reader, writer):
|
|
||||||
await reader.readline()
|
|
||||||
writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
|
|
||||||
await writer.drain()
|
|
||||||
writer.close()
|
|
||||||
await writer.wait_closed()
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.models.health import HealthSnapshot
|
|
||||||
from app.models.proxy import ProxyDocument
|
|
||||||
from app.models.service import ServiceEndpointDocument
|
|
||||||
from app.services.mitmproxy.health_monitor import HealthMonitor
|
|
||||||
|
|
||||||
|
|
||||||
def make_proxy(**updates):
|
|
||||||
data = {
|
|
||||||
"proxy_port": 8080,
|
|
||||||
"name": "svc",
|
|
||||||
"code": "",
|
|
||||||
"status": "running",
|
|
||||||
"service": ServiceEndpointDocument(port=9000),
|
|
||||||
}
|
|
||||||
data.update(updates)
|
|
||||||
return ProxyDocument(**data)
|
|
||||||
|
|
||||||
|
|
||||||
def make_snapshot(proxy_port: int, *, overall: str = "healthy") -> HealthSnapshot:
|
|
||||||
return HealthSnapshot(
|
|
||||||
proxy_port=proxy_port,
|
|
||||||
timestamp=datetime.now(UTC),
|
|
||||||
enabled=True,
|
|
||||||
overall=overall,
|
|
||||||
checks=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_cycle_records_and_publishes_running_healthchecks(monkeypatch):
|
|
||||||
monitor = HealthMonitor()
|
|
||||||
proxy = make_proxy()
|
|
||||||
captured: dict[str, object] = {}
|
|
||||||
snapshot = make_snapshot(8080)
|
|
||||||
|
|
||||||
async def fake_list_proxies():
|
|
||||||
return [proxy]
|
|
||||||
|
|
||||||
async def fake_check_proxy(document):
|
|
||||||
captured["checked"] = document.proxy_port
|
|
||||||
return snapshot
|
|
||||||
|
|
||||||
async def fake_record_health_sample(document):
|
|
||||||
captured["recorded"] = document
|
|
||||||
return document
|
|
||||||
|
|
||||||
async def fake_publish(proxy_port, document):
|
|
||||||
captured["published"] = (proxy_port, document)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.health_monitor.list_proxies",
|
|
||||||
fake_list_proxies,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.health_monitor.check_proxy",
|
|
||||||
fake_check_proxy,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.health_monitor.record_health_sample",
|
|
||||||
fake_record_health_sample,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(monitor, "publish", fake_publish)
|
|
||||||
|
|
||||||
asyncio.run(monitor.run_cycle())
|
|
||||||
|
|
||||||
assert captured["checked"] == 8080
|
|
||||||
assert captured["recorded"] == snapshot
|
|
||||||
assert captured["published"] == (8080, snapshot)
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_cycle_skips_stopped_or_disabled_proxies(monkeypatch):
|
|
||||||
monitor = HealthMonitor()
|
|
||||||
running_disabled = make_proxy(healthcheck={"enabled": False})
|
|
||||||
stopped = make_proxy(proxy_port=8081, status="stopped")
|
|
||||||
called = False
|
|
||||||
|
|
||||||
async def fake_list_proxies():
|
|
||||||
return [running_disabled, stopped]
|
|
||||||
|
|
||||||
async def fake_check_proxy(_document):
|
|
||||||
nonlocal called
|
|
||||||
called = True
|
|
||||||
return make_snapshot(8080)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.health_monitor.list_proxies",
|
|
||||||
fake_list_proxies,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.health_monitor.check_proxy",
|
|
||||||
fake_check_proxy,
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.run(monitor.run_cycle())
|
|
||||||
|
|
||||||
assert called is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_cycle_checks_proxies_in_parallel(monkeypatch):
|
|
||||||
monitor = HealthMonitor()
|
|
||||||
proxies = [
|
|
||||||
make_proxy(proxy_port=8080),
|
|
||||||
make_proxy(proxy_port=8081),
|
|
||||||
make_proxy(proxy_port=8082),
|
|
||||||
]
|
|
||||||
started: list[int] = []
|
|
||||||
release = asyncio.Event()
|
|
||||||
|
|
||||||
async def fake_list_proxies():
|
|
||||||
return proxies
|
|
||||||
|
|
||||||
async def fake_check_proxy(document):
|
|
||||||
started.append(document.proxy_port)
|
|
||||||
if len(started) == len(proxies):
|
|
||||||
release.set()
|
|
||||||
await release.wait()
|
|
||||||
return make_snapshot(document.proxy_port)
|
|
||||||
|
|
||||||
async def fake_record_health_sample(document):
|
|
||||||
return document
|
|
||||||
|
|
||||||
async def fake_publish(_proxy_port, _document):
|
|
||||||
return None
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.health_monitor.list_proxies",
|
|
||||||
fake_list_proxies,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.health_monitor.check_proxy",
|
|
||||||
fake_check_proxy,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.health_monitor.record_health_sample",
|
|
||||||
fake_record_health_sample,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(monitor, "publish", fake_publish)
|
|
||||||
|
|
||||||
asyncio.run(asyncio.wait_for(monitor.run_cycle(), timeout=1))
|
|
||||||
|
|
||||||
assert started == [8080, 8081, 8082]
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_cycle_continues_when_one_proxy_check_fails(monkeypatch):
|
|
||||||
monitor = HealthMonitor()
|
|
||||||
proxies = [
|
|
||||||
make_proxy(proxy_port=8080),
|
|
||||||
make_proxy(proxy_port=8081),
|
|
||||||
]
|
|
||||||
captured: list[int] = []
|
|
||||||
|
|
||||||
async def fake_list_proxies():
|
|
||||||
return proxies
|
|
||||||
|
|
||||||
async def fake_check_proxy(document):
|
|
||||||
if document.proxy_port == 8080:
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
return make_snapshot(document.proxy_port)
|
|
||||||
|
|
||||||
async def fake_record_health_sample(document):
|
|
||||||
captured.append(document.proxy_port)
|
|
||||||
return document
|
|
||||||
|
|
||||||
async def fake_publish(proxy_port, _document):
|
|
||||||
captured.append(proxy_port + 10_000)
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.health_monitor.list_proxies",
|
|
||||||
fake_list_proxies,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.health_monitor.check_proxy",
|
|
||||||
fake_check_proxy,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.health_monitor.record_health_sample",
|
|
||||||
fake_record_health_sample,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(monitor, "publish", fake_publish)
|
|
||||||
|
|
||||||
asyncio.run(monitor.run_cycle())
|
|
||||||
|
|
||||||
assert captured == [8081, 18081]
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_retries_after_cycle_failure(monkeypatch):
|
|
||||||
monitor = HealthMonitor()
|
|
||||||
calls = {"run_cycle": 0}
|
|
||||||
sleep_calls: list[float] = []
|
|
||||||
|
|
||||||
async def fake_run_cycle():
|
|
||||||
calls["run_cycle"] += 1
|
|
||||||
if calls["run_cycle"] == 1:
|
|
||||||
raise RuntimeError("temporary failure")
|
|
||||||
raise asyncio.CancelledError
|
|
||||||
|
|
||||||
async def fake_sleep(seconds):
|
|
||||||
sleep_calls.append(seconds)
|
|
||||||
return None
|
|
||||||
|
|
||||||
monkeypatch.setattr(monitor, "run_cycle", fake_run_cycle)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.health_monitor.asyncio.sleep",
|
|
||||||
fake_sleep,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
asyncio.run(monitor.run())
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
assert calls["run_cycle"] == 2
|
|
||||||
assert len(sleep_calls) == 1
|
|
||||||
assert sleep_calls[0] == pytest.approx(1.0, abs=0.01)
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.models.proxy import ProxyDocument
|
|
||||||
from app.models.service import ServiceEndpointDocument
|
|
||||||
from app.services.mitmproxy.manager import MitmdumpError, MitmdumpManager
|
|
||||||
|
|
||||||
|
|
||||||
def make_proxy(**updates):
|
|
||||||
data = {
|
|
||||||
"proxy_port": 8080,
|
|
||||||
"name": "svc",
|
|
||||||
"code": "",
|
|
||||||
"service": ServiceEndpointDocument(port=9000),
|
|
||||||
}
|
|
||||||
data.update(updates)
|
|
||||||
return ProxyDocument(**data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reverse_mode_is_built_from_service():
|
|
||||||
manager = MitmdumpManager()
|
|
||||||
proxy = make_proxy(protocol="tcp")
|
|
||||||
|
|
||||||
command = manager.build_command(
|
|
||||||
"mitmdump",
|
|
||||||
proxy,
|
|
||||||
9100,
|
|
||||||
Path("/tmp/addon.py"),
|
|
||||||
Path("/tmp/conf/config.yaml"),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "reverse:tcp://127.0.0.1:9100@8080" in command
|
|
||||||
assert "--listen-port" not in command
|
|
||||||
|
|
||||||
|
|
||||||
def test_raw_mode_spec_overrides_generated_mode():
|
|
||||||
manager = MitmdumpManager()
|
|
||||||
proxy = make_proxy(mode="regular", mode_spec="wireguard:/tmp/wg.conf@51820")
|
|
||||||
|
|
||||||
command = manager.build_command(
|
|
||||||
"mitmdump",
|
|
||||||
proxy,
|
|
||||||
None,
|
|
||||||
Path("/tmp/addon.py"),
|
|
||||||
Path("/tmp/conf/config.yaml"),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "wireguard:/tmp/wg.conf@51820" in command
|
|
||||||
assert "--listen-port" not in command
|
|
||||||
|
|
||||||
|
|
||||||
def test_regular_mode_uses_proxy_port_as_listen_port():
|
|
||||||
manager = MitmdumpManager()
|
|
||||||
proxy = make_proxy(mode="regular")
|
|
||||||
|
|
||||||
command = manager.build_command(
|
|
||||||
"mitmdump",
|
|
||||||
proxy,
|
|
||||||
None,
|
|
||||||
Path("/tmp/addon.py"),
|
|
||||||
Path("/tmp/conf/config.yaml"),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "regular" in command
|
|
||||||
assert command[-2:] == ["--listen-port", "8080"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_rejects_reserved_backend_port():
|
|
||||||
manager = MitmdumpManager()
|
|
||||||
proxy = make_proxy(proxy_port=3671)
|
|
||||||
|
|
||||||
async def run():
|
|
||||||
await manager.start(proxy)
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
MitmdumpError,
|
|
||||||
match="Proxy port 3671 is reserved for the backend API",
|
|
||||||
):
|
|
||||||
asyncio.run(run())
|
|
||||||
|
|
||||||
|
|
||||||
def test_running_document_without_pid_is_treated_as_stopped():
|
|
||||||
manager = MitmdumpManager()
|
|
||||||
proxy = make_proxy(status="running", pid=None)
|
|
||||||
|
|
||||||
async def run():
|
|
||||||
return await manager.status(proxy)
|
|
||||||
|
|
||||||
assert asyncio.run(run()) == "stopped"
|
|
||||||
|
|
||||||
|
|
||||||
def test_matches_proxy_process_uses_expected_script_and_confdir():
|
|
||||||
manager = MitmdumpManager()
|
|
||||||
proxy = make_proxy()
|
|
||||||
|
|
||||||
manager.read_cmdline = lambda _pid: [
|
|
||||||
"/home/test/.venv/bin/python",
|
|
||||||
"/home/test/.venv/bin/mitmdump",
|
|
||||||
"--mode",
|
|
||||||
"reverse:http://127.0.0.1:9000@8080",
|
|
||||||
"--listen-host",
|
|
||||||
"0.0.0.0",
|
|
||||||
"--set",
|
|
||||||
"confdir=/tmp/theplug/conf/8080",
|
|
||||||
"-s",
|
|
||||||
"/tmp/theplug/addons/8080.py",
|
|
||||||
]
|
|
||||||
|
|
||||||
assert manager.matches_proxy_process(proxy, 1234) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_reconcile_reattaches_matching_process():
|
|
||||||
manager = MitmdumpManager()
|
|
||||||
proxy = make_proxy(
|
|
||||||
status="running",
|
|
||||||
pid=4321,
|
|
||||||
service=ServiceEndpointDocument(port=9000, upstream_port=54003),
|
|
||||||
)
|
|
||||||
manager.is_process_alive = lambda _pid: True
|
|
||||||
manager.matches_proxy_process = lambda _proxy, _pid: True
|
|
||||||
|
|
||||||
async def run():
|
|
||||||
return await manager.reconcile(proxy)
|
|
||||||
|
|
||||||
values = asyncio.run(run())
|
|
||||||
|
|
||||||
assert values is not None
|
|
||||||
assert values["status"] == "running"
|
|
||||||
assert values["pid"] == 4321
|
|
||||||
assert manager.attached_pids[8080] == 4321
|
|
||||||
|
|
||||||
|
|
||||||
def test_reconcile_marks_error_for_mismatched_live_process():
|
|
||||||
manager = MitmdumpManager()
|
|
||||||
proxy = make_proxy(
|
|
||||||
status="running",
|
|
||||||
pid=4321,
|
|
||||||
service=ServiceEndpointDocument(port=9000, upstream_port=54003),
|
|
||||||
)
|
|
||||||
manager.is_process_alive = lambda _pid: True
|
|
||||||
manager.matches_proxy_process = lambda _proxy, _pid: False
|
|
||||||
|
|
||||||
async def run():
|
|
||||||
return await manager.reconcile(proxy)
|
|
||||||
|
|
||||||
values = asyncio.run(run())
|
|
||||||
|
|
||||||
assert values is not None
|
|
||||||
assert values["status"] == "error"
|
|
||||||
assert values["pid"] is None
|
|
||||||
assert "does not match" in str(values["last_error"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_reconcile_restores_upstream_when_process_is_gone():
|
|
||||||
manager = MitmdumpManager()
|
|
||||||
proxy = make_proxy(
|
|
||||||
status="running",
|
|
||||||
pid=4321,
|
|
||||||
service=ServiceEndpointDocument(
|
|
||||||
port=9000,
|
|
||||||
upstream_port=54003,
|
|
||||||
name="echo",
|
|
||||||
folder="/tmp/playground",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
manager.is_process_alive = lambda _pid: False
|
|
||||||
|
|
||||||
async def fake_restore(_proxy, _upstream_port):
|
|
||||||
return True
|
|
||||||
|
|
||||||
manager.restore_upstream = fake_restore
|
|
||||||
|
|
||||||
async def run():
|
|
||||||
return await manager.reconcile(proxy)
|
|
||||||
|
|
||||||
values = asyncio.run(run())
|
|
||||||
|
|
||||||
assert values == {
|
|
||||||
"status": "stopped",
|
|
||||||
"pid": None,
|
|
||||||
"service.upstream_port": None,
|
|
||||||
"last_error": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_runtime_artifacts_removes_generated_files(tmp_path: Path):
|
|
||||||
manager = MitmdumpManager(work_dir=str(tmp_path / "theplug"))
|
|
||||||
proxy = make_proxy(
|
|
||||||
script_path=str(manager.script_path_for(make_proxy())),
|
|
||||||
config_path=str(manager.config_path_for(make_proxy())),
|
|
||||||
log_path=str(manager.log_path_for(make_proxy())),
|
|
||||||
)
|
|
||||||
|
|
||||||
script_path = Path(proxy.script_path)
|
|
||||||
config_path = Path(proxy.config_path)
|
|
||||||
log_path = Path(proxy.log_path)
|
|
||||||
script_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
script_path.write_text("print('hello')", encoding="utf-8")
|
|
||||||
config_path.write_text("block_global: false\n", encoding="utf-8")
|
|
||||||
log_path.write_text("old log\n", encoding="utf-8")
|
|
||||||
|
|
||||||
manager.cleanup_runtime_artifacts(proxy)
|
|
||||||
|
|
||||||
assert script_path.exists() is False
|
|
||||||
assert log_path.exists() is False
|
|
||||||
assert config_path.exists() is False
|
|
||||||
assert manager.conf_dir_for(proxy).exists() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_upstream_retries_until_port_remap_succeeds(monkeypatch):
|
|
||||||
manager = MitmdumpManager()
|
|
||||||
proxy = make_proxy(
|
|
||||||
mode="reverse",
|
|
||||||
service=ServiceEndpointDocument(
|
|
||||||
port=9000,
|
|
||||||
name="echo",
|
|
||||||
folder="/tmp/playground",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
attempted_ports: list[int] = []
|
|
||||||
candidate_ports = iter([54001, 54002, 54003])
|
|
||||||
|
|
||||||
async def fake_change_port(
|
|
||||||
_service_name,
|
|
||||||
_folder,
|
|
||||||
_port_from,
|
|
||||||
port_to,
|
|
||||||
**_kwargs,
|
|
||||||
):
|
|
||||||
attempted_ports.append(port_to)
|
|
||||||
return port_to == 54003
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.manager.get_random_port",
|
|
||||||
lambda: next(candidate_ports),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.manager.change_port",
|
|
||||||
fake_change_port,
|
|
||||||
)
|
|
||||||
|
|
||||||
upstream_port = asyncio.run(manager.prepare_upstream(proxy))
|
|
||||||
|
|
||||||
assert upstream_port == 54003
|
|
||||||
assert attempted_ports == [54001, 54002, 54003]
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_upstream_fails_after_retry_budget(monkeypatch):
|
|
||||||
manager = MitmdumpManager()
|
|
||||||
manager.upstream_port_attempts = 3
|
|
||||||
proxy = make_proxy(
|
|
||||||
mode="reverse",
|
|
||||||
service=ServiceEndpointDocument(
|
|
||||||
port=9000,
|
|
||||||
name="echo",
|
|
||||||
folder="/tmp/playground",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
candidate_ports = iter([54001, 54002, 54003])
|
|
||||||
|
|
||||||
async def fake_change_port(*_args, **_kwargs):
|
|
||||||
return False
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.manager.get_random_port",
|
|
||||||
lambda: next(candidate_ports),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.mitmproxy.manager.change_port",
|
|
||||||
fake_change_port,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(
|
|
||||||
MitmdumpError,
|
|
||||||
match="Unable to move Docker service port after retries",
|
|
||||||
):
|
|
||||||
asyncio.run(manager.prepare_upstream(proxy))
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from app.services.mitmproxy.options import (
|
|
||||||
MitmOption,
|
|
||||||
parse_options,
|
|
||||||
public_options,
|
|
||||||
validate_value,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_options_extracts_types_and_choices():
|
|
||||||
text = """
|
|
||||||
# Enable feature. Type bool.
|
|
||||||
enabled: false
|
|
||||||
|
|
||||||
# Valid values are 'eager', 'lazy'.
|
|
||||||
strategy: eager
|
|
||||||
|
|
||||||
# Hosts. Type sequence of str.
|
|
||||||
hosts: []
|
|
||||||
"""
|
|
||||||
options = parse_options(
|
|
||||||
text,
|
|
||||||
{"enabled": False, "strategy": "eager", "hosts": []},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert options["enabled"].value_type == "bool"
|
|
||||||
assert options["strategy"].choices == ["eager", "lazy"]
|
|
||||||
assert options["hosts"].value_type == "sequence of str"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("option", "value", "message"),
|
|
||||||
[
|
|
||||||
(MitmOption("anticache", "bool", False, "", []), "true", "expected bool"),
|
|
||||||
(MitmOption("flow_detail", "int", 1, "", []), "1", "expected int"),
|
|
||||||
(MitmOption("allow_hosts", "sequence of str", [], "", []), "x", "list[str]"),
|
|
||||||
(MitmOption("strategy", "unknown", "eager", "", ["eager"]), "lazy", "one of"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_validate_value_rejects_wrong_types(option, value, message):
|
|
||||||
assert message in validate_value(option, value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_options_include_docs_links():
|
|
||||||
options = {
|
|
||||||
"flow_detail": MitmOption("flow_detail", "int", 1, "Flow detail.", []),
|
|
||||||
}
|
|
||||||
|
|
||||||
public = public_options(options)
|
|
||||||
|
|
||||||
assert public[0]["docs_url"].endswith("#flow_detail")
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from app.services.editor import python_lsp
|
|
||||||
|
|
||||||
|
|
||||||
def test_encode_message():
|
|
||||||
message = '{"jsonrpc":"2.0"}'
|
|
||||||
|
|
||||||
payload = python_lsp.encode_message(message)
|
|
||||||
|
|
||||||
assert payload.endswith(message.encode("utf-8"))
|
|
||||||
assert payload.startswith(
|
|
||||||
f"Content-Length: {len(message.encode('utf-8'))}\r\n\r\n".encode("ascii")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_frame_buffer_reads_split_messages():
|
|
||||||
buffer = python_lsp.LspFrameBuffer()
|
|
||||||
first = python_lsp.encode_message('{"id":1}')
|
|
||||||
second = python_lsp.encode_message('{"id":2}')
|
|
||||||
|
|
||||||
parts = [
|
|
||||||
first[:12],
|
|
||||||
first[12:] + second[:10],
|
|
||||||
second[10:],
|
|
||||||
]
|
|
||||||
|
|
||||||
messages: list[str] = []
|
|
||||||
for part in parts:
|
|
||||||
messages.extend(buffer.feed(part))
|
|
||||||
|
|
||||||
assert messages == ['{"id":1}', '{"id":2}']
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
from app.models.proxy import ProxyDocument
|
|
||||||
from app.models.service import ServiceEndpointDocument
|
|
||||||
from app.services.mitmproxy import recovery as recovery_module
|
|
||||||
|
|
||||||
|
|
||||||
def make_proxy(**updates):
|
|
||||||
data = {
|
|
||||||
"proxy_port": 8080,
|
|
||||||
"name": "svc",
|
|
||||||
"code": "",
|
|
||||||
"service": ServiceEndpointDocument(port=9000),
|
|
||||||
}
|
|
||||||
data.update(updates)
|
|
||||||
return ProxyDocument(**data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reconcile_runtime_persists_manager_updates(monkeypatch):
|
|
||||||
proxy = make_proxy(status="running", pid=1234)
|
|
||||||
captured: list[tuple[int, dict[str, object]]] = []
|
|
||||||
|
|
||||||
async def fake_list_proxies():
|
|
||||||
return [proxy]
|
|
||||||
|
|
||||||
class FakeManager:
|
|
||||||
async def reconcile(self, document: ProxyDocument):
|
|
||||||
assert document.proxy_port == 8080
|
|
||||||
return {"status": "running", "pid": 1234, "last_error": None}
|
|
||||||
|
|
||||||
async def fake_update_proxy_runtime(proxy_port: int, values: dict[str, object]):
|
|
||||||
captured.append((proxy_port, values))
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
monkeypatch.setattr(recovery_module, "list_proxies", fake_list_proxies)
|
|
||||||
monkeypatch.setattr(recovery_module, "manager", FakeManager())
|
|
||||||
monkeypatch.setattr(
|
|
||||||
recovery_module,
|
|
||||||
"update_proxy_runtime",
|
|
||||||
fake_update_proxy_runtime,
|
|
||||||
)
|
|
||||||
|
|
||||||
changes = asyncio.run(recovery_module.reconcile_runtime())
|
|
||||||
|
|
||||||
assert changes == [(8080, {"status": "running", "pid": 1234, "last_error": None})]
|
|
||||||
assert captured == [(8080, {"status": "running", "pid": 1234, "last_error": None})]
|
|
||||||
|
|
||||||
|
|
||||||
def test_reconcile_runtime_skips_unchanged_proxies(monkeypatch):
|
|
||||||
proxy = make_proxy()
|
|
||||||
updated = False
|
|
||||||
|
|
||||||
async def fake_list_proxies():
|
|
||||||
return [proxy]
|
|
||||||
|
|
||||||
class FakeManager:
|
|
||||||
async def reconcile(self, _document: ProxyDocument):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def fake_update_proxy_runtime(_proxy_port: int, _values: dict[str, object]):
|
|
||||||
nonlocal updated
|
|
||||||
updated = True
|
|
||||||
return proxy
|
|
||||||
|
|
||||||
monkeypatch.setattr(recovery_module, "list_proxies", fake_list_proxies)
|
|
||||||
monkeypatch.setattr(recovery_module, "manager", FakeManager())
|
|
||||||
monkeypatch.setattr(
|
|
||||||
recovery_module,
|
|
||||||
"update_proxy_runtime",
|
|
||||||
fake_update_proxy_runtime,
|
|
||||||
)
|
|
||||||
|
|
||||||
changes = asyncio.run(recovery_module.reconcile_runtime())
|
|
||||||
|
|
||||||
assert changes == []
|
|
||||||
assert updated is False
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from app.services.mitmproxy.templates import list_file_templates
|
|
||||||
|
|
||||||
|
|
||||||
def test_file_templates_use_four_space_indentation():
|
|
||||||
for template in list_file_templates():
|
|
||||||
for line in template.code.splitlines():
|
|
||||||
assert "\t" not in line, f"{template.id} contains a tab: {line!r}"
|
|
||||||
if not line.strip():
|
|
||||||
continue
|
|
||||||
indent = len(line) - len(line.lstrip(" "))
|
|
||||||
assert indent % 4 == 0, (
|
|
||||||
f"{template.id} has non-4-space indentation: {line!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_file_templates_loads_yaml_backed_templates():
|
|
||||||
templates = list_file_templates()
|
|
||||||
|
|
||||||
assert templates
|
|
||||||
assert all(template.origin == "file" for template in templates)
|
|
||||||
assert all(template.built_in is True for template in templates)
|
|
||||||
assert all(template.protocol in {"http", "tcp"} for template in templates)
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_file_templates_skips_invalid_files(tmp_path, caplog):
|
|
||||||
path = tmp_path / "invalid.yaml"
|
|
||||||
path.write_text("[]\n", encoding="utf-8")
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
templates = list_file_templates(tmp_path)
|
|
||||||
|
|
||||||
assert templates == []
|
|
||||||
assert "Skipping template file" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_file_templates_skips_duplicate_ids(tmp_path, caplog):
|
|
||||||
payload = {
|
|
||||||
"id": "duplicate",
|
|
||||||
"name": "Duplicate",
|
|
||||||
"protocol": "http",
|
|
||||||
"description": "Duplicate template",
|
|
||||||
"code": "from mitmproxy import http\n",
|
|
||||||
}
|
|
||||||
document = yaml.safe_dump(payload, sort_keys=False)
|
|
||||||
(tmp_path / "one.yaml").write_text(document, encoding="utf-8")
|
|
||||||
(tmp_path / "two.yaml").write_text(document, encoding="utf-8")
|
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING):
|
|
||||||
templates = list_file_templates(tmp_path)
|
|
||||||
|
|
||||||
assert templates == []
|
|
||||||
assert "duplicate file-backed template id" in caplog.text
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
from app.core.security import create_access_token, decode_access_token
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_access_token_uses_twelve_hour_default_window(monkeypatch):
|
|
||||||
monkeypatch.setattr("app.core.security.settings.JWT_EXPIRE_MINUTES", 720)
|
|
||||||
|
|
||||||
issued_at = datetime.now(UTC)
|
|
||||||
token = create_access_token(subject="admin")
|
|
||||||
payload = decode_access_token(token)
|
|
||||||
expires_at = datetime.fromtimestamp(payload.exp, UTC)
|
|
||||||
|
|
||||||
lifetime_seconds = (expires_at - issued_at).total_seconds()
|
|
||||||
|
|
||||||
assert 11.9 * 60 * 60 <= lifetime_seconds <= 12.1 * 60 * 60
|
|
||||||
Generated
-1908
File diff suppressed because it is too large
Load Diff
@@ -1,182 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
|
||||||
die() { echo -e "${RED}[x] FATAL:${NC} $*" >&2; exit 1; }
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
BACKEND_DIR="$ROOT_DIR/backend"
|
|
||||||
FRONTEND_DIR="$ROOT_DIR/frontend"
|
|
||||||
PLAYGROUND_DIR="$ROOT_DIR/playground"
|
|
||||||
ENV_FILE="$BACKEND_DIR/.env"
|
|
||||||
|
|
||||||
require_command() {
|
|
||||||
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
|
|
||||||
}
|
|
||||||
|
|
||||||
require_env() {
|
|
||||||
[[ -f "$ENV_FILE" ]] || die "Missing $ENV_FILE. Run ./setup.sh first."
|
|
||||||
}
|
|
||||||
|
|
||||||
env_value() {
|
|
||||||
local key="$1"
|
|
||||||
local default_value="${2:-}"
|
|
||||||
local value
|
|
||||||
value=$(grep -E "^${key}=" "$ENV_FILE" | cut -d '=' -f2- || true)
|
|
||||||
if [[ -n "$value" ]]; then
|
|
||||||
printf '%s\n' "$value"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
printf '%s\n' "$default_value"
|
|
||||||
}
|
|
||||||
|
|
||||||
compose() {
|
|
||||||
docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.yml" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
service_compose_path() {
|
|
||||||
local name="$1"
|
|
||||||
local path="$PLAYGROUND_DIR/$name/docker-compose.yml"
|
|
||||||
[[ -f "$path" ]] || die "Unknown service group: $name"
|
|
||||||
printf '%s\n' "$path"
|
|
||||||
}
|
|
||||||
|
|
||||||
service_names() {
|
|
||||||
find "$PLAYGROUND_DIR" -mindepth 2 -maxdepth 2 -name docker-compose.yml -printf '%h\n' \
|
|
||||||
| xargs -r -n1 basename \
|
|
||||||
| sort
|
|
||||||
}
|
|
||||||
|
|
||||||
run_backend() {
|
|
||||||
require_command uv
|
|
||||||
require_env
|
|
||||||
local api_port
|
|
||||||
api_port=$(env_value API_PORT 3671)
|
|
||||||
info "Starting backend dev server on http://0.0.0.0:$api_port"
|
|
||||||
(
|
|
||||||
cd "$BACKEND_DIR"
|
|
||||||
uv run fastapi dev app/main.py --host 0.0.0.0 --port "$api_port"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
run_frontend() {
|
|
||||||
require_command npm
|
|
||||||
require_env
|
|
||||||
info "Starting frontend Vite dev server"
|
|
||||||
(
|
|
||||||
cd "$FRONTEND_DIR"
|
|
||||||
npm run dev
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
run_mongo() {
|
|
||||||
require_command docker
|
|
||||||
require_env
|
|
||||||
local action="${1:-status}"
|
|
||||||
case "$action" in
|
|
||||||
up)
|
|
||||||
compose up -d mongodb
|
|
||||||
;;
|
|
||||||
down)
|
|
||||||
compose stop mongodb
|
|
||||||
;;
|
|
||||||
status)
|
|
||||||
compose ps mongodb
|
|
||||||
;;
|
|
||||||
logs)
|
|
||||||
compose logs -f mongodb
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
die "Unknown mongo action: $action"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
run_services() {
|
|
||||||
require_command docker
|
|
||||||
local action="${1:-list}"
|
|
||||||
shift || true
|
|
||||||
|
|
||||||
case "$action" in
|
|
||||||
list)
|
|
||||||
service_names
|
|
||||||
;;
|
|
||||||
up|down|status)
|
|
||||||
local target="${1:-all}"
|
|
||||||
if [[ "$target" == "all" ]]; then
|
|
||||||
while IFS= read -r name; do
|
|
||||||
[[ -n "$name" ]] || continue
|
|
||||||
local compose_file
|
|
||||||
compose_file=$(service_compose_path "$name")
|
|
||||||
case "$action" in
|
|
||||||
up) docker compose -f "$compose_file" up -d ;;
|
|
||||||
down) docker compose -f "$compose_file" down ;;
|
|
||||||
status) docker compose -f "$compose_file" ps ;;
|
|
||||||
esac
|
|
||||||
done < <(service_names)
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
local compose_file
|
|
||||||
compose_file=$(service_compose_path "$target")
|
|
||||||
case "$action" in
|
|
||||||
up) docker compose -f "$compose_file" up -d ;;
|
|
||||||
down) docker compose -f "$compose_file" down ;;
|
|
||||||
status) docker compose -f "$compose_file" ps ;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
die "Unknown services action: $action"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
show_help() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Usage:
|
|
||||||
./dev.sh backend
|
|
||||||
./dev.sh frontend
|
|
||||||
./dev.sh mongo [up|down|status|logs]
|
|
||||||
./dev.sh services [list|up|down|status] [name|all]
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
./dev.sh mongo up
|
|
||||||
./dev.sh backend
|
|
||||||
./dev.sh frontend
|
|
||||||
./dev.sh services list
|
|
||||||
./dev.sh services up GabibbiTowers
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
local command="${1:-help}"
|
|
||||||
shift || true
|
|
||||||
|
|
||||||
case "$command" in
|
|
||||||
backend)
|
|
||||||
run_backend "$@"
|
|
||||||
;;
|
|
||||||
frontend)
|
|
||||||
run_frontend "$@"
|
|
||||||
;;
|
|
||||||
mongo)
|
|
||||||
run_mongo "$@"
|
|
||||||
;;
|
|
||||||
services)
|
|
||||||
run_services "$@"
|
|
||||||
;;
|
|
||||||
help|-h|--help)
|
|
||||||
show_help
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
die "Unknown command: $command"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
+50
-22
@@ -1,34 +1,62 @@
|
|||||||
services:
|
services:
|
||||||
frontend:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: .
|
||||||
container_name: theplug-frontend
|
dockerfile: Dockerfile
|
||||||
|
container_name: theplug-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
|
||||||
API_UPSTREAM: "host.docker.internal:${API_PORT:-3671}"
|
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-5173}:80"
|
- "${API_PORT:-3671}:3671"
|
||||||
extra_hosts:
|
environment:
|
||||||
- "host.docker.internal:host-gateway"
|
MONGODB_URL: mongodb://${MONGO_USER:-theplug}:${MONGO_PASSWORD:-theplug_dev_password}@mongodb:27017/${MONGO_DB:-theplug}?authSource=admin
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
depends_on:
|
||||||
mongodb:
|
mongodb:
|
||||||
image: mongo:8.2
|
condition: service_healthy
|
||||||
container_name: theplug-mongodb
|
redis:
|
||||||
restart: unless-stopped
|
condition: service_healthy
|
||||||
environment:
|
|
||||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug}
|
|
||||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password}
|
|
||||||
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
|
|
||||||
ports:
|
|
||||||
- "27017:27017"
|
|
||||||
volumes:
|
|
||||||
- theplug_mongodb:/data/db
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "mongosh --quiet --username ${MONGO_USER:-theplug} --password ${MONGO_INITDB_ROOT_PASSWORD:-theplug_dev_password} --authenticationDatabase admin --eval \"db.adminCommand({ ping: 1 }).ok\" | grep 1"]
|
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:3671/').read()\""]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
mongodb:
|
||||||
|
image: mongo:8
|
||||||
|
container_name: theplug-mongodb
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-theplug}
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-pass}
|
||||||
|
MONGO_INITDB_DATABASE: ${MONGO_DB:-theplug}
|
||||||
|
ports:
|
||||||
|
- "27017:27017"
|
||||||
|
volumes:
|
||||||
|
- mongodb_data:/data/db
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mongosh --quiet --username ${MONGO_USER:-theplug} --password ${MONGO_PASSWORD:-theplug_dev_password} --authenticationDatabase admin --eval \"db.adminCommand({ ping: 1 }).ok\" | grep 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:8-alpine
|
||||||
|
container_name: theplug-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
theplug_mongodb:
|
mongodb_data:
|
||||||
|
redis_data:
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.vite
|
|
||||||
npm-debug.log
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
FROM node:22-alpine AS build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM nginx:1.29-alpine
|
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/templates/default.conf.template
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>thePlug</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://${API_UPSTREAM}/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-1692
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "theplug-frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite --host 0.0.0.0",
|
|
||||||
"build": "vue-tsc -b && vite build",
|
|
||||||
"preview": "vite preview --host 0.0.0.0",
|
|
||||||
"typecheck": "vue-tsc -b"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/autocomplete": "^6.20.1",
|
|
||||||
"@codemirror/commands": "^6.10.3",
|
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
|
||||||
"@codemirror/lint": "^6.9.5",
|
|
||||||
"@codemirror/lsp-client": "^6.2.3",
|
|
||||||
"@codemirror/search": "^6.7.0",
|
|
||||||
"@codemirror/state": "^6.6.0",
|
|
||||||
"@codemirror/view": "^6.41.1",
|
|
||||||
"codemirror": "^6.0.2",
|
|
||||||
"vue": "^3.5.26",
|
|
||||||
"vue-router": "^4.6.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"vite": "^7.2.7",
|
|
||||||
"vue-tsc": "^3.1.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, provide } from "vue";
|
|
||||||
|
|
||||||
import AppHeader from "./components/AppHeader.vue";
|
|
||||||
import AppToast from "./components/AppToast.vue";
|
|
||||||
import LoginView from "./components/LoginView.vue";
|
|
||||||
import ProxyList from "./components/ProxyList.vue";
|
|
||||||
import ProxyWorkbench from "./components/ProxyWorkbench.vue";
|
|
||||||
import {
|
|
||||||
controlPlaneKey,
|
|
||||||
useControlPlane,
|
|
||||||
} from "./composables/useControlPlane";
|
|
||||||
|
|
||||||
const plane = useControlPlane();
|
|
||||||
provide(controlPlaneKey, plane);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (plane.token) void plane.refreshAll();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<LoginView
|
|
||||||
v-if="!plane.token"
|
|
||||||
v-model:username="plane.username"
|
|
||||||
v-model:password="plane.password"
|
|
||||||
:loading="plane.loading"
|
|
||||||
:error="plane.authMessage || plane.loginError"
|
|
||||||
@submit="plane.signIn"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main v-else class="app-shell">
|
|
||||||
<AppToast v-if="plane.notice" :message="plane.notice" />
|
|
||||||
|
|
||||||
<AppHeader
|
|
||||||
:loading="plane.loading"
|
|
||||||
:show-discovery="Boolean(plane.selectedProxy)"
|
|
||||||
@discovery="plane.openDiscovery"
|
|
||||||
@refresh="plane.refreshAll"
|
|
||||||
@create="plane.openCreateWizard()"
|
|
||||||
@logout="plane.signOut"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="app-body">
|
|
||||||
<ProxyList
|
|
||||||
:proxies="plane.proxies"
|
|
||||||
:selected-port="plane.selectedPort"
|
|
||||||
@select="plane.selectedPort = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ProxyWorkbench />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import type {
|
|
||||||
HealthHistoryResponse,
|
|
||||||
HealthResult,
|
|
||||||
MitmOption,
|
|
||||||
PythonEditorSession,
|
|
||||||
ProxyDocument,
|
|
||||||
ProxyDraft,
|
|
||||||
ProxyTemplate,
|
|
||||||
ProxyTemplateCreateInput,
|
|
||||||
ServiceDiscoveryResponse,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
const TOKEN_KEY = "theplug.token";
|
|
||||||
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
status: number;
|
|
||||||
|
|
||||||
constructor(status: number, message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = "ApiError";
|
|
||||||
this.status = status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getToken() {
|
|
||||||
return localStorage.getItem(TOKEN_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setToken(token: string | null) {
|
|
||||||
if (token) {
|
|
||||||
localStorage.setItem(TOKEN_KEY, token);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSocketUrl(path: string) {
|
|
||||||
const token = getToken();
|
|
||||||
const base = new URL(API_BASE, window.location.origin);
|
|
||||||
base.protocol = base.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
base.pathname = `${base.pathname.replace(/\/$/, "")}${path}`;
|
|
||||||
if (token) {
|
|
||||||
base.searchParams.set("token", token);
|
|
||||||
}
|
|
||||||
return base.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
||||||
const headers = new Headers(init.headers);
|
|
||||||
const token = getToken();
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers.set("Authorization", `Bearer ${token}`);
|
|
||||||
}
|
|
||||||
if (init.body && !headers.has("Content-Type")) {
|
|
||||||
headers.set("Content-Type", "application/json");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
|
||||||
if (response.status === 204) {
|
|
||||||
return undefined as T;
|
|
||||||
}
|
|
||||||
const body = await response.json().catch(() => null);
|
|
||||||
if (!response.ok) {
|
|
||||||
const detail = body?.detail ?? response.statusText;
|
|
||||||
const message =
|
|
||||||
typeof detail === "string" ? detail : JSON.stringify(detail);
|
|
||||||
throw new ApiError(response.status, message);
|
|
||||||
}
|
|
||||||
return body as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(username: string, password: string) {
|
|
||||||
const response = await request<{ access_token: string }>("/auth/login", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
});
|
|
||||||
setToken(response.access_token);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout() {
|
|
||||||
await request<void>("/auth/logout", { method: "POST" }).catch(() => undefined);
|
|
||||||
setToken(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
proxies: () => request<ProxyDocument[]>("/proxy"),
|
|
||||||
services: (scanRoot?: string) =>
|
|
||||||
request<ServiceDiscoveryResponse>(
|
|
||||||
`/proxy/services${scanRoot ? `?scan_root=${encodeURIComponent(scanRoot)}` : ""}`,
|
|
||||||
),
|
|
||||||
options: () => request<MitmOption[]>("/proxy/mitmproxy/options"),
|
|
||||||
templates: () => request<ProxyTemplate[]>("/proxy/mitmproxy/templates"),
|
|
||||||
editorSession: () =>
|
|
||||||
request<PythonEditorSession>("/proxy/editor/python/session"),
|
|
||||||
createTemplate: (payload: ProxyTemplateCreateInput) =>
|
|
||||||
request<ProxyTemplate>("/proxy/mitmproxy/templates", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}),
|
|
||||||
deleteTemplate: (id: string) =>
|
|
||||||
request<void>(`/proxy/mitmproxy/templates/${encodeURIComponent(id)}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
}),
|
|
||||||
createProxy: (payload: ProxyDraft) =>
|
|
||||||
request<ProxyDocument>("/proxy", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}),
|
|
||||||
updateProxy: (port: number, payload: Partial<ProxyDraft>) =>
|
|
||||||
request<ProxyDocument>(`/proxy/${port}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}),
|
|
||||||
deleteProxy: (port: number) =>
|
|
||||||
request<void>(`/proxy/${port}`, { method: "DELETE" }),
|
|
||||||
start: (port: number) =>
|
|
||||||
request<ProxyDocument>(`/proxy/${port}/start`, { method: "POST" }),
|
|
||||||
stop: (port: number) =>
|
|
||||||
request<ProxyDocument>(`/proxy/${port}/stop`, { method: "POST" }),
|
|
||||||
restart: (port: number) =>
|
|
||||||
request<ProxyDocument>(`/proxy/${port}/restart`, { method: "POST" }),
|
|
||||||
health: (port: number) => request<HealthResult>(`/proxy/${port}/health`),
|
|
||||||
healthHistory: (port: number) =>
|
|
||||||
request<HealthHistoryResponse>(`/proxy/${port}/health/history`),
|
|
||||||
logs: (port: number) => request<{ log: string }>(`/proxy/${port}/logs`),
|
|
||||||
};
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import AppLogo from "./AppLogo.vue";
|
|
||||||
import UiIcon from "./UiIcon.vue";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
loading: boolean;
|
|
||||||
showDiscovery: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
discovery: [];
|
|
||||||
refresh: [];
|
|
||||||
create: [];
|
|
||||||
logout: [];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<header class="app-header">
|
|
||||||
<div class="brand">
|
|
||||||
<AppLogo />
|
|
||||||
<div>
|
|
||||||
<strong>thePlug</strong>
|
|
||||||
<small>il proxy barese</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="header-meta">
|
|
||||||
<span v-if="loading" class="muted">saving</span>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<button v-if="showDiscovery" class="ghost button-icon" :disabled="loading" @click="emit('discovery')">
|
|
||||||
<UiIcon name="back" />
|
|
||||||
<span>Services</span>
|
|
||||||
</button>
|
|
||||||
<button class="ghost button-icon" :disabled="loading" @click="emit('refresh')">
|
|
||||||
<UiIcon name="refresh" />
|
|
||||||
<span>Refresh</span>
|
|
||||||
</button>
|
|
||||||
<button class="primary button-icon" @click="emit('create')">
|
|
||||||
<UiIcon name="plus" />
|
|
||||||
<span>New</span>
|
|
||||||
</button>
|
|
||||||
<button class="ghost button-icon" @click="emit('logout')">
|
|
||||||
<UiIcon name="logout" />
|
|
||||||
<span>Logout</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg class="logo" viewBox="0 0 64 64" role="img" aria-label="thePlug logo">
|
|
||||||
<rect x="10" y="10" width="44" height="44" rx="10" />
|
|
||||||
<path d="M26 19v10M38 19v10" />
|
|
||||||
<path d="M22 29h20v6a10 10 0 0 1-10 10 10 10 0 0 1-10-10v-6z" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
message: string;
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="app-toast" role="status" aria-live="polite">
|
|
||||||
{{ message }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { basicSetup, EditorView } from "codemirror";
|
|
||||||
import { keymap } from "@codemirror/view";
|
|
||||||
import { python } from "@codemirror/lang-python";
|
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
||||||
|
|
||||||
import { api } from "../api";
|
|
||||||
import { connectPythonLsp } from "../editor/pythonLsp";
|
|
||||||
|
|
||||||
const model = defineModel<string>({ required: true });
|
|
||||||
const emit = defineEmits<{
|
|
||||||
save: [];
|
|
||||||
}>();
|
|
||||||
const host = ref<HTMLElement | null>(null);
|
|
||||||
const error = ref("");
|
|
||||||
let editor: EditorView | null = null;
|
|
||||||
let languageClient: Awaited<ReturnType<typeof connectPythonLsp>>["client"] | null =
|
|
||||||
null;
|
|
||||||
let languageSocket: WebSocket | null = null;
|
|
||||||
|
|
||||||
const editorTheme = EditorView.theme(
|
|
||||||
{
|
|
||||||
"&": {
|
|
||||||
height: "100%",
|
|
||||||
maxHeight: "100%",
|
|
||||||
color: "#edf2f7",
|
|
||||||
backgroundColor: "#0b0f12",
|
|
||||||
fontSize: "14px",
|
|
||||||
},
|
|
||||||
".cm-editor": {
|
|
||||||
height: "100%",
|
|
||||||
maxHeight: "100%",
|
|
||||||
},
|
|
||||||
".cm-scroller": {
|
|
||||||
overflow: "auto",
|
|
||||||
fontFamily: "IBM Plex Mono, JetBrains Mono, ui-monospace, monospace",
|
|
||||||
lineHeight: "1.6",
|
|
||||||
},
|
|
||||||
".cm-content": {
|
|
||||||
minWidth: "fit-content",
|
|
||||||
padding: "14px 0",
|
|
||||||
caretColor: "#e7fbff",
|
|
||||||
},
|
|
||||||
".cm-gutters": {
|
|
||||||
border: "none",
|
|
||||||
backgroundColor: "#0b0f12",
|
|
||||||
color: "#6f7c86",
|
|
||||||
},
|
|
||||||
".cm-activeLine": {
|
|
||||||
backgroundColor: "#12181d",
|
|
||||||
},
|
|
||||||
".cm-activeLineGutter": {
|
|
||||||
backgroundColor: "#12181d",
|
|
||||||
},
|
|
||||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
|
||||||
backgroundColor: "#1b2630",
|
|
||||||
},
|
|
||||||
".cm-tooltip": {
|
|
||||||
border: "1px solid #24303a",
|
|
||||||
backgroundColor: "#10151a",
|
|
||||||
color: "#edf2f7",
|
|
||||||
},
|
|
||||||
".cm-panels": {
|
|
||||||
backgroundColor: "#10151a",
|
|
||||||
color: "#edf2f7",
|
|
||||||
},
|
|
||||||
".cm-searchMatch": {
|
|
||||||
backgroundColor: "#23303b",
|
|
||||||
outline: "1px solid #334455",
|
|
||||||
},
|
|
||||||
".cm-cursor": {
|
|
||||||
borderLeftColor: "#e7fbff",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ dark: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
function emitSave() {
|
|
||||||
emit("save");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!host.value) return;
|
|
||||||
void mountEditor();
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(model, (value) => {
|
|
||||||
if (!editor) return;
|
|
||||||
const current = editor.state.doc.toString();
|
|
||||||
if (current === value) return;
|
|
||||||
editor.dispatch({
|
|
||||||
changes: {
|
|
||||||
from: 0,
|
|
||||||
to: current.length,
|
|
||||||
insert: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
languageClient?.disconnect();
|
|
||||||
languageClient = null;
|
|
||||||
languageSocket?.close();
|
|
||||||
languageSocket = null;
|
|
||||||
editor?.destroy();
|
|
||||||
editor = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function mountEditor() {
|
|
||||||
if (!host.value) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
error.value = "";
|
|
||||||
const session = await api.editorSession();
|
|
||||||
const connection = await connectPythonLsp(session);
|
|
||||||
languageClient = connection.client;
|
|
||||||
languageSocket = connection.socket;
|
|
||||||
void languageClient.initializing.catch(() => {
|
|
||||||
error.value = "Python language tools are unavailable.";
|
|
||||||
});
|
|
||||||
|
|
||||||
editor = new EditorView({
|
|
||||||
doc: model.value,
|
|
||||||
extensions: [
|
|
||||||
basicSetup,
|
|
||||||
python(),
|
|
||||||
editorTheme,
|
|
||||||
keymap.of([{ key: "Mod-s", run: emitSave }]),
|
|
||||||
EditorView.updateListener.of((update) => {
|
|
||||||
if (!update.docChanged) return;
|
|
||||||
const value = update.state.doc.toString();
|
|
||||||
if (value !== model.value) model.value = value;
|
|
||||||
}),
|
|
||||||
languageClient.plugin(session.file_uri, "python"),
|
|
||||||
],
|
|
||||||
parent: host.value,
|
|
||||||
});
|
|
||||||
} catch (value) {
|
|
||||||
const message =
|
|
||||||
value instanceof Error ? value.message : "Unable to start the editor";
|
|
||||||
error.value = message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="code-editor-wrap">
|
|
||||||
<p v-if="error" class="field-error editor-error">{{ error }}</p>
|
|
||||||
<div ref="host" class="code-editor" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { defineAsyncComponent } from "vue";
|
|
||||||
|
|
||||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
|
||||||
import UiIcon from "./UiIcon.vue";
|
|
||||||
|
|
||||||
const CodeEditor = defineAsyncComponent(() => import("./CodeEditor.vue"));
|
|
||||||
const plane = useControlPlaneContext();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="editor-pane panel">
|
|
||||||
<div class="panel-title">
|
|
||||||
<h2>Proxy script</h2>
|
|
||||||
<div class="editor-tools">
|
|
||||||
<button
|
|
||||||
class="ghost button-icon editor-save"
|
|
||||||
:disabled="!plane.canSaveScript"
|
|
||||||
@click="plane.saveScript"
|
|
||||||
>
|
|
||||||
<UiIcon name="save" />
|
|
||||||
<span>Save script</span>
|
|
||||||
<span class="shortcut-hint">Ctrl/Cmd+S</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="editor-shell">
|
|
||||||
<CodeEditor v-model="plane.draft.code" @save="plane.saveScript" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from "vue";
|
|
||||||
|
|
||||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
|
||||||
import UiIcon from "./UiIcon.vue";
|
|
||||||
|
|
||||||
const plane = useControlPlaneContext();
|
|
||||||
const monitoringActive = computed(() => plane.runtimeStatus === "running");
|
|
||||||
const overallLabel = computed(() =>
|
|
||||||
monitoringActive.value ? plane.health?.overall ?? "pending" : "stopped",
|
|
||||||
);
|
|
||||||
|
|
||||||
const latestTimestamp = computed(() => {
|
|
||||||
if (!monitoringActive.value) return "Monitoring inactive";
|
|
||||||
if (!plane.health?.timestamp) return "No checks yet";
|
|
||||||
return new Date(plane.health.timestamp).toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const targetRows = computed(() => {
|
|
||||||
if (!monitoringActive.value) {
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
const checks = plane.health?.checks ?? [];
|
|
||||||
return [
|
|
||||||
checks.find((check) => check.target === "proxy") ?? null,
|
|
||||||
checks.find((check) => check.target === "upstream") ?? null,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
const benchmarkEnabled = computed(
|
|
||||||
() =>
|
|
||||||
plane.selectedProxy?.healthcheck.benchmark_enabled ??
|
|
||||||
plane.draft.healthcheck.benchmark_enabled,
|
|
||||||
);
|
|
||||||
|
|
||||||
function benchmarkSeries(target: "proxy" | "upstream") {
|
|
||||||
return plane.healthHistory
|
|
||||||
.map((sample, index) => {
|
|
||||||
const check = sample.checks.find((item) => item.target === target);
|
|
||||||
const duration =
|
|
||||||
typeof check?.duration_ms === "number" ? check.duration_ms : null;
|
|
||||||
if (duration === null) return null;
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
duration,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((item): item is { index: number; duration: number } => item !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestBenchmark(target: "proxy" | "upstream") {
|
|
||||||
const check = plane.health?.checks.find((item) => item.target === target);
|
|
||||||
return typeof check?.duration_ms === "number" ? check.duration_ms : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function averageBenchmark(target: "proxy" | "upstream") {
|
|
||||||
const values = benchmarkSeries(target).map((item) => item.duration);
|
|
||||||
if (!values.length) return null;
|
|
||||||
const total = values.reduce((sum, value) => sum + value, 0);
|
|
||||||
return Math.round((total / values.length) * 100) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const benchmarkSummary = computed(() => ({
|
|
||||||
proxyLast: latestBenchmark("proxy"),
|
|
||||||
proxyAvg: averageBenchmark("proxy"),
|
|
||||||
upstreamLast: latestBenchmark("upstream"),
|
|
||||||
upstreamAvg: averageBenchmark("upstream"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const chartPoints = computed(() => {
|
|
||||||
const history = plane.healthHistory;
|
|
||||||
const width = 220;
|
|
||||||
const height = 54;
|
|
||||||
const build = (target: "proxy" | "upstream") => {
|
|
||||||
const points = history
|
|
||||||
.map((sample, index) => {
|
|
||||||
const check = sample.checks.find((item) => item.target === target);
|
|
||||||
if (!check) return null;
|
|
||||||
const x = history.length === 1 ? width / 2 : (index / (history.length - 1)) * width;
|
|
||||||
const y = check.healthy ? 10 : height - 10;
|
|
||||||
return `${x},${y}`;
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
return points.join(" ");
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
proxy: build("proxy"),
|
|
||||||
upstream: build("upstream"),
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const benchmarkChart = computed(() => {
|
|
||||||
const width = 220;
|
|
||||||
const height = 72;
|
|
||||||
const proxySeries = benchmarkSeries("proxy");
|
|
||||||
const upstreamSeries = benchmarkSeries("upstream");
|
|
||||||
const allValues = [...proxySeries, ...upstreamSeries].map((item) => item.duration);
|
|
||||||
const maxDuration = allValues.length ? Math.max(...allValues) : 0;
|
|
||||||
const domainMax = maxDuration > 0 ? maxDuration : 1;
|
|
||||||
|
|
||||||
const toPoints = (series: { index: number; duration: number }[]) =>
|
|
||||||
series
|
|
||||||
.map((item) => {
|
|
||||||
const x =
|
|
||||||
plane.healthHistory.length <= 1
|
|
||||||
? width / 2
|
|
||||||
: (item.index / (plane.healthHistory.length - 1)) * width;
|
|
||||||
const y = height - 10 - (item.duration / domainMax) * (height - 20);
|
|
||||||
return `${x},${y}`;
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
return {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
proxy: toPoints(proxySeries),
|
|
||||||
upstream: toPoints(upstreamSeries),
|
|
||||||
hasData: allValues.length > 0,
|
|
||||||
maxDuration: Math.round(domainMax * 100) / 100,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatDuration(value: number | null) {
|
|
||||||
return value === null ? "n/a" : `${value.toFixed(2)} ms`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="plane.healthModalOpen" class="modal-backdrop" @click.self="plane.closeHealthModal">
|
|
||||||
<section class="modal-shell health-modal-shell">
|
|
||||||
<div class="modal-head">
|
|
||||||
<div>
|
|
||||||
<h2>Health</h2>
|
|
||||||
<p class="modal-step-copy">
|
|
||||||
Runtime checks for the live proxy path and the real upstream service.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-head-actions">
|
|
||||||
<button
|
|
||||||
class="ghost button-icon"
|
|
||||||
:disabled="plane.loading || !monitoringActive"
|
|
||||||
@click="plane.checkHealth"
|
|
||||||
>
|
|
||||||
<UiIcon name="pulse" />
|
|
||||||
<span>Run now</span>
|
|
||||||
</button>
|
|
||||||
<button class="ghost button-icon" @click="plane.closeHealthModal">
|
|
||||||
<UiIcon name="close" />
|
|
||||||
<span>Close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body health-modal-body">
|
|
||||||
<div
|
|
||||||
class="health-runtime-overview"
|
|
||||||
:class="overallLabel"
|
|
||||||
>
|
|
||||||
<strong>{{ overallLabel }}</strong>
|
|
||||||
<span>Last check {{ latestTimestamp }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="health-runtime-list">
|
|
||||||
<article
|
|
||||||
v-for="(row, index) in targetRows"
|
|
||||||
:key="row?.target ?? `pending-${index}`"
|
|
||||||
class="health-runtime-card"
|
|
||||||
:class="
|
|
||||||
row
|
|
||||||
? row.healthy
|
|
||||||
? 'healthy'
|
|
||||||
: 'unhealthy'
|
|
||||||
: monitoringActive
|
|
||||||
? 'pending'
|
|
||||||
: 'inactive'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div class="health-runtime-card-head">
|
|
||||||
<strong>{{
|
|
||||||
row?.target ?? (index === 0 ? "proxy" : "upstream")
|
|
||||||
}}</strong>
|
|
||||||
<span>{{
|
|
||||||
row
|
|
||||||
? row.healthy
|
|
||||||
? "ok"
|
|
||||||
: "issue"
|
|
||||||
: monitoringActive
|
|
||||||
? "pending"
|
|
||||||
: "inactive"
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<p class="muted small compact-copy">
|
|
||||||
{{
|
|
||||||
row
|
|
||||||
? `${row.host}:${row.port} · ${row.protocol}`
|
|
||||||
: monitoringActive
|
|
||||||
? "No data yet."
|
|
||||||
: "Start the proxy to enable active checks."
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="benchmarkEnabled && typeof row?.duration_ms === 'number'"
|
|
||||||
class="muted small compact-copy"
|
|
||||||
>
|
|
||||||
Last response {{ formatDuration(row.duration_ms) }}
|
|
||||||
</p>
|
|
||||||
<p v-if="row?.error" class="error compact health-inline-error">
|
|
||||||
{{ row.error }}
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="health-chart-card">
|
|
||||||
<div class="panel-title">
|
|
||||||
<h3>Recent timeline</h3>
|
|
||||||
<span class="muted small">{{ plane.healthHistory.length }} samples</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="plane.healthHistory.length" class="health-chart-grid">
|
|
||||||
<div class="health-chart-row">
|
|
||||||
<span>Proxy</span>
|
|
||||||
<svg
|
|
||||||
class="health-chart"
|
|
||||||
:viewBox="`0 0 ${chartPoints.width} ${chartPoints.height}`"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
>
|
|
||||||
<polyline class="health-chart-line proxy" :points="chartPoints.proxy" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="health-chart-row">
|
|
||||||
<span>Upstream</span>
|
|
||||||
<svg
|
|
||||||
class="health-chart"
|
|
||||||
:viewBox="`0 0 ${chartPoints.width} ${chartPoints.height}`"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
>
|
|
||||||
<polyline class="health-chart-line upstream" :points="chartPoints.upstream" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="muted small">
|
|
||||||
{{
|
|
||||||
monitoringActive
|
|
||||||
? "Health history will appear after the first scheduled runs."
|
|
||||||
: "Health history starts once the proxy is running."
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="benchmarkEnabled" class="health-chart-card">
|
|
||||||
<div class="panel-title">
|
|
||||||
<h3>Benchmark</h3>
|
|
||||||
<span class="muted small">Response latency</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="benchmark-summary-grid">
|
|
||||||
<article class="metric-card">
|
|
||||||
<span class="metric-label">Proxy last</span>
|
|
||||||
<strong>{{ formatDuration(benchmarkSummary.proxyLast) }}</strong>
|
|
||||||
</article>
|
|
||||||
<article class="metric-card">
|
|
||||||
<span class="metric-label">Proxy avg</span>
|
|
||||||
<strong>{{ formatDuration(benchmarkSummary.proxyAvg) }}</strong>
|
|
||||||
</article>
|
|
||||||
<article class="metric-card">
|
|
||||||
<span class="metric-label">Upstream last</span>
|
|
||||||
<strong>{{ formatDuration(benchmarkSummary.upstreamLast) }}</strong>
|
|
||||||
</article>
|
|
||||||
<article class="metric-card">
|
|
||||||
<span class="metric-label">Upstream avg</span>
|
|
||||||
<strong>{{ formatDuration(benchmarkSummary.upstreamAvg) }}</strong>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="benchmarkChart.hasData" class="health-chart-grid">
|
|
||||||
<div class="health-chart-row">
|
|
||||||
<span>Proxy</span>
|
|
||||||
<svg
|
|
||||||
class="health-chart benchmark-chart"
|
|
||||||
:viewBox="`0 0 ${benchmarkChart.width} ${benchmarkChart.height}`"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
>
|
|
||||||
<polyline class="health-chart-line proxy" :points="benchmarkChart.proxy" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="health-chart-row">
|
|
||||||
<span>Upstream</span>
|
|
||||||
<svg
|
|
||||||
class="health-chart benchmark-chart"
|
|
||||||
:viewBox="`0 0 ${benchmarkChart.width} ${benchmarkChart.height}`"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
>
|
|
||||||
<polyline class="health-chart-line upstream" :points="benchmarkChart.upstream" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p class="muted small compact-copy">
|
|
||||||
Scale 0 → {{ benchmarkChart.maxDuration.toFixed(2) }} ms
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p v-else class="muted small">
|
|
||||||
Collecting benchmark data from scheduled health runs.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { protocols, useControlPlaneContext } from "../composables/useControlPlane";
|
|
||||||
import UiIcon from "./UiIcon.vue";
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
|
|
||||||
embedded: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const plane = useControlPlaneContext();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel compact-panel'">
|
|
||||||
<div v-if="!props.embedded" class="panel-title">
|
|
||||||
<div><h2>Checks</h2></div>
|
|
||||||
<button
|
|
||||||
class="button-icon"
|
|
||||||
:disabled="!plane.selectedProxy"
|
|
||||||
@click="plane.checkHealth"
|
|
||||||
>
|
|
||||||
<UiIcon name="refresh" />
|
|
||||||
<span>Run</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!props.embedded" class="metric-grid">
|
|
||||||
<article class="metric-card">
|
|
||||||
<span class="metric-label">State</span>
|
|
||||||
<strong>{{ plane.draft.healthcheck.enabled ? "Enabled" : "Disabled" }}</strong>
|
|
||||||
</article>
|
|
||||||
<article class="metric-card">
|
|
||||||
<span class="metric-label">Path</span>
|
|
||||||
<strong>{{ plane.draft.healthcheck.path }}</strong>
|
|
||||||
</article>
|
|
||||||
<article class="metric-card">
|
|
||||||
<span class="metric-label">Method</span>
|
|
||||||
<strong>{{ plane.draft.healthcheck.method }}</strong>
|
|
||||||
</article>
|
|
||||||
<article class="metric-card">
|
|
||||||
<span class="metric-label">Timeout</span>
|
|
||||||
<strong>{{ plane.draft.healthcheck.timeout_seconds }}s</strong>
|
|
||||||
</article>
|
|
||||||
<article class="metric-card">
|
|
||||||
<span class="metric-label">Benchmark</span>
|
|
||||||
<strong>{{ plane.draft.healthcheck.benchmark_enabled ? "Enabled" : "Disabled" }}</strong>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid tight-grid">
|
|
||||||
<label class="checkbox-row">
|
|
||||||
<input v-model="plane.draft.healthcheck.enabled" type="checkbox" />
|
|
||||||
<span>Enabled</span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Protocol override
|
|
||||||
<select v-model="plane.draft.healthcheck.protocol">
|
|
||||||
<option :value="null">Use proxy protocol</option>
|
|
||||||
<option v-for="protocol in protocols" :key="protocol" :value="protocol">
|
|
||||||
{{ protocol }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Method
|
|
||||||
<select v-model="plane.draft.healthcheck.method">
|
|
||||||
<option>GET</option>
|
|
||||||
<option>HEAD</option>
|
|
||||||
<option>POST</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Path
|
|
||||||
<input v-model="plane.draft.healthcheck.path" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Expected status
|
|
||||||
<input
|
|
||||||
v-model.number="plane.draft.healthcheck.expected_status"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Timeout
|
|
||||||
<input
|
|
||||||
v-model.number="plane.draft.healthcheck.timeout_seconds"
|
|
||||||
type="number"
|
|
||||||
step="0.5"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
UDP payload
|
|
||||||
<input
|
|
||||||
v-model="plane.draft.healthcheck.udp_payload"
|
|
||||||
placeholder="used for udp-like checks"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-row">
|
|
||||||
<input v-model="plane.draft.healthcheck.expect_response" type="checkbox" />
|
|
||||||
<span>Expect response</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-row">
|
|
||||||
<input v-model="plane.draft.healthcheck.verify_tls" type="checkbox" />
|
|
||||||
<span>Verify TLS</span>
|
|
||||||
</label>
|
|
||||||
<label class="checkbox-row">
|
|
||||||
<input v-model="plane.draft.healthcheck.benchmark_enabled" type="checkbox" />
|
|
||||||
<span>Benchmark response time</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="plane.health" class="health-box" :class="plane.health.overall">
|
|
||||||
<strong>{{ plane.health.overall }}</strong>
|
|
||||||
<div class="health-list">
|
|
||||||
<div v-for="check in plane.health.checks" :key="check.target" class="health-row">
|
|
||||||
<span>{{ check.target }}</span>
|
|
||||||
<span>{{ check.protocol }}</span>
|
|
||||||
<span>{{ check.healthy ? "ok" : check.error }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from "vue";
|
|
||||||
|
|
||||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
|
||||||
import UiIcon from "./UiIcon.vue";
|
|
||||||
|
|
||||||
const plane = useControlPlaneContext();
|
|
||||||
const monitoringActive = computed(() => plane.runtimeStatus === "running");
|
|
||||||
const overallLabel = computed(() =>
|
|
||||||
monitoringActive.value ? plane.health?.overall ?? "pending" : "stopped",
|
|
||||||
);
|
|
||||||
|
|
||||||
const latestTimestamp = computed(() => {
|
|
||||||
if (!monitoringActive.value) return "Monitoring inactive";
|
|
||||||
if (!plane.health?.timestamp) return "No checks yet";
|
|
||||||
return new Date(plane.health.timestamp).toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const proxyCheck = computed(() =>
|
|
||||||
plane.health?.checks.find((check) => check.target === "proxy") ?? null,
|
|
||||||
);
|
|
||||||
const upstreamCheck = computed(() =>
|
|
||||||
plane.health?.checks.find((check) => check.target === "upstream") ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const proxyLabel = computed(() => {
|
|
||||||
if (!monitoringActive.value) return "inactive";
|
|
||||||
return proxyCheck.value ? (proxyCheck.value.healthy ? "ok" : "issue") : "pending";
|
|
||||||
});
|
|
||||||
|
|
||||||
const upstreamLabel = computed(() => {
|
|
||||||
if (!monitoringActive.value) return "inactive";
|
|
||||||
return upstreamCheck.value
|
|
||||||
? (upstreamCheck.value.healthy ? "ok" : "issue")
|
|
||||||
: "pending";
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section
|
|
||||||
v-if="plane.selectedProxy"
|
|
||||||
class="health-summary-card"
|
|
||||||
:class="overallLabel"
|
|
||||||
>
|
|
||||||
<div class="health-summary-line">
|
|
||||||
<strong class="health-summary-overall">{{ overallLabel }}</strong>
|
|
||||||
<span class="health-summary-item">
|
|
||||||
<small>Proxy</small>
|
|
||||||
<strong>{{ proxyLabel }}</strong>
|
|
||||||
</span>
|
|
||||||
<span class="health-summary-item">
|
|
||||||
<small>Upstream</small>
|
|
||||||
<strong>{{ upstreamLabel }}</strong>
|
|
||||||
</span>
|
|
||||||
<span class="health-summary-item health-summary-time">
|
|
||||||
<small>Last check</small>
|
|
||||||
<strong>{{ latestTimestamp }}</strong>
|
|
||||||
</span>
|
|
||||||
<div class="health-summary-actions">
|
|
||||||
<button
|
|
||||||
class="ghost button-icon"
|
|
||||||
:disabled="plane.loading || !monitoringActive"
|
|
||||||
@click="plane.checkHealth"
|
|
||||||
>
|
|
||||||
<UiIcon name="pulse" />
|
|
||||||
<span>Run</span>
|
|
||||||
</button>
|
|
||||||
<button class="ghost button-icon" @click="plane.openHealthModal">
|
|
||||||
<span>Details</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import AppLogo from "./AppLogo.vue";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
loading: boolean;
|
|
||||||
error: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const username = defineModel<string>("username", { required: true });
|
|
||||||
const password = defineModel<string>("password", { required: true });
|
|
||||||
const emit = defineEmits<{
|
|
||||||
submit: [];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<main class="login-screen">
|
|
||||||
<section class="login-card">
|
|
||||||
<div class="login-brand">
|
|
||||||
<AppLogo />
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">thePlug</p>
|
|
||||||
<h1>Sign in</h1>
|
|
||||||
<p class="muted">Manage proxy routes, runtime state, and mitmdump scripts.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form @submit.prevent="emit('submit')">
|
|
||||||
<label>Username<input v-model="username" autocomplete="username" /></label>
|
|
||||||
<label>Password<input v-model="password" type="password" autocomplete="current-password" /></label>
|
|
||||||
<button class="primary" :disabled="loading">Sign in</button>
|
|
||||||
</form>
|
|
||||||
<p v-if="error" class="error auth-alert">{{ error }}</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
|
||||||
import UiIcon from "./UiIcon.vue";
|
|
||||||
|
|
||||||
const plane = useControlPlaneContext();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="panel stack-panel tool-panel logs-panel">
|
|
||||||
<div class="panel-title">
|
|
||||||
<div><h2>Logs</h2></div>
|
|
||||||
<div class="doc-actions">
|
|
||||||
<button class="ghost button-icon" :disabled="!plane.selectedProxy" @click="plane.toggleLogsPaused">
|
|
||||||
<UiIcon :name="plane.logsPaused ? 'resume' : 'pause'" />
|
|
||||||
<span>{{ plane.logsPaused ? "Resume" : "Pause" }}</span>
|
|
||||||
</button>
|
|
||||||
<button class="ghost button-icon" @click="plane.clearLogs">
|
|
||||||
<UiIcon name="clear" />
|
|
||||||
<span>Clear</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input v-model="plane.logFilter" placeholder="Filter logs" />
|
|
||||||
|
|
||||||
<pre class="terminal">{{
|
|
||||||
plane.filteredLogs || "Logs will appear here automatically."
|
|
||||||
}}</pre>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { MitmOption } from "../types";
|
|
||||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
|
||||||
import UiIcon from "./UiIcon.vue";
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
|
|
||||||
embedded: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const plane = useControlPlaneContext();
|
|
||||||
|
|
||||||
function isBool(option?: MitmOption | null) {
|
|
||||||
return option?.type === "bool";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNumber(option?: MitmOption | null) {
|
|
||||||
return option?.type === "int" || option?.type === "optional int";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSequence(option?: MitmOption | null) {
|
|
||||||
return option?.type === "sequence of str";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDefault(option?: MitmOption | null) {
|
|
||||||
if (!option) return "n/a";
|
|
||||||
if (Array.isArray(option.default)) {
|
|
||||||
return option.default.length ? option.default.join(", ") : "[]";
|
|
||||||
}
|
|
||||||
if (option.default === null || option.default === "") return "none";
|
|
||||||
return String(option.default);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel tool-panel'">
|
|
||||||
<div v-if="!props.embedded" class="panel-title">
|
|
||||||
<h2>Options</h2>
|
|
||||||
<div class="title-actions">
|
|
||||||
<span class="counter">{{ plane.activeOptionCount }}</span>
|
|
||||||
<div class="segmented">
|
|
||||||
<button
|
|
||||||
:class="{ active: plane.optionScope === 'focused' }"
|
|
||||||
@click="plane.toggleOptionScope('focused')"
|
|
||||||
>
|
|
||||||
Common
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="{ active: plane.optionScope === 'all' }"
|
|
||||||
@click="plane.toggleOptionScope('all')"
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="props.embedded" class="option-step-layout">
|
|
||||||
<aside class="option-browser option-browser-embedded">
|
|
||||||
<div class="option-toolbar">
|
|
||||||
<input v-model="plane.optionSearch" placeholder="Search options" />
|
|
||||||
<div class="segmented">
|
|
||||||
<button
|
|
||||||
:class="{ active: plane.optionScope === 'focused' }"
|
|
||||||
@click="plane.toggleOptionScope('focused')"
|
|
||||||
>
|
|
||||||
Common
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="{ active: plane.optionScope === 'all' }"
|
|
||||||
@click="plane.toggleOptionScope('all')"
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="plane.optionsLoading" class="option-browser-meta">
|
|
||||||
<span class="option-count">...</span>
|
|
||||||
<p class="muted small">Loading mitmdump options…</p>
|
|
||||||
</div>
|
|
||||||
<div v-else class="option-browser-meta">
|
|
||||||
<span class="option-count">{{ plane.filteredOptions.length }}</span>
|
|
||||||
<p class="muted small">
|
|
||||||
{{
|
|
||||||
plane.optionScope === "focused"
|
|
||||||
? "Common options for the usual proxy setup."
|
|
||||||
: `All ${plane.optionCount} public mitmdump options.`
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="plane.optionsLoading" class="option-empty-state">
|
|
||||||
<p class="muted small">The option catalog is still loading.</p>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="plane.filteredOptions.length" class="option-table">
|
|
||||||
<button
|
|
||||||
v-for="option in plane.filteredOptions"
|
|
||||||
:key="option.name"
|
|
||||||
class="option-entry"
|
|
||||||
:class="{ selected: plane.selectedOption?.name === option.name }"
|
|
||||||
@click="plane.selectOption(option.name)"
|
|
||||||
>
|
|
||||||
<strong>{{ option.name }}</strong>
|
|
||||||
<small>{{ option.type }}</small>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="option-empty-state">
|
|
||||||
<p class="muted small">No options matched the current filter.</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div class="option-step-main">
|
|
||||||
<article
|
|
||||||
v-if="!plane.optionsLoading && plane.selectedOption"
|
|
||||||
class="option-doc option-doc-embedded"
|
|
||||||
>
|
|
||||||
<div class="option-doc-head">
|
|
||||||
<div>
|
|
||||||
<h3>{{ plane.selectedOption.name }}</h3>
|
|
||||||
<p class="doc-copy">{{ plane.selectedOption.description }}</p>
|
|
||||||
</div>
|
|
||||||
<span class="type-chip">{{ plane.selectedOption.type }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl class="doc-facts doc-facts-compact">
|
|
||||||
<div class="doc-fact">
|
|
||||||
<dt>Default</dt>
|
|
||||||
<dd>{{ formatDefault(plane.selectedOption) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="plane.selectedOption.choices.length"
|
|
||||||
class="doc-fact"
|
|
||||||
>
|
|
||||||
<dt>Choices</dt>
|
|
||||||
<dd>{{ plane.selectedOption.choices.join(", ") }}</dd>
|
|
||||||
</div>
|
|
||||||
<div v-if="plane.selectedOption.reserved" class="doc-fact">
|
|
||||||
<dt>State</dt>
|
|
||||||
<dd>Managed by thePlug</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div class="doc-actions">
|
|
||||||
<button
|
|
||||||
class="button-icon"
|
|
||||||
:disabled="plane.selectedOption.reserved"
|
|
||||||
@click="plane.setOption(plane.selectedOption)"
|
|
||||||
>
|
|
||||||
<UiIcon name="plus" />
|
|
||||||
<span>Add option</span>
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
class="button-link"
|
|
||||||
v-if="plane.selectedOption.docs_url"
|
|
||||||
:href="plane.selectedOption.docs_url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<UiIcon name="link" />
|
|
||||||
Official docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<section class="option-active-card">
|
|
||||||
<div class="option-active-head">
|
|
||||||
<h3>Configured options</h3>
|
|
||||||
<span class="counter">{{ plane.activeOptionCount }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="plane.activeOptions.length" class="active-options compact-table">
|
|
||||||
<div class="table-head">
|
|
||||||
<span>Option</span>
|
|
||||||
<span>Value</span>
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
<div class="table-body compact-scroll">
|
|
||||||
<div v-for="item in plane.activeOptions" :key="item.name" class="table-row">
|
|
||||||
<button class="table-link" @click="plane.selectOption(item.name)">
|
|
||||||
{{ item.name }}
|
|
||||||
</button>
|
|
||||||
<label v-if="isBool(item.option)" class="inline-toggle">
|
|
||||||
<input
|
|
||||||
:checked="item.value === 'true'"
|
|
||||||
type="checkbox"
|
|
||||||
@change="
|
|
||||||
plane.updateOption(
|
|
||||||
item.name,
|
|
||||||
String(($event.target as HTMLInputElement).checked),
|
|
||||||
item.option,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<span>{{ item.value }}</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-else
|
|
||||||
:value="item.value"
|
|
||||||
:type="isNumber(item.option) ? 'number' : 'text'"
|
|
||||||
:placeholder="isSequence(item.option) ? 'comma separated values' : ''"
|
|
||||||
@input="
|
|
||||||
plane.updateOption(
|
|
||||||
item.name,
|
|
||||||
($event.target as HTMLInputElement).value,
|
|
||||||
item.option,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<button class="ghost table-action" @click="plane.removeOption(item.name)">
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="muted small">No custom options configured yet.</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="active-options compact-table">
|
|
||||||
<div class="table-head">
|
|
||||||
<span>Active</span>
|
|
||||||
<span>Value</span>
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
<div v-if="plane.activeOptions.length" class="table-body compact-scroll">
|
|
||||||
<div v-for="item in plane.activeOptions" :key="item.name" class="table-row">
|
|
||||||
<button class="table-link" @click="plane.selectOption(item.name)">
|
|
||||||
{{ item.name }}
|
|
||||||
</button>
|
|
||||||
<label v-if="isBool(item.option)" class="inline-toggle">
|
|
||||||
<input
|
|
||||||
:checked="item.value === 'true'"
|
|
||||||
type="checkbox"
|
|
||||||
@change="
|
|
||||||
plane.updateOption(
|
|
||||||
item.name,
|
|
||||||
String(($event.target as HTMLInputElement).checked),
|
|
||||||
item.option,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<span>{{ item.value }}</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-else
|
|
||||||
:value="item.value"
|
|
||||||
:type="isNumber(item.option) ? 'number' : 'text'"
|
|
||||||
:placeholder="isSequence(item.option) ? 'comma separated values' : ''"
|
|
||||||
@input="
|
|
||||||
plane.updateOption(
|
|
||||||
item.name,
|
|
||||||
($event.target as HTMLInputElement).value,
|
|
||||||
item.option,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<button class="ghost table-action" @click="plane.removeOption(item.name)">
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="muted small">No active options.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="option-shell">
|
|
||||||
<div class="option-browser">
|
|
||||||
<div class="option-search-row">
|
|
||||||
<input v-model="plane.optionSearch" placeholder="Search options" />
|
|
||||||
<span class="option-count">
|
|
||||||
{{ plane.optionsLoading ? "..." : plane.filteredOptions.length }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="plane.optionsLoading" class="muted small compact-copy">
|
|
||||||
Loading mitmdump options…
|
|
||||||
</p>
|
|
||||||
<p v-else class="muted small compact-copy">
|
|
||||||
{{
|
|
||||||
plane.optionScope === "focused"
|
|
||||||
? "Showing the options that are usually useful in this UI."
|
|
||||||
: `Showing all ${plane.optionCount} public mitmdump options.`
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div v-if="plane.optionsLoading" class="option-empty-state">
|
|
||||||
<p class="muted small">The option catalog is still loading.</p>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="plane.filteredOptions.length" class="option-table">
|
|
||||||
<button
|
|
||||||
v-for="option in plane.filteredOptions"
|
|
||||||
:key="option.name"
|
|
||||||
class="option-entry"
|
|
||||||
:class="{ selected: plane.selectedOption?.name === option.name }"
|
|
||||||
@click="plane.selectOption(option.name)"
|
|
||||||
>
|
|
||||||
<strong>{{ option.name }}</strong>
|
|
||||||
<small>{{ option.type }}</small>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="option-empty-state">
|
|
||||||
<p class="muted small">No options matched the current filter.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article v-if="!plane.optionsLoading && plane.selectedOption" class="option-doc">
|
|
||||||
<div class="option-doc-head">
|
|
||||||
<div>
|
|
||||||
<h3>{{ plane.selectedOption.name }}</h3>
|
|
||||||
</div>
|
|
||||||
<span class="type-chip">{{ plane.selectedOption.type }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="doc-copy">{{ plane.selectedOption.description }}</p>
|
|
||||||
|
|
||||||
<dl class="doc-facts">
|
|
||||||
<div class="doc-fact">
|
|
||||||
<dt>Default</dt>
|
|
||||||
<dd>{{ formatDefault(plane.selectedOption) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="plane.selectedOption.choices.length"
|
|
||||||
class="doc-fact"
|
|
||||||
>
|
|
||||||
<dt>Choices</dt>
|
|
||||||
<dd>{{ plane.selectedOption.choices.join(", ") }}</dd>
|
|
||||||
</div>
|
|
||||||
<div v-if="plane.selectedOption.reserved" class="doc-fact">
|
|
||||||
<dt>State</dt>
|
|
||||||
<dd>Managed by thePlug</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div class="doc-actions">
|
|
||||||
<button
|
|
||||||
class="button-icon"
|
|
||||||
:disabled="plane.selectedOption.reserved"
|
|
||||||
@click="plane.setOption(plane.selectedOption)"
|
|
||||||
>
|
|
||||||
<UiIcon name="plus" />
|
|
||||||
<span>Add option</span>
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
class="button-link"
|
|
||||||
v-if="plane.selectedOption.docs_url"
|
|
||||||
:href="plane.selectedOption.docs_url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<UiIcon name="link" />
|
|
||||||
Official docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ProxyDocument } from "../types";
|
|
||||||
import StatusPill from "./StatusPill.vue";
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
proxies: ProxyDocument[];
|
|
||||||
selectedPort: number | null;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
select: [port: number];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<aside class="rail panel">
|
|
||||||
<div class="panel-title">
|
|
||||||
<div>
|
|
||||||
<h2>Proxies</h2>
|
|
||||||
</div>
|
|
||||||
<span class="counter">{{ proxies.length }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!proxies.length" class="empty-state">
|
|
||||||
<strong>No proxies yet</strong>
|
|
||||||
<span>Create one to start.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="proxy-list">
|
|
||||||
<button
|
|
||||||
v-for="proxy in proxies"
|
|
||||||
:key="proxy.proxy_port"
|
|
||||||
class="proxy-card"
|
|
||||||
:class="{ selected: proxy.proxy_port === selectedPort }"
|
|
||||||
@click="emit('select', proxy.proxy_port)"
|
|
||||||
>
|
|
||||||
<div class="proxy-card-head">
|
|
||||||
<strong>{{ proxy.name }}</strong>
|
|
||||||
<StatusPill :status="proxy.status" />
|
|
||||||
</div>
|
|
||||||
<div class="proxy-meta">
|
|
||||||
<span>:{{ proxy.proxy_port }}</span>
|
|
||||||
<span>{{ proxy.mode }} / {{ proxy.protocol }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="proxy-route">
|
|
||||||
{{ proxy.service.host }}:{{ proxy.service.port }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</template>
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import HealthPanel from "./HealthPanel.vue";
|
|
||||||
import OptionsPanel from "./OptionsPanel.vue";
|
|
||||||
import RuntimePanel from "./RuntimePanel.vue";
|
|
||||||
import UiIcon from "./UiIcon.vue";
|
|
||||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
|
||||||
|
|
||||||
const plane = useControlPlaneContext();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="plane.wizardOpen" class="modal-backdrop" @click.self="plane.closeWizard">
|
|
||||||
<section class="modal-shell">
|
|
||||||
<header class="modal-head">
|
|
||||||
<div>
|
|
||||||
<h2>{{ plane.wizardTitle }}</h2>
|
|
||||||
<p class="modal-step-copy">
|
|
||||||
Step {{ plane.wizardStep + 1 }} of 3: {{ plane.wizardStepTitle }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="modal-head-actions">
|
|
||||||
<button class="primary button-icon" :disabled="!plane.canSaveProxy" @click="plane.saveProxy">
|
|
||||||
<UiIcon name="save" />
|
|
||||||
<span>Save & close</span>
|
|
||||||
</button>
|
|
||||||
<button class="ghost button-icon" @click="plane.closeWizard">
|
|
||||||
<UiIcon name="close" />
|
|
||||||
<span>Close</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p v-if="plane.wizardError" class="error modal-error">
|
|
||||||
{{ plane.wizardError }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="modal-stepper">
|
|
||||||
<button
|
|
||||||
v-for="(step, index) in ['Proxy route', 'Healthchecks', 'Custom mitmdump options']"
|
|
||||||
:key="step"
|
|
||||||
class="modal-step-chip"
|
|
||||||
:class="{ active: plane.wizardStep === index, done: plane.wizardStep > index }"
|
|
||||||
@click="plane.wizardStep = index"
|
|
||||||
>
|
|
||||||
<span>{{ index + 1 }}</span>
|
|
||||||
<strong>{{ step }}</strong>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
<RuntimePanel v-if="plane.wizardStep === 0" embedded />
|
|
||||||
<HealthPanel v-else-if="plane.wizardStep === 1" embedded />
|
|
||||||
<OptionsPanel v-else embedded />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="modal-actions">
|
|
||||||
<button
|
|
||||||
v-if="plane.canGoBackWizard"
|
|
||||||
class="ghost button-icon"
|
|
||||||
@click="plane.previousWizardStep"
|
|
||||||
>
|
|
||||||
<UiIcon name="back" />
|
|
||||||
<span>Back</span>
|
|
||||||
</button>
|
|
||||||
<div class="modal-action-spacer" />
|
|
||||||
<button
|
|
||||||
v-if="plane.canGoNextWizard"
|
|
||||||
class="primary button-icon"
|
|
||||||
@click="plane.nextWizardStep"
|
|
||||||
>
|
|
||||||
<UiIcon name="next" />
|
|
||||||
<span>Next</span>
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
|
||||||
import EditorPane from "./EditorPane.vue";
|
|
||||||
import HealthDetailsModal from "./HealthDetailsModal.vue";
|
|
||||||
import HealthSummaryBar from "./HealthSummaryBar.vue";
|
|
||||||
import LogsPanel from "./LogsPanel.vue";
|
|
||||||
import ProxyWizardModal from "./ProxyWizardModal.vue";
|
|
||||||
import ServiceDiscoveryPanel from "./ServiceDiscoveryPanel.vue";
|
|
||||||
import StatusPill from "./StatusPill.vue";
|
|
||||||
import TemplatesPanel from "./TemplatesPanel.vue";
|
|
||||||
import UiIcon from "./UiIcon.vue";
|
|
||||||
|
|
||||||
const plane = useControlPlaneContext();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="workbench">
|
|
||||||
<header class="workbench-head panel">
|
|
||||||
<div class="workbench-copy">
|
|
||||||
<h1>{{ plane.selectedProxy ? plane.draft.name : "Proxy workspace" }}</h1>
|
|
||||||
<div v-if="plane.selectedProxy" class="route-row route-summary">
|
|
||||||
<span class="route-token">{{ plane.routeState.proxySummary }}</span>
|
|
||||||
<span class="route-target">{{ plane.routeState.upstreamSummary }}</span>
|
|
||||||
<StatusPill
|
|
||||||
:status="plane.selectedProxy.status"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-else class="summary-copy">
|
|
||||||
Create a proxy from autodiscovery or open one from the inventory.
|
|
||||||
</p>
|
|
||||||
<p v-if="plane.selectedProxy && plane.routeState.proxyPortReservedMessage" class="error compact">
|
|
||||||
{{ plane.routeState.proxyPortReservedMessage }}
|
|
||||||
</p>
|
|
||||||
<p v-if="plane.selectedProxy?.last_error" class="error compact">
|
|
||||||
{{ plane.selectedProxy.last_error }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HealthSummaryBar />
|
|
||||||
|
|
||||||
<div class="action-row">
|
|
||||||
<button
|
|
||||||
v-if="plane.selectedProxy"
|
|
||||||
class="ghost button-icon"
|
|
||||||
@click="plane.openEditWizard()"
|
|
||||||
:disabled="plane.loading"
|
|
||||||
>
|
|
||||||
<UiIcon name="settings" />
|
|
||||||
<span>Settings</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="plane.actionState.showStart"
|
|
||||||
class="success button-icon"
|
|
||||||
@click="plane.runtimeAction('start')"
|
|
||||||
:disabled="!plane.actionState.canStart"
|
|
||||||
>
|
|
||||||
<UiIcon name="play" />
|
|
||||||
<span>Start</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="plane.actionState.showStop"
|
|
||||||
class="danger button-icon"
|
|
||||||
@click="plane.runtimeAction('stop')"
|
|
||||||
:disabled="!plane.actionState.canStop"
|
|
||||||
>
|
|
||||||
<UiIcon name="stop" />
|
|
||||||
<span>Stop</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="plane.actionState.showRestart"
|
|
||||||
class="warning button-icon"
|
|
||||||
@click="plane.runtimeAction('restart')"
|
|
||||||
:disabled="!plane.actionState.canRestart"
|
|
||||||
>
|
|
||||||
<UiIcon name="restart" />
|
|
||||||
<span>Restart</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="plane.selectedProxy"
|
|
||||||
class="danger button-icon"
|
|
||||||
@click="plane.removeProxy"
|
|
||||||
:disabled="!plane.actionState.canDelete"
|
|
||||||
>
|
|
||||||
<UiIcon name="trash" />
|
|
||||||
<span>Delete</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<p v-if="plane.workspaceError" class="error">{{ plane.workspaceError }}</p>
|
|
||||||
|
|
||||||
<ServiceDiscoveryPanel v-if="!plane.selectedProxy && !plane.wizardOpen" />
|
|
||||||
|
|
||||||
<div v-if="plane.selectedProxy" class="workspace-main-row">
|
|
||||||
<div class="workspace-editor-pane">
|
|
||||||
<EditorPane />
|
|
||||||
</div>
|
|
||||||
<div class="workspace-logs-pane">
|
|
||||||
<LogsPanel />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="plane.selectedProxy" class="workspace-templates-row">
|
|
||||||
<TemplatesPanel />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProxyWizardModal />
|
|
||||||
<HealthDetailsModal />
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user