feat: enhance service discovery and proxy management
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
# thePlug
|
||||
|
||||
Wrapper web per gestire proxy `mitmdump` davanti ai servizi di una competizione
|
||||
CTF attack/defense.
|
||||
Wrapper web per gestire proxy `mitmdump` davanti a servizi esposti via Docker
|
||||
Compose.
|
||||
|
||||
## Struttura
|
||||
|
||||
```text
|
||||
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
|
||||
@@ -24,6 +25,12 @@ Frontend http://localhost:5173
|
||||
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:
|
||||
|
||||
```bash
|
||||
@@ -40,6 +47,37 @@ npm run dev:backend
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -63,6 +101,13 @@ cd backend && uv run ruff check . && uv run pytest
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
Per testare solo il playground:
|
||||
|
||||
```bash
|
||||
npm run playground:up
|
||||
npm run playground:down
|
||||
```
|
||||
|
||||
## API principali
|
||||
|
||||
- `POST /auth/login`
|
||||
|
||||
+413
@@ -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.
|
||||
@@ -1,35 +1,35 @@
|
||||
UI SCHIFOSA
|
||||
andrebbe rifatto proprio il layout.
|
||||
Rivedere la parte UI/UX
|
||||
|
||||
dovrebbe essere in ordine di priorità, l'editor non può stare così tanto sotto.
|
||||
card come:
|
||||
- Runtime mapping
|
||||
- What start does
|
||||
vanno eliminati.
|
||||
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)
|
||||
|
||||
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.
|
||||
|
||||
```
|
||||
No proxies configured
|
||||
Create a draft, save it, then start it when you want mitmdump to own the route.
|
||||
```
|
||||
troppa roba, riassumere.
|
||||
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.
|
||||
|
||||
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
@@ -1,11 +1,17 @@
|
||||
API_PORT=3671
|
||||
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_DB_NAME=theplug
|
||||
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_EXPIRE_MINUTES=30
|
||||
JWT_EXPIRE_MINUTES=720
|
||||
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=
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from core.config import settings
|
||||
from core.security import require_authenticated_user
|
||||
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.proxy import ProxyCreateInput, ProxyUpdateInput
|
||||
from pymongo.errors import DuplicateKeyError
|
||||
@@ -120,8 +123,12 @@ async def list_proxies_handler():
|
||||
|
||||
|
||||
@router.get("/services")
|
||||
async def list_services(request: Request):
|
||||
return getattr(request.app.state, "services", [])
|
||||
async def list_services(request: Request, scan_root: str | None = None):
|
||||
_ = settings
|
||||
root = Path(scan_root).expanduser() if scan_root else service_scan_root()
|
||||
services = get_all_services_in_dir(root)
|
||||
request.app.state.services = services
|
||||
return {"scan_root": str(root), "services": services}
|
||||
|
||||
|
||||
@router.get("/mitmproxy/options")
|
||||
|
||||
@@ -15,9 +15,10 @@ class Settings(BaseSettings):
|
||||
REDIS_URL: str
|
||||
JWT_SECRET_KEY: str
|
||||
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_PASSWORD: str
|
||||
SERVICE_SCAN_ROOT: str | None = None
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
import yaml
|
||||
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.yml",
|
||||
@@ -50,20 +50,31 @@ def get_services(folder: Path) -> list[DockerContainer] | None:
|
||||
if not isinstance(mappings, list) or not mappings:
|
||||
continue
|
||||
|
||||
ports = [
|
||||
p
|
||||
for p in (_get_service_port(mapping) for mapping in mappings)
|
||||
if p is not None
|
||||
bindings = [
|
||||
{"published": published, "host": host}
|
||||
for published, host in (
|
||||
_get_service_binding(mapping) for mapping in mappings
|
||||
)
|
||||
if published is not None
|
||||
]
|
||||
if not ports:
|
||||
if not bindings:
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
if not path.is_dir():
|
||||
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()
|
||||
@@ -16,6 +16,8 @@ async def change_port(
|
||||
folder: Path,
|
||||
port_from: int,
|
||||
port_to: int,
|
||||
host_to: str | None = None,
|
||||
rewrite_host: bool = False,
|
||||
) -> bool:
|
||||
compose_data = find_docker_compose(folder)
|
||||
if compose_data is None:
|
||||
@@ -38,7 +40,13 @@ async def change_port(
|
||||
if idx is None:
|
||||
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)
|
||||
original_text = compose_path.read_text()
|
||||
|
||||
@@ -2,7 +2,13 @@ from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class DockerPortBinding(TypedDict):
|
||||
published: int
|
||||
host: str | None
|
||||
|
||||
|
||||
class DockerContainer(TypedDict):
|
||||
name: str
|
||||
ports: list[int]
|
||||
bindings: list[DockerPortBinding]
|
||||
folder: Path
|
||||
|
||||
@@ -14,48 +14,64 @@ def get_random_port() -> int:
|
||||
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):
|
||||
return mapping
|
||||
return mapping, None
|
||||
|
||||
if isinstance(mapping, Mapping):
|
||||
published = mapping.get("published")
|
||||
if published is None:
|
||||
return None
|
||||
return None, None
|
||||
try:
|
||||
return int(published)
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
port = int(published)
|
||||
except TypeError:
|
||||
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(":")
|
||||
match parts:
|
||||
case [""]:
|
||||
return None
|
||||
return None, None
|
||||
case [port] | [port, _]:
|
||||
try:
|
||||
return int(port)
|
||||
return int(port), None
|
||||
except ValueError:
|
||||
return None
|
||||
case ["localhost" | "127.0.0.1", _, _]:
|
||||
return None
|
||||
case [_, port, _]:
|
||||
return None, None
|
||||
case [host, port, _]:
|
||||
try:
|
||||
return int(port)
|
||||
return int(port), host or None
|
||||
except ValueError:
|
||||
return None
|
||||
return None, None
|
||||
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(
|
||||
mapping: str | int | Mapping[str, object],
|
||||
port_from: int,
|
||||
port_to: int,
|
||||
host_to: str | None = None,
|
||||
rewrite_host: bool = False,
|
||||
) -> str | dict[str, object]:
|
||||
if isinstance(mapping, Mapping):
|
||||
updated = dict(mapping)
|
||||
if _get_service_port(updated) == port_from:
|
||||
updated["published"] = str(port_to)
|
||||
if rewrite_host:
|
||||
if host_to is None:
|
||||
updated.pop("host_ip", None)
|
||||
else:
|
||||
updated["host_ip"] = host_to
|
||||
return updated
|
||||
|
||||
parts = str(mapping).split(":")
|
||||
@@ -63,8 +79,14 @@ def _replace_port_in_mapping(
|
||||
case [_]:
|
||||
return str(port_to)
|
||||
case [_, container_port]:
|
||||
if rewrite_host and host_to:
|
||||
return f"{host_to}:{port_to}:{container_port}"
|
||||
return f"{port_to}:{container_port}"
|
||||
case [ip, _, container_port]:
|
||||
if rewrite_host:
|
||||
if host_to:
|
||||
return f"{host_to}:{port_to}:{container_port}"
|
||||
return f"{port_to}:{container_port}"
|
||||
return f"{ip}:{port_to}:{container_port}"
|
||||
case _:
|
||||
return str(port_to)
|
||||
|
||||
+19
-5
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from api.auth.router import router as auth_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 fastapi import FastAPI
|
||||
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.examples import ensure_example_indexes
|
||||
from repositories.proxy import ensure_proxy_indexes
|
||||
from services.mitmproxy.recovery import reconcile_runtime
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -28,14 +29,27 @@ async def lifespan(_: FastAPI):
|
||||
)
|
||||
await ensure_proxy_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())
|
||||
app.state.services = get_all_services_in_dir(home_dir) or []
|
||||
scan_root = service_scan_root()
|
||||
app.state.services = get_all_services_in_dir(scan_root) or []
|
||||
if not app.state.services:
|
||||
warning = (
|
||||
"\n" + "!" * 60 + "\n"
|
||||
" WARNING: No services found in home directory!\n"
|
||||
f" Scanned: {home_dir}\n" + "!" * 60 + "\n"
|
||||
" WARNING: No services found in scan root!\n"
|
||||
f" Scanned: {scan_root}\n" + "!" * 60 + "\n"
|
||||
)
|
||||
print(warning, file=sys.stderr)
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@ class ServiceEndpointDocument(BaseModel):
|
||||
|
||||
host: str = Field(default="127.0.0.1", description="Upstream service host")
|
||||
port: int = Field(..., description="Original upstream service port")
|
||||
bind_host: str | None = Field(
|
||||
default=None,
|
||||
description="Original bind host exposed by Docker Compose",
|
||||
)
|
||||
name: str | None = Field(default=None, description="Docker Compose service name")
|
||||
folder: str | None = Field(
|
||||
default=None,
|
||||
|
||||
@@ -4,11 +4,11 @@ DOCS_EXAMPLES_URL = "https://docs.mitmproxy.org/stable/addons/examples/"
|
||||
|
||||
BUILTIN_EXAMPLES = [
|
||||
AddonExampleDocument(
|
||||
id="http-add-response-header",
|
||||
name="HTTP add response header",
|
||||
id="http-tag-traffic",
|
||||
name="Tag proxied traffic",
|
||||
description=(
|
||||
"Injects a fixed response header on every HTTP response so you can "
|
||||
"verify that traffic is passing through the proxy response path."
|
||||
"Adds a visible response header on every HTTP reply so you can tell "
|
||||
"immediately whether the request crossed the proxy."
|
||||
),
|
||||
category="http",
|
||||
source_url=DOCS_EXAMPLES_URL,
|
||||
@@ -21,11 +21,11 @@ def response(flow: http.HTTPFlow) -> None:
|
||||
""",
|
||||
),
|
||||
AddonExampleDocument(
|
||||
id="http-modify-query",
|
||||
name="HTTP modify query",
|
||||
id="http-force-query-flag",
|
||||
name="Force request flag",
|
||||
description=(
|
||||
"Adds or overwrites a query parameter before each request is "
|
||||
"forwarded upstream. Useful for forced debug or feature flags."
|
||||
"Adds or overwrites a request query flag before the flow is sent "
|
||||
"upstream. Useful when you need a stable marker on every request."
|
||||
),
|
||||
category="http",
|
||||
source_url=DOCS_EXAMPLES_URL,
|
||||
@@ -38,11 +38,11 @@ def request(flow: http.HTTPFlow) -> None:
|
||||
""",
|
||||
),
|
||||
AddonExampleDocument(
|
||||
id="http-reply-from-proxy",
|
||||
name="HTTP reply from proxy",
|
||||
id="http-local-health-response",
|
||||
name="Serve local health response",
|
||||
description=(
|
||||
"Serves a local HTTP response for a selected path without touching "
|
||||
"the upstream service. Useful for probes, maintenance, or stubs."
|
||||
"Answers one health endpoint directly from the proxy without "
|
||||
"touching the upstream service. Useful for smoke tests and checks."
|
||||
),
|
||||
category="http",
|
||||
source_url=DOCS_EXAMPLES_URL,
|
||||
@@ -57,14 +57,32 @@ def request(flow: http.HTTPFlow) -> None:
|
||||
b"ok",
|
||||
{"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(
|
||||
id="websocket-rewrite",
|
||||
name="WebSocket rewrite",
|
||||
description=(
|
||||
"Rewrites matching bytes inside live WebSocket frames to alter "
|
||||
"runtime flags or payload markers in transit."
|
||||
"Rewrites matching bytes inside live WebSocket frames to flip a "
|
||||
"runtime marker while the flow is passing through the proxy."
|
||||
),
|
||||
category="websocket",
|
||||
source_url=DOCS_EXAMPLES_URL,
|
||||
@@ -83,8 +101,8 @@ def websocket_message(flow: http.HTTPFlow) -> None:
|
||||
id="tcp-log-messages",
|
||||
name="TCP log messages",
|
||||
description=(
|
||||
"Logs raw TCP message direction and size for each frame so you can "
|
||||
"observe binary traffic without altering it."
|
||||
"Logs direction and byte size for every TCP frame so you can see "
|
||||
"what is crossing the route without modifying the payload."
|
||||
),
|
||||
category="tcp",
|
||||
source_url=DOCS_EXAMPLES_URL,
|
||||
@@ -104,8 +122,8 @@ def tcp_message(flow: tcp.TCPFlow) -> None:
|
||||
id="dns-spoof-a-record",
|
||||
name="DNS spoof A record",
|
||||
description=(
|
||||
"Overrides one DNS A record while forwarding every other query "
|
||||
"unchanged. Useful for selective service redirection."
|
||||
"Overrides one DNS A record while leaving all other queries alone. "
|
||||
"Useful for selective redirection during local testing."
|
||||
),
|
||||
category="dns",
|
||||
source_url=DOCS_EXAMPLES_URL,
|
||||
|
||||
@@ -20,6 +20,7 @@ class MitmdumpManager:
|
||||
self.work_dir = Path(work_dir)
|
||||
self.command = command
|
||||
self.processes: dict[int, asyncio.subprocess.Process] = {}
|
||||
self.attached_pids: dict[int, int] = {}
|
||||
self.locks: dict[int, asyncio.Lock] = {}
|
||||
|
||||
async def start(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||
@@ -67,6 +68,7 @@ class MitmdumpManager:
|
||||
raise MitmdumpError(reason)
|
||||
|
||||
self.processes[proxy.proxy_port] = process
|
||||
self.attached_pids.pop(proxy.proxy_port, None)
|
||||
return {
|
||||
"status": "running",
|
||||
"pid": process.pid,
|
||||
@@ -79,6 +81,7 @@ class MitmdumpManager:
|
||||
|
||||
async def stop_unlocked(self, proxy: ProxyDocument) -> dict[str, object]:
|
||||
process = self.processes.pop(proxy.proxy_port, None)
|
||||
self.attached_pids.pop(proxy.proxy_port, None)
|
||||
if process is not None and process.returncode is None:
|
||||
await self.stop_process(process)
|
||||
elif proxy.pid is not None:
|
||||
@@ -98,19 +101,70 @@ class MitmdumpManager:
|
||||
if process.returncode is None:
|
||||
return "running"
|
||||
self.processes.pop(proxy.proxy_port, None)
|
||||
self.attached_pids.pop(proxy.proxy_port, None)
|
||||
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
|
||||
|
||||
try:
|
||||
os.kill(proxy.pid, 0)
|
||||
except ProcessLookupError:
|
||||
if not self.is_process_alive(pid):
|
||||
self.attached_pids.pop(proxy.proxy_port, None)
|
||||
return "stopped"
|
||||
if not self.matches_proxy_process(proxy, pid):
|
||||
self.attached_pids.pop(proxy.proxy_port, None)
|
||||
return "stopped"
|
||||
except PermissionError:
|
||||
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:
|
||||
if proxy.mode_spec is not None or proxy.mode != "reverse":
|
||||
return None
|
||||
@@ -126,6 +180,8 @@ class MitmdumpManager:
|
||||
Path(proxy.service.folder),
|
||||
proxy.service.port,
|
||||
upstream_port,
|
||||
host_to="127.0.0.1",
|
||||
rewrite_host=True,
|
||||
)
|
||||
if not changed:
|
||||
raise MitmdumpError("Unable to move Docker service port")
|
||||
@@ -135,19 +191,21 @@ class MitmdumpManager:
|
||||
self,
|
||||
proxy: ProxyDocument,
|
||||
upstream_port: int | None,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
if upstream_port is None:
|
||||
return
|
||||
return True
|
||||
if proxy.service.name is None or proxy.service.folder is None:
|
||||
return
|
||||
return True
|
||||
if upstream_port == proxy.service.port:
|
||||
return
|
||||
return True
|
||||
|
||||
await docker.change_port(
|
||||
return await docker.change_port(
|
||||
proxy.service.name,
|
||||
Path(proxy.service.folder),
|
||||
upstream_port,
|
||||
proxy.service.port,
|
||||
host_to=proxy.service.bind_host,
|
||||
rewrite_host=True,
|
||||
)
|
||||
|
||||
def build_command(
|
||||
@@ -200,20 +258,20 @@ class MitmdumpManager:
|
||||
return proxy.mode in {"regular", "socks5", "dns", "transparent"}
|
||||
|
||||
def write_script(self, proxy: ProxyDocument) -> Path:
|
||||
path = self.work_dir / "addons" / f"{proxy.proxy_port}.py"
|
||||
path = self.script_path_for(proxy)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(proxy.code, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def write_config(self, proxy: ProxyDocument) -> Path:
|
||||
path = self.work_dir / "conf" / str(proxy.proxy_port) / "config.yaml"
|
||||
path = self.config_path_for(proxy)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
options = {"block_global": False, **proxy.mitm_options}
|
||||
path.write_text(yaml.safe_dump(options, sort_keys=True), encoding="utf-8")
|
||||
return path
|
||||
|
||||
def log_path(self, proxy: ProxyDocument) -> Path:
|
||||
path = self.work_dir / "logs" / f"{proxy.proxy_port}.log"
|
||||
path = self.log_path_for(proxy)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
@@ -227,19 +285,7 @@ class MitmdumpManager:
|
||||
)
|
||||
|
||||
async def stop_process(self, process) -> None:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except TimeoutError:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
await process.wait()
|
||||
await self.stop_pid(process.pid)
|
||||
|
||||
async def exited_early(self, process) -> bool:
|
||||
try:
|
||||
@@ -249,6 +295,18 @@ class MitmdumpManager:
|
||||
return True
|
||||
|
||||
async def stop_pid(self, pid: int) -> None:
|
||||
try:
|
||||
pgid = os.getpgid(pid)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except PermissionError:
|
||||
pgid = pid
|
||||
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except PermissionError:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
@@ -256,16 +314,77 @@ class MitmdumpManager:
|
||||
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0.2)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
if not self.is_process_alive(pid):
|
||||
return
|
||||
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except PermissionError:
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
def reattach(self, proxy: ProxyDocument) -> None:
|
||||
if proxy.pid is not None:
|
||||
self.attached_pids[proxy.proxy_port] = proxy.pid
|
||||
|
||||
def needs_recovery(self, proxy: ProxyDocument) -> bool:
|
||||
return (
|
||||
proxy.status == "running"
|
||||
or proxy.pid is not None
|
||||
or self.needs_upstream_restore(proxy)
|
||||
)
|
||||
|
||||
def needs_upstream_restore(self, proxy: ProxyDocument) -> bool:
|
||||
upstream_port = proxy.service.upstream_port
|
||||
return upstream_port is not None and upstream_port != proxy.service.port
|
||||
|
||||
def is_process_alive(self, pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
def matches_proxy_process(self, proxy: ProxyDocument, pid: int) -> bool:
|
||||
cmdline = self.read_cmdline(pid)
|
||||
if not cmdline:
|
||||
return False
|
||||
|
||||
expected_command = Path(self.command).name
|
||||
if not any(Path(part).name == expected_command for part in cmdline):
|
||||
return False
|
||||
|
||||
expected_script = str(self.script_path_for(proxy))
|
||||
expected_confdir = f"confdir={self.conf_dir_for(proxy)}"
|
||||
return expected_script in cmdline and expected_confdir in cmdline
|
||||
|
||||
def read_cmdline(self, pid: int) -> list[str]:
|
||||
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:
|
||||
if not path.exists():
|
||||
return ""
|
||||
|
||||
@@ -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
|
||||
@@ -52,6 +52,38 @@ def test_list_proxies_returns_documents(client, monkeypatch: object):
|
||||
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):
|
||||
async def fake_get_proxy_by_port(proxy_port: int) -> None:
|
||||
return None
|
||||
|
||||
@@ -13,7 +13,7 @@ os.environ.setdefault("MONGODB_DB_NAME", "theplug")
|
||||
os.environ.setdefault("REDIS_URL", "redis://localhost:6379")
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key")
|
||||
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_PASSWORD", "admin")
|
||||
|
||||
@@ -33,7 +33,7 @@ def auth_context(fake_user):
|
||||
from core.security import AuthContext
|
||||
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)
|
||||
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)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Generated
+23
-1
@@ -17,7 +17,8 @@
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"vue": "^3.5.26"
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
@@ -1131,6 +1132,12 @@
|
||||
"@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": {
|
||||
"version": "3.2.8",
|
||||
"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": {
|
||||
"version": "3.2.8",
|
||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.8.tgz",
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"vue": "^3.5.26"
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMounted, provide } from "vue";
|
||||
|
||||
import AppHeader from "./components/AppHeader.vue";
|
||||
import AppToast from "./components/AppToast.vue";
|
||||
import LoginView from "./components/LoginView.vue";
|
||||
import ProxyList from "./components/ProxyList.vue";
|
||||
import ProxyWorkbench from "./components/ProxyWorkbench.vue";
|
||||
@@ -24,15 +25,17 @@ onMounted(() => {
|
||||
v-model:username="plane.username"
|
||||
v-model:password="plane.password"
|
||||
:loading="plane.loading"
|
||||
:error="plane.error"
|
||||
:error="plane.authMessage || plane.error"
|
||||
@submit="plane.signIn"
|
||||
/>
|
||||
|
||||
<main v-else class="app-shell">
|
||||
<AppToast v-if="plane.notice" :message="plane.notice" />
|
||||
|
||||
<AppHeader
|
||||
:loading="plane.loading"
|
||||
@refresh="plane.refreshAll"
|
||||
@create="plane.newProxy"
|
||||
@create="plane.openCreateWizard()"
|
||||
@logout="plane.signOut"
|
||||
/>
|
||||
|
||||
|
||||
+18
-3
@@ -1,16 +1,26 @@
|
||||
import type {
|
||||
AddonExample,
|
||||
DockerService,
|
||||
HealthResult,
|
||||
MitmOption,
|
||||
PythonEditorSession,
|
||||
ProxyDocument,
|
||||
ProxyDraft,
|
||||
ServiceDiscoveryResponse,
|
||||
} from "./types";
|
||||
|
||||
const TOKEN_KEY = "theplug.token";
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
@@ -52,7 +62,9 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const detail = body?.detail ?? response.statusText;
|
||||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||
const message =
|
||||
typeof detail === "string" ? detail : JSON.stringify(detail);
|
||||
throw new ApiError(response.status, message);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
@@ -72,7 +84,10 @@ export async function logout() {
|
||||
|
||||
export const api = {
|
||||
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"),
|
||||
examples: () => request<AddonExample[]>("/proxy/mitmproxy/examples"),
|
||||
editorSession: () =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import AppLogo from "./AppLogo.vue";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
|
||||
defineProps<{
|
||||
loading: boolean;
|
||||
@@ -22,13 +23,21 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<span class="team">mitmdump</span>
|
||||
<span v-if="loading" class="muted">applying changes</span>
|
||||
<span v-if="loading" class="muted">saving</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="ghost" :disabled="loading" @click="emit('refresh')">Refresh</button>
|
||||
<button class="primary" @click="emit('create')">New</button>
|
||||
<button class="ghost" @click="emit('logout')">Logout</button>
|
||||
<button class="ghost button-icon" :disabled="loading" @click="emit('refresh')">
|
||||
<UiIcon name="refresh" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<button class="primary button-icon" @click="emit('create')">
|
||||
<UiIcon name="plus" />
|
||||
<span>New</span>
|
||||
</button>
|
||||
<button class="ghost button-icon" @click="emit('logout')">
|
||||
<UiIcon name="logout" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-toast" role="status" aria-live="polite">
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,7 +12,6 @@ const emit = defineEmits<{
|
||||
save: [];
|
||||
}>();
|
||||
const host = ref<HTMLElement | null>(null);
|
||||
const status = ref("Connecting Python tools...");
|
||||
const error = ref("");
|
||||
let editor: EditorView | null = null;
|
||||
let languageClient: Awaited<ReturnType<typeof connectPythonLsp>>["client"] | null =
|
||||
@@ -110,15 +109,9 @@ async function mountEditor() {
|
||||
const connection = await connectPythonLsp(session);
|
||||
languageClient = connection.client;
|
||||
languageSocket = connection.socket;
|
||||
|
||||
void languageClient.initializing.then(
|
||||
() => {
|
||||
status.value = "Python tools ready";
|
||||
},
|
||||
() => {
|
||||
status.value = "Python tools unavailable";
|
||||
},
|
||||
);
|
||||
void languageClient.initializing.catch(() => {
|
||||
error.value = "Python language tools are unavailable.";
|
||||
});
|
||||
|
||||
editor = new EditorView({
|
||||
doc: model.value,
|
||||
@@ -141,17 +134,13 @@ async function mountEditor() {
|
||||
const message =
|
||||
value instanceof Error ? value.message : "Unable to start the editor";
|
||||
error.value = message;
|
||||
status.value = "Python tools unavailable";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="code-editor-wrap">
|
||||
<div class="code-editor-status">
|
||||
<span>{{ status }}</span>
|
||||
<span v-if="error" class="field-error">{{ error }}</span>
|
||||
</div>
|
||||
<p v-if="error" class="field-error editor-error">{{ error }}</p>
|
||||
<div ref="host" class="code-editor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,13 +11,11 @@ const plane = useControlPlaneContext();
|
||||
<section class="editor-pane panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Code</p>
|
||||
<h2>Addon editor</h2>
|
||||
<h2>Proxy script</h2>
|
||||
</div>
|
||||
<div class="editor-meta">
|
||||
<span>{{ plane.codeLineCount }} lines</span>
|
||||
<span>{{ plane.draft.mode }} / {{ plane.draft.protocol }}</span>
|
||||
<button class="ghost" @click="plane.toggleEditor(false)">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-shell">
|
||||
|
||||
@@ -7,23 +7,20 @@ const plane = useControlPlaneContext();
|
||||
<template>
|
||||
<section class="panel stack-panel tool-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Templates</p>
|
||||
<h2>Examples</h2>
|
||||
</div>
|
||||
<div><h2>Templates</h2></div>
|
||||
</div>
|
||||
|
||||
<div class="inline-grid">
|
||||
<input
|
||||
v-model="plane.customExampleName"
|
||||
placeholder="Save current code as..."
|
||||
placeholder="Save current script as template..."
|
||||
/>
|
||||
<button @click="plane.saveCurrentAsExample">Save</button>
|
||||
</div>
|
||||
|
||||
<div class="example-shell">
|
||||
<div class="example-browser">
|
||||
<input v-model="plane.exampleSearch" placeholder="Search examples" />
|
||||
<input v-model="plane.exampleSearch" placeholder="Search templates" />
|
||||
|
||||
<div class="example-index">
|
||||
<button
|
||||
@@ -35,6 +32,7 @@ const plane = useControlPlaneContext();
|
||||
>
|
||||
<strong>{{ example.name }}</strong>
|
||||
<small>{{ example.category }}</small>
|
||||
<span class="example-snippet">{{ example.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,7 +40,6 @@ const plane = useControlPlaneContext();
|
||||
<article v-if="plane.selectedExample" class="example-preview">
|
||||
<div class="option-doc-head">
|
||||
<div>
|
||||
<p class="eyebrow">Selected</p>
|
||||
<h3>{{ plane.selectedExample.name }}</h3>
|
||||
</div>
|
||||
<div class="template-meta">
|
||||
@@ -55,6 +52,10 @@ const plane = useControlPlaneContext();
|
||||
|
||||
<p class="doc-copy">{{ plane.selectedExample.description }}</p>
|
||||
|
||||
<pre class="terminal template-preview-terminal">{{
|
||||
plane.selectedExample.code
|
||||
}}</pre>
|
||||
|
||||
<div class="doc-actions">
|
||||
<button @click="plane.applyExample(plane.selectedExample)">Use template</button>
|
||||
<a
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { protocols, useControlPlaneContext } from "../composables/useControlPlane";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
|
||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
|
||||
embedded: false,
|
||||
});
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel stack-panel compact-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Health</p>
|
||||
<h2>Checks</h2>
|
||||
</div>
|
||||
<button :disabled="!plane.selectedProxy" @click="plane.checkHealth">
|
||||
Run
|
||||
<section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel compact-panel'">
|
||||
<div v-if="!props.embedded" class="panel-title">
|
||||
<div><h2>Checks</h2></div>
|
||||
<button
|
||||
class="button-icon"
|
||||
:disabled="!plane.selectedProxy"
|
||||
@click="plane.checkHealth"
|
||||
>
|
||||
<UiIcon name="refresh" />
|
||||
<span>Run</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<div v-if="!props.embedded" class="metric-grid">
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">State</span>
|
||||
<strong>{{ plane.draft.healthcheck.enabled ? "Enabled" : "Disabled" }}</strong>
|
||||
@@ -35,9 +42,6 @@ const plane = useControlPlaneContext();
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<details class="compact-details">
|
||||
<summary>Advanced check settings</summary>
|
||||
|
||||
<div class="form-grid tight-grid">
|
||||
<label class="checkbox-row">
|
||||
<input v-model="plane.draft.healthcheck.enabled" type="checkbox" />
|
||||
@@ -95,7 +99,6 @@ const plane = useControlPlaneContext();
|
||||
<span>Verify TLS</span>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div v-if="plane.health" class="health-box" :class="plane.health.overall">
|
||||
<strong>{{ plane.health.overall }}</strong>
|
||||
|
||||
@@ -20,16 +20,16 @@ const emit = defineEmits<{
|
||||
<AppLogo />
|
||||
<div>
|
||||
<p class="eyebrow">thePlug</p>
|
||||
<h1>Proxy control plane</h1>
|
||||
<p class="muted">Manage routes, runtime state, and mitmdump addons from one console.</p>
|
||||
<h1>Sign in</h1>
|
||||
<p class="muted">Manage proxy routes, runtime state, and mitmdump scripts.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="emit('submit')">
|
||||
<label>Username<input v-model="username" autocomplete="username" /></label>
|
||||
<label>Password<input v-model="password" type="password" autocomplete="current-password" /></label>
|
||||
<button class="primary" :disabled="loading">Login</button>
|
||||
<button class="primary" :disabled="loading">Sign in</button>
|
||||
</form>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-if="error" class="error auth-alert">{{ error }}</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
@@ -7,13 +8,23 @@ const plane = useControlPlaneContext();
|
||||
<template>
|
||||
<section class="panel stack-panel tool-panel logs-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Output</p>
|
||||
<h2>Logs</h2>
|
||||
<div><h2>Logs</h2></div>
|
||||
<div class="doc-actions">
|
||||
<button class="ghost button-icon" :disabled="!plane.selectedProxy" @click="plane.toggleLogsPaused">
|
||||
<UiIcon :name="plane.logsPaused ? 'resume' : 'pause'" />
|
||||
<span>{{ plane.logsPaused ? "Resume" : "Pause" }}</span>
|
||||
</button>
|
||||
<button class="ghost button-icon" @click="plane.clearLogs">
|
||||
<UiIcon name="clear" />
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
</div>
|
||||
<button :disabled="!plane.selectedProxy" @click="plane.loadLogs">Load</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { MitmOption } from "../types";
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
|
||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
|
||||
embedded: false,
|
||||
});
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
|
||||
@@ -27,22 +32,135 @@ function formatDefault(option?: MitmOption | null) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel stack-panel tool-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Mitmdump</p>
|
||||
<section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel tool-panel'">
|
||||
<div v-if="!props.embedded" class="panel-title">
|
||||
<h2>Options</h2>
|
||||
<div class="title-actions">
|
||||
<span class="counter">{{ plane.activeOptionCount }}</span>
|
||||
<div class="segmented">
|
||||
<button
|
||||
:class="{ active: plane.optionScope === 'focused' }"
|
||||
@click="plane.toggleOptionScope('focused')"
|
||||
>
|
||||
Common
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: plane.optionScope === 'all' }"
|
||||
@click="plane.toggleOptionScope('all')"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="counter">{{ plane.optionCount }}</span>
|
||||
</div>
|
||||
|
||||
<div class="active-options compact-table">
|
||||
<div v-if="props.embedded" class="option-step-layout">
|
||||
<aside class="option-browser option-browser-embedded">
|
||||
<div class="option-toolbar">
|
||||
<input v-model="plane.optionSearch" placeholder="Search options" />
|
||||
<div class="segmented">
|
||||
<button
|
||||
:class="{ active: plane.optionScope === 'focused' }"
|
||||
@click="plane.toggleOptionScope('focused')"
|
||||
>
|
||||
Common
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: plane.optionScope === 'all' }"
|
||||
@click="plane.toggleOptionScope('all')"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div 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">
|
||||
<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>
|
||||
</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>Active</span>
|
||||
<span>Option</span>
|
||||
<span>Value</span>
|
||||
<span />
|
||||
</div>
|
||||
<div v-if="plane.activeOptions.length" class="table-body">
|
||||
<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 }}
|
||||
@@ -74,7 +192,59 @@ function formatDefault(option?: MitmOption | null) {
|
||||
)
|
||||
"
|
||||
/>
|
||||
<button class="ghost" @click="plane.removeOption(item.name)">Remove</button>
|
||||
<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>
|
||||
@@ -82,10 +252,17 @@ function formatDefault(option?: MitmOption | null) {
|
||||
|
||||
<div class="option-shell">
|
||||
<div class="option-browser">
|
||||
<input
|
||||
v-model="plane.optionSearch"
|
||||
placeholder="Search options"
|
||||
/>
|
||||
<div class="option-search-row">
|
||||
<input v-model="plane.optionSearch" placeholder="Search options" />
|
||||
<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
|
||||
@@ -104,7 +281,6 @@ function formatDefault(option?: MitmOption | null) {
|
||||
<article v-if="plane.selectedOption" class="option-doc">
|
||||
<div class="option-doc-head">
|
||||
<div>
|
||||
<p class="eyebrow">Reference</p>
|
||||
<h3>{{ plane.selectedOption.name }}</h3>
|
||||
</div>
|
||||
<span class="type-chip">{{ plane.selectedOption.type }}</span>
|
||||
@@ -112,31 +288,46 @@ function formatDefault(option?: MitmOption | null) {
|
||||
|
||||
<p class="doc-copy">{{ plane.selectedOption.description }}</p>
|
||||
|
||||
<div class="doc-facts">
|
||||
<span>Default: {{ formatDefault(plane.selectedOption) }}</span>
|
||||
<span v-if="plane.selectedOption.choices.length">
|
||||
Choices: {{ plane.selectedOption.choices.join(", ") }}
|
||||
</span>
|
||||
<span v-if="plane.selectedOption.reserved">Managed by thePlug</span>
|
||||
<dl class="doc-facts">
|
||||
<div class="doc-fact">
|
||||
<dt>Default</dt>
|
||||
<dd>{{ formatDefault(plane.selectedOption) }}</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="plane.selectedOption.choices.length"
|
||||
class="doc-fact"
|
||||
>
|
||||
<dt>Choices</dt>
|
||||
<dd>{{ plane.selectedOption.choices.join(", ") }}</dd>
|
||||
</div>
|
||||
<div v-if="plane.selectedOption.reserved" class="doc-fact">
|
||||
<dt>State</dt>
|
||||
<dd>Managed by thePlug</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="doc-actions">
|
||||
<button
|
||||
class="button-icon"
|
||||
:disabled="plane.selectedOption.reserved"
|
||||
@click="plane.setOption(plane.selectedOption)"
|
||||
>
|
||||
Add option
|
||||
<UiIcon name="plus" />
|
||||
<span>Add option</span>
|
||||
</button>
|
||||
<a
|
||||
class="button-link"
|
||||
v-if="plane.selectedOption.docs_url"
|
||||
:href="plane.selectedOption.docs_url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<UiIcon name="link" />
|
||||
Official docs
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -16,15 +16,14 @@ const emit = defineEmits<{
|
||||
<aside class="rail panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Inventory</p>
|
||||
<h2>Proxies</h2>
|
||||
</div>
|
||||
<span class="counter">{{ proxies.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!proxies.length" class="empty-state">
|
||||
<strong>No proxies configured</strong>
|
||||
<span>Create a draft, save it, then start it when you want mitmdump to own the route.</span>
|
||||
<strong>No proxies yet</strong>
|
||||
<span>Create one to start.</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -2,12 +2,11 @@
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
import EditorPane from "./EditorPane.vue";
|
||||
import ExamplesPanel from "./ExamplesPanel.vue";
|
||||
import HealthPanel from "./HealthPanel.vue";
|
||||
import LogsPanel from "./LogsPanel.vue";
|
||||
import OptionsPanel from "./OptionsPanel.vue";
|
||||
import RuntimePanel from "./RuntimePanel.vue";
|
||||
import ServicePanel from "./ServicePanel.vue";
|
||||
import ProxyWizardModal from "./ProxyWizardModal.vue";
|
||||
import ServiceDiscoveryPanel from "./ServiceDiscoveryPanel.vue";
|
||||
import StatusPill from "./StatusPill.vue";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
@@ -16,141 +15,92 @@ const plane = useControlPlaneContext();
|
||||
<section class="workbench">
|
||||
<header class="workbench-head panel">
|
||||
<div class="workbench-copy">
|
||||
<p class="eyebrow">Workspace</p>
|
||||
<h1>{{ plane.draft.name || "Untitled proxy" }}</h1>
|
||||
<div class="route-row">
|
||||
<h1>{{ plane.selectedProxy ? plane.draft.name : "Proxy workspace" }}</h1>
|
||||
<div v-if="plane.selectedProxy" class="route-row route-summary">
|
||||
<span class="route-token">{{ plane.routeState.configuredListen }}</span>
|
||||
<span class="route-arrow">to</span>
|
||||
<span class="route-target">{{ plane.routeState.configuredTarget }}</span>
|
||||
<StatusPill
|
||||
v-if="plane.selectedProxy"
|
||||
:status="plane.selectedProxy.status"
|
||||
/>
|
||||
</div>
|
||||
<p class="summary-copy">{{ plane.routeState.startNotice }}</p>
|
||||
<p v-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">
|
||||
{{ plane.selectedProxy.last_error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button @click="plane.saveProxy" :disabled="plane.loading">Save</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')"
|
||||
:disabled="!plane.selectedProxy || plane.loading"
|
||||
:disabled="!plane.actionState.canStart"
|
||||
>
|
||||
Start
|
||||
<UiIcon name="play" />
|
||||
<span>Start</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="plane.actionState.showStop"
|
||||
class="danger button-icon"
|
||||
@click="plane.runtimeAction('stop')"
|
||||
:disabled="!plane.selectedProxy || plane.loading"
|
||||
:disabled="!plane.actionState.canStop"
|
||||
>
|
||||
Stop
|
||||
<UiIcon name="stop" />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="plane.actionState.showRestart"
|
||||
class="warning button-icon"
|
||||
@click="plane.runtimeAction('restart')"
|
||||
:disabled="!plane.selectedProxy || plane.loading"
|
||||
:disabled="!plane.actionState.canRestart"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button class="ghost" @click="plane.toggleEditor()">
|
||||
{{ plane.editorOpen ? "Hide editor" : "Open editor" }}
|
||||
<UiIcon name="restart" />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
<button
|
||||
class="danger"
|
||||
v-if="plane.selectedProxy"
|
||||
class="danger button-icon"
|
||||
@click="plane.removeProxy"
|
||||
:disabled="!plane.selectedProxy || plane.loading"
|
||||
:disabled="!plane.actionState.canDelete"
|
||||
>
|
||||
Delete
|
||||
<UiIcon name="trash" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="plane.error" class="error">{{ plane.error }}</p>
|
||||
<p v-if="plane.notice" class="notice">{{ plane.notice }}</p>
|
||||
|
||||
<section class="summary-grid">
|
||||
<article class="panel summary-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Route</p>
|
||||
<h2>Runtime mapping</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-grid">
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Listen</span>
|
||||
<strong>{{ plane.routeState.configuredListen }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Configured upstream</span>
|
||||
<strong>{{ plane.routeState.configuredTarget }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Live upstream</span>
|
||||
<strong>{{ plane.routeState.liveTarget }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Mode</span>
|
||||
<strong>{{ plane.routeState.modeLabel }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
<p class="inline-note">{{ plane.routeState.liveNotice }}</p>
|
||||
</article>
|
||||
<ServiceDiscoveryPanel v-if="!plane.selectedProxy && !plane.wizardOpen" />
|
||||
|
||||
<article class="panel summary-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Behaviour</p>
|
||||
<h2>What start does</h2>
|
||||
<div v-if="plane.selectedProxy" class="workspace-main-row">
|
||||
<div class="workspace-editor-pane">
|
||||
<EditorPane />
|
||||
</div>
|
||||
<div class="workspace-logs-pane">
|
||||
<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>
|
||||
|
||||
<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 v-if="plane.selectedProxy" class="workspace-templates-row">
|
||||
<ExamplesPanel />
|
||||
</div>
|
||||
|
||||
<OptionsPanel v-if="plane.toolView === 'options'" />
|
||||
<ExamplesPanel v-else-if="plane.toolView === 'examples'" />
|
||||
<LogsPanel v-else />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<EditorPane v-if="plane.editorOpen" />
|
||||
<ProxyWizardModal />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,26 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { modes, protocols, useControlPlaneContext } from "../composables/useControlPlane";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
|
||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
|
||||
embedded: false,
|
||||
});
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel stack-panel compact-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Runtime</p>
|
||||
<h2>Proxy settings</h2>
|
||||
</div>
|
||||
<section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel compact-panel'">
|
||||
<div v-if="!props.embedded" class="panel-title">
|
||||
<h2>Proxy route</h2>
|
||||
</div>
|
||||
|
||||
<div class="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>
|
||||
Name
|
||||
<input v-model="plane.draft.name" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-grid tight-grid">
|
||||
<label>
|
||||
Proxy port
|
||||
<input v-model.number="plane.draft.proxy_port" 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>
|
||||
Mode
|
||||
@@ -34,21 +77,25 @@ const plane = useControlPlaneContext();
|
||||
<option v-for="protocol in protocols" :key="protocol">{{ protocol }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Listen host
|
||||
<input v-model="plane.draft.listen_host" />
|
||||
</label>
|
||||
<label>
|
||||
<label class="route-mode-spec">
|
||||
Mode spec
|
||||
<input
|
||||
v-model="plane.draft.mode_spec"
|
||||
placeholder="optional raw mitmproxy mode"
|
||||
placeholder="optional raw mitmdump mode"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="inline-note">
|
||||
Save stores this configuration. Start launches mitmdump with the selected mode.
|
||||
<div
|
||||
v-if="plane.routeState.takeoverMismatch"
|
||||
class="inline-grid advanced-inline-action"
|
||||
>
|
||||
<p class="muted small compact-copy">
|
||||
Proxy port differs from the original service port.
|
||||
</p>
|
||||
<button class="ghost button-icon" @click="plane.matchProxyPortToService">
|
||||
<UiIcon name="link" />
|
||||
<span>Use service port</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -7,19 +7,38 @@ import {
|
||||
watch,
|
||||
type InjectionKey,
|
||||
} 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 {
|
||||
AddonExample,
|
||||
DockerService,
|
||||
DockerServiceBinding,
|
||||
HealthResult,
|
||||
MitmOption,
|
||||
ProxyDocument,
|
||||
ProxyDraft,
|
||||
} from "../types";
|
||||
|
||||
const editorStorageKey = "theplug.editor.open";
|
||||
const toolStorageKey = "theplug.tool.view";
|
||||
const optionScopeStorageKey = "theplug.option.scope";
|
||||
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 = [
|
||||
"reverse",
|
||||
@@ -46,7 +65,7 @@ export const protocols = [
|
||||
];
|
||||
|
||||
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 {
|
||||
return {
|
||||
@@ -82,6 +101,7 @@ def request(flow: http.HTTPFlow) -> None:
|
||||
service: {
|
||||
host: "127.0.0.1",
|
||||
port: 9000,
|
||||
bind_host: null,
|
||||
name: null,
|
||||
folder: null,
|
||||
upstream_port: null,
|
||||
@@ -99,13 +119,48 @@ function writeSession(key: string, value: string) {
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const token = ref(getToken());
|
||||
const username = ref("admin");
|
||||
const password = ref("");
|
||||
const authMessage = ref("");
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
const loading = ref(false);
|
||||
const servicesLoading = ref(false);
|
||||
const proxies = ref<ProxyDocument[]>([]);
|
||||
const services = ref<DockerService[]>([]);
|
||||
const options = ref<MitmOption[]>([]);
|
||||
@@ -113,21 +168,153 @@ export function useControlPlane() {
|
||||
const selectedPort = ref<number | null>(null);
|
||||
const health = ref<HealthResult | null>(null);
|
||||
const logs = ref("");
|
||||
const logFilter = ref("");
|
||||
const logsPaused = ref(false);
|
||||
const optionSearch = ref("");
|
||||
const exampleSearch = ref("");
|
||||
const customExampleName = ref("");
|
||||
const editorOpen = ref(readSession(editorStorageKey, "0") === "1");
|
||||
const toolView = ref<ToolView>(
|
||||
readSession(toolStorageKey, "options") as ToolView,
|
||||
const scanRoot = ref(readSession(scanRootStorageKey, "/root"));
|
||||
const optionScope = ref<OptionScope>(
|
||||
readSession(optionScopeStorageKey, "focused") as OptionScope,
|
||||
);
|
||||
const selectedOptionName = ref("");
|
||||
const selectedExampleId = ref("");
|
||||
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(() =>
|
||||
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 livePort =
|
||||
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 configuredTarget = `${draft.service.host}:${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.";
|
||||
if (reverseManaged && dockerManaged) {
|
||||
let startNotice: string | null = null;
|
||||
if (takeoverReady) {
|
||||
startNotice =
|
||||
`Starting this reverse proxy will move the service from :${draft.service.port} ` +
|
||||
`to a temporary upstream port and bind mitmdump on :${draft.proxy_port}.`;
|
||||
`Start will move the real service from :${draft.service.port} to a temporary upstream port and put 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) {
|
||||
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") {
|
||||
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) {
|
||||
liveNotice = `Live upstream is currently ${liveTarget}. Stop restores :${draft.service.port}.`;
|
||||
} else if (reverseManaged && dockerManaged) {
|
||||
liveNotice = "The original service port stays unchanged until Start is executed.";
|
||||
} else if (takeoverMismatch) {
|
||||
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 {
|
||||
configuredListen: `${draft.listen_host}:${draft.proxy_port}`,
|
||||
configuredTarget,
|
||||
@@ -167,26 +370,35 @@ export function useControlPlane() {
|
||||
reverseManaged,
|
||||
remapped,
|
||||
livePort,
|
||||
takeoverReady,
|
||||
takeoverMismatch,
|
||||
startNotice,
|
||||
liveNotice,
|
||||
summaryHint,
|
||||
};
|
||||
});
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const query = optionSearch.value.toLowerCase().trim();
|
||||
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) => {
|
||||
if (!query) return true;
|
||||
return `${option.name} ${option.description}`.toLowerCase().includes(query);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return filtered.slice(0, query ? 80 : 36);
|
||||
return filtered.slice(0, query ? 80 : optionScope.value === "all" ? 80 : 18);
|
||||
});
|
||||
|
||||
const optionCount = computed(
|
||||
() => options.value.filter((option) => !option.reserved).length,
|
||||
);
|
||||
const activeOptionCount = computed(() => Object.keys(draft.mitm_options).length);
|
||||
|
||||
const selectedOption = computed(
|
||||
() =>
|
||||
@@ -226,19 +438,43 @@ export function useControlPlane() {
|
||||
|
||||
const codeLineCount = computed(() => draft.code.split("\n").length);
|
||||
|
||||
watch(selectedProxy, (proxy) => {
|
||||
watch(
|
||||
[selectedPort, proxies],
|
||||
([port]) => {
|
||||
if (port === null || port === loadedPort.value) return;
|
||||
const proxy = proxies.value.find((item) => item.proxy_port === port);
|
||||
if (!proxy) return;
|
||||
Object.assign(draft, structuredClone(proxy));
|
||||
health.value = null;
|
||||
logs.value = "";
|
||||
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) => {
|
||||
writeSession(editorStorageKey, value ? "1" : "0");
|
||||
});
|
||||
|
||||
watch(toolView, (value) => {
|
||||
writeSession(toolStorageKey, value);
|
||||
watch(scanRoot, (value) => {
|
||||
writeSession(scanRootStorageKey, value);
|
||||
});
|
||||
|
||||
watch(filteredOptions, (items) => {
|
||||
@@ -253,60 +489,196 @@ export function useControlPlane() {
|
||||
}
|
||||
}, { 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() {
|
||||
await run(async () => {
|
||||
await login(username.value, password.value);
|
||||
token.value = getToken();
|
||||
authMessage.value = "";
|
||||
password.value = "";
|
||||
await refreshAll();
|
||||
}, "Logged in");
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
if (token.value) {
|
||||
await logout();
|
||||
token.value = null;
|
||||
proxies.value = [];
|
||||
}
|
||||
clearSession();
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await run(async () => {
|
||||
const [proxyList, serviceList, optionList, exampleList] = await Promise.all([
|
||||
servicesLoading.value = true;
|
||||
try {
|
||||
const [proxyList, serviceResponse, optionList, exampleList] =
|
||||
await Promise.all([
|
||||
api.proxies(),
|
||||
api.services(),
|
||||
api.services(scanRoot.value.trim() || undefined),
|
||||
api.options(),
|
||||
api.examples(),
|
||||
]);
|
||||
proxies.value = proxyList;
|
||||
services.value = serviceList;
|
||||
services.value = serviceResponse.services;
|
||||
scanRoot.value = serviceResponse.scan_root;
|
||||
options.value = optionList;
|
||||
examples.value = exampleList;
|
||||
if (!selectedPort.value && proxyList[0]) {
|
||||
selectedPort.value = proxyList[0].proxy_port;
|
||||
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;
|
||||
loadedPort.value = null;
|
||||
health.value = null;
|
||||
logs.value = "";
|
||||
logFilter.value = "";
|
||||
Object.assign(
|
||||
draft,
|
||||
createDefaultDraft(nextPort(), examples.value[0]?.code ?? draft.code),
|
||||
);
|
||||
notice.value = "New proxy draft created. Save stores configuration only.";
|
||||
toolView.value = "options";
|
||||
if (announce) {
|
||||
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() {
|
||||
let persisted = false;
|
||||
await run(async () => {
|
||||
const payload = cleanDraft();
|
||||
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 refreshAll();
|
||||
selectedPort.value = saved.proxy_port;
|
||||
const refreshed = proxies.value.find((proxy) => proxy.proxy_port === saved.proxy_port);
|
||||
hydrateDraft(refreshed ?? saved);
|
||||
persisted = true;
|
||||
}, "Proxy saved");
|
||||
if (persisted) {
|
||||
wizardOpen.value = false;
|
||||
wizardStep.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProxy() {
|
||||
@@ -315,6 +687,7 @@ export function useControlPlane() {
|
||||
await run(async () => {
|
||||
await api.deleteProxy(proxy.proxy_port);
|
||||
selectedPort.value = null;
|
||||
loadedPort.value = null;
|
||||
await refreshAll();
|
||||
}, "Proxy deleted");
|
||||
}
|
||||
@@ -330,8 +703,10 @@ export function useControlPlane() {
|
||||
: kind === "stop"
|
||||
? await api.stop(port)
|
||||
: await api.restart(port);
|
||||
replaceProxy(updated);
|
||||
await refreshAll();
|
||||
selectedPort.value = updated.proxy_port;
|
||||
const refreshed = proxies.value.find((item) => item.proxy_port === updated.proxy_port);
|
||||
hydrateDraft(refreshed ?? updated);
|
||||
}, `Proxy ${kind} requested`);
|
||||
}
|
||||
|
||||
@@ -344,11 +719,19 @@ export function useControlPlane() {
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
await syncLogs(false);
|
||||
}
|
||||
|
||||
async function syncLogs(silent = false) {
|
||||
const proxy = selectedProxy.value;
|
||||
if (!proxy) return;
|
||||
await run(async () => {
|
||||
try {
|
||||
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() {
|
||||
@@ -364,7 +747,7 @@ export function useControlPlane() {
|
||||
await api.createExample({
|
||||
id,
|
||||
name,
|
||||
description: `Captured from ${draft.mode} ${draft.protocol} proxy ${draft.name}.`,
|
||||
description: `Saved from ${draft.name} (${draft.mode} / ${draft.protocol}).`,
|
||||
category: "custom",
|
||||
code: draft.code,
|
||||
source_url: null,
|
||||
@@ -383,19 +766,38 @@ export function useControlPlane() {
|
||||
}, "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.folder = service.folder;
|
||||
draft.service.port = port;
|
||||
draft.service.bind_host = bindHost;
|
||||
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) {
|
||||
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;
|
||||
draft.code = example.code;
|
||||
notice.value = editorOpen.value
|
||||
? `Loaded example: ${example.name}`
|
||||
: `Loaded example: ${example.name}. Open the editor to review or modify the code.`;
|
||||
notice.value = `Loaded ${example.name}.`;
|
||||
}
|
||||
|
||||
function selectExample(exampleId: string) {
|
||||
@@ -436,12 +838,20 @@ export function useControlPlane() {
|
||||
draft.mitm_options[name] = value;
|
||||
}
|
||||
|
||||
function toggleEditor(force?: boolean) {
|
||||
editorOpen.value = typeof force === "boolean" ? force : !editorOpen.value;
|
||||
function matchProxyPortToService() {
|
||||
draft.proxy_port = draft.service.port;
|
||||
}
|
||||
|
||||
function setToolView(view: ToolView) {
|
||||
toolView.value = view;
|
||||
function toggleOptionScope(scope: OptionScope) {
|
||||
optionScope.value = scope;
|
||||
}
|
||||
|
||||
function toggleLogsPaused() {
|
||||
logsPaused.value = !logsPaused.value;
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = "";
|
||||
}
|
||||
|
||||
function cleanDraft(): ProxyDraft {
|
||||
@@ -463,6 +873,7 @@ export function useControlPlane() {
|
||||
service: {
|
||||
host: draft.service.host,
|
||||
port: draft.service.port,
|
||||
bind_host: draft.service.bind_host || null,
|
||||
name: draft.service.name || null,
|
||||
folder: draft.service.folder || null,
|
||||
upstream_port: draft.service.upstream_port || null,
|
||||
@@ -471,12 +882,35 @@ export function useControlPlane() {
|
||||
) as ProxyDraft;
|
||||
}
|
||||
|
||||
function replaceProxy(proxy: ProxyDocument) {
|
||||
const index = proxies.value.findIndex(
|
||||
(item) => item.proxy_port === proxy.proxy_port,
|
||||
);
|
||||
if (index === -1) proxies.value.push(proxy);
|
||||
else proxies.value[index] = proxy;
|
||||
function hydrateDraft(proxy: ProxyDocument) {
|
||||
Object.assign(draft, JSON.parse(JSON.stringify(proxy)) as ProxyDraft);
|
||||
loadedPort.value = proxy.proxy_port;
|
||||
health.value = null;
|
||||
logs.value = "";
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -497,26 +931,83 @@ export function useControlPlane() {
|
||||
async function run(task: () => Promise<void>, success?: string) {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
clearNotice();
|
||||
try {
|
||||
await task();
|
||||
if (success) notice.value = success;
|
||||
if (success) showNotice(success);
|
||||
} 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);
|
||||
} finally {
|
||||
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({
|
||||
activeOptionCount,
|
||||
activeOptions,
|
||||
applyExample,
|
||||
applyService,
|
||||
actionState,
|
||||
authMessage,
|
||||
checkHealth,
|
||||
clearLogs,
|
||||
closeWizard,
|
||||
codeLineCount,
|
||||
filteredLogs,
|
||||
logFilter,
|
||||
logsPaused,
|
||||
createMissingServiceProxies,
|
||||
createProxyFromService,
|
||||
customExampleName,
|
||||
draft,
|
||||
editorOpen,
|
||||
error,
|
||||
exampleSearch,
|
||||
filteredExamples,
|
||||
@@ -527,15 +1018,21 @@ export function useControlPlane() {
|
||||
logs,
|
||||
newProxy,
|
||||
notice,
|
||||
openCreateWizard,
|
||||
openEditWizard,
|
||||
optionCount,
|
||||
optionScope,
|
||||
optionSearch,
|
||||
password,
|
||||
previousWizardStep,
|
||||
proxies,
|
||||
refreshServices,
|
||||
refreshAll,
|
||||
removeExample,
|
||||
removeOption,
|
||||
removeProxy,
|
||||
routeState,
|
||||
runtimeStatus,
|
||||
runtimeAction,
|
||||
saveCurrentAsExample,
|
||||
saveProxy,
|
||||
@@ -545,16 +1042,28 @@ export function useControlPlane() {
|
||||
selectedPort,
|
||||
selectedProxy,
|
||||
selectOption,
|
||||
serviceEntries,
|
||||
services,
|
||||
setOption,
|
||||
setToolView,
|
||||
signIn,
|
||||
signOut,
|
||||
toggleEditor,
|
||||
matchProxyPortToService,
|
||||
missingServiceCount,
|
||||
nextWizardStep,
|
||||
toggleOptionScope,
|
||||
toggleLogsPaused,
|
||||
token,
|
||||
toolView,
|
||||
updateOption,
|
||||
username,
|
||||
wizardMode,
|
||||
wizardOpen,
|
||||
wizardStep,
|
||||
wizardStepTitle,
|
||||
wizardTitle,
|
||||
canGoBackWizard,
|
||||
canGoNextWizard,
|
||||
scanRoot,
|
||||
servicesLoading,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
|
||||
import App from "./App.vue";
|
||||
import { router } from "./router";
|
||||
import "./styles.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
createApp(App).use(router).mount("#app");
|
||||
|
||||
@@ -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
@@ -2,23 +2,23 @@
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #08090b;
|
||||
--bg-elevated: #0d1014;
|
||||
--panel: #11151a;
|
||||
--bg: #090b0d;
|
||||
--bg-elevated: #0f1317;
|
||||
--panel: #11161b;
|
||||
--panel-2: #151b21;
|
||||
--line: #252d35;
|
||||
--line-strong: #33404d;
|
||||
--text: #eef3f7;
|
||||
--muted: #9da8b2;
|
||||
--accent: #9be7ff;
|
||||
--accent-2: #c6f2ff;
|
||||
--ok: #8ef0b0;
|
||||
--warn: #ffd88c;
|
||||
--danger: #ff9f9f;
|
||||
--radius-lg: 14px;
|
||||
--radius-md: 10px;
|
||||
--radius-sm: 8px;
|
||||
--shadow: 0 20px 40px rgba(0, 0, 0, 0.22);
|
||||
--line: #252c34;
|
||||
--line-strong: #36414c;
|
||||
--text: #f1f5f8;
|
||||
--muted: #9aa6b2;
|
||||
--accent: #a8ecff;
|
||||
--accent-2: #d5f7ff;
|
||||
--ok: #8ce7af;
|
||||
--warn: #ffd585;
|
||||
--danger: #ff9d9d;
|
||||
--radius-lg: 8px;
|
||||
--radius-md: 6px;
|
||||
--radius-sm: 4px;
|
||||
--shadow: none;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -39,11 +39,7 @@ body {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-family: "IBM Plex Sans", sans-serif;
|
||||
background:
|
||||
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;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -72,6 +68,24 @@ button {
|
||||
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) {
|
||||
border-color: var(--line-strong);
|
||||
background: #1c232b;
|
||||
@@ -96,6 +110,26 @@ button.ghost {
|
||||
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 {
|
||||
color: #ffe1e1;
|
||||
background: #2b1717;
|
||||
@@ -201,7 +235,6 @@ pre {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@@ -262,7 +295,7 @@ pre {
|
||||
|
||||
.app-body {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
grid-template-columns: 250px minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
margin-top: 1rem;
|
||||
@@ -278,7 +311,7 @@ pre {
|
||||
.panel-title h2,
|
||||
.workbench-copy h1,
|
||||
.login-card h1 {
|
||||
margin: 0.2rem 0 0;
|
||||
margin: 0;
|
||||
font-size: clamp(1.18rem, 1.7vw, 1.5rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
@@ -343,8 +376,8 @@ pre {
|
||||
}
|
||||
|
||||
.proxy-card.selected {
|
||||
border-color: #4f6b7c;
|
||||
background: #151c23;
|
||||
border-color: #4e6272;
|
||||
background: #161d24;
|
||||
}
|
||||
|
||||
.proxy-card-head,
|
||||
@@ -402,7 +435,6 @@ pre {
|
||||
}
|
||||
|
||||
.workbench-head,
|
||||
.summary-panel,
|
||||
.editor-pane,
|
||||
.stack-panel {
|
||||
padding: 1rem;
|
||||
@@ -454,8 +486,7 @@ pre {
|
||||
|
||||
.action-row,
|
||||
.doc-actions,
|
||||
.template-actions,
|
||||
.tool-switcher {
|
||||
.template-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
@@ -486,22 +517,27 @@ pre {
|
||||
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 {
|
||||
display: inline-block;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -523,6 +559,12 @@ pre {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.route-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
@@ -530,23 +572,27 @@ pre {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.summary-lines {
|
||||
.workspace-main-row {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.workbench-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.85fr) minmax(0, 1.15fr);
|
||||
grid-template-columns: minmax(0, 5fr) minmax(320px, 3fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.overview-stack,
|
||||
.utility-panel {
|
||||
.workspace-editor-pane,
|
||||
.workspace-logs-pane,
|
||||
.workspace-templates-row {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-editor-pane,
|
||||
.workspace-logs-pane {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wizard-step-shell {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.compact-panel {
|
||||
@@ -597,37 +643,14 @@ pre {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.service-grid {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
.service-folder {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.port-chip.active {
|
||||
color: #071117;
|
||||
background: var(--accent);
|
||||
border-color: #7dcfe7;
|
||||
font-size: 0.74rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compact-details {
|
||||
@@ -688,14 +711,29 @@ pre {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tool-switcher button {
|
||||
background: #0f1318;
|
||||
.title-actions,
|
||||
.segmented {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.tool-switcher button.active {
|
||||
color: #081016;
|
||||
.segmented {
|
||||
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);
|
||||
border-color: #7dcfe7;
|
||||
}
|
||||
|
||||
.tool-panel {
|
||||
@@ -710,7 +748,7 @@ pre {
|
||||
.table-head,
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr) auto;
|
||||
grid-template-columns: 150px minmax(0, 1fr) auto;
|
||||
gap: 0.7rem;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -729,11 +767,19 @@ pre {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.compact-scroll {
|
||||
max-height: 180px;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
padding: 0.6rem 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.table-row input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-link {
|
||||
justify-self: start;
|
||||
padding: 0;
|
||||
@@ -751,15 +797,62 @@ pre {
|
||||
.option-shell,
|
||||
.example-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1fr);
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
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,
|
||||
.example-browser,
|
||||
.option-doc,
|
||||
.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;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
@@ -768,11 +861,39 @@ pre {
|
||||
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,
|
||||
.example-index {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
max-height: 380px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -780,7 +901,7 @@ pre {
|
||||
.example-entry {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
gap: 0.2rem;
|
||||
text-align: left;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: #0d1116;
|
||||
@@ -820,25 +941,67 @@ pre {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.compact-copy {
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.example-snippet {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.doc-facts,
|
||||
.template-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.doc-facts span {
|
||||
padding: 0.22rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: #0d1116;
|
||||
.doc-facts {
|
||||
margin: 0;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.doc-fact {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #0d1116;
|
||||
}
|
||||
|
||||
.logs-panel .terminal {
|
||||
min-height: 300px;
|
||||
max-height: 420px;
|
||||
.doc-fact dt {
|
||||
color: var(--muted);
|
||||
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 {
|
||||
@@ -855,6 +1018,7 @@ pre {
|
||||
|
||||
.editor-pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -870,7 +1034,7 @@ pre {
|
||||
}
|
||||
|
||||
.editor-shell {
|
||||
min-height: 620px;
|
||||
min-height: clamp(420px, 68vh, 760px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
@@ -879,28 +1043,219 @@ pre {
|
||||
|
||||
.code-editor-wrap {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.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;
|
||||
height: 100%;
|
||||
min-height: clamp(420px, 68vh, 760px);
|
||||
}
|
||||
|
||||
.code-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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 {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
@@ -930,8 +1285,8 @@ pre {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.workbench-grid {
|
||||
@media (max-width: 1180px) {
|
||||
.option-step-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -941,9 +1296,9 @@ pre {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
@media (max-width: 980px) {
|
||||
.app-body,
|
||||
.summary-grid {
|
||||
.workspace-main-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -951,6 +1306,10 @@ pre {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.logs-panel .terminal {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
@@ -967,9 +1326,13 @@ pre {
|
||||
|
||||
.form-grid,
|
||||
.metric-grid,
|
||||
.route-overview-grid,
|
||||
.doc-facts-compact,
|
||||
.table-head,
|
||||
.table-row,
|
||||
.health-row {
|
||||
.health-row,
|
||||
.doc-facts,
|
||||
.route-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -982,6 +1345,21 @@ pre {
|
||||
.code-editor {
|
||||
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) {
|
||||
@@ -1000,4 +1378,12 @@ pre {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.service-discovery-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.service-discovery-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
export type ServiceEndpoint = {
|
||||
host: string;
|
||||
port: number;
|
||||
bind_host?: string | null;
|
||||
name?: string | null;
|
||||
folder?: string | null;
|
||||
upstream_port?: number | null;
|
||||
};
|
||||
|
||||
export type DockerServiceBinding = {
|
||||
published: number;
|
||||
host?: string | null;
|
||||
};
|
||||
|
||||
export type HealthCheck = {
|
||||
enabled: boolean;
|
||||
protocol?: string | null;
|
||||
@@ -42,9 +48,15 @@ export type ProxyDocument = {
|
||||
export type DockerService = {
|
||||
name: string;
|
||||
ports: number[];
|
||||
bindings?: DockerServiceBinding[];
|
||||
folder: string;
|
||||
};
|
||||
|
||||
export type ServiceDiscoveryResponse = {
|
||||
scan_root: string;
|
||||
services: DockerService[];
|
||||
};
|
||||
|
||||
export type MitmOption = {
|
||||
name: string;
|
||||
type: string;
|
||||
|
||||
@@ -10,7 +10,7 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3671",
|
||||
target: "http://127.0.0.1:3771",
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
|
||||
+5
-2
@@ -3,14 +3,17 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"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:deps": "./setup.sh deps",
|
||||
"test:backend": "cd backend && uv run pytest",
|
||||
"lint:backend": "cd backend && uv run ruff check .",
|
||||
"format:backend": "cd backend && uv run ruff format .",
|
||||
"build:frontend": "cd frontend && npm run build",
|
||||
"test": "npm run lint:backend && npm run test:backend && npm run build:frontend",
|
||||
"compose:up": "./setup.sh",
|
||||
"compose:down": "docker compose --env-file backend/.env -f docker-compose.yml down"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
echo:
|
||||
image: hashicorp/http-echo:1.0
|
||||
command:
|
||||
- -listen=:5678
|
||||
- -text=theplug playground upstream
|
||||
ports:
|
||||
- 9081:5678
|
||||
@@ -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
|
||||
@@ -17,6 +17,22 @@ compose() {
|
||||
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() {
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
return
|
||||
@@ -64,12 +80,29 @@ EOF
|
||||
|
||||
start_stack() {
|
||||
ensure_env
|
||||
sync_env_values
|
||||
info "Building and starting backend, frontend, MongoDB and Redis..."
|
||||
compose up -d --build
|
||||
info "Backend: http://localhost:$(grep -E '^API_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 3671)"
|
||||
info "Frontend: http://localhost:$(grep -E '^FRONTEND_PORT=' "$ENV_FILE" | cut -d '=' -f2 || echo 5173)"
|
||||
}
|
||||
|
||||
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
|
||||
ensure_env
|
||||
install_service
|
||||
@@ -77,6 +110,16 @@ if [[ "${1:-}" == "service" ]]; then
|
||||
exit 0
|
||||
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
|
||||
info "thePlug found in /srv/theplug, switching there"
|
||||
cd /srv/theplug
|
||||
|
||||
Reference in New Issue
Block a user