feat: enhance service discovery and proxy management

This commit is contained in:
gabdevele
2026-05-05 13:57:46 +02:00 Unverified
parent 93ba8e9ac2
commit a523c2faa3
53 changed files with 3206 additions and 706 deletions
+48 -3
View File
@@ -1,13 +1,14 @@
# thePlug # thePlug
Wrapper web per gestire proxy `mitmdump` davanti ai servizi di una competizione Wrapper web per gestire proxy `mitmdump` davanti a servizi esposti via Docker
CTF attack/defense. Compose.
## Struttura ## Struttura
```text ```text
backend/ FastAPI, MongoDB, Redis, mitmdump manager backend/ FastAPI, MongoDB, Redis, mitmdump manager
frontend/ Vue 3, Vite, Monaco editor frontend/ Vue 3, Vite, CodeMirror + Python LSP
playground/ servizio locale minimale per test end-to-end
``` ```
## Setup rapido ## Setup rapido
@@ -24,6 +25,12 @@ Frontend http://localhost:5173
Backend http://localhost:3671 Backend http://localhost:3671
``` ```
Questa modalità è buona per smoke test UI/API. Se vuoi provare davvero il
takeover della porta di un servizio Docker locale, fai girare il backend sulla
macchina host.
Guida completa: [TESTING.md](/home/gabdevele/CTF/thePlug/TESTING.md)
Per installare anche il servizio systemd user: Per installare anche il servizio systemd user:
```bash ```bash
@@ -40,6 +47,37 @@ npm run dev:backend
Docs FastAPI in dev: `http://localhost:3671/docs`. Docs FastAPI in dev: `http://localhost:3671/docs`.
## Test locale realistico
Per simulare il deployment reale, usa il backend sulla macchina host:
```bash
./setup.sh deps
export SERVICE_SCAN_ROOT="$PWD"
npm run dev:backend
npm run dev:frontend
./setup.sh playground
```
Poi:
1. Apri `http://localhost:5173`.
2. Fai login con le credenziali in `backend/.env`.
3. Crea un nuovo proxy.
4. Seleziona il servizio `echo` rilevato dal backend sotto la cartella `playground`.
5. Lascia `mode=reverse`.
6. Verifica che `proxy_port` combaci con la porta del servizio (`9081`) se vuoi che il proxy prenda davvero il posto della route originale.
7. Salva e premi `Start`.
8. Esegui `curl http://127.0.0.1:9081/`.
Se il proxy è attivo, la route originale è ora posseduta da `mitmdump` e il
servizio reale è stato spostato su una porta upstream temporanea, visibile nella
UI e nello stato runtime.
`SERVICE_SCAN_ROOT` controls where the backend looks for services. The scan is
intentionally shallow: it inspects only the immediate child directories of that
root, which matches the expected host layout.
## Dev frontend ## Dev frontend
```bash ```bash
@@ -63,6 +101,13 @@ cd backend && uv run ruff check . && uv run pytest
cd frontend && npm run build cd frontend && npm run build
``` ```
Per testare solo il playground:
```bash
npm run playground:up
npm run playground:down
```
## API principali ## API principali
- `POST /auth/login` - `POST /auth/login`
+413
View File
@@ -0,0 +1,413 @@
# Testing Guide
Questa guida descrive come preparare e testare `thePlug` in tre scenari:
1. laptop con backend sulla macchina host
2. laptop con stack tutto Dockerizzato
3. vulnbox reale con backend fuori Docker
L’obiettivo non è solo far partire UI e API, ma verificare il comportamento
reale del proxy: remap della porta del servizio, avvio di `mitmdump`,
healthcheck e ripristino su `Stop`.
## Concetti Chiave
### Cosa fa `Save`
`Save` salva solo la configurazione del proxy nel backend. Non tocca:
- porte del servizio reale
- processi `mitmdump`
- Docker Compose
### Cosa fa `Start`
In `reverse` mode, se il proxy è associato a un servizio Docker Compose e
`mode_spec` non è impostato:
1. il backend sposta la porta pubblica del servizio reale su una porta libera
2. salva quella nuova porta come upstream live
3. avvia `mitmdump` sulla porta originale
Quindi, se vuoi che il proxy prenda davvero il posto del servizio originale,
`proxy_port` deve coincidere con la porta pubblica del servizio.
### Come vengono scoperti i servizi
Il backend cerca i servizi Docker Compose sotto una root di scansione:
- di default: `HOME`
- override: `SERVICE_SCAN_ROOT`
La scansione è volutamente shallow:
- il backend guarda solo le cartelle immediate sotto la root
- non scende ricorsivamente in profondità
Questa scelta riflette il layout atteso sulla macchina reale.
Esempio valido:
```text
/home/user/service-a/docker-compose.yml
/home/user/service-b/docker-compose.yml
```
Esempio non visto se la root è `/home/user`:
```text
/home/user/projects/lab/service-a/docker-compose.yml
```
In quel caso devi cambiare `SERVICE_SCAN_ROOT`.
## Prerequisiti
Su tutte le macchine:
- `docker`
- `docker compose`
- `uv`
- `python 3.14`
- `node 22`
Il backend installerà anche:
- `mitmproxy`
- `basedpyright`
Porte usate tipicamente:
- `3671` backend
- `5173` frontend
- `27017` mongodb
- `6379` redis
- una o più porte proxy/test
## Scenario 1: Laptop con Backend sulla Macchina Host
Questo è il setup migliore per testare davvero il comportamento del proxy.
### Quando usarlo
Usa questo scenario se vuoi verificare:
- discovery servizi
- remap porta Docker Compose
- takeover reale della porta pubblica
- start/stop/restart di `mitmdump`
- esempi script e editor
### Preparazione
Dalla root del progetto:
```bash
cd /home/gabdevele/CTF/thePlug
./setup.sh deps
export SERVICE_SCAN_ROOT="$PWD"
./setup.sh playground
```
Questo fa due cose:
- avvia solo `mongodb` e `redis` in Docker
- avvia un piccolo upstream locale in `playground/`
Il playground espone:
```text
http://127.0.0.1:9081
```
### Avvio applicazione
In un terminale:
```bash
cd /home/gabdevele/CTF/thePlug
npm run dev:backend
```
In un secondo terminale:
```bash
cd /home/gabdevele/CTF/thePlug
npm run dev:frontend
```
### Login
Le credenziali sono in:
- [backend/.env](/home/gabdevele/CTF/thePlug/backend/.env)
Apri:
```text
http://localhost:5173
```
### Test End-to-End Consigliato
1. Crea un nuovo proxy.
2. Seleziona il servizio `echo` rilevato dal backend.
3. Lascia `mode=reverse`.
4. Verifica che `proxy_port=9081`.
5. Salva.
6. Premi `Start`.
7. Esegui:
```bash
curl -i http://127.0.0.1:9081/
```
### Cosa devi vedere
Prima di `Start`:
- `9081` risponde direttamente il servizio playground
Dopo `Start`:
- `mitmdump` è in ascolto su `9081`
- il servizio reale è stato spostato su una porta upstream temporanea
- la UI mostra il live upstream cambiato
- i log del proxy iniziano a popolarsi
Dopo `Stop`:
- la porta pubblica originaria torna al servizio reale
- `service.upstream_port` torna `null`
### Verifiche Utili
Controlla i servizi rilevati:
```bash
curl -H "Authorization: Bearer <TOKEN>" http://127.0.0.1:3671/proxy/services
```
Controlla i log proxy dalla UI o via API:
```bash
curl -H "Authorization: Bearer <TOKEN>" http://127.0.0.1:3671/proxy/9081/logs
```
### Cleanup
```bash
npm run playground:down
docker compose --env-file backend/.env -f docker-compose.yml down
```
## Scenario 2: Laptop con Stack Tutto Dockerizzato
Questo scenario è adatto soprattutto a smoke test applicativi.
### Quando usarlo
Usalo per verificare:
- login/logout
- CRUD proxy
- examples
- options
- persistenza Mongo/Redis
- build frontend e wiring API
### Avvio
```bash
cd /home/gabdevele/CTF/thePlug
./setup.sh
```
Apri:
- frontend: `http://localhost:5173`
- backend: `http://localhost:3671`
### Cosa testare qui
Puoi testare bene:
- creazione proxy
- update/delete
- validazione codice
- examples custom
- health endpoint
- editor e autocomplete
### Limite importante
Questo non è il setup migliore per validare il takeover reale delle porte di un
servizio locale, perché il backend è dentro Docker e non replica la topologia
host-native del deployment reale.
Quindi:
- bene per smoke test UI/API
- non abbastanza per validare il flusso finale di orchestration
Per il test realistico del proxy usa lo Scenario 1.
### Spegnimento
```bash
docker compose --env-file backend/.env -f docker-compose.yml down
```
## Scenario 3: Vulnbox Reale con Backend fuori Docker
Questo è il modello più vicino al deployment reale.
### Layout atteso dei servizi
Caso ideale sotto `HOME`:
```text
/home/user/service-a/docker-compose.yml
/home/user/service-b/docker-compose.yml
/home/user/service-c/docker-compose.yml
```
Se il layout è diverso, imposta `SERVICE_SCAN_ROOT`.
### Preparazione backend
```bash
cd /path/to/thePlug
cp backend/.env.example backend/.env
./setup.sh deps
```
Se i servizi non sono sotto `HOME`:
```bash
export SERVICE_SCAN_ROOT="/path/che/contiene/le/cartelle-servizio"
```
### Avvio backend
```bash
cd /path/to/thePlug
npm run dev:backend
```
### Avvio frontend
In sviluppo:
```bash
cd /path/to/thePlug
npm run dev:frontend
```
Oppure build statico:
```bash
cd /path/to/thePlug/frontend
VITE_API_BASE_URL=http://IP_O_HOST:3671 npm run build
```
### Test operativo reale
1. Apri la UI.
2. Seleziona un servizio reale scoperto dal backend.
3. Se vuoi takeover vero della porta pubblica:
`proxy_port` deve essere uguale alla porta pubblica del servizio.
4. Salva.
5. Premi `Start`.
6. Verifica da terminale o con la tua applicazione client che il traffico passi
ancora sulla porta originale.
### Cosa aspettarsi
Su `Start`:
- il servizio reale viene spostato su una porta temporanea
- `mitmdump` si lega alla porta originaria
- il traffico continua a entrare dalla porta originale
- la UI mostra il nuovo live upstream
Su `Stop`:
- la porta originaria torna al servizio reale
- il proxy viene fermato
## Failure Modes Comuni
### Il backend non trova nessun servizio
Controlla:
- valore di `SERVICE_SCAN_ROOT`
- che i compose siano nelle cartelle immediate sotto quella root
- che i file si chiamino uno tra:
- `compose.yml`
- `compose.yaml`
- `docker-compose.yml`
- `docker-compose.yaml`
### Il proxy parte ma non prende il posto del servizio
Controlla:
- `mode=reverse`
- `mode_spec` vuoto
- `proxy_port` uguale alla porta del servizio
- servizio selezionato dalla UI correttamente
### `Start` fallisce
Controlla:
- `mitmdump` disponibile nel backend host
- conflitti di porta
- errori di sintassi nello script
- log del proxy
### Il frontend mostra session expired
Vuol dire che il token JWT è scaduto o non è più valido. Fai login di nuovo.
## Comandi Utili
Test backend:
```bash
cd backend
uv run ruff check .
uv run pytest
```
Build frontend:
```bash
cd frontend
npm run build
```
Avvio playground:
```bash
./setup.sh playground
```
Solo dipendenze backend per test host:
```bash
./setup.sh deps
```
## Raccomandazione Finale
Per lavorare in modo efficiente:
- usa `./setup.sh` per smoke test rapidi
- usa `backend host + frontend dev + playground` per il test vero del proxy
- usa `backend host` sulla vulnbox reale per validare il comportamento finale
Se devi testare davvero il remap porta, considera valido solo un setup con
backend fuori Docker.
+22 -22
View File
@@ -1,35 +1,35 @@
UI SCHIFOSA Rivedere la parte UI/UX
andrebbe rifatto proprio il layout.
dovrebbe essere in ordine di priorità, l'editor non può stare così tanto sotto. L'idea è avere una sezione di creazione del proxy, dove c'è l'autodiscovery e quindi si possono selezionare i vari compose trovati, specificare la cartella dove scannerizzare i vari servizi da frontend, senza dover cambiare la variabile d'ambiente. (il default è in /root)
card come:
- Runtime mapping
- What start does
vanno eliminati.
di default quando si crea un reverse proxy, l'intenzione è quella di spostare la porta del servizio ad un'altra e mettere il proxy al posto della porta del servizio iniziale. IL proxy su un servizio verrà quindi creato tramite una modale, specificando i vari campi (autocompletato in caso di autodiscovery), con vari step:
- Proxy route
- Healthchecks
- Custom mitmdump options
anche ```Python tools ready``` è inutile Una volta creato, nella pagina del Proxy ci sarà prima di tutto l'editor del proxy script, poi non si chiameranno più examples ma "template" con la possibilità di leggere i template tramite preview ed essere selezionati. inoltre anche i log devono essere visualizzabili, senza dover cliccare load ogni volta, vengono mostrati e aggiornati in automatico.
Addon editor come titolo non mi piace Poi cliccando un tasto apposito è possibile riaprire la modale a step per modificare le impostazioni del proxy, e ovviamente salvare.
``` Questo serve per migliorare l'usabilità e l'esperienza utente, in modo da customizzarlo gestendo l'ordine del flusso di creazione, perchè logicamente certe cose le setti solo la prima volta non sempre, e l'ordine di quante volte usarai una cosa, non di importanza.
No proxies configured
Create a draft, save it, then start it when you want mitmdump to own the route.
```
troppa roba, riassumere.
Options card buggattissima lato UI, inoltre a questo punto va fatta una scrematura sulle opzioni sulle cose che possono servire veramente, inutile mettere troppe cose. Altri spunti di miglioramento:
1. Gestione dell'Autodiscovery e dei Caricamenti
gli esempi vanno aggiornati con roba reale utile. Feedback visivo: La scansione delle cartelle (specialmente /root) potrebbe richiedere qualche secondo. Prevedi uno stato di caricamento esplicito (uno skeleton loader o uno spinner) mentre i compose vengono trovati, per evitare che l'utente pensi che l'app sia bloccata.
2. Navigazione nella Modale a Step
capire come testare davvero sulla mia macchina Stepper visivo: Inserisci un indicatore di progresso in cima alla modale (es. Step 1 di 3: Proxy Route).
cambiare text sulla pagina di login Navigazione flessibile: Permetti all'utente di tornare indietro agli step precedenti senza perdere i dati inseriti.
cambiare dove e come viene mostrato alert quando c'è errore di autenticazione (loggati in precedenza con token scaduto.) 3. Sicurezza nell'Editor e nei Template
rendere lo script davvero funzionante Prevenzione degli errori: Quando l'utente seleziona un "Template" dall'anteprima, se c'è già del codice scritto nell'editor, l'applicazione del template lo sovrascriverà. Inserisci un alert di conferma: "Applicare questo template sovrascriverà il tuo script attuale. Vuoi procedere?"
solo il backend non deve runnare dentro docker ma sulla macchina reale. 4. Controlli avanzati per i Log
Pausa e Clear: Dato che i log si aggiornano in automatico, lo scorrimento continuo può rendere difficile la lettura di una riga specifica. Aggiungi due piccoli tasti sopra il terminale dei log: Metti in pausa (Pause autoscroll) e Svuota (Clear).
Filtri di base: Se possibile, un semplice campo di ricerca testuale per filtrare i log al volo farebbe un'enorme differenza.
+10 -4
View File
@@ -1,11 +1,17 @@
API_PORT=3671 API_PORT=3671
FRONTEND_PORT=5173 FRONTEND_PORT=5173
MONGO_INITDB_ROOT_PASSWORD=pass# esegui 'openssl rand -hex 5' in un terminale e copia qui # Use a random value in real environments.
MONGO_INITDB_ROOT_PASSWORD=pass
MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin MONGODB_URI=mongodb://theplug:pass@localhost:27017/theplug?authSource=admin
MONGODB_DB_NAME=theplug MONGODB_DB_NAME=theplug
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
JWT_SECRET_KEY= # esegui 'openssl rand -hex 20' in un terminale e copia qui # Use a random value in real environments.
JWT_SECRET_KEY=change-me
JWT_ALGORITHM=HS256 JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=30 JWT_EXPIRE_MINUTES=720
AUTH_USERNAME=admin AUTH_USERNAME=admin
AUTH_PASSWORD= # esegui 'openssl rand -hex 20' in un terminale e copia qui # 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=
+9 -2
View File
@@ -1,7 +1,10 @@
from pathlib import Path from pathlib import Path
from core.config import settings
from core.security import require_authenticated_user from core.security import require_authenticated_user
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from integrations.docker import get_all_services_in_dir
from integrations.docker.discovery import service_scan_root
from models.example import AddonExampleCreateInput from models.example import AddonExampleCreateInput
from models.proxy import ProxyCreateInput, ProxyUpdateInput from models.proxy import ProxyCreateInput, ProxyUpdateInput
from pymongo.errors import DuplicateKeyError from pymongo.errors import DuplicateKeyError
@@ -120,8 +123,12 @@ async def list_proxies_handler():
@router.get("/services") @router.get("/services")
async def list_services(request: Request): async def list_services(request: Request, scan_root: str | None = None):
return getattr(request.app.state, "services", []) _ = settings
root = Path(scan_root).expanduser() if scan_root else service_scan_root()
services = get_all_services_in_dir(root)
request.app.state.services = services
return {"scan_root": str(root), "services": services}
@router.get("/mitmproxy/options") @router.get("/mitmproxy/options")
+2 -1
View File
@@ -15,9 +15,10 @@ class Settings(BaseSettings):
REDIS_URL: str REDIS_URL: str
JWT_SECRET_KEY: str JWT_SECRET_KEY: str
JWT_ALGORITHM: str = "HS256" JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = Field(default=30, gt=0) JWT_EXPIRE_MINUTES: int = Field(default=720, gt=0)
AUTH_USERNAME: str AUTH_USERNAME: str
AUTH_PASSWORD: str AUTH_PASSWORD: str
SERVICE_SCAN_ROOT: str | None = None
settings = Settings() settings = Settings()
+19 -8
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import yaml import yaml
from integrations.docker.models import DockerContainer from integrations.docker.models import DockerContainer
from integrations.docker.ports import _get_service_port from integrations.docker.ports import _get_service_binding
COMPOSE_NAMES = ( COMPOSE_NAMES = (
"compose.yml", "compose.yml",
@@ -50,20 +50,31 @@ def get_services(folder: Path) -> list[DockerContainer] | None:
if not isinstance(mappings, list) or not mappings: if not isinstance(mappings, list) or not mappings:
continue continue
ports = [ bindings = [
p {"published": published, "host": host}
for p in (_get_service_port(mapping) for mapping in mappings) for published, host in (
if p is not None _get_service_binding(mapping) for mapping in mappings
)
if published is not None
] ]
if not ports: if not bindings:
continue continue
services.append({"name": service_name, "ports": ports, "folder": folder}) services.append(
{
"name": service_name,
"ports": [binding["published"] for binding in bindings],
"bindings": bindings,
"folder": folder,
}
)
return services return services
def get_all_services_in_dir(directory: str | Path) -> list[DockerContainer]: def get_all_services_in_dir(
directory: str | Path,
) -> list[DockerContainer]:
path = Path(directory) path = Path(directory)
if not path.is_dir(): if not path.is_dir():
return [] return []
@@ -0,0 +1,10 @@
from pathlib import Path
from core.config import settings
def service_scan_root() -> Path:
configured = settings.SERVICE_SCAN_ROOT
if configured:
return Path(configured).expanduser()
return Path.home()
+9 -1
View File
@@ -16,6 +16,8 @@ async def change_port(
folder: Path, folder: Path,
port_from: int, port_from: int,
port_to: int, port_to: int,
host_to: str | None = None,
rewrite_host: bool = False,
) -> bool: ) -> bool:
compose_data = find_docker_compose(folder) compose_data = find_docker_compose(folder)
if compose_data is None: if compose_data is None:
@@ -38,7 +40,13 @@ async def change_port(
if idx is None: if idx is None:
return False return False
mappings[idx] = _replace_port_in_mapping(mappings[idx], port_from, port_to) mappings[idx] = _replace_port_in_mapping(
mappings[idx],
port_from,
port_to,
host_to=host_to,
rewrite_host=rewrite_host,
)
backup_path = _backup_path_for(compose_path) backup_path = _backup_path_for(compose_path)
original_text = compose_path.read_text() original_text = compose_path.read_text()
@@ -2,7 +2,13 @@ from pathlib import Path
from typing import TypedDict from typing import TypedDict
class DockerPortBinding(TypedDict):
published: int
host: str | None
class DockerContainer(TypedDict): class DockerContainer(TypedDict):
name: str name: str
ports: list[int] ports: list[int]
bindings: list[DockerPortBinding]
folder: Path folder: Path
+37 -15
View File
@@ -14,48 +14,64 @@ def get_random_port() -> int:
raise RuntimeError(f"Could not find an available port: {e}") from e raise RuntimeError(f"Could not find an available port: {e}") from e
def _get_service_port(mapping: str | int | Mapping[str, object]) -> int | None: def _get_service_binding(
mapping: str | int | Mapping[str, object],
) -> tuple[int | None, str | None]:
if isinstance(mapping, int): if isinstance(mapping, int):
return mapping return mapping, None
if isinstance(mapping, Mapping): if isinstance(mapping, Mapping):
published = mapping.get("published") published = mapping.get("published")
if published is None: if published is None:
return None return None, None
try: try:
return int(published) port = int(published)
except TypeError, ValueError: except TypeError:
return None return None, None
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(":") parts = str(mapping).split(":")
match parts: match parts:
case [""]: case [""]:
return None return None, None
case [port] | [port, _]: case [port] | [port, _]:
try: try:
return int(port) return int(port), None
except ValueError: except ValueError:
return None return None, None
case ["localhost" | "127.0.0.1", _, _]: case [host, port, _]:
return None
case [_, port, _]:
try: try:
return int(port) return int(port), host or None
except ValueError: except ValueError:
return None return None, None
case _: case _:
return None 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( def _replace_port_in_mapping(
mapping: str | int | Mapping[str, object], mapping: str | int | Mapping[str, object],
port_from: int, port_from: int,
port_to: int, port_to: int,
host_to: str | None = None,
rewrite_host: bool = False,
) -> str | dict[str, object]: ) -> str | dict[str, object]:
if isinstance(mapping, Mapping): if isinstance(mapping, Mapping):
updated = dict(mapping) updated = dict(mapping)
if _get_service_port(updated) == port_from: if _get_service_port(updated) == port_from:
updated["published"] = str(port_to) 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 return updated
parts = str(mapping).split(":") parts = str(mapping).split(":")
@@ -63,8 +79,14 @@ def _replace_port_in_mapping(
case [_]: case [_]:
return str(port_to) return str(port_to)
case [_, container_port]: case [_, container_port]:
if rewrite_host and host_to:
return f"{host_to}:{port_to}:{container_port}"
return f"{port_to}:{container_port}" return f"{port_to}:{container_port}"
case [ip, _, 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}" return f"{ip}:{port_to}:{container_port}"
case _: case _:
return str(port_to) return str(port_to)
+19 -5
View File
@@ -1,7 +1,6 @@
import os import os
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from api.auth.router import router as auth_router from api.auth.router import router as auth_router
from api.editor.router import router as editor_router from api.editor.router import router as editor_router
@@ -12,9 +11,11 @@ from core.redis import close_redis, init_redis
from core.security import hash_password from core.security import hash_password
from fastapi import FastAPI from fastapi import FastAPI
from integrations.docker import get_all_services_in_dir from integrations.docker import get_all_services_in_dir
from integrations.docker.discovery import service_scan_root
from repositories.auth import ensure_auth_indexes, ensure_single_account from repositories.auth import ensure_auth_indexes, ensure_single_account
from repositories.examples import ensure_example_indexes from repositories.examples import ensure_example_indexes
from repositories.proxy import ensure_proxy_indexes from repositories.proxy import ensure_proxy_indexes
from services.mitmproxy.recovery import reconcile_runtime
@asynccontextmanager @asynccontextmanager
@@ -28,14 +29,27 @@ async def lifespan(_: FastAPI):
) )
await ensure_proxy_indexes() await ensure_proxy_indexes()
await ensure_example_indexes() await ensure_example_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,
)
home_dir = str(Path.home()) scan_root = service_scan_root()
app.state.services = get_all_services_in_dir(home_dir) or [] app.state.services = get_all_services_in_dir(scan_root) or []
if not app.state.services: if not app.state.services:
warning = ( warning = (
"\n" + "!" * 60 + "\n" "\n" + "!" * 60 + "\n"
" WARNING: No services found in home directory!\n" " WARNING: No services found in scan root!\n"
f" Scanned: {home_dir}\n" + "!" * 60 + "\n" f" Scanned: {scan_root}\n" + "!" * 60 + "\n"
) )
print(warning, file=sys.stderr) print(warning, file=sys.stderr)
+4
View File
@@ -6,6 +6,10 @@ class ServiceEndpointDocument(BaseModel):
host: str = Field(default="127.0.0.1", description="Upstream service host") host: str = Field(default="127.0.0.1", description="Upstream service host")
port: int = Field(..., description="Original upstream service port") 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") name: str | None = Field(default=None, description="Docker Compose service name")
folder: str | None = Field( folder: str | None = Field(
default=None, default=None,
+36 -18
View File
@@ -4,11 +4,11 @@ DOCS_EXAMPLES_URL = "https://docs.mitmproxy.org/stable/addons/examples/"
BUILTIN_EXAMPLES = [ BUILTIN_EXAMPLES = [
AddonExampleDocument( AddonExampleDocument(
id="http-add-response-header", id="http-tag-traffic",
name="HTTP add response header", name="Tag proxied traffic",
description=( description=(
"Injects a fixed response header on every HTTP response so you can " "Adds a visible response header on every HTTP reply so you can tell "
"verify that traffic is passing through the proxy response path." "immediately whether the request crossed the proxy."
), ),
category="http", category="http",
source_url=DOCS_EXAMPLES_URL, source_url=DOCS_EXAMPLES_URL,
@@ -21,11 +21,11 @@ def response(flow: http.HTTPFlow) -> None:
""", """,
), ),
AddonExampleDocument( AddonExampleDocument(
id="http-modify-query", id="http-force-query-flag",
name="HTTP modify query", name="Force request flag",
description=( description=(
"Adds or overwrites a query parameter before each request is " "Adds or overwrites a request query flag before the flow is sent "
"forwarded upstream. Useful for forced debug or feature flags." "upstream. Useful when you need a stable marker on every request."
), ),
category="http", category="http",
source_url=DOCS_EXAMPLES_URL, source_url=DOCS_EXAMPLES_URL,
@@ -38,11 +38,11 @@ def request(flow: http.HTTPFlow) -> None:
""", """,
), ),
AddonExampleDocument( AddonExampleDocument(
id="http-reply-from-proxy", id="http-local-health-response",
name="HTTP reply from proxy", name="Serve local health response",
description=( description=(
"Serves a local HTTP response for a selected path without touching " "Answers one health endpoint directly from the proxy without "
"the upstream service. Useful for probes, maintenance, or stubs." "touching the upstream service. Useful for smoke tests and checks."
), ),
category="http", category="http",
source_url=DOCS_EXAMPLES_URL, source_url=DOCS_EXAMPLES_URL,
@@ -57,14 +57,32 @@ def request(flow: http.HTTPFlow) -> None:
b"ok", b"ok",
{"content-type": "text/plain"}, {"content-type": "text/plain"},
) )
""",
),
AddonExampleDocument(
id="http-rewrite-health-path",
name="Rewrite one request path",
description=(
"Rewrites a single incoming path before forwarding it upstream. "
"Useful when the public route and upstream route do not match."
),
category="http",
source_url=DOCS_EXAMPLES_URL,
built_in=True,
code="""from mitmproxy import http
def request(flow: http.HTTPFlow) -> None:
if flow.request.path == "/healthz":
flow.request.path = "/status"
""", """,
), ),
AddonExampleDocument( AddonExampleDocument(
id="websocket-rewrite", id="websocket-rewrite",
name="WebSocket rewrite", name="WebSocket rewrite",
description=( description=(
"Rewrites matching bytes inside live WebSocket frames to alter " "Rewrites matching bytes inside live WebSocket frames to flip a "
"runtime flags or payload markers in transit." "runtime marker while the flow is passing through the proxy."
), ),
category="websocket", category="websocket",
source_url=DOCS_EXAMPLES_URL, source_url=DOCS_EXAMPLES_URL,
@@ -83,8 +101,8 @@ def websocket_message(flow: http.HTTPFlow) -> None:
id="tcp-log-messages", id="tcp-log-messages",
name="TCP log messages", name="TCP log messages",
description=( description=(
"Logs raw TCP message direction and size for each frame so you can " "Logs direction and byte size for every TCP frame so you can see "
"observe binary traffic without altering it." "what is crossing the route without modifying the payload."
), ),
category="tcp", category="tcp",
source_url=DOCS_EXAMPLES_URL, source_url=DOCS_EXAMPLES_URL,
@@ -104,8 +122,8 @@ def tcp_message(flow: tcp.TCPFlow) -> None:
id="dns-spoof-a-record", id="dns-spoof-a-record",
name="DNS spoof A record", name="DNS spoof A record",
description=( description=(
"Overrides one DNS A record while forwarding every other query " "Overrides one DNS A record while leaving all other queries alone. "
"unchanged. Useful for selective service redirection." "Useful for selective redirection during local testing."
), ),
category="dns", category="dns",
source_url=DOCS_EXAMPLES_URL, source_url=DOCS_EXAMPLES_URL,
+152 -33
View File
@@ -20,6 +20,7 @@ class MitmdumpManager:
self.work_dir = Path(work_dir) self.work_dir = Path(work_dir)
self.command = command self.command = command
self.processes: dict[int, asyncio.subprocess.Process] = {} self.processes: dict[int, asyncio.subprocess.Process] = {}
self.attached_pids: dict[int, int] = {}
self.locks: dict[int, asyncio.Lock] = {} self.locks: dict[int, asyncio.Lock] = {}
async def start(self, proxy: ProxyDocument) -> dict[str, object]: async def start(self, proxy: ProxyDocument) -> dict[str, object]:
@@ -67,6 +68,7 @@ class MitmdumpManager:
raise MitmdumpError(reason) raise MitmdumpError(reason)
self.processes[proxy.proxy_port] = process self.processes[proxy.proxy_port] = process
self.attached_pids.pop(proxy.proxy_port, None)
return { return {
"status": "running", "status": "running",
"pid": process.pid, "pid": process.pid,
@@ -79,6 +81,7 @@ class MitmdumpManager:
async def stop_unlocked(self, proxy: ProxyDocument) -> dict[str, object]: async def stop_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
process = self.processes.pop(proxy.proxy_port, None) 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: if process is not None and process.returncode is None:
await self.stop_process(process) await self.stop_process(process)
elif proxy.pid is not None: elif proxy.pid is not None:
@@ -98,19 +101,70 @@ class MitmdumpManager:
if process.returncode is None: if process.returncode is None:
return "running" return "running"
self.processes.pop(proxy.proxy_port, None) self.processes.pop(proxy.proxy_port, None)
self.attached_pids.pop(proxy.proxy_port, None)
return "stopped" return "stopped"
if proxy.pid is None: pid = self.attached_pids.get(proxy.proxy_port, proxy.pid)
if pid is None:
return "stopped" if proxy.status == "running" else proxy.status return "stopped" if proxy.status == "running" else proxy.status
try: if not self.is_process_alive(pid):
os.kill(proxy.pid, 0) self.attached_pids.pop(proxy.proxy_port, None)
except ProcessLookupError: return "stopped"
if not self.matches_proxy_process(proxy, pid):
self.attached_pids.pop(proxy.proxy_port, None)
return "stopped" return "stopped"
except PermissionError:
return "running"
return "running" 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: async def prepare_upstream(self, proxy: ProxyDocument) -> int | None:
if proxy.mode_spec is not None or proxy.mode != "reverse": if proxy.mode_spec is not None or proxy.mode != "reverse":
return None return None
@@ -126,6 +180,8 @@ class MitmdumpManager:
Path(proxy.service.folder), Path(proxy.service.folder),
proxy.service.port, proxy.service.port,
upstream_port, upstream_port,
host_to="127.0.0.1",
rewrite_host=True,
) )
if not changed: if not changed:
raise MitmdumpError("Unable to move Docker service port") raise MitmdumpError("Unable to move Docker service port")
@@ -135,19 +191,21 @@ class MitmdumpManager:
self, self,
proxy: ProxyDocument, proxy: ProxyDocument,
upstream_port: int | None, upstream_port: int | None,
) -> None: ) -> bool:
if upstream_port is None: if upstream_port is None:
return return True
if proxy.service.name is None or proxy.service.folder is None: if proxy.service.name is None or proxy.service.folder is None:
return return True
if upstream_port == proxy.service.port: if upstream_port == proxy.service.port:
return return True
await docker.change_port( return await docker.change_port(
proxy.service.name, proxy.service.name,
Path(proxy.service.folder), Path(proxy.service.folder),
upstream_port, upstream_port,
proxy.service.port, proxy.service.port,
host_to=proxy.service.bind_host,
rewrite_host=True,
) )
def build_command( def build_command(
@@ -200,20 +258,20 @@ class MitmdumpManager:
return proxy.mode in {"regular", "socks5", "dns", "transparent"} return proxy.mode in {"regular", "socks5", "dns", "transparent"}
def write_script(self, proxy: ProxyDocument) -> Path: def write_script(self, proxy: ProxyDocument) -> Path:
path = self.work_dir / "addons" / f"{proxy.proxy_port}.py" path = self.script_path_for(proxy)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(proxy.code, encoding="utf-8") path.write_text(proxy.code, encoding="utf-8")
return path return path
def write_config(self, proxy: ProxyDocument) -> Path: def write_config(self, proxy: ProxyDocument) -> Path:
path = self.work_dir / "conf" / str(proxy.proxy_port) / "config.yaml" path = self.config_path_for(proxy)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
options = {"block_global": False, **proxy.mitm_options} options = {"block_global": False, **proxy.mitm_options}
path.write_text(yaml.safe_dump(options, sort_keys=True), encoding="utf-8") path.write_text(yaml.safe_dump(options, sort_keys=True), encoding="utf-8")
return path return path
def log_path(self, proxy: ProxyDocument) -> Path: def log_path(self, proxy: ProxyDocument) -> Path:
path = self.work_dir / "logs" / f"{proxy.proxy_port}.log" path = self.log_path_for(proxy)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
return path return path
@@ -227,19 +285,7 @@ class MitmdumpManager:
) )
async def stop_process(self, process) -> None: async def stop_process(self, process) -> None:
try: await self.stop_pid(process.pid)
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
return
try:
await asyncio.wait_for(process.wait(), timeout=5)
except TimeoutError:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
return
await process.wait()
async def exited_early(self, process) -> bool: async def exited_early(self, process) -> bool:
try: try:
@@ -250,21 +296,94 @@ class MitmdumpManager:
async def stop_pid(self, pid: int) -> None: async def stop_pid(self, pid: int) -> None:
try: try:
os.kill(pid, signal.SIGTERM) pgid = os.getpgid(pid)
except ProcessLookupError: except ProcessLookupError:
return return
except PermissionError:
pgid = pid
for _ in range(10): try:
await asyncio.sleep(0.2) os.killpg(pgid, signal.SIGTERM)
except ProcessLookupError:
return
except PermissionError:
try: try:
os.kill(pid, 0) os.kill(pid, signal.SIGTERM)
except ProcessLookupError: except ProcessLookupError:
return return
for _ in range(10):
await asyncio.sleep(0.2)
if not self.is_process_alive(pid):
return
try: try:
os.kill(pid, signal.SIGKILL) os.killpg(pgid, signal.SIGKILL)
except ProcessLookupError: except ProcessLookupError:
return 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]:
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]
def script_path_for(self, proxy: ProxyDocument) -> Path:
return self.work_dir / "addons" / f"{proxy.proxy_port}.py"
def conf_dir_for(self, proxy: ProxyDocument) -> Path:
return self.work_dir / "conf" / str(proxy.proxy_port)
def config_path_for(self, proxy: ProxyDocument) -> Path:
return self.conf_dir_for(proxy) / "config.yaml"
def log_path_for(self, proxy: ProxyDocument) -> Path:
return self.work_dir / "logs" / f"{proxy.proxy_port}.log"
def read_log_tail(self, path: Path, limit: int = 4000) -> str: def read_log_tail(self, path: Path, limit: int = 4000) -> str:
if not path.exists(): if not path.exists():
@@ -0,0 +1,16 @@
from repositories.proxy import list_proxies, update_proxy_runtime
from 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
+32
View File
@@ -52,6 +52,38 @@ def test_list_proxies_returns_documents(client, monkeypatch: object):
assert [item["proxy_port"] for item in body] == [8080, 8081] 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() == {
"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): def test_get_proxy_returns_404_when_not_found(client, monkeypatch: object):
async def fake_get_proxy_by_port(proxy_port: int) -> None: async def fake_get_proxy_by_port(proxy_port: int) -> None:
return None return None
+2 -2
View File
@@ -13,7 +13,7 @@ os.environ.setdefault("MONGODB_DB_NAME", "theplug")
os.environ.setdefault("REDIS_URL", "redis://localhost:6379") os.environ.setdefault("REDIS_URL", "redis://localhost:6379")
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key") os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key")
os.environ.setdefault("JWT_ALGORITHM", "HS256") os.environ.setdefault("JWT_ALGORITHM", "HS256")
os.environ.setdefault("JWT_EXPIRE_MINUTES", "30") os.environ.setdefault("JWT_EXPIRE_MINUTES", "720")
os.environ.setdefault("AUTH_USERNAME", "admin") os.environ.setdefault("AUTH_USERNAME", "admin")
os.environ.setdefault("AUTH_PASSWORD", "admin") os.environ.setdefault("AUTH_PASSWORD", "admin")
@@ -33,7 +33,7 @@ def auth_context(fake_user):
from core.security import AuthContext from core.security import AuthContext
from models.auth import AccessTokenPayload from models.auth import AccessTokenPayload
exp = int((datetime.now(UTC) + timedelta(minutes=30)).timestamp()) exp = int((datetime.now(UTC) + timedelta(minutes=720)).timestamp())
payload = AccessTokenPayload(sub=fake_user.username, jti="test-jti", exp=exp) payload = AccessTokenPayload(sub=fake_user.username, jti="test-jti", exp=exp)
return AuthContext(token="test-token", user=fake_user, payload=payload) return AuthContext(token="test-token", user=fake_user, payload=payload)
@@ -0,0 +1,50 @@
from pathlib import Path
from integrations.docker.compose import get_all_services_in_dir
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 == []
@@ -0,0 +1,30 @@
from 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"
@@ -73,3 +73,97 @@ def test_running_document_without_pid_is_treated_as_stopped():
return await manager.status(proxy) return await manager.status(proxy)
assert asyncio.run(run()) == "stopped" 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,
}
+76
View File
@@ -0,0 +1,76 @@
import asyncio
from models.proxy import ProxyDocument
from models.service import ServiceEndpointDocument
from 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
+16
View File
@@ -0,0 +1,16 @@
from datetime import UTC, datetime
from core.security import create_access_token, decode_access_token
def test_create_access_token_uses_twelve_hour_default_window(monkeypatch):
monkeypatch.setattr("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
+23 -1
View File
@@ -17,7 +17,8 @@
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.1", "@codemirror/view": "^6.41.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"vue": "^3.5.26" "vue": "^3.5.26",
"vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
@@ -1131,6 +1132,12 @@
"@vue/shared": "3.5.33" "@vue/shared": "3.5.33"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/language-core": { "node_modules/@vue/language-core": {
"version": "3.2.8", "version": "3.2.8",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.8.tgz", "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.8.tgz",
@@ -1643,6 +1650,21 @@
} }
} }
}, },
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-tsc": { "node_modules/vue-tsc": {
"version": "3.2.8", "version": "3.2.8",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.8.tgz", "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.8.tgz",
+2 -1
View File
@@ -19,7 +19,8 @@
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.1", "@codemirror/view": "^6.41.1",
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"vue": "^3.5.26" "vue": "^3.5.26",
"vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
+5 -2
View File
@@ -2,6 +2,7 @@
import { onMounted, provide } from "vue"; import { onMounted, provide } from "vue";
import AppHeader from "./components/AppHeader.vue"; import AppHeader from "./components/AppHeader.vue";
import AppToast from "./components/AppToast.vue";
import LoginView from "./components/LoginView.vue"; import LoginView from "./components/LoginView.vue";
import ProxyList from "./components/ProxyList.vue"; import ProxyList from "./components/ProxyList.vue";
import ProxyWorkbench from "./components/ProxyWorkbench.vue"; import ProxyWorkbench from "./components/ProxyWorkbench.vue";
@@ -24,15 +25,17 @@ onMounted(() => {
v-model:username="plane.username" v-model:username="plane.username"
v-model:password="plane.password" v-model:password="plane.password"
:loading="plane.loading" :loading="plane.loading"
:error="plane.error" :error="plane.authMessage || plane.error"
@submit="plane.signIn" @submit="plane.signIn"
/> />
<main v-else class="app-shell"> <main v-else class="app-shell">
<AppToast v-if="plane.notice" :message="plane.notice" />
<AppHeader <AppHeader
:loading="plane.loading" :loading="plane.loading"
@refresh="plane.refreshAll" @refresh="plane.refreshAll"
@create="plane.newProxy" @create="plane.openCreateWizard()"
@logout="plane.signOut" @logout="plane.signOut"
/> />
+18 -3
View File
@@ -1,16 +1,26 @@
import type { import type {
AddonExample, AddonExample,
DockerService,
HealthResult, HealthResult,
MitmOption, MitmOption,
PythonEditorSession, PythonEditorSession,
ProxyDocument, ProxyDocument,
ProxyDraft, ProxyDraft,
ServiceDiscoveryResponse,
} from "./types"; } from "./types";
const TOKEN_KEY = "theplug.token"; const TOKEN_KEY = "theplug.token";
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api"; 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() { export function getToken() {
return localStorage.getItem(TOKEN_KEY); return localStorage.getItem(TOKEN_KEY);
} }
@@ -52,7 +62,9 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const body = await response.json().catch(() => null); const body = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
const detail = body?.detail ?? response.statusText; const detail = body?.detail ?? response.statusText;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); const message =
typeof detail === "string" ? detail : JSON.stringify(detail);
throw new ApiError(response.status, message);
} }
return body as T; return body as T;
} }
@@ -72,7 +84,10 @@ export async function logout() {
export const api = { export const api = {
proxies: () => request<ProxyDocument[]>("/proxy"), proxies: () => request<ProxyDocument[]>("/proxy"),
services: () => request<DockerService[]>("/proxy/services"), services: (scanRoot?: string) =>
request<ServiceDiscoveryResponse>(
`/proxy/services${scanRoot ? `?scan_root=${encodeURIComponent(scanRoot)}` : ""}`,
),
options: () => request<MitmOption[]>("/proxy/mitmproxy/options"), options: () => request<MitmOption[]>("/proxy/mitmproxy/options"),
examples: () => request<AddonExample[]>("/proxy/mitmproxy/examples"), examples: () => request<AddonExample[]>("/proxy/mitmproxy/examples"),
editorSession: () => editorSession: () =>
+14 -5
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import AppLogo from "./AppLogo.vue"; import AppLogo from "./AppLogo.vue";
import UiIcon from "./UiIcon.vue";
defineProps<{ defineProps<{
loading: boolean; loading: boolean;
@@ -22,13 +23,21 @@ const emit = defineEmits<{
</div> </div>
</div> </div>
<div class="header-meta"> <div class="header-meta">
<span class="team">mitmdump</span> <span v-if="loading" class="muted">saving</span>
<span v-if="loading" class="muted">applying changes</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="ghost" :disabled="loading" @click="emit('refresh')">Refresh</button> <button class="ghost button-icon" :disabled="loading" @click="emit('refresh')">
<button class="primary" @click="emit('create')">New</button> <UiIcon name="refresh" />
<button class="ghost" @click="emit('logout')">Logout</button> <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> </div>
</header> </header>
</template> </template>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
defineProps<{
message: string;
}>();
</script>
<template>
<div class="app-toast" role="status" aria-live="polite">
{{ message }}
</div>
</template>
+4 -15
View File
@@ -12,7 +12,6 @@ const emit = defineEmits<{
save: []; save: [];
}>(); }>();
const host = ref<HTMLElement | null>(null); const host = ref<HTMLElement | null>(null);
const status = ref("Connecting Python tools...");
const error = ref(""); const error = ref("");
let editor: EditorView | null = null; let editor: EditorView | null = null;
let languageClient: Awaited<ReturnType<typeof connectPythonLsp>>["client"] | null = let languageClient: Awaited<ReturnType<typeof connectPythonLsp>>["client"] | null =
@@ -110,15 +109,9 @@ async function mountEditor() {
const connection = await connectPythonLsp(session); const connection = await connectPythonLsp(session);
languageClient = connection.client; languageClient = connection.client;
languageSocket = connection.socket; languageSocket = connection.socket;
void languageClient.initializing.catch(() => {
void languageClient.initializing.then( error.value = "Python language tools are unavailable.";
() => { });
status.value = "Python tools ready";
},
() => {
status.value = "Python tools unavailable";
},
);
editor = new EditorView({ editor = new EditorView({
doc: model.value, doc: model.value,
@@ -141,17 +134,13 @@ async function mountEditor() {
const message = const message =
value instanceof Error ? value.message : "Unable to start the editor"; value instanceof Error ? value.message : "Unable to start the editor";
error.value = message; error.value = message;
status.value = "Python tools unavailable";
} }
} }
</script> </script>
<template> <template>
<div class="code-editor-wrap"> <div class="code-editor-wrap">
<div class="code-editor-status"> <p v-if="error" class="field-error editor-error">{{ error }}</p>
<span>{{ status }}</span>
<span v-if="error" class="field-error">{{ error }}</span>
</div>
<div ref="host" class="code-editor" /> <div ref="host" class="code-editor" />
</div> </div>
</template> </template>
+1 -3
View File
@@ -11,13 +11,11 @@ const plane = useControlPlaneContext();
<section class="editor-pane panel"> <section class="editor-pane panel">
<div class="panel-title"> <div class="panel-title">
<div> <div>
<p class="eyebrow">Code</p> <h2>Proxy script</h2>
<h2>Addon editor</h2>
</div> </div>
<div class="editor-meta"> <div class="editor-meta">
<span>{{ plane.codeLineCount }} lines</span> <span>{{ plane.codeLineCount }} lines</span>
<span>{{ plane.draft.mode }} / {{ plane.draft.protocol }}</span> <span>{{ plane.draft.mode }} / {{ plane.draft.protocol }}</span>
<button class="ghost" @click="plane.toggleEditor(false)">Close</button>
</div> </div>
</div> </div>
<div class="editor-shell"> <div class="editor-shell">
+8 -7
View File
@@ -7,23 +7,20 @@ const plane = useControlPlaneContext();
<template> <template>
<section class="panel stack-panel tool-panel"> <section class="panel stack-panel tool-panel">
<div class="panel-title"> <div class="panel-title">
<div> <div><h2>Templates</h2></div>
<p class="eyebrow">Templates</p>
<h2>Examples</h2>
</div>
</div> </div>
<div class="inline-grid"> <div class="inline-grid">
<input <input
v-model="plane.customExampleName" v-model="plane.customExampleName"
placeholder="Save current code as..." placeholder="Save current script as template..."
/> />
<button @click="plane.saveCurrentAsExample">Save</button> <button @click="plane.saveCurrentAsExample">Save</button>
</div> </div>
<div class="example-shell"> <div class="example-shell">
<div class="example-browser"> <div class="example-browser">
<input v-model="plane.exampleSearch" placeholder="Search examples" /> <input v-model="plane.exampleSearch" placeholder="Search templates" />
<div class="example-index"> <div class="example-index">
<button <button
@@ -35,6 +32,7 @@ const plane = useControlPlaneContext();
> >
<strong>{{ example.name }}</strong> <strong>{{ example.name }}</strong>
<small>{{ example.category }}</small> <small>{{ example.category }}</small>
<span class="example-snippet">{{ example.description }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -42,7 +40,6 @@ const plane = useControlPlaneContext();
<article v-if="plane.selectedExample" class="example-preview"> <article v-if="plane.selectedExample" class="example-preview">
<div class="option-doc-head"> <div class="option-doc-head">
<div> <div>
<p class="eyebrow">Selected</p>
<h3>{{ plane.selectedExample.name }}</h3> <h3>{{ plane.selectedExample.name }}</h3>
</div> </div>
<div class="template-meta"> <div class="template-meta">
@@ -55,6 +52,10 @@ const plane = useControlPlaneContext();
<p class="doc-copy">{{ plane.selectedExample.description }}</p> <p class="doc-copy">{{ plane.selectedExample.description }}</p>
<pre class="terminal template-preview-terminal">{{
plane.selectedExample.code
}}</pre>
<div class="doc-actions"> <div class="doc-actions">
<button @click="plane.applyExample(plane.selectedExample)">Use template</button> <button @click="plane.applyExample(plane.selectedExample)">Use template</button>
<a <a
+73 -70
View File
@@ -1,22 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { protocols, useControlPlaneContext } from "../composables/useControlPlane"; import { protocols, useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
embedded: false,
});
const plane = useControlPlaneContext(); const plane = useControlPlaneContext();
</script> </script>
<template> <template>
<section class="panel stack-panel compact-panel"> <section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel compact-panel'">
<div class="panel-title"> <div v-if="!props.embedded" class="panel-title">
<div> <div><h2>Checks</h2></div>
<p class="eyebrow">Health</p> <button
<h2>Checks</h2> class="button-icon"
</div> :disabled="!plane.selectedProxy"
<button :disabled="!plane.selectedProxy" @click="plane.checkHealth"> @click="plane.checkHealth"
Run >
<UiIcon name="refresh" />
<span>Run</span>
</button> </button>
</div> </div>
<div class="metric-grid"> <div v-if="!props.embedded" class="metric-grid">
<article class="metric-card"> <article class="metric-card">
<span class="metric-label">State</span> <span class="metric-label">State</span>
<strong>{{ plane.draft.healthcheck.enabled ? "Enabled" : "Disabled" }}</strong> <strong>{{ plane.draft.healthcheck.enabled ? "Enabled" : "Disabled" }}</strong>
@@ -35,67 +42,63 @@ const plane = useControlPlaneContext();
</article> </article>
</div> </div>
<details class="compact-details"> <div class="form-grid tight-grid">
<summary>Advanced check settings</summary> <label class="checkbox-row">
<input v-model="plane.draft.healthcheck.enabled" type="checkbox" />
<div class="form-grid tight-grid"> <span>Enabled</span>
<label class="checkbox-row"> </label>
<input v-model="plane.draft.healthcheck.enabled" type="checkbox" /> <label>
<span>Enabled</span> Protocol override
</label> <select v-model="plane.draft.healthcheck.protocol">
<label> <option :value="null">Use proxy protocol</option>
Protocol override <option v-for="protocol in protocols" :key="protocol" :value="protocol">
<select v-model="plane.draft.healthcheck.protocol"> {{ protocol }}
<option :value="null">Use proxy protocol</option> </option>
<option v-for="protocol in protocols" :key="protocol" :value="protocol"> </select>
{{ protocol }} </label>
</option> <label>
</select> Method
</label> <select v-model="plane.draft.healthcheck.method">
<label> <option>GET</option>
Method <option>HEAD</option>
<select v-model="plane.draft.healthcheck.method"> <option>POST</option>
<option>GET</option> </select>
<option>HEAD</option> </label>
<option>POST</option> <label>
</select> Path
</label> <input v-model="plane.draft.healthcheck.path" />
<label> </label>
Path <label>
<input v-model="plane.draft.healthcheck.path" /> Expected status
</label> <input
<label> v-model.number="plane.draft.healthcheck.expected_status"
Expected status type="number"
<input />
v-model.number="plane.draft.healthcheck.expected_status" </label>
type="number" <label>
/> Timeout
</label> <input
<label> v-model.number="plane.draft.healthcheck.timeout_seconds"
Timeout type="number"
<input step="0.5"
v-model.number="plane.draft.healthcheck.timeout_seconds" />
type="number" </label>
step="0.5" <label>
/> UDP payload
</label> <input
<label> v-model="plane.draft.healthcheck.udp_payload"
UDP payload placeholder="used for udp-like checks"
<input />
v-model="plane.draft.healthcheck.udp_payload" </label>
placeholder="used for udp-like checks" <label class="checkbox-row">
/> <input v-model="plane.draft.healthcheck.expect_response" type="checkbox" />
</label> <span>Expect response</span>
<label class="checkbox-row"> </label>
<input v-model="plane.draft.healthcheck.expect_response" type="checkbox" /> <label class="checkbox-row">
<span>Expect response</span> <input v-model="plane.draft.healthcheck.verify_tls" type="checkbox" />
</label> <span>Verify TLS</span>
<label class="checkbox-row"> </label>
<input v-model="plane.draft.healthcheck.verify_tls" type="checkbox" /> </div>
<span>Verify TLS</span>
</label>
</div>
</details>
<div v-if="plane.health" class="health-box" :class="plane.health.overall"> <div v-if="plane.health" class="health-box" :class="plane.health.overall">
<strong>{{ plane.health.overall }}</strong> <strong>{{ plane.health.overall }}</strong>
+4 -4
View File
@@ -20,16 +20,16 @@ const emit = defineEmits<{
<AppLogo /> <AppLogo />
<div> <div>
<p class="eyebrow">thePlug</p> <p class="eyebrow">thePlug</p>
<h1>Proxy control plane</h1> <h1>Sign in</h1>
<p class="muted">Manage routes, runtime state, and mitmdump addons from one console.</p> <p class="muted">Manage proxy routes, runtime state, and mitmdump scripts.</p>
</div> </div>
</div> </div>
<form @submit.prevent="emit('submit')"> <form @submit.prevent="emit('submit')">
<label>Username<input v-model="username" autocomplete="username" /></label> <label>Username<input v-model="username" autocomplete="username" /></label>
<label>Password<input v-model="password" type="password" autocomplete="current-password" /></label> <label>Password<input v-model="password" type="password" autocomplete="current-password" /></label>
<button class="primary" :disabled="loading">Login</button> <button class="primary" :disabled="loading">Sign in</button>
</form> </form>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error auth-alert">{{ error }}</p>
</section> </section>
</main> </main>
</template> </template>
+16 -5
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane"; import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext(); const plane = useControlPlaneContext();
</script> </script>
@@ -7,13 +8,23 @@ const plane = useControlPlaneContext();
<template> <template>
<section class="panel stack-panel tool-panel logs-panel"> <section class="panel stack-panel tool-panel logs-panel">
<div class="panel-title"> <div class="panel-title">
<div> <div><h2>Logs</h2></div>
<p class="eyebrow">Output</p> <div class="doc-actions">
<h2>Logs</h2> <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>
<button :disabled="!plane.selectedProxy" @click="plane.loadLogs">Load</button>
</div> </div>
<pre class="terminal">{{ plane.logs || "No logs loaded." }}</pre> <input v-model="plane.logFilter" placeholder="Filter logs" />
<pre class="terminal">{{
plane.filteredLogs || "Logs will appear here automatically."
}}</pre>
</section> </section>
</template> </template>
+277 -86
View File
@@ -1,6 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MitmOption } from "../types"; import type { MitmOption } from "../types";
import { useControlPlaneContext } from "../composables/useControlPlane"; import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
embedded: false,
});
const plane = useControlPlaneContext(); const plane = useControlPlaneContext();
@@ -27,65 +32,57 @@ function formatDefault(option?: MitmOption | null) {
</script> </script>
<template> <template>
<section class="panel stack-panel tool-panel"> <section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel tool-panel'">
<div class="panel-title"> <div v-if="!props.embedded" class="panel-title">
<div> <h2>Options</h2>
<p class="eyebrow">Mitmdump</p> <div class="title-actions">
<h2>Options</h2> <span class="counter">{{ plane.activeOptionCount }}</span>
</div> <div class="segmented">
<span class="counter">{{ plane.optionCount }}</span> <button
</div> :class="{ active: plane.optionScope === 'focused' }"
@click="plane.toggleOptionScope('focused')"
<div class="active-options compact-table"> >
<div class="table-head"> Common
<span>Active</span> </button>
<span>Value</span> <button
<span /> :class="{ active: plane.optionScope === 'all' }"
</div> @click="plane.toggleOptionScope('all')"
<div v-if="plane.activeOptions.length" class="table-body"> >
<div v-for="item in plane.activeOptions" :key="item.name" class="table-row"> All
<button class="table-link" @click="plane.selectOption(item.name)">
{{ item.name }}
</button> </button>
<label v-if="isBool(item.option)" class="inline-toggle">
<input
:checked="item.value === 'true'"
type="checkbox"
@change="
plane.updateOption(
item.name,
String(($event.target as HTMLInputElement).checked),
item.option,
)
"
/>
<span>{{ item.value }}</span>
</label>
<input
v-else
:value="item.value"
:type="isNumber(item.option) ? 'number' : 'text'"
:placeholder="isSequence(item.option) ? 'comma separated values' : ''"
@input="
plane.updateOption(
item.name,
($event.target as HTMLInputElement).value,
item.option,
)
"
/>
<button class="ghost" @click="plane.removeOption(item.name)">Remove</button>
</div> </div>
</div> </div>
<p v-else class="muted small">No active options.</p>
</div> </div>
<div class="option-shell"> <div v-if="props.embedded" class="option-step-layout">
<div class="option-browser"> <aside class="option-browser option-browser-embedded">
<input <div class="option-toolbar">
v-model="plane.optionSearch" <input v-model="plane.optionSearch" placeholder="Search options" />
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 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 class="option-table"> <div class="option-table">
<button <button
@@ -99,44 +96,238 @@ function formatDefault(option?: MitmOption | null) {
<small>{{ option.type }}</small> <small>{{ option.type }}</small>
</button> </button>
</div> </div>
</aside>
<div class="option-step-main">
<article v-if="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>
<article v-if="plane.selectedOption" class="option-doc"> <div class="option-shell">
<div class="option-doc-head"> <div class="option-browser">
<div> <div class="option-search-row">
<p class="eyebrow">Reference</p> <input v-model="plane.optionSearch" placeholder="Search options" />
<h3>{{ plane.selectedOption.name }}</h3> <span class="option-count">{{ plane.filteredOptions.length }}</span>
</div>
<p 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 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>
<span class="type-chip">{{ plane.selectedOption.type }}</span>
</div> </div>
<p class="doc-copy">{{ plane.selectedOption.description }}</p> <article v-if="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>
<div class="doc-facts"> <p class="doc-copy">{{ plane.selectedOption.description }}</p>
<span>Default: {{ formatDefault(plane.selectedOption) }}</span>
<span v-if="plane.selectedOption.choices.length">
Choices: {{ plane.selectedOption.choices.join(", ") }}
</span>
<span v-if="plane.selectedOption.reserved">Managed by thePlug</span>
</div>
<div class="doc-actions"> <dl class="doc-facts">
<button <div class="doc-fact">
:disabled="plane.selectedOption.reserved" <dt>Default</dt>
@click="plane.setOption(plane.selectedOption)" <dd>{{ formatDefault(plane.selectedOption) }}</dd>
> </div>
Add option <div
</button> v-if="plane.selectedOption.choices.length"
<a class="doc-fact"
v-if="plane.selectedOption.docs_url" >
:href="plane.selectedOption.docs_url" <dt>Choices</dt>
target="_blank" <dd>{{ plane.selectedOption.choices.join(", ") }}</dd>
rel="noreferrer" </div>
> <div v-if="plane.selectedOption.reserved" class="doc-fact">
Official docs <dt>State</dt>
</a> <dd>Managed by thePlug</dd>
</div> </div>
</article> </dl>
</div>
<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> </section>
</template> </template>
+2 -3
View File
@@ -16,15 +16,14 @@ const emit = defineEmits<{
<aside class="rail panel"> <aside class="rail panel">
<div class="panel-title"> <div class="panel-title">
<div> <div>
<p class="eyebrow">Inventory</p>
<h2>Proxies</h2> <h2>Proxies</h2>
</div> </div>
<span class="counter">{{ proxies.length }}</span> <span class="counter">{{ proxies.length }}</span>
</div> </div>
<div v-if="!proxies.length" class="empty-state"> <div v-if="!proxies.length" class="empty-state">
<strong>No proxies configured</strong> <strong>No proxies yet</strong>
<span>Create a draft, save it, then start it when you want mitmdump to own the route.</span> <span>Create one to start.</span>
</div> </div>
<div class="proxy-list"> <div class="proxy-list">
@@ -0,0 +1,73 @@
<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" @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>
<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>
+52 -102
View File
@@ -2,12 +2,11 @@
import { useControlPlaneContext } from "../composables/useControlPlane"; import { useControlPlaneContext } from "../composables/useControlPlane";
import EditorPane from "./EditorPane.vue"; import EditorPane from "./EditorPane.vue";
import ExamplesPanel from "./ExamplesPanel.vue"; import ExamplesPanel from "./ExamplesPanel.vue";
import HealthPanel from "./HealthPanel.vue";
import LogsPanel from "./LogsPanel.vue"; import LogsPanel from "./LogsPanel.vue";
import OptionsPanel from "./OptionsPanel.vue"; import ProxyWizardModal from "./ProxyWizardModal.vue";
import RuntimePanel from "./RuntimePanel.vue"; import ServiceDiscoveryPanel from "./ServiceDiscoveryPanel.vue";
import ServicePanel from "./ServicePanel.vue";
import StatusPill from "./StatusPill.vue"; import StatusPill from "./StatusPill.vue";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext(); const plane = useControlPlaneContext();
</script> </script>
@@ -16,141 +15,92 @@ const plane = useControlPlaneContext();
<section class="workbench"> <section class="workbench">
<header class="workbench-head panel"> <header class="workbench-head panel">
<div class="workbench-copy"> <div class="workbench-copy">
<p class="eyebrow">Workspace</p> <h1>{{ plane.selectedProxy ? plane.draft.name : "Proxy workspace" }}</h1>
<h1>{{ plane.draft.name || "Untitled proxy" }}</h1> <div v-if="plane.selectedProxy" class="route-row route-summary">
<div class="route-row">
<span class="route-token">{{ plane.routeState.configuredListen }}</span> <span class="route-token">{{ plane.routeState.configuredListen }}</span>
<span class="route-arrow">to</span> <span class="route-arrow">to</span>
<span class="route-target">{{ plane.routeState.configuredTarget }}</span> <span class="route-target">{{ plane.routeState.configuredTarget }}</span>
<StatusPill <StatusPill
v-if="plane.selectedProxy"
:status="plane.selectedProxy.status" :status="plane.selectedProxy.status"
/> />
</div> </div>
<p class="summary-copy">{{ plane.routeState.startNotice }}</p> <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.summaryHint" class="summary-copy">
{{ plane.routeState.summaryHint }}
</p>
<p v-if="plane.selectedProxy?.last_error" class="error compact"> <p v-if="plane.selectedProxy?.last_error" class="error compact">
{{ plane.selectedProxy.last_error }} {{ plane.selectedProxy.last_error }}
</p> </p>
</div> </div>
<div class="action-row"> <div class="action-row">
<button @click="plane.saveProxy" :disabled="plane.loading">Save</button>
<button <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')" @click="plane.runtimeAction('start')"
:disabled="!plane.selectedProxy || plane.loading" :disabled="!plane.actionState.canStart"
> >
Start <UiIcon name="play" />
<span>Start</span>
</button> </button>
<button <button
v-if="plane.actionState.showStop"
class="danger button-icon"
@click="plane.runtimeAction('stop')" @click="plane.runtimeAction('stop')"
:disabled="!plane.selectedProxy || plane.loading" :disabled="!plane.actionState.canStop"
> >
Stop <UiIcon name="stop" />
<span>Stop</span>
</button> </button>
<button <button
v-if="plane.actionState.showRestart"
class="warning button-icon"
@click="plane.runtimeAction('restart')" @click="plane.runtimeAction('restart')"
:disabled="!plane.selectedProxy || plane.loading" :disabled="!plane.actionState.canRestart"
> >
Restart <UiIcon name="restart" />
</button> <span>Restart</span>
<button class="ghost" @click="plane.toggleEditor()">
{{ plane.editorOpen ? "Hide editor" : "Open editor" }}
</button> </button>
<button <button
class="danger" v-if="plane.selectedProxy"
class="danger button-icon"
@click="plane.removeProxy" @click="plane.removeProxy"
:disabled="!plane.selectedProxy || plane.loading" :disabled="!plane.actionState.canDelete"
> >
Delete <UiIcon name="trash" />
<span>Delete</span>
</button> </button>
</div> </div>
</header> </header>
<p v-if="plane.error" class="error">{{ plane.error }}</p> <p v-if="plane.error" class="error">{{ plane.error }}</p>
<p v-if="plane.notice" class="notice">{{ plane.notice }}</p>
<section class="summary-grid"> <ServiceDiscoveryPanel v-if="!plane.selectedProxy && !plane.wizardOpen" />
<article class="panel summary-panel">
<div class="panel-title">
<div>
<p class="eyebrow">Route</p>
<h2>Runtime mapping</h2>
</div>
</div>
<div class="metric-grid">
<article class="metric-card">
<span class="metric-label">Listen</span>
<strong>{{ plane.routeState.configuredListen }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Configured upstream</span>
<strong>{{ plane.routeState.configuredTarget }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Live upstream</span>
<strong>{{ plane.routeState.liveTarget }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Mode</span>
<strong>{{ plane.routeState.modeLabel }}</strong>
</article>
</div>
<p class="inline-note">{{ plane.routeState.liveNotice }}</p>
</article>
<article class="panel summary-panel"> <div v-if="plane.selectedProxy" class="workspace-main-row">
<div class="panel-title"> <div class="workspace-editor-pane">
<div> <EditorPane />
<p class="eyebrow">Behaviour</p> </div>
<h2>What start does</h2> <div class="workspace-logs-pane">
</div> <LogsPanel />
</div>
<div class="summary-lines">
<span>Service source: {{ plane.routeState.serviceLabel }}</span>
<span>Save never remaps upstream ports.</span>
<span>Start is the only action that applies runtime routing.</span>
<span v-if="plane.routeState.remapped">
This proxy is currently holding the original public service port.
</span>
</div>
</article>
</section>
<div class="workbench-grid">
<div class="overview-stack">
<RuntimePanel />
<ServicePanel />
<HealthPanel />
</div> </div>
<section class="utility-panel">
<div class="tool-switcher">
<button
:class="{ active: plane.toolView === 'options' }"
@click="plane.setToolView('options')"
>
Options
</button>
<button
:class="{ active: plane.toolView === 'examples' }"
@click="plane.setToolView('examples')"
>
Examples
</button>
<button
:class="{ active: plane.toolView === 'logs' }"
@click="plane.setToolView('logs')"
>
Logs
</button>
</div>
<OptionsPanel v-if="plane.toolView === 'options'" />
<ExamplesPanel v-else-if="plane.toolView === 'examples'" />
<LogsPanel v-else />
</section>
</div> </div>
<EditorPane v-if="plane.editorOpen" /> <div v-if="plane.selectedProxy" class="workspace-templates-row">
<ExamplesPanel />
</div>
<ProxyWizardModal />
</section> </section>
</template> </template>
+65 -18
View File
@@ -1,26 +1,69 @@
<script setup lang="ts"> <script setup lang="ts">
import { modes, protocols, useControlPlaneContext } from "../composables/useControlPlane"; import { modes, protocols, useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
embedded: false,
});
const plane = useControlPlaneContext(); const plane = useControlPlaneContext();
</script> </script>
<template> <template>
<section class="panel stack-panel compact-panel"> <section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel compact-panel'">
<div class="panel-title"> <div v-if="!props.embedded" class="panel-title">
<div> <h2>Proxy route</h2>
<p class="eyebrow">Runtime</p>
<h2>Proxy settings</h2>
</div>
</div> </div>
<div class="form-grid tight-grid"> <div class="route-strip">
<span class="route-chip">{{ plane.routeState.configuredListen }}</span>
<span class="route-arrow">to</span>
<span class="route-chip">{{ plane.routeState.configuredTarget }}</span>
<span class="route-arrow">live</span>
<span class="route-chip">{{ plane.routeState.liveTarget }}</span>
</div>
<div class="route-overview-grid">
<article class="metric-card">
<span class="metric-label">Service</span>
<strong>{{ plane.routeState.serviceLabel }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Mode</span>
<strong>{{ plane.routeState.modeLabel }}</strong>
</article>
</div>
<div class="form-grid route-form-grid tight-grid single-row-grid">
<label> <label>
Name Name
<input v-model="plane.draft.name" /> <input v-model="plane.draft.name" />
</label> </label>
</div>
<div class="form-grid tight-grid">
<label> <label>
Proxy port Proxy port
<input v-model.number="plane.draft.proxy_port" type="number" /> <input
v-model.number="plane.draft.proxy_port"
:disabled="Boolean(plane.selectedProxy)"
type="number"
/>
<small v-if="plane.selectedProxy">
Port is fixed for existing proxies. Create a new proxy to change it.
</small>
</label>
<label>
Upstream host
<input v-model="plane.draft.service.host" />
</label>
<label>
Upstream port
<input v-model.number="plane.draft.service.port" type="number" />
</label>
<label>
Listen host
<input v-model="plane.draft.listen_host" />
</label> </label>
<label> <label>
Mode Mode
@@ -34,21 +77,25 @@ const plane = useControlPlaneContext();
<option v-for="protocol in protocols" :key="protocol">{{ protocol }}</option> <option v-for="protocol in protocols" :key="protocol">{{ protocol }}</option>
</select> </select>
</label> </label>
<label> <label class="route-mode-spec">
Listen host
<input v-model="plane.draft.listen_host" />
</label>
<label>
Mode spec Mode spec
<input <input
v-model="plane.draft.mode_spec" v-model="plane.draft.mode_spec"
placeholder="optional raw mitmproxy mode" placeholder="optional raw mitmdump mode"
/> />
</label> </label>
<div
v-if="plane.routeState.takeoverMismatch"
class="inline-grid advanced-inline-action"
>
<p class="muted small compact-copy">
Proxy port differs from the original service port.
</p>
<button class="ghost button-icon" @click="plane.matchProxyPortToService">
<UiIcon name="link" />
<span>Use service port</span>
</button>
</div>
</div> </div>
<p class="inline-note">
Save stores this configuration. Start launches mitmdump with the selected mode.
</p>
</section> </section>
</template> </template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
import StatusPill from "./StatusPill.vue";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel compact-panel">
<div class="panel-title">
<h2>Services</h2>
<button class="ghost button-icon" :disabled="plane.servicesLoading" @click="plane.refreshServices">
<UiIcon name="refresh" />
<span>{{ plane.servicesLoading ? "Scanning" : "Refresh" }}</span>
</button>
</div>
<div class="inline-grid">
<label>
Scan root
<input v-model="plane.scanRoot" placeholder="/root" />
</label>
<button class="primary button-icon" :disabled="plane.servicesLoading" @click="plane.refreshServices">
<UiIcon name="refresh" />
<span>Scan</span>
</button>
</div>
<p v-if="plane.servicesLoading" class="muted small compact-copy">
Scanning compose folders in {{ plane.scanRoot }}...
</p>
<p v-else-if="!plane.serviceEntries.length" class="muted small compact-copy">
No compose services detected in the scan root.
</p>
<div v-else class="service-discovery-list">
<article
v-for="entry in plane.serviceEntries"
:key="entry.id"
class="service-discovery-row"
>
<div class="service-discovery-main">
<strong>{{ entry.service.name }}</strong>
<span class="service-route-copy">
{{ entry.listenHost }}:{{ entry.binding.published }}
</span>
<small class="service-folder">{{ entry.service.folder }}</small>
</div>
<div class="service-discovery-actions">
<template v-if="entry.proxy">
<StatusPill :status="entry.proxy.status" />
<button class="ghost button-icon" @click="plane.selectedPort = entry.proxy.proxy_port">
<UiIcon name="link" />
<span>Open proxy</span>
</button>
<button
class="ghost button-icon"
@click="plane.openEditWizard(entry.proxy)"
>
<UiIcon name="settings" />
<span>Edit</span>
</button>
</template>
<button
v-else
class="primary button-icon"
:disabled="plane.loading"
@click="plane.openCreateWizard(entry.service, entry.binding)"
>
<UiIcon name="plus" />
<span>Create proxy</span>
</button>
</div>
</article>
</div>
</section>
</template>
-68
View File
@@ -1,68 +0,0 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel compact-panel">
<div class="panel-title">
<div>
<p class="eyebrow">Upstream</p>
<h2>Service route</h2>
</div>
</div>
<div class="metric-grid">
<article class="metric-card">
<span class="metric-label">Configured target</span>
<strong>{{ plane.routeState.configuredTarget }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Live upstream</span>
<strong>{{ plane.routeState.liveTarget }}</strong>
</article>
</div>
<div class="form-grid tight-grid">
<label>
Host
<input v-model="plane.draft.service.host" />
</label>
<label>
Port
<input v-model.number="plane.draft.service.port" type="number" />
</label>
</div>
<p class="inline-note">{{ plane.routeState.liveNotice }}</p>
<div v-if="plane.services.length" class="service-grid">
<div
v-for="service in plane.services"
:key="service.name + service.folder"
class="service-chip"
>
<strong>{{ service.name }}</strong>
<small>{{ service.folder }}</small>
<div class="port-row">
<button
v-for="port in service.ports"
:key="port"
class="port-chip"
:class="{
active:
plane.draft.service.name === service.name &&
plane.draft.service.folder === service.folder &&
plane.draft.service.port === port,
}"
@click="plane.applyService(service, port)"
>
{{ port }}
</button>
</div>
</div>
</div>
<p v-else class="muted small">No Docker services detected by the backend.</p>
</section>
</template>
+184
View File
@@ -0,0 +1,184 @@
<script setup lang="ts">
defineProps<{
name:
| "save"
| "play"
| "stop"
| "restart"
| "trash"
| "plus"
| "refresh"
| "logout"
| "terminal"
| "link"
| "spark"
| "close"
| "back"
| "next"
| "settings"
| "pause"
| "resume"
| "clear";
}>();
</script>
<template>
<svg
v-if="name === 'save'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M3 2.5h8l2.5 2.5v8.5H2.5v-11z" />
<path d="M5 2.5v4h5v-4" />
<path d="M5 11h6" />
</svg>
<svg
v-else-if="name === 'play'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M5 3.5l7 4.5-7 4.5z" />
</svg>
<svg
v-else-if="name === 'stop'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<rect x="4" y="4" width="8" height="8" />
</svg>
<svg
v-else-if="name === 'restart'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M12 5V2.5L14 4.5 12 6.5V5h-1.5A4.5 4.5 0 106.6 12" />
</svg>
<svg
v-else-if="name === 'trash'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M3.5 4.5h9" />
<path d="M6 4.5v-2h4v2" />
<path d="M5 4.5l.5 8h5l.5-8" />
</svg>
<svg
v-else-if="name === 'plus'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M8 3v10" />
<path d="M3 8h10" />
</svg>
<svg
v-else-if="name === 'refresh'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M12 4.5V2l2 2-2 2V4.5H7.5a4 4 0 103.8 5.2" />
</svg>
<svg
v-else-if="name === 'logout'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M7 3H3.5v9H7" />
<path d="M9 5.5L12 8l-3 2.5" />
<path d="M5 8h7" />
</svg>
<svg
v-else-if="name === 'terminal'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M3 4.5l3 3-3 3" />
<path d="M7.5 11h4.5" />
</svg>
<svg
v-else-if="name === 'link'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M6.5 9.5l3-3" />
<path d="M5 11H4a2.5 2.5 0 010-5h2" />
<path d="M11 5h1a2.5 2.5 0 010 5H10" />
</svg>
<svg
v-else-if="name === 'close'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M4 4l8 8" />
<path d="M12 4l-8 8" />
</svg>
<svg
v-else-if="name === 'back'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M10.5 3.5L5.5 8l5 4.5" />
</svg>
<svg
v-else-if="name === 'next'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M5.5 3.5L10.5 8l-5 4.5" />
</svg>
<svg
v-else-if="name === 'settings'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M3.5 4.5h9" />
<path d="M3.5 8h9" />
<path d="M3.5 11.5h9" />
<circle cx="6" cy="4.5" r="1.1" />
<circle cx="10" cy="8" r="1.1" />
<circle cx="7.5" cy="11.5" r="1.1" />
</svg>
<svg
v-else-if="name === 'pause'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M5.5 3.5v9" />
<path d="M10.5 3.5v9" />
</svg>
<svg
v-else-if="name === 'resume'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M5 3.5l7 4.5-7 4.5z" />
</svg>
<svg
v-else-if="name === 'clear'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M4 4l8 8" />
<path d="M12 4l-8 8" />
<path d="M3.5 12.5h9" />
</svg>
<svg v-else viewBox="0 0 16 16" aria-hidden="true" class="ui-icon">
<path d="M8 2.5l1.1 2.3 2.4.3-1.8 1.8.4 2.6L8 8.3 5.9 9.5l.4-2.6-1.8-1.8 2.4-.3z" />
</svg>
</template>
+581 -72
View File
@@ -7,19 +7,38 @@ import {
watch, watch,
type InjectionKey, type InjectionKey,
} from "vue"; } from "vue";
import { useRoute, useRouter } from "vue-router";
import { api, getToken, login, logout } from "../api"; import { ApiError, api, getToken, login, logout, setToken } from "../api";
import type { import type {
AddonExample, AddonExample,
DockerService, DockerService,
DockerServiceBinding,
HealthResult, HealthResult,
MitmOption, MitmOption,
ProxyDocument, ProxyDocument,
ProxyDraft, ProxyDraft,
} from "../types"; } from "../types";
const editorStorageKey = "theplug.editor.open"; const optionScopeStorageKey = "theplug.option.scope";
const toolStorageKey = "theplug.tool.view"; const scanRootStorageKey = "theplug.scan.root";
const focusedMitmOptions = new Set([
"anticache",
"block_global",
"connection_strategy",
"flow_detail",
"http2",
"http3",
"keep_host_header",
"rawtcp",
"showhost",
"ssl_insecure",
"termlog_verbosity",
"upstream_cert",
"validate_inbound_headers",
"websocket",
]);
export const modes = [ export const modes = [
"reverse", "reverse",
@@ -46,7 +65,7 @@ export const protocols = [
]; ];
export type RuntimeAction = "start" | "stop" | "restart"; export type RuntimeAction = "start" | "stop" | "restart";
export type ToolView = "options" | "examples" | "logs"; export type OptionScope = "focused" | "all";
export function createDefaultDraft(port = 8080, code?: string): ProxyDraft { export function createDefaultDraft(port = 8080, code?: string): ProxyDraft {
return { return {
@@ -82,6 +101,7 @@ def request(flow: http.HTTPFlow) -> None:
service: { service: {
host: "127.0.0.1", host: "127.0.0.1",
port: 9000, port: 9000,
bind_host: null,
name: null, name: null,
folder: null, folder: null,
upstream_port: null, upstream_port: null,
@@ -99,13 +119,48 @@ function writeSession(key: string, value: string) {
window.sessionStorage.setItem(key, value); window.sessionStorage.setItem(key, value);
} }
function parseProxyPort(value: unknown): number | null {
const port = Number(value);
return Number.isInteger(port) && port > 0 ? port : null;
}
function parseWizardMode(value: unknown): "create" | "edit" | null {
return value === "create" || value === "edit" ? value : null;
}
function parseWizardStep(value: unknown): number {
const step = Number(value);
return step >= 0 && step <= 2 ? step : 0;
}
function sameQuery(
current: Record<string, unknown>,
expected: Record<string, string | undefined>,
) {
const keys = new Set([...Object.keys(current), ...Object.keys(expected)]);
for (const key of keys) {
const currentValue = current[key];
const normalizedCurrent = Array.isArray(currentValue)
? String(currentValue[0] ?? "")
: typeof currentValue === "string"
? currentValue
: undefined;
if (normalizedCurrent !== expected[key]) return false;
}
return true;
}
export function useControlPlane() { export function useControlPlane() {
const router = useRouter();
const route = useRoute();
const token = ref(getToken()); const token = ref(getToken());
const username = ref("admin"); const username = ref("admin");
const password = ref(""); const password = ref("");
const authMessage = ref("");
const error = ref(""); const error = ref("");
const notice = ref(""); const notice = ref("");
const loading = ref(false); const loading = ref(false);
const servicesLoading = ref(false);
const proxies = ref<ProxyDocument[]>([]); const proxies = ref<ProxyDocument[]>([]);
const services = ref<DockerService[]>([]); const services = ref<DockerService[]>([]);
const options = ref<MitmOption[]>([]); const options = ref<MitmOption[]>([]);
@@ -113,21 +168,153 @@ export function useControlPlane() {
const selectedPort = ref<number | null>(null); const selectedPort = ref<number | null>(null);
const health = ref<HealthResult | null>(null); const health = ref<HealthResult | null>(null);
const logs = ref(""); const logs = ref("");
const logFilter = ref("");
const logsPaused = ref(false);
const optionSearch = ref(""); const optionSearch = ref("");
const exampleSearch = ref(""); const exampleSearch = ref("");
const customExampleName = ref(""); const customExampleName = ref("");
const editorOpen = ref(readSession(editorStorageKey, "0") === "1"); const scanRoot = ref(readSession(scanRootStorageKey, "/root"));
const toolView = ref<ToolView>( const optionScope = ref<OptionScope>(
readSession(toolStorageKey, "options") as ToolView, readSession(optionScopeStorageKey, "focused") as OptionScope,
); );
const selectedOptionName = ref(""); const selectedOptionName = ref("");
const selectedExampleId = ref(""); const selectedExampleId = ref("");
const draft = reactive<ProxyDraft>(createDefaultDraft()); const draft = reactive<ProxyDraft>(createDefaultDraft());
const loadedPort = ref<number | null>(null);
const wizardOpen = ref(false);
const wizardMode = ref<"create" | "edit">("create");
const wizardStep = ref(0);
let noticeTimer: number | null = null;
let logPollTimer: number | null = null;
let syncingRoute = false;
const selectedProxy = computed(() => const selectedProxy = computed(() =>
proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value), proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value),
); );
const runtimeStatus = computed(() => selectedProxy.value?.status ?? "draft");
const isRunning = computed(() =>
["running", "starting", "restarting"].includes(runtimeStatus.value),
);
const actionState = computed(() => ({
canSave: !loading.value && !isRunning.value,
canStart: !loading.value && Boolean(selectedProxy.value) && !isRunning.value,
canStop: !loading.value && Boolean(selectedProxy.value) && isRunning.value,
canRestart: !loading.value && Boolean(selectedProxy.value) && isRunning.value,
canDelete: !loading.value && Boolean(selectedProxy.value),
showSave: !isRunning.value,
showStart: Boolean(selectedProxy.value) && !isRunning.value,
showStop: Boolean(selectedProxy.value) && isRunning.value,
showRestart: Boolean(selectedProxy.value) && isRunning.value,
}));
const wizardTitle = computed(() =>
wizardMode.value === "edit" ? "Edit proxy settings" : "Create proxy",
);
const wizardStepTitle = computed(() => {
return ["Proxy route", "Healthchecks", "Custom mitmdump options"][
wizardStep.value
] ?? "Proxy route";
});
const canGoBackWizard = computed(() => wizardStep.value > 0);
const canGoNextWizard = computed(() => wizardStep.value < 2);
const filteredLogs = computed(() => {
const source = logs.value || "";
const query = logFilter.value.trim().toLowerCase();
if (!query) return source;
return source
.split("\n")
.filter((line) => line.toLowerCase().includes(query))
.join("\n");
});
function buildRouteQuery() {
const query: Record<string, string | undefined> = {
scanRoot: scanRoot.value.trim() && scanRoot.value.trim() !== "/root"
? scanRoot.value.trim()
: undefined,
};
if (wizardOpen.value) {
query.modal = wizardMode.value;
query.step = String(wizardStep.value);
}
return query;
}
async function syncRouteState() {
if (syncingRoute) return;
const path = selectedPort.value ? `/proxies/${selectedPort.value}` : "/proxies";
const query = buildRouteQuery();
if (route.path === path && sameQuery(route.query as Record<string, unknown>, query)) {
return;
}
syncingRoute = true;
try {
await router.replace({ path, query });
} finally {
syncingRoute = false;
}
}
function applyRouteState() {
if (syncingRoute) return;
syncingRoute = true;
try {
selectedPort.value = parseProxyPort(route.params.proxyPort);
const routedScanRoot =
typeof route.query.scanRoot === "string" ? route.query.scanRoot : null;
scanRoot.value = routedScanRoot || readSession(scanRootStorageKey, "/root");
const modal = parseWizardMode(route.query.modal);
const step = parseWizardStep(route.query.step);
if (modal === "create") {
prepareDraftForCreate();
wizardMode.value = "create";
wizardStep.value = step;
wizardOpen.value = true;
} else if (modal === "edit" && selectedPort.value !== null) {
wizardMode.value = "edit";
wizardStep.value = step;
wizardOpen.value = true;
} else {
wizardOpen.value = false;
wizardStep.value = 0;
}
} finally {
syncingRoute = false;
}
}
const serviceEntries = computed(() =>
services.value.flatMap((service) =>
bindingsFor(service).map((binding) => {
const proxy =
proxies.value.find(
(item) =>
item.service.name === service.name &&
item.service.folder === service.folder &&
item.service.port === binding.published,
) ?? null;
return {
id: `${service.folder}:${service.name}:${binding.host ?? "*"}:${binding.published}`,
service,
binding,
proxy,
listenHost: binding.host ?? "0.0.0.0",
};
}),
),
);
const missingServiceCount = computed(
() => serviceEntries.value.filter((entry) => !entry.proxy).length,
);
const routeState = computed(() => { const routeState = computed(() => {
const livePort = const livePort =
selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null; selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null;
@@ -136,27 +323,43 @@ export function useControlPlane() {
const remapped = livePort !== null && livePort !== draft.service.port; const remapped = livePort !== null && livePort !== draft.service.port;
const configuredTarget = `${draft.service.host}:${draft.service.port}`; const configuredTarget = `${draft.service.host}:${draft.service.port}`;
const liveTarget = `${draft.service.host}:${livePort ?? draft.service.port}`; const liveTarget = `${draft.service.host}:${livePort ?? draft.service.port}`;
const takeoverReady =
reverseManaged && dockerManaged && draft.proxy_port === draft.service.port;
const takeoverMismatch =
reverseManaged && dockerManaged && draft.proxy_port !== draft.service.port;
let startNotice = "Save stores configuration only. Start applies runtime routing."; let startNotice: string | null = null;
if (reverseManaged && dockerManaged) { if (takeoverReady) {
startNotice = startNotice =
`Starting this reverse proxy will move the service from :${draft.service.port} ` + `Start will move the real service from :${draft.service.port} to a temporary upstream port and put mitmdump on :${draft.proxy_port}.`;
`to a temporary upstream port and bind mitmdump on :${draft.proxy_port}.`; } else if (takeoverMismatch) {
startNotice =
`Start will move the real service away from :${draft.service.port}, but this proxy is listening on :${draft.proxy_port}. Set the proxy port to :${draft.service.port} if you want mitmdump to replace the original route.`;
} else if (draft.mode_spec) { } else if (draft.mode_spec) {
startNotice = startNotice =
"This proxy uses a raw mode specification. Start will follow the custom mitmdump mode exactly as configured."; "Raw mode spec is enabled. Start will use the exact mitmdump mode string you configured.";
} else if (draft.mode !== "reverse") { } else if (draft.mode !== "reverse") {
startNotice = startNotice =
"This mode does not use Docker port remapping. Start only launches the configured mitmdump listener."; "This mode does not remap the Docker service port. Start only launches the configured mitmdump listener.";
} }
let liveNotice = "No runtime remap is active."; let liveNotice: string | null = null;
if (remapped) { if (remapped) {
liveNotice = `Live upstream is currently ${liveTarget}. Stop restores :${draft.service.port}.`; liveNotice = `Live upstream is currently ${liveTarget}. Stop restores :${draft.service.port}.`;
} else if (reverseManaged && dockerManaged) { } else if (takeoverMismatch) {
liveNotice = "The original service port stays unchanged until Start is executed."; liveNotice = "The service will be moved away on Start, but the original route is not taken over unless the proxy port matches the service port.";
} }
const summaryHint = takeoverMismatch
? startNotice
: remapped
? liveNotice
: draft.mode_spec
? startNotice
: draft.mode !== "reverse"
? startNotice
: null;
return { return {
configuredListen: `${draft.listen_host}:${draft.proxy_port}`, configuredListen: `${draft.listen_host}:${draft.proxy_port}`,
configuredTarget, configuredTarget,
@@ -167,26 +370,35 @@ export function useControlPlane() {
reverseManaged, reverseManaged,
remapped, remapped,
livePort, livePort,
takeoverReady,
takeoverMismatch,
startNotice, startNotice,
liveNotice, liveNotice,
summaryHint,
}; };
}); });
const filteredOptions = computed(() => { const filteredOptions = computed(() => {
const query = optionSearch.value.toLowerCase().trim(); const query = optionSearch.value.toLowerCase().trim();
const available = options.value.filter((option) => !option.reserved); const available = options.value.filter((option) => !option.reserved);
const filtered = available const scoped = query
? available
: optionScope.value === "all"
? available
: available.filter((option) => focusedMitmOptions.has(option.name));
const filtered = scoped
.filter((option) => { .filter((option) => {
if (!query) return true; if (!query) return true;
return `${option.name} ${option.description}`.toLowerCase().includes(query); return `${option.name} ${option.description}`.toLowerCase().includes(query);
}) })
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
return filtered.slice(0, query ? 80 : 36); return filtered.slice(0, query ? 80 : optionScope.value === "all" ? 80 : 18);
}); });
const optionCount = computed( const optionCount = computed(
() => options.value.filter((option) => !option.reserved).length, () => options.value.filter((option) => !option.reserved).length,
); );
const activeOptionCount = computed(() => Object.keys(draft.mitm_options).length);
const selectedOption = computed( const selectedOption = computed(
() => () =>
@@ -226,19 +438,43 @@ export function useControlPlane() {
const codeLineCount = computed(() => draft.code.split("\n").length); const codeLineCount = computed(() => draft.code.split("\n").length);
watch(selectedProxy, (proxy) => { watch(
if (!proxy) return; [selectedPort, proxies],
Object.assign(draft, structuredClone(proxy)); ([port]) => {
health.value = null; if (port === null || port === loadedPort.value) return;
logs.value = ""; const proxy = proxies.value.find((item) => item.proxy_port === port);
if (!proxy) return;
hydrateDraft(proxy);
},
{ immediate: true },
);
watch(
() => [
route.params.proxyPort,
route.query.modal,
route.query.step,
route.query.scanRoot,
],
() => {
applyRouteState();
},
{ immediate: true },
);
watch(
[selectedPort, wizardOpen, wizardMode, wizardStep, scanRoot],
() => {
void syncRouteState();
},
);
watch(optionScope, (value) => {
writeSession(optionScopeStorageKey, value);
}); });
watch(editorOpen, (value) => { watch(scanRoot, (value) => {
writeSession(editorStorageKey, value ? "1" : "0"); writeSession(scanRootStorageKey, value);
});
watch(toolView, (value) => {
writeSession(toolStorageKey, value);
}); });
watch(filteredOptions, (items) => { watch(filteredOptions, (items) => {
@@ -253,60 +489,196 @@ export function useControlPlane() {
} }
}, { immediate: true }); }, { immediate: true });
watch(selectedProxy, (proxy) => {
if (!proxy) return;
if (!wizardOpen.value) {
hydrateDraft(proxy);
}
});
watch(
[selectedPort, token, logsPaused],
([port, authToken, paused]) => {
stopLogPolling();
if (!authToken || port === null || paused) return;
void syncLogs(true);
if (typeof window === "undefined") return;
logPollTimer = window.setInterval(() => {
void syncLogs(true);
}, 2000);
},
{ immediate: true },
);
async function signIn() { async function signIn() {
await run(async () => { await run(async () => {
await login(username.value, password.value); await login(username.value, password.value);
token.value = getToken(); token.value = getToken();
authMessage.value = "";
password.value = ""; password.value = "";
await refreshAll(); await refreshAll();
}, "Logged in"); }, "Logged in");
} }
async function signOut() { async function signOut() {
await logout(); if (token.value) {
token.value = null; await logout();
proxies.value = []; }
clearSession();
} }
async function refreshAll() { async function refreshAll() {
await run(async () => { await run(async () => {
const [proxyList, serviceList, optionList, exampleList] = await Promise.all([ servicesLoading.value = true;
api.proxies(), try {
api.services(), const [proxyList, serviceResponse, optionList, exampleList] =
api.options(), await Promise.all([
api.examples(), api.proxies(),
]); api.services(scanRoot.value.trim() || undefined),
proxies.value = proxyList; api.options(),
services.value = serviceList; api.examples(),
options.value = optionList; ]);
examples.value = exampleList; proxies.value = proxyList;
if (!selectedPort.value && proxyList[0]) { services.value = serviceResponse.services;
selectedPort.value = proxyList[0].proxy_port; scanRoot.value = serviceResponse.scan_root;
options.value = optionList;
examples.value = exampleList;
if (!proxyList.some((proxy) => proxy.proxy_port === selectedPort.value)) {
selectedPort.value = null;
}
} finally {
servicesLoading.value = false;
} }
}); });
} }
function newProxy() { async function refreshServices() {
servicesLoading.value = true;
error.value = "";
try {
const response = await api.services(scanRoot.value.trim() || undefined);
scanRoot.value = response.scan_root;
services.value = response.services;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
servicesLoading.value = false;
}
}
function prepareDraftForCreate(announce = false) {
selectedPort.value = null; selectedPort.value = null;
loadedPort.value = null;
health.value = null; health.value = null;
logs.value = ""; logs.value = "";
logFilter.value = "";
Object.assign( Object.assign(
draft, draft,
createDefaultDraft(nextPort(), examples.value[0]?.code ?? draft.code), createDefaultDraft(nextPort(), examples.value[0]?.code ?? draft.code),
); );
notice.value = "New proxy draft created. Save stores configuration only."; if (announce) {
toolView.value = "options"; notice.value = "Draft created.";
}
}
function newProxy() {
prepareDraftForCreate(true);
}
function openCreateWizard(
service?: DockerService,
binding?: DockerServiceBinding,
) {
prepareDraftForCreate();
if (service && binding) {
Object.assign(draft, draftForService(service, binding));
}
wizardMode.value = "create";
wizardStep.value = 0;
wizardOpen.value = true;
void syncRouteState();
}
function openEditWizard(proxy = selectedProxy.value) {
if (!proxy) return;
selectedPort.value = proxy.proxy_port;
hydrateDraft(proxy);
wizardMode.value = "edit";
wizardStep.value = 0;
wizardOpen.value = true;
void syncRouteState();
}
function closeWizard() {
if (wizardMode.value === "edit" && selectedProxy.value) {
hydrateDraft(selectedProxy.value);
} else if (wizardMode.value === "create") {
prepareDraftForCreate();
}
wizardOpen.value = false;
wizardStep.value = 0;
void syncRouteState();
}
function nextWizardStep() {
wizardStep.value = Math.min(wizardStep.value + 1, 2);
void syncRouteState();
}
function previousWizardStep() {
wizardStep.value = Math.max(wizardStep.value - 1, 0);
void syncRouteState();
}
async function createProxyFromService(
service: DockerService,
binding: DockerServiceBinding,
) {
openCreateWizard(service, binding);
}
async function createMissingServiceProxies() {
await run(async () => {
for (const entry of serviceEntries.value) {
if (entry.proxy) continue;
await api.createProxy(draftForService(entry.service, entry.binding));
}
await refreshAll();
const first = proxies.value[0];
if (first) {
selectedPort.value = first.proxy_port;
hydrateDraft(first);
}
}, "Missing proxies created");
} }
async function saveProxy() { async function saveProxy() {
let persisted = false;
await run(async () => { await run(async () => {
const payload = cleanDraft(); const payload = cleanDraft();
const saved = selectedProxy.value const saved = selectedProxy.value
? await api.updateProxy(selectedProxy.value.proxy_port, payload) ? await api.updateProxy(selectedProxy.value.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,
})
: await api.createProxy(payload); : await api.createProxy(payload);
await refreshAll(); await refreshAll();
selectedPort.value = saved.proxy_port; selectedPort.value = saved.proxy_port;
const refreshed = proxies.value.find((proxy) => proxy.proxy_port === saved.proxy_port);
hydrateDraft(refreshed ?? saved);
persisted = true;
}, "Proxy saved"); }, "Proxy saved");
if (persisted) {
wizardOpen.value = false;
wizardStep.value = 0;
}
} }
async function removeProxy() { async function removeProxy() {
@@ -315,6 +687,7 @@ export function useControlPlane() {
await run(async () => { await run(async () => {
await api.deleteProxy(proxy.proxy_port); await api.deleteProxy(proxy.proxy_port);
selectedPort.value = null; selectedPort.value = null;
loadedPort.value = null;
await refreshAll(); await refreshAll();
}, "Proxy deleted"); }, "Proxy deleted");
} }
@@ -330,8 +703,10 @@ export function useControlPlane() {
: kind === "stop" : kind === "stop"
? await api.stop(port) ? await api.stop(port)
: await api.restart(port); : await api.restart(port);
replaceProxy(updated); await refreshAll();
selectedPort.value = updated.proxy_port; selectedPort.value = updated.proxy_port;
const refreshed = proxies.value.find((item) => item.proxy_port === updated.proxy_port);
hydrateDraft(refreshed ?? updated);
}, `Proxy ${kind} requested`); }, `Proxy ${kind} requested`);
} }
@@ -344,11 +719,19 @@ export function useControlPlane() {
} }
async function loadLogs() { async function loadLogs() {
await syncLogs(false);
}
async function syncLogs(silent = false) {
const proxy = selectedProxy.value; const proxy = selectedProxy.value;
if (!proxy) return; if (!proxy) return;
await run(async () => { try {
logs.value = (await api.logs(proxy.proxy_port)).log; logs.value = (await api.logs(proxy.proxy_port)).log;
}); } catch (err) {
if (!silent) {
error.value = err instanceof Error ? err.message : String(err);
}
}
} }
async function saveCurrentAsExample() { async function saveCurrentAsExample() {
@@ -364,7 +747,7 @@ export function useControlPlane() {
await api.createExample({ await api.createExample({
id, id,
name, name,
description: `Captured from ${draft.mode} ${draft.protocol} proxy ${draft.name}.`, description: `Saved from ${draft.name} (${draft.mode} / ${draft.protocol}).`,
category: "custom", category: "custom",
code: draft.code, code: draft.code,
source_url: null, source_url: null,
@@ -383,19 +766,38 @@ export function useControlPlane() {
}, "Custom example deleted"); }, "Custom example deleted");
} }
function applyService(service: DockerService, port: number) { function applyService(
service: DockerService,
port: number,
bindHost: string | null = null,
) {
const shouldFollowServicePort =
!selectedProxy.value || draft.proxy_port === draft.service.port;
draft.service.name = service.name; draft.service.name = service.name;
draft.service.folder = service.folder; draft.service.folder = service.folder;
draft.service.port = port; draft.service.port = port;
draft.service.bind_host = bindHost;
draft.service.host = "127.0.0.1"; draft.service.host = "127.0.0.1";
draft.listen_host = bindHost ?? "0.0.0.0";
if (draft.mode === "reverse" && !draft.mode_spec && shouldFollowServicePort) {
draft.proxy_port = port;
}
} }
function applyExample(example: AddonExample) { function applyExample(example: AddonExample) {
if (
draft.code.trim() &&
draft.code.trim() !== example.code.trim() &&
typeof window !== "undefined" &&
!window.confirm(
"Applying this template will overwrite your current proxy script. Continue?",
)
) {
return;
}
selectedExampleId.value = example.id; selectedExampleId.value = example.id;
draft.code = example.code; draft.code = example.code;
notice.value = editorOpen.value notice.value = `Loaded ${example.name}.`;
? `Loaded example: ${example.name}`
: `Loaded example: ${example.name}. Open the editor to review or modify the code.`;
} }
function selectExample(exampleId: string) { function selectExample(exampleId: string) {
@@ -436,12 +838,20 @@ export function useControlPlane() {
draft.mitm_options[name] = value; draft.mitm_options[name] = value;
} }
function toggleEditor(force?: boolean) { function matchProxyPortToService() {
editorOpen.value = typeof force === "boolean" ? force : !editorOpen.value; draft.proxy_port = draft.service.port;
} }
function setToolView(view: ToolView) { function toggleOptionScope(scope: OptionScope) {
toolView.value = view; optionScope.value = scope;
}
function toggleLogsPaused() {
logsPaused.value = !logsPaused.value;
}
function clearLogs() {
logs.value = "";
} }
function cleanDraft(): ProxyDraft { function cleanDraft(): ProxyDraft {
@@ -463,6 +873,7 @@ export function useControlPlane() {
service: { service: {
host: draft.service.host, host: draft.service.host,
port: draft.service.port, port: draft.service.port,
bind_host: draft.service.bind_host || null,
name: draft.service.name || null, name: draft.service.name || null,
folder: draft.service.folder || null, folder: draft.service.folder || null,
upstream_port: draft.service.upstream_port || null, upstream_port: draft.service.upstream_port || null,
@@ -471,12 +882,35 @@ export function useControlPlane() {
) as ProxyDraft; ) as ProxyDraft;
} }
function replaceProxy(proxy: ProxyDocument) { function hydrateDraft(proxy: ProxyDocument) {
const index = proxies.value.findIndex( Object.assign(draft, JSON.parse(JSON.stringify(proxy)) as ProxyDraft);
(item) => item.proxy_port === proxy.proxy_port, loadedPort.value = proxy.proxy_port;
); health.value = null;
if (index === -1) proxies.value.push(proxy); logs.value = "";
else proxies.value[index] = proxy; }
function bindingsFor(service: DockerService) {
return service.bindings?.length
? service.bindings
: service.ports.map((published) => ({ published, host: null }));
}
function draftForService(service: DockerService, binding: DockerServiceBinding) {
const template = createDefaultDraft(binding.published, examples.value[0]?.code);
const portSuffix = bindingsFor(service).length > 1 ? `-${binding.published}` : "";
return {
...template,
name: `${service.name}${portSuffix}`,
listen_host: binding.host ?? "0.0.0.0",
service: {
host: "127.0.0.1",
port: binding.published,
bind_host: binding.host ?? null,
name: service.name,
folder: service.folder,
upstream_port: null,
},
} satisfies ProxyDraft;
} }
function nextPort() { function nextPort() {
@@ -497,26 +931,83 @@ export function useControlPlane() {
async function run(task: () => Promise<void>, success?: string) { async function run(task: () => Promise<void>, success?: string) {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
notice.value = ""; clearNotice();
try { try {
await task(); await task();
if (success) notice.value = success; if (success) showNotice(success);
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 401) {
clearSession("Session expired. Sign in again.");
return;
}
error.value = err instanceof Error ? err.message : String(err); error.value = err instanceof Error ? err.message : String(err);
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
function showNotice(message: string, timeout = 2600) {
notice.value = message;
if (typeof window === "undefined") return;
if (noticeTimer !== null) window.clearTimeout(noticeTimer);
noticeTimer = window.setTimeout(() => {
notice.value = "";
noticeTimer = null;
}, timeout);
}
function clearNotice() {
notice.value = "";
if (typeof window === "undefined" || noticeTimer === null) return;
window.clearTimeout(noticeTimer);
noticeTimer = null;
}
function clearSession(message = "") {
stopLogPolling();
setToken(null);
token.value = null;
proxies.value = [];
services.value = [];
options.value = [];
examples.value = [];
selectedPort.value = null;
loadedPort.value = null;
health.value = null;
logs.value = "";
logFilter.value = "";
wizardOpen.value = false;
wizardStep.value = 0;
wizardMode.value = "create";
Object.assign(draft, createDefaultDraft());
authMessage.value = message;
void router.replace({ path: "/proxies", query: {} });
}
function stopLogPolling() {
if (typeof window === "undefined" || logPollTimer === null) return;
window.clearInterval(logPollTimer);
logPollTimer = null;
}
return proxyRefs({ return proxyRefs({
activeOptionCount,
activeOptions, activeOptions,
applyExample, applyExample,
applyService, applyService,
actionState,
authMessage,
checkHealth, checkHealth,
clearLogs,
closeWizard,
codeLineCount, codeLineCount,
filteredLogs,
logFilter,
logsPaused,
createMissingServiceProxies,
createProxyFromService,
customExampleName, customExampleName,
draft, draft,
editorOpen,
error, error,
exampleSearch, exampleSearch,
filteredExamples, filteredExamples,
@@ -527,15 +1018,21 @@ export function useControlPlane() {
logs, logs,
newProxy, newProxy,
notice, notice,
openCreateWizard,
openEditWizard,
optionCount, optionCount,
optionScope,
optionSearch, optionSearch,
password, password,
previousWizardStep,
proxies, proxies,
refreshServices,
refreshAll, refreshAll,
removeExample, removeExample,
removeOption, removeOption,
removeProxy, removeProxy,
routeState, routeState,
runtimeStatus,
runtimeAction, runtimeAction,
saveCurrentAsExample, saveCurrentAsExample,
saveProxy, saveProxy,
@@ -545,16 +1042,28 @@ export function useControlPlane() {
selectedPort, selectedPort,
selectedProxy, selectedProxy,
selectOption, selectOption,
serviceEntries,
services, services,
setOption, setOption,
setToolView,
signIn, signIn,
signOut, signOut,
toggleEditor, matchProxyPortToService,
missingServiceCount,
nextWizardStep,
toggleOptionScope,
toggleLogsPaused,
token, token,
toolView,
updateOption, updateOption,
username, username,
wizardMode,
wizardOpen,
wizardStep,
wizardStepTitle,
wizardTitle,
canGoBackWizard,
canGoNextWizard,
scanRoot,
servicesLoading,
}); });
} }
+2 -1
View File
@@ -1,6 +1,7 @@
import { createApp } from "vue"; import { createApp } from "vue";
import App from "./App.vue"; import App from "./App.vue";
import { router } from "./router";
import "./styles.css"; import "./styles.css";
createApp(App).mount("#app"); createApp(App).use(router).mount("#app");
+13
View File
@@ -0,0 +1,13 @@
import { createRouter, createWebHistory } from "vue-router";
const RouteAnchor = { template: "<div />" };
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/proxies" },
{ path: "/proxies", component: RouteAnchor },
{ path: "/proxies/:proxyPort(\\d+)", component: RouteAnchor },
{ path: "/:pathMatch(.*)*", redirect: "/proxies" },
],
});
+509 -123
View File
@@ -2,23 +2,23 @@
:root { :root {
color-scheme: dark; color-scheme: dark;
--bg: #08090b; --bg: #090b0d;
--bg-elevated: #0d1014; --bg-elevated: #0f1317;
--panel: #11151a; --panel: #11161b;
--panel-2: #151b21; --panel-2: #151b21;
--line: #252d35; --line: #252c34;
--line-strong: #33404d; --line-strong: #36414c;
--text: #eef3f7; --text: #f1f5f8;
--muted: #9da8b2; --muted: #9aa6b2;
--accent: #9be7ff; --accent: #a8ecff;
--accent-2: #c6f2ff; --accent-2: #d5f7ff;
--ok: #8ef0b0; --ok: #8ce7af;
--warn: #ffd88c; --warn: #ffd585;
--danger: #ff9f9f; --danger: #ff9d9d;
--radius-lg: 14px; --radius-lg: 8px;
--radius-md: 10px; --radius-md: 6px;
--radius-sm: 8px; --radius-sm: 4px;
--shadow: 0 20px 40px rgba(0, 0, 0, 0.22); --shadow: none;
} }
* { * {
@@ -39,11 +39,7 @@ body {
margin: 0; margin: 0;
color: var(--text); color: var(--text);
font-family: "IBM Plex Sans", sans-serif; font-family: "IBM Plex Sans", sans-serif;
background: background: var(--bg);
linear-gradient(rgba(255, 255, 255, 0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.015) 1px, transparent 1px),
var(--bg);
background-size: 28px 28px, 28px 28px, auto;
} }
button, button,
@@ -72,6 +68,24 @@ button {
background: #171d24; background: #171d24;
} }
.button-icon {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.ui-icon {
width: 0.95rem;
height: 0.95rem;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
flex: 0 0 auto;
}
button:hover:not(:disabled) { button:hover:not(:disabled) {
border-color: var(--line-strong); border-color: var(--line-strong);
background: #1c232b; background: #1c232b;
@@ -96,6 +110,26 @@ button.ghost {
background: #12171d; background: #12171d;
} }
button.success {
color: #08120c;
background: var(--ok);
border-color: #5fb37d;
}
button.success:hover:not(:disabled) {
background: #a6f0bf;
}
button.warning {
color: #161006;
background: var(--warn);
border-color: #c9a24c;
}
button.warning:hover:not(:disabled) {
background: #ffe19e;
}
button.danger { button.danger {
color: #ffe1e1; color: #ffe1e1;
background: #2b1717; background: #2b1717;
@@ -201,7 +235,6 @@ pre {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: var(--panel); background: var(--panel);
box-shadow: var(--shadow);
} }
.app-header { .app-header {
@@ -262,7 +295,7 @@ pre {
.app-body { .app-body {
display: grid; display: grid;
grid-template-columns: 280px minmax(0, 1fr); grid-template-columns: 250px minmax(0, 1fr);
gap: 1rem; gap: 1rem;
align-items: start; align-items: start;
margin-top: 1rem; margin-top: 1rem;
@@ -278,7 +311,7 @@ pre {
.panel-title h2, .panel-title h2,
.workbench-copy h1, .workbench-copy h1,
.login-card h1 { .login-card h1 {
margin: 0.2rem 0 0; margin: 0;
font-size: clamp(1.18rem, 1.7vw, 1.5rem); font-size: clamp(1.18rem, 1.7vw, 1.5rem);
line-height: 1.1; line-height: 1.1;
} }
@@ -343,8 +376,8 @@ pre {
} }
.proxy-card.selected { .proxy-card.selected {
border-color: #4f6b7c; border-color: #4e6272;
background: #151c23; background: #161d24;
} }
.proxy-card-head, .proxy-card-head,
@@ -402,7 +435,6 @@ pre {
} }
.workbench-head, .workbench-head,
.summary-panel,
.editor-pane, .editor-pane,
.stack-panel { .stack-panel {
padding: 1rem; padding: 1rem;
@@ -454,8 +486,7 @@ pre {
.action-row, .action-row,
.doc-actions, .doc-actions,
.template-actions, .template-actions {
.tool-switcher {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.6rem; gap: 0.6rem;
@@ -486,22 +517,27 @@ pre {
border-color: #2f4d60; border-color: #2f4d60;
} }
.app-toast {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 30;
min-width: 220px;
max-width: 360px;
padding: 0.85rem 1rem;
border: 1px solid #2f4d60;
border-radius: var(--radius-md);
background: rgba(21, 32, 40, 0.96);
color: #ddf7ff;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(8px);
}
.compact { .compact {
display: inline-block; display: inline-block;
width: fit-content; width: fit-content;
} }
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.summary-panel {
display: grid;
gap: 0.85rem;
}
.metric-grid { .metric-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -523,6 +559,12 @@ pre {
line-height: 1.35; line-height: 1.35;
} }
.route-overview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.metric-label { .metric-label {
color: var(--muted); color: var(--muted);
font-size: 0.75rem; font-size: 0.75rem;
@@ -530,23 +572,27 @@ pre {
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.summary-lines { .workspace-main-row {
display: grid; display: grid;
gap: 0.55rem; grid-template-columns: minmax(0, 5fr) minmax(320px, 3fr);
color: var(--muted);
}
.workbench-grid {
display: grid;
grid-template-columns: minmax(320px, 0.85fr) minmax(0, 1.15fr);
gap: 1rem; gap: 1rem;
align-items: start; align-items: start;
} }
.overview-stack, .workspace-editor-pane,
.utility-panel { .workspace-logs-pane,
.workspace-templates-row {
min-width: 0;
}
.workspace-editor-pane,
.workspace-logs-pane {
display: grid; display: grid;
gap: 1rem; }
.wizard-step-shell {
display: grid;
gap: 0.9rem;
} }
.compact-panel { .compact-panel {
@@ -597,37 +643,14 @@ pre {
accent-color: var(--accent); accent-color: var(--accent);
} }
.service-grid { .service-folder {
display: grid; display: block;
gap: 0.7rem; overflow: hidden;
} color: var(--muted);
.service-chip {
display: grid;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.port-row {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.port-chip {
padding: 0.38rem 0.6rem;
background: #0d1116;
font-family: "IBM Plex Mono", monospace; font-family: "IBM Plex Mono", monospace;
font-size: 0.8rem; font-size: 0.74rem;
} text-overflow: ellipsis;
white-space: nowrap;
.port-chip.active {
color: #071117;
background: var(--accent);
border-color: #7dcfe7;
} }
.compact-details { .compact-details {
@@ -688,14 +711,29 @@ pre {
font-size: 0.8rem; font-size: 0.8rem;
} }
.tool-switcher button { .title-actions,
background: #0f1318; .segmented {
display: flex;
align-items: center;
gap: 0.55rem;
} }
.tool-switcher button.active { .segmented {
color: #081016; padding: 0.2rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #0d1115;
}
.segmented button {
padding: 0.45rem 0.7rem;
border-color: transparent;
background: transparent;
}
.segmented button.active {
color: #071116;
background: var(--accent); background: var(--accent);
border-color: #7dcfe7;
} }
.tool-panel { .tool-panel {
@@ -710,7 +748,7 @@ pre {
.table-head, .table-head,
.table-row { .table-row {
display: grid; display: grid;
grid-template-columns: 180px minmax(0, 1fr) auto; grid-template-columns: 150px minmax(0, 1fr) auto;
gap: 0.7rem; gap: 0.7rem;
align-items: center; align-items: center;
} }
@@ -729,11 +767,19 @@ pre {
overflow: auto; overflow: auto;
} }
.compact-scroll {
max-height: 180px;
}
.table-row { .table-row {
padding: 0.6rem 0; padding: 0.6rem 0;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.table-row input {
min-width: 0;
}
.table-link { .table-link {
justify-self: start; justify-self: start;
padding: 0; padding: 0;
@@ -751,15 +797,62 @@ pre {
.option-shell, .option-shell,
.example-shell { .example-shell {
display: grid; display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1fr); grid-template-columns: 260px minmax(0, 1fr);
gap: 0.9rem; gap: 0.9rem;
min-height: 0; min-height: 0;
} }
.option-step-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.option-step-main {
display: grid;
gap: 0.9rem;
min-width: 0;
}
.option-browser, .option-browser,
.example-browser, .example-browser,
.option-doc, .option-doc,
.example-preview { .example-preview {
display: grid;
align-content: start;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.option-browser-embedded,
.option-doc-embedded,
.option-active-card {
min-height: 0;
}
.option-toolbar {
display: grid;
gap: 0.75rem;
}
.option-browser-meta,
.option-active-head {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
}
.option-browser-meta p,
.option-active-head h3 {
margin: 0;
}
.option-active-card {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
padding: 0.85rem; padding: 0.85rem;
@@ -768,11 +861,39 @@ pre {
background: var(--bg-elevated); background: var(--bg-elevated);
} }
.option-doc-embedded .option-doc-head {
gap: 1rem;
}
.doc-facts-compact {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.option-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.65rem;
align-items: center;
}
.option-count {
display: inline-grid;
place-items: center;
min-width: 1.8rem;
min-height: 1.8rem;
padding: 0 0.45rem;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
font-family: "IBM Plex Mono", monospace;
font-size: 0.76rem;
}
.option-table, .option-table,
.example-index { .example-index {
display: grid; display: grid;
gap: 0.45rem; gap: 0.45rem;
max-height: 380px; max-height: 320px;
overflow: auto; overflow: auto;
} }
@@ -780,7 +901,7 @@ pre {
.example-entry { .example-entry {
width: 100%; width: 100%;
display: grid; display: grid;
gap: 0.18rem; gap: 0.2rem;
text-align: left; text-align: left;
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
background: #0d1116; background: #0d1116;
@@ -820,25 +941,67 @@ pre {
line-height: 1.55; line-height: 1.55;
} }
.compact-copy {
max-width: 70ch;
}
.example-snippet {
color: var(--muted);
font-size: 0.78rem;
line-height: 1.45;
}
.doc-facts, .doc-facts,
.template-meta { .template-meta {
display: flex; display: grid;
flex-wrap: wrap; gap: 0.55rem;
gap: 0.5rem;
} }
.doc-facts span { .doc-facts {
padding: 0.22rem 0.45rem; margin: 0;
border-radius: 999px; grid-template-columns: repeat(2, minmax(0, 1fr));
background: #0d1116; }
.doc-fact {
display: grid;
gap: 0.2rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--line); border: 1px solid var(--line);
color: var(--muted); border-radius: var(--radius-sm);
font-size: 0.76rem; background: #0d1116;
} }
.logs-panel .terminal { .doc-fact dt {
min-height: 300px; color: var(--muted);
max-height: 420px; font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.doc-fact dd {
margin: 0;
color: var(--text);
font-family: "IBM Plex Mono", monospace;
font-size: 0.8rem;
}
.doc-actions {
align-items: center;
}
.button-link {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.table-action {
padding: 0.55rem 0.7rem;
}
.template-preview-terminal {
min-height: 260px;
max-height: 360px;
} }
.terminal { .terminal {
@@ -855,6 +1018,7 @@ pre {
.editor-pane { .editor-pane {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.85rem; gap: 0.85rem;
} }
@@ -870,7 +1034,7 @@ pre {
} }
.editor-shell { .editor-shell {
min-height: 620px; min-height: clamp(420px, 68vh, 760px);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-md); border-radius: var(--radius-md);
overflow: hidden; overflow: hidden;
@@ -879,28 +1043,219 @@ pre {
.code-editor-wrap { .code-editor-wrap {
display: grid; display: grid;
grid-template-rows: auto 1fr; height: 100%;
min-height: 620px; min-height: clamp(420px, 68vh, 760px);
}
.code-editor-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.9rem;
border-bottom: 1px solid var(--line);
background: #0f1418;
color: var(--muted);
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
} }
.code-editor { .code-editor {
width: 100%; width: 100%;
height: 100%;
min-height: 0; min-height: 0;
} }
.logs-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
max-height: clamp(420px, 68vh, 760px);
overflow: hidden;
}
.logs-panel .terminal {
min-height: 0;
height: 100%;
max-height: 100%;
overflow: auto;
}
.editor-error,
.field-error {
margin: 0;
padding: 0.7rem 0.85rem;
color: #ffd9d9;
background: #241617;
border-bottom: 1px solid #5c2c2c;
font-size: 0.82rem;
}
.editor-placeholder {
display: grid;
gap: 0.85rem;
min-height: 180px;
padding: 1rem;
}
.route-summary {
align-items: center;
}
.route-strip {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
align-items: center;
}
.route-chip {
display: inline-flex;
align-items: center;
min-height: 1.9rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #0d1116;
color: var(--text);
font-family: "IBM Plex Mono", monospace;
font-size: 0.81rem;
}
.route-form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.single-row-grid {
grid-template-columns: minmax(0, 1fr);
}
.advanced-inline-action {
align-items: center;
}
.service-discovery-list {
display: grid;
gap: 0.65rem;
}
.service-discovery-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
padding: 0.8rem 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.service-discovery-main {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.service-route-copy {
color: var(--text);
font-family: "IBM Plex Mono", monospace;
font-size: 0.82rem;
}
.service-discovery-actions {
display: flex;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(6, 8, 10, 0.72);
backdrop-filter: blur(4px);
}
.modal-shell {
width: min(980px, 100%);
max-height: calc(100vh - 2rem);
display: grid;
gap: 1rem;
overflow: auto;
padding: 1rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-lg);
background: var(--panel);
}
.modal-head,
.modal-actions {
display: flex;
align-items: center;
gap: 0.75rem;
justify-content: space-between;
}
.modal-head-actions {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
justify-content: flex-end;
}
.modal-head h2 {
margin: 0;
font-size: 1.3rem;
}
.modal-step-copy {
margin: 0.35rem 0 0;
color: var(--muted);
}
.modal-stepper {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.modal-step-chip {
display: grid;
gap: 0.25rem;
justify-items: start;
text-align: left;
padding: 0.8rem;
background: #0f1318;
}
.modal-step-chip span {
display: inline-grid;
place-items: center;
width: 1.7rem;
height: 1.7rem;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
font-family: "IBM Plex Mono", monospace;
font-size: 0.76rem;
}
.modal-step-chip.active {
border-color: #7dcfe7;
background: #141c22;
}
.modal-step-chip.active span,
.modal-step-chip.done span {
color: #071117;
background: var(--accent);
border-color: #7dcfe7;
}
.modal-body {
display: grid;
gap: 1rem;
}
.modal-action-spacer {
flex: 1 1 auto;
}
.auth-alert {
margin-top: 0.2rem;
}
.logo { .logo {
width: 44px; width: 44px;
height: 44px; height: 44px;
@@ -930,8 +1285,8 @@ pre {
background: transparent; background: transparent;
} }
@media (max-width: 1320px) { @media (max-width: 1180px) {
.workbench-grid { .option-step-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -941,9 +1296,9 @@ pre {
} }
} }
@media (max-width: 1120px) { @media (max-width: 980px) {
.app-body, .app-body,
.summary-grid { .workspace-main-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -951,6 +1306,10 @@ pre {
position: static; position: static;
max-height: none; max-height: none;
} }
.logs-panel .terminal {
min-height: 0;
}
} }
@media (max-width: 860px) { @media (max-width: 860px) {
@@ -967,9 +1326,13 @@ pre {
.form-grid, .form-grid,
.metric-grid, .metric-grid,
.route-overview-grid,
.doc-facts-compact,
.table-head, .table-head,
.table-row, .table-row,
.health-row { .health-row,
.doc-facts,
.route-form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -982,6 +1345,21 @@ pre {
.code-editor { .code-editor {
min-height: 500px; min-height: 500px;
} }
.modal-head,
.modal-actions {
align-items: flex-start;
flex-direction: column;
}
.modal-head-actions {
width: 100%;
justify-content: flex-start;
}
.modal-stepper {
grid-template-columns: 1fr;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -1000,4 +1378,12 @@ pre {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.service-discovery-row {
grid-template-columns: 1fr;
}
.service-discovery-actions {
justify-content: flex-start;
}
} }
+12
View File
@@ -1,11 +1,17 @@
export type ServiceEndpoint = { export type ServiceEndpoint = {
host: string; host: string;
port: number; port: number;
bind_host?: string | null;
name?: string | null; name?: string | null;
folder?: string | null; folder?: string | null;
upstream_port?: number | null; upstream_port?: number | null;
}; };
export type DockerServiceBinding = {
published: number;
host?: string | null;
};
export type HealthCheck = { export type HealthCheck = {
enabled: boolean; enabled: boolean;
protocol?: string | null; protocol?: string | null;
@@ -42,9 +48,15 @@ export type ProxyDocument = {
export type DockerService = { export type DockerService = {
name: string; name: string;
ports: number[]; ports: number[];
bindings?: DockerServiceBinding[];
folder: string; folder: string;
}; };
export type ServiceDiscoveryResponse = {
scan_root: string;
services: DockerService[];
};
export type MitmOption = { export type MitmOption = {
name: string; name: string;
type: string; type: string;
+1 -1
View File
@@ -10,7 +10,7 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
"/api": { "/api": {
target: "http://127.0.0.1:3671", target: "http://127.0.0.1:3771",
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
rewrite: (path) => path.replace(/^\/api/, ""), rewrite: (path) => path.replace(/^\/api/, ""),
+5 -2
View File
@@ -3,14 +3,17 @@
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"scripts": { "scripts": {
"dev:backend": "cd backend && PYTHONPATH=app uv run fastapi dev app/main.py", "dev:backend": "cd backend && PYTHONPATH=app uv run fastapi dev app/main.py --host 0.0.0.0 --port 3671",
"dev:frontend": "cd frontend && npm run dev", "dev:frontend": "cd frontend && npm run dev",
"dev:deps": "./setup.sh deps",
"test:backend": "cd backend && uv run pytest", "test:backend": "cd backend && uv run pytest",
"lint:backend": "cd backend && uv run ruff check .", "lint:backend": "cd backend && uv run ruff check .",
"format:backend": "cd backend && uv run ruff format .", "format:backend": "cd backend && uv run ruff format .",
"build:frontend": "cd frontend && npm run build", "build:frontend": "cd frontend && npm run build",
"test": "npm run lint:backend && npm run test:backend && npm run build:frontend", "test": "npm run lint:backend && npm run test:backend && npm run build:frontend",
"compose:up": "./setup.sh", "compose:up": "./setup.sh",
"compose:down": "docker compose --env-file backend/.env -f docker-compose.yml down" "compose:down": "docker compose --env-file backend/.env -f docker-compose.yml down",
"playground:up": "docker compose -f playground/docker-compose.yml up -d",
"playground:down": "docker compose -f playground/docker-compose.yml down"
} }
} }
+8
View File
@@ -0,0 +1,8 @@
services:
echo:
image: hashicorp/http-echo:1.0
command:
- -listen=:5678
- -text=theplug playground upstream
ports:
- 9081:5678
+8
View File
@@ -0,0 +1,8 @@
services:
echo:
image: hashicorp/http-echo:1.0
command:
- -listen=:5678
- -text=theplug playground upstream
ports:
- 127.0.0.1:54283:5678
+43
View File
@@ -17,6 +17,22 @@ compose() {
docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.yml" "$@" docker compose --env-file "$ENV_FILE" -f "$ROOT_DIR/docker-compose.yml" "$@"
} }
playground() {
docker compose -f "$ROOT_DIR/playground/docker-compose.yml" "$@"
}
sync_env_values() {
local mongo_pass mongo_db
mongo_pass=$(grep -E '^MONGO_INITDB_ROOT_PASSWORD=' "$ENV_FILE" | cut -d '=' -f2-)
mongo_db=$(grep -E '^MONGODB_DB_NAME=' "$ENV_FILE" | cut -d '=' -f2-)
[[ -n "$mongo_db" ]] || mongo_db="theplug"
if [[ -n "$mongo_pass" ]]; then
sed -i "s|^MONGODB_URI=.*|MONGODB_URI=mongodb://theplug:$mongo_pass@localhost:27017/$mongo_db?authSource=admin|" "$ENV_FILE"
fi
}
ensure_env() { ensure_env() {
if [[ -f "$ENV_FILE" ]]; then if [[ -f "$ENV_FILE" ]]; then
return return
@@ -64,12 +80,29 @@ EOF
start_stack() { start_stack() {
ensure_env ensure_env
sync_env_values
info "Building and starting backend, frontend, MongoDB and Redis..." info "Building and starting backend, frontend, MongoDB and Redis..."
compose up -d --build compose up -d --build
info "Backend: http://localhost:$(grep -E '^API_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 3671)" info "Backend: http://localhost:$(grep -E '^API_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 3671)"
info "Frontend: http://localhost:$(grep -E '^FRONTEND_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 5173)" info "Frontend: http://localhost:$(grep -E '^FRONTEND_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 5173)"
} }
start_deps() {
ensure_env
sync_env_values
info "Starting MongoDB and Redis for host-backend testing..."
compose up -d mongodb redis
info "Now run these on the host:"
info " npm run dev:backend"
info " npm run dev:frontend"
}
start_playground() {
info "Starting local playground upstream service..."
playground up -d
info "Playground service: http://127.0.0.1:9081"
}
if [[ "${1:-}" == "service" ]]; then if [[ "${1:-}" == "service" ]]; then
ensure_env ensure_env
install_service install_service
@@ -77,6 +110,16 @@ if [[ "${1:-}" == "service" ]]; then
exit 0 exit 0
fi fi
if [[ "${1:-}" == "deps" ]]; then
start_deps
exit 0
fi
if [[ "${1:-}" == "playground" ]]; then
start_playground
exit 0
fi
if [[ -d "/srv/theplug" && "$ROOT_DIR" != "/srv/theplug" ]]; then if [[ -d "/srv/theplug" && "$ROOT_DIR" != "/srv/theplug" ]]; then
info "thePlug found in /srv/theplug, switching there" info "thePlug found in /srv/theplug, switching there"
cd /srv/theplug cd /srv/theplug