pypproxy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pypproxy/__init__.py +0 -0
- pypproxy/api/__init__.py +0 -0
- pypproxy/api/server.py +427 -0
- pypproxy/bulk/__init__.py +0 -0
- pypproxy/bulk/sender.py +97 -0
- pypproxy/cert/__init__.py +0 -0
- pypproxy/cert/ca.py +144 -0
- pypproxy/cert/client_cert.py +65 -0
- pypproxy/codec.py +176 -0
- pypproxy/config/__init__.py +0 -0
- pypproxy/config/config.py +106 -0
- pypproxy/dns/__init__.py +0 -0
- pypproxy/dns/server.py +149 -0
- pypproxy/exporter/__init__.py +0 -0
- pypproxy/exporter/exporter.py +122 -0
- pypproxy/exporter/importer.py +169 -0
- pypproxy/graphql/__init__.py +0 -0
- pypproxy/graphql/detector.py +76 -0
- pypproxy/graphql/introspection.py +217 -0
- pypproxy/graphql/modifier.py +98 -0
- pypproxy/graphql/schema_store.py +33 -0
- pypproxy/intercept/__init__.py +0 -0
- pypproxy/intercept/manager.py +142 -0
- pypproxy/interceptor/__init__.py +0 -0
- pypproxy/interceptor/interceptor.py +172 -0
- pypproxy/proto/__init__.py +0 -0
- pypproxy/proto/grpc.py +48 -0
- pypproxy/proto/mqtt.py +119 -0
- pypproxy/proto/ws.py +120 -0
- pypproxy/proto/ws_intercept.py +117 -0
- pypproxy/proxy/__init__.py +0 -0
- pypproxy/proxy/proxy.py +407 -0
- pypproxy/replay/__init__.py +0 -0
- pypproxy/replay/replay.py +77 -0
- pypproxy/rule/__init__.py +0 -0
- pypproxy/rule/rule.py +198 -0
- pypproxy/scan/__init__.py +0 -0
- pypproxy/scan/scanner.py +296 -0
- pypproxy/script/__init__.py +0 -0
- pypproxy/script/engine.py +49 -0
- pypproxy/security/__init__.py +0 -0
- pypproxy/security/header_checker.py +308 -0
- pypproxy/security/int_overflow.py +193 -0
- pypproxy/security/jwt_checker.py +273 -0
- pypproxy/security/plugin.py +152 -0
- pypproxy/security/randomness.py +165 -0
- pypproxy/store/__init__.py +0 -0
- pypproxy/store/db.py +189 -0
- pypproxy/store/filter_parser.py +181 -0
- pypproxy/store/fts.py +105 -0
- pypproxy/store/models.py +81 -0
- pypproxy/store/scope.py +63 -0
- pypproxy/store/store.py +120 -0
- pypproxy/ui/__init__.py +0 -0
- pypproxy/ui/app.py +386 -0
- pypproxy/ui/bulk_sender_ui.py +125 -0
- pypproxy/ui/cui.py +162 -0
- pypproxy/ui/detail.py +179 -0
- pypproxy/ui/diff_view.py +118 -0
- pypproxy/ui/graphql_tab.py +265 -0
- pypproxy/ui/import_tab.py +136 -0
- pypproxy/ui/intercept_dialog.py +74 -0
- pypproxy/ui/resender.py +140 -0
- pypproxy/ui/scan_tab.py +98 -0
- pypproxy/ui/security_tab.py +356 -0
- pypproxy/ui/settings.py +413 -0
- pypproxy/ui/theme.py +59 -0
- pypproxy-0.1.0.dist-info/METADATA +19 -0
- pypproxy-0.1.0.dist-info/RECORD +72 -0
- pypproxy-0.1.0.dist-info/WHEEL +4 -0
- pypproxy-0.1.0.dist-info/entry_points.txt +2 -0
- pypproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
pypproxy/store/store.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from .models import Entry, Filter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Store:
|
|
11
|
+
"""In-memory traffic store with optional SQLite persistence."""
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self._entries: list[Entry] = []
|
|
15
|
+
self._by_id: dict[int, Entry] = {}
|
|
16
|
+
self._counter = 0
|
|
17
|
+
self._lock = threading.Lock()
|
|
18
|
+
self._subscribers: list[asyncio.Queue] = []
|
|
19
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
20
|
+
self._db: object | None = None # paxy.store.db.Database
|
|
21
|
+
|
|
22
|
+
def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
23
|
+
self._loop = loop
|
|
24
|
+
|
|
25
|
+
def set_db(self, db: object) -> None:
|
|
26
|
+
self._db = db
|
|
27
|
+
|
|
28
|
+
# --- write ---
|
|
29
|
+
|
|
30
|
+
def add(self, entry: Entry) -> Entry:
|
|
31
|
+
with self._lock:
|
|
32
|
+
self._counter += 1
|
|
33
|
+
entry.id = self._counter
|
|
34
|
+
self._entries.append(entry)
|
|
35
|
+
self._by_id[entry.id] = entry
|
|
36
|
+
self._publish(entry)
|
|
37
|
+
self._db_insert(entry)
|
|
38
|
+
return entry
|
|
39
|
+
|
|
40
|
+
def update(self, entry: Entry) -> None:
|
|
41
|
+
with self._lock:
|
|
42
|
+
self._by_id[entry.id] = entry
|
|
43
|
+
# update in-place in list
|
|
44
|
+
for i, e in enumerate(self._entries):
|
|
45
|
+
if e.id == entry.id:
|
|
46
|
+
self._entries[i] = entry
|
|
47
|
+
break
|
|
48
|
+
self._publish(entry)
|
|
49
|
+
self._db_update(entry)
|
|
50
|
+
|
|
51
|
+
def set_color(self, entry_id: int, color: str) -> None:
|
|
52
|
+
with self._lock:
|
|
53
|
+
e = self._by_id.get(entry_id)
|
|
54
|
+
if e:
|
|
55
|
+
e.color = color
|
|
56
|
+
self.update(e)
|
|
57
|
+
|
|
58
|
+
def clear(self) -> None:
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._entries.clear()
|
|
61
|
+
self._by_id.clear()
|
|
62
|
+
if self._loop and self._db:
|
|
63
|
+
asyncio.run_coroutine_threadsafe(self._db.clear(), self._loop)
|
|
64
|
+
|
|
65
|
+
# --- read ---
|
|
66
|
+
|
|
67
|
+
def get(self, entry_id: int) -> Entry | None:
|
|
68
|
+
return self._by_id.get(entry_id)
|
|
69
|
+
|
|
70
|
+
def list(self, f: Filter, offset: int = 0, limit: int = 100) -> tuple[list[Entry], int]:
|
|
71
|
+
with self._lock:
|
|
72
|
+
filtered = [e for e in self._entries if f.matches(e)]
|
|
73
|
+
total = len(filtered)
|
|
74
|
+
if limit == 0:
|
|
75
|
+
return filtered[offset:], total
|
|
76
|
+
return filtered[offset : offset + limit], total
|
|
77
|
+
|
|
78
|
+
# --- pub/sub ---
|
|
79
|
+
|
|
80
|
+
def subscribe(self) -> asyncio.Queue:
|
|
81
|
+
q: asyncio.Queue = asyncio.Queue(maxsize=512)
|
|
82
|
+
with self._lock:
|
|
83
|
+
self._subscribers.append(q)
|
|
84
|
+
return q
|
|
85
|
+
|
|
86
|
+
def unsubscribe(self, q: asyncio.Queue) -> None:
|
|
87
|
+
with self._lock, contextlib.suppress(ValueError):
|
|
88
|
+
self._subscribers.remove(q)
|
|
89
|
+
|
|
90
|
+
def _publish(self, entry: Entry) -> None:
|
|
91
|
+
if self._loop is None:
|
|
92
|
+
return
|
|
93
|
+
with self._lock:
|
|
94
|
+
subs = list(self._subscribers)
|
|
95
|
+
for q in subs:
|
|
96
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
97
|
+
self._loop.call_soon_threadsafe(q.put_nowait, entry)
|
|
98
|
+
|
|
99
|
+
# --- DB helpers (fire-and-forget) ---
|
|
100
|
+
|
|
101
|
+
def _db_insert(self, entry: Entry) -> None:
|
|
102
|
+
if self._loop and self._db:
|
|
103
|
+
asyncio.run_coroutine_threadsafe(self._db.insert(entry), self._loop)
|
|
104
|
+
|
|
105
|
+
def _db_update(self, entry: Entry) -> None:
|
|
106
|
+
if self._loop and self._db:
|
|
107
|
+
asyncio.run_coroutine_threadsafe(self._db.update(entry), self._loop)
|
|
108
|
+
|
|
109
|
+
# --- restore from DB on startup ---
|
|
110
|
+
|
|
111
|
+
async def load_from_db(self) -> None:
|
|
112
|
+
if not self._db:
|
|
113
|
+
return
|
|
114
|
+
entries, _ = await self._db.list(Filter(), offset=0, limit=0)
|
|
115
|
+
with self._lock:
|
|
116
|
+
for e in entries:
|
|
117
|
+
self._entries.append(e)
|
|
118
|
+
self._by_id[e.id] = e
|
|
119
|
+
if e.id > self._counter:
|
|
120
|
+
self._counter = e.id
|
pypproxy/ui/__init__.py
ADDED
|
File without changes
|
pypproxy/ui/app.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from nicegui import app as nicegui_app
|
|
6
|
+
from nicegui import ui
|
|
7
|
+
|
|
8
|
+
from pypproxy.intercept.manager import InterceptManager
|
|
9
|
+
from pypproxy.store.models import Entry, Filter
|
|
10
|
+
from pypproxy.store.store import Store
|
|
11
|
+
|
|
12
|
+
from .detail import render_detail
|
|
13
|
+
from .intercept_dialog import build_intercept_panel
|
|
14
|
+
from .theme import apply_dark_theme
|
|
15
|
+
|
|
16
|
+
_ROW_COLORS = ["", "#b71c1c", "#1b5e20", "#0d47a1", "#f57f17", "#4a148c"]
|
|
17
|
+
_COLOR_LABELS = ["None", "Red", "Green", "Blue", "Yellow", "Purple"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_ui(
|
|
21
|
+
store: Store,
|
|
22
|
+
intercept_mgr: InterceptManager | None = None,
|
|
23
|
+
settings_kwargs: dict | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
if settings_kwargs:
|
|
26
|
+
from .settings import build_settings_page
|
|
27
|
+
|
|
28
|
+
build_settings_page(**settings_kwargs)
|
|
29
|
+
|
|
30
|
+
@ui.page("/")
|
|
31
|
+
async def index() -> None:
|
|
32
|
+
apply_dark_theme()
|
|
33
|
+
ui.dark_mode().enable()
|
|
34
|
+
|
|
35
|
+
state: dict = {
|
|
36
|
+
"entries": [],
|
|
37
|
+
"selected": None,
|
|
38
|
+
"filter": Filter(),
|
|
39
|
+
"compare_left": None,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# toolbar
|
|
43
|
+
with ui.header().classes("items-center q-px-md gap-4").style("background:#1a1a2e"):
|
|
44
|
+
ui.label("paxy").classes("text-h6 text-weight-bold")
|
|
45
|
+
ui.badge("●", color="positive").props("rounded").tooltip("Proxy running")
|
|
46
|
+
|
|
47
|
+
filter_input = (
|
|
48
|
+
ui.input(placeholder="Filter: host == example.com && method == POST")
|
|
49
|
+
.props("dense outlined dark")
|
|
50
|
+
.classes("w-96")
|
|
51
|
+
.tooltip(
|
|
52
|
+
"Fields: host path method status protocol request response full_text "
|
|
53
|
+
"Ops: == != contains ~ Logic: && ||"
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
ui.space()
|
|
57
|
+
|
|
58
|
+
if intercept_mgr is not None:
|
|
59
|
+
intercept_toggle = (
|
|
60
|
+
ui.switch("Intercept")
|
|
61
|
+
.props("dense dark color=warning")
|
|
62
|
+
.tooltip("Pause requests for manual review")
|
|
63
|
+
)
|
|
64
|
+
intercept_toggle.on(
|
|
65
|
+
"update:model-value",
|
|
66
|
+
lambda e: intercept_mgr.set_enabled(e.args),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
clear_btn = ui.button("Clear", icon="delete_sweep").props("color=negative size=sm flat")
|
|
70
|
+
ui.button(icon="settings", on_click=lambda: ui.navigate.to("/settings")).props(
|
|
71
|
+
"flat dark size=sm"
|
|
72
|
+
).tooltip("Settings")
|
|
73
|
+
|
|
74
|
+
# main tabs
|
|
75
|
+
with ui.tabs().props("dense dark").classes("bg-dark") as tabs:
|
|
76
|
+
traffic_tab = ui.tab("Traffic", icon="list")
|
|
77
|
+
resender_tab = ui.tab("Resender", icon="send")
|
|
78
|
+
bulk_tab = ui.tab("Bulk Sender", icon="dynamic_feed")
|
|
79
|
+
diff_tab = ui.tab("Diff", icon="difference")
|
|
80
|
+
security_tab = ui.tab("Security", icon="security")
|
|
81
|
+
scan_tab = ui.tab("Scan", icon="search")
|
|
82
|
+
graphql_tab = ui.tab("GraphQL", icon="account_tree")
|
|
83
|
+
import_tab_btn = ui.tab("Import/Search", icon="upload")
|
|
84
|
+
|
|
85
|
+
with (
|
|
86
|
+
ui.tab_panels(tabs, value=traffic_tab)
|
|
87
|
+
.classes("w-full flex-1")
|
|
88
|
+
.style("height:calc(100vh - 96px)")
|
|
89
|
+
):
|
|
90
|
+
with (
|
|
91
|
+
ui.tab_panel(traffic_tab).classes("p-0 h-full"),
|
|
92
|
+
ui.splitter(value=60).classes("w-full h-full") as splitter,
|
|
93
|
+
):
|
|
94
|
+
with splitter.before, ui.column().classes("w-full h-full overflow-auto"):
|
|
95
|
+
table = _build_table()
|
|
96
|
+
with (
|
|
97
|
+
splitter.after,
|
|
98
|
+
ui.column().classes("w-full h-full overflow-auto q-pa-sm") as detail_col,
|
|
99
|
+
):
|
|
100
|
+
render_detail(None, detail_col)
|
|
101
|
+
|
|
102
|
+
with ui.tab_panel(resender_tab).classes("p-0 h-full"):
|
|
103
|
+
from .resender import build_resender_tab
|
|
104
|
+
|
|
105
|
+
resender_container = ui.column().classes("w-full h-full")
|
|
106
|
+
with resender_container:
|
|
107
|
+
build_resender_tab(store)
|
|
108
|
+
|
|
109
|
+
with ui.tab_panel(bulk_tab).classes("p-0 h-full"):
|
|
110
|
+
from .bulk_sender_ui import build_bulk_sender
|
|
111
|
+
|
|
112
|
+
bulk_container = ui.column().classes("w-full h-full overflow-auto q-pa-md")
|
|
113
|
+
bulk_state = build_bulk_sender(bulk_container)
|
|
114
|
+
|
|
115
|
+
with ui.tab_panel(diff_tab).classes("p-0 h-full"):
|
|
116
|
+
from .diff_view import build_diff_view
|
|
117
|
+
|
|
118
|
+
diff_container = ui.column().classes("w-full h-full overflow-auto q-pa-md")
|
|
119
|
+
diff_state = build_diff_view(diff_container)
|
|
120
|
+
|
|
121
|
+
with ui.tab_panel(security_tab).classes("p-0 h-full"):
|
|
122
|
+
from .security_tab import build_security_tab
|
|
123
|
+
|
|
124
|
+
sec_state = build_security_tab(store)
|
|
125
|
+
|
|
126
|
+
with ui.tab_panel(scan_tab).classes("p-0 h-full"):
|
|
127
|
+
from .scan_tab import build_scan_tab
|
|
128
|
+
|
|
129
|
+
scan_state = build_scan_tab(store)
|
|
130
|
+
|
|
131
|
+
with ui.tab_panel(graphql_tab).classes("p-0 h-full"):
|
|
132
|
+
from .graphql_tab import build_graphql_tab
|
|
133
|
+
|
|
134
|
+
gql_state = build_graphql_tab(store)
|
|
135
|
+
|
|
136
|
+
with ui.tab_panel(import_tab_btn).classes("p-0 h-full"):
|
|
137
|
+
from .import_tab import build_import_tab
|
|
138
|
+
|
|
139
|
+
build_import_tab(store)
|
|
140
|
+
|
|
141
|
+
clear_btn.on("click", lambda: _clear(store, state, table, detail_col))
|
|
142
|
+
|
|
143
|
+
def apply_filter() -> None:
|
|
144
|
+
state["filter"] = Filter(expression=filter_input.value or "")
|
|
145
|
+
_refresh_table(store, state, table)
|
|
146
|
+
|
|
147
|
+
filter_input.on("update:model-value", lambda: apply_filter())
|
|
148
|
+
|
|
149
|
+
async def on_row_click(e) -> None: # noqa: ANN001
|
|
150
|
+
try:
|
|
151
|
+
row = e.args[1] if isinstance(e.args, list) else e.args
|
|
152
|
+
entry_id = int(row["id"])
|
|
153
|
+
except (IndexError, KeyError, TypeError, ValueError):
|
|
154
|
+
return
|
|
155
|
+
entry = store.get(entry_id)
|
|
156
|
+
if entry:
|
|
157
|
+
state["selected"] = entry
|
|
158
|
+
render_detail(entry, detail_col)
|
|
159
|
+
|
|
160
|
+
table.on("row-click", on_row_click)
|
|
161
|
+
|
|
162
|
+
async def on_row_contextmenu(e) -> None: # noqa: ANN001
|
|
163
|
+
try:
|
|
164
|
+
row = e.args[1] if isinstance(e.args, list) else e.args
|
|
165
|
+
entry_id = int(row["id"])
|
|
166
|
+
except (IndexError, KeyError, TypeError, ValueError):
|
|
167
|
+
return
|
|
168
|
+
entry = store.get(entry_id)
|
|
169
|
+
if not entry:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
with ui.menu() as menu:
|
|
173
|
+
ui.menu_item(
|
|
174
|
+
"Send to Resender",
|
|
175
|
+
on_click=lambda: (
|
|
176
|
+
tabs.set_value(resender_tab),
|
|
177
|
+
ui.notify(f"Opened #{entry.id} in Resender", type="info"),
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
ui.menu_item(
|
|
181
|
+
"Send to Bulk Sender",
|
|
182
|
+
on_click=lambda: (bulk_state["open_entry"](entry), tabs.set_value(bulk_tab)),
|
|
183
|
+
)
|
|
184
|
+
ui.menu_item(
|
|
185
|
+
"Security check",
|
|
186
|
+
on_click=lambda: (sec_state["open_entry"](entry), tabs.set_value(security_tab)),
|
|
187
|
+
)
|
|
188
|
+
ui.menu_item(
|
|
189
|
+
"Active Scan",
|
|
190
|
+
on_click=lambda: (scan_state["open_entry"](entry), tabs.set_value(scan_tab)),
|
|
191
|
+
)
|
|
192
|
+
if "graphql" in (entry.tags or []):
|
|
193
|
+
ui.menu_item(
|
|
194
|
+
"Open in GraphQL tab",
|
|
195
|
+
on_click=lambda: (
|
|
196
|
+
gql_state["open_entry"](entry),
|
|
197
|
+
tabs.set_value(graphql_tab),
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
ui.menu_item(
|
|
201
|
+
"Set as Diff left",
|
|
202
|
+
on_click=lambda: _set_diff_left(entry, state),
|
|
203
|
+
)
|
|
204
|
+
if state.get("compare_left"):
|
|
205
|
+
ui.menu_item(
|
|
206
|
+
"Diff with left",
|
|
207
|
+
on_click=lambda eid=entry_id: _compare(
|
|
208
|
+
eid, state, diff_state, tabs, diff_tab, store
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
ui.separator()
|
|
212
|
+
ui.label("Set color").classes("q-px-sm text-caption text-grey")
|
|
213
|
+
for color, label in zip(_ROW_COLORS, _COLOR_LABELS, strict=False):
|
|
214
|
+
|
|
215
|
+
def _set_color(c=color, eid=entry_id) -> None:
|
|
216
|
+
store.set_color(eid, c)
|
|
217
|
+
_refresh_table(store, state, table)
|
|
218
|
+
menu.close()
|
|
219
|
+
|
|
220
|
+
with ui.menu_item(on_click=_set_color), ui.row().classes("items-center gap-2"):
|
|
221
|
+
if color:
|
|
222
|
+
ui.element("div").style(
|
|
223
|
+
f"width:12px;height:12px;border-radius:2px;background:{color}"
|
|
224
|
+
)
|
|
225
|
+
ui.label(label)
|
|
226
|
+
menu.open()
|
|
227
|
+
|
|
228
|
+
table.on("row-contextmenu", on_row_contextmenu)
|
|
229
|
+
|
|
230
|
+
if intercept_mgr is not None:
|
|
231
|
+
build_intercept_panel(intercept_mgr, detail_col)
|
|
232
|
+
|
|
233
|
+
_refresh_table(store, state, table)
|
|
234
|
+
q = store.subscribe()
|
|
235
|
+
|
|
236
|
+
async def _poller() -> None:
|
|
237
|
+
try:
|
|
238
|
+
while True:
|
|
239
|
+
try:
|
|
240
|
+
entry = q.get_nowait()
|
|
241
|
+
if state["filter"].matches(entry):
|
|
242
|
+
existing = next(
|
|
243
|
+
(i for i, e in enumerate(state["entries"]) if e.id == entry.id),
|
|
244
|
+
None,
|
|
245
|
+
)
|
|
246
|
+
if existing is not None:
|
|
247
|
+
state["entries"][existing] = entry
|
|
248
|
+
else:
|
|
249
|
+
state["entries"].insert(0, entry)
|
|
250
|
+
_update_table_rows(state, table)
|
|
251
|
+
except asyncio.QueueEmpty:
|
|
252
|
+
pass
|
|
253
|
+
await asyncio.sleep(0.2)
|
|
254
|
+
except asyncio.CancelledError:
|
|
255
|
+
store.unsubscribe(q)
|
|
256
|
+
|
|
257
|
+
nicegui_app.on_shutdown(lambda: store.unsubscribe(q))
|
|
258
|
+
asyncio.ensure_future(_poller())
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _set_diff_left(entry: Entry, state: dict) -> None:
|
|
262
|
+
state["compare_left"] = entry
|
|
263
|
+
ui.notify(f"#{entry.id} set as left side", type="info")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _compare(
|
|
267
|
+
right_id: int,
|
|
268
|
+
state: dict,
|
|
269
|
+
diff_state: dict,
|
|
270
|
+
tabs: ui.tabs,
|
|
271
|
+
tab: ui.tab,
|
|
272
|
+
store: Store,
|
|
273
|
+
) -> None:
|
|
274
|
+
left = state.get("compare_left")
|
|
275
|
+
right = store.get(right_id)
|
|
276
|
+
if left and right:
|
|
277
|
+
diff_state["set_entries"](left, right)
|
|
278
|
+
tabs.set_value(tab)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _build_table() -> ui.table:
|
|
282
|
+
columns = [
|
|
283
|
+
{"name": "id", "label": "ID", "field": "id", "align": "right", "style": "width:50px"},
|
|
284
|
+
{
|
|
285
|
+
"name": "method",
|
|
286
|
+
"label": "Method",
|
|
287
|
+
"field": "method",
|
|
288
|
+
"align": "center",
|
|
289
|
+
"style": "width:80px",
|
|
290
|
+
},
|
|
291
|
+
{"name": "host", "label": "Host", "field": "host", "align": "left"},
|
|
292
|
+
{"name": "path", "label": "Path", "field": "path", "align": "left"},
|
|
293
|
+
{
|
|
294
|
+
"name": "status",
|
|
295
|
+
"label": "Status",
|
|
296
|
+
"field": "status_code",
|
|
297
|
+
"align": "center",
|
|
298
|
+
"style": "width:70px",
|
|
299
|
+
},
|
|
300
|
+
{"name": "size", "label": "Size", "field": "size", "align": "right", "style": "width:70px"},
|
|
301
|
+
{
|
|
302
|
+
"name": "duration",
|
|
303
|
+
"label": "ms",
|
|
304
|
+
"field": "duration_ms",
|
|
305
|
+
"align": "right",
|
|
306
|
+
"style": "width:60px",
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
"name": "protocol",
|
|
310
|
+
"label": "Proto",
|
|
311
|
+
"field": "protocol",
|
|
312
|
+
"align": "center",
|
|
313
|
+
"style": "width:60px",
|
|
314
|
+
},
|
|
315
|
+
]
|
|
316
|
+
table = (
|
|
317
|
+
ui.table(columns=columns, rows=[], row_key="id")
|
|
318
|
+
.classes("w-full")
|
|
319
|
+
.props("dense flat dark virtual-scroll")
|
|
320
|
+
)
|
|
321
|
+
table.add_slot(
|
|
322
|
+
"body",
|
|
323
|
+
r"""
|
|
324
|
+
<q-tr :props="props"
|
|
325
|
+
:style="props.row.color ? 'background:' + props.row.color + '33' : ''"
|
|
326
|
+
@click="$emit('row-click', $event, props.row)"
|
|
327
|
+
@contextmenu.prevent="$emit('row-contextmenu', $event, props.row)"
|
|
328
|
+
style="cursor:pointer">
|
|
329
|
+
<q-td key="id" :props="props" class="text-right text-grey">{{ props.row.id }}</q-td>
|
|
330
|
+
<q-td key="method" :props="props">
|
|
331
|
+
<q-badge
|
|
332
|
+
:color="{'GET':'blue','POST':'green','PUT':'orange','PATCH':'purple','DELETE':'red'}[props.row.method] || 'grey'"
|
|
333
|
+
:label="props.row.method" rounded />
|
|
334
|
+
</q-td>
|
|
335
|
+
<q-td key="host" :props="props">{{ props.row.host }}</q-td>
|
|
336
|
+
<q-td key="path" :props="props">{{ props.row.path }}</q-td>
|
|
337
|
+
<q-td key="status" :props="props">
|
|
338
|
+
<q-badge v-if="props.row.status_code"
|
|
339
|
+
:color="props.row.status_code < 300 ? 'positive' : props.row.status_code < 400 ? 'info' : props.row.status_code < 500 ? 'warning' : 'negative'"
|
|
340
|
+
:label="props.row.status_code" rounded />
|
|
341
|
+
</q-td>
|
|
342
|
+
<q-td key="size" :props="props" class="text-right text-grey">{{ props.row.size }}</q-td>
|
|
343
|
+
<q-td key="duration" :props="props" class="text-right text-grey">{{ props.row.duration_ms }}</q-td>
|
|
344
|
+
<q-td key="protocol" :props="props">
|
|
345
|
+
<q-badge color="grey-7" :label="props.row.protocol" rounded />
|
|
346
|
+
</q-td>
|
|
347
|
+
</q-tr>
|
|
348
|
+
""",
|
|
349
|
+
)
|
|
350
|
+
return table
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _refresh_table(store: Store, state: dict, table: ui.table) -> None:
|
|
354
|
+
entries, _ = store.list(state["filter"], 0, 500)
|
|
355
|
+
state["entries"] = list(reversed(entries))
|
|
356
|
+
_update_table_rows(state, table)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _update_table_rows(state: dict, table: ui.table) -> None:
|
|
360
|
+
table.rows = [_entry_to_row(e) for e in state["entries"]]
|
|
361
|
+
table.update()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _entry_to_row(e: Entry) -> dict:
|
|
365
|
+
size = len(e.resp_body) if e.resp_body else 0
|
|
366
|
+
return {
|
|
367
|
+
"id": e.id,
|
|
368
|
+
"method": e.method,
|
|
369
|
+
"host": e.host,
|
|
370
|
+
"path": e.path + (f"?{e.query}" if e.query else ""),
|
|
371
|
+
"status_code": e.status_code,
|
|
372
|
+
"size": f"{size:,}" if size else "",
|
|
373
|
+
"duration_ms": e.duration_ms or "",
|
|
374
|
+
"protocol": e.protocol,
|
|
375
|
+
"color": getattr(e, "color", ""),
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
async def _clear(store: Store, state: dict, table: ui.table, detail_col: ui.element) -> None:
|
|
380
|
+
store.clear()
|
|
381
|
+
state["entries"] = []
|
|
382
|
+
state["selected"] = None
|
|
383
|
+
table.rows = []
|
|
384
|
+
table.update()
|
|
385
|
+
render_detail(None, detail_col)
|
|
386
|
+
ui.notify("Cleared", type="info")
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from nicegui import ui
|
|
6
|
+
|
|
7
|
+
from pypproxy.bulk.sender import BulkPayload, bulk_send, race_send
|
|
8
|
+
from pypproxy.store.models import Entry
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_bulk_sender(container: ui.element) -> dict:
|
|
12
|
+
"""Build the bulk sender UI. Returns state with open_entry method."""
|
|
13
|
+
state: dict = {"entry": None}
|
|
14
|
+
|
|
15
|
+
container.clear()
|
|
16
|
+
with container:
|
|
17
|
+
ui.label("Bulk Sender").classes("text-subtitle2 q-mb-sm")
|
|
18
|
+
entry_label = ui.label("No entry selected").classes("text-grey text-caption q-mb-sm")
|
|
19
|
+
|
|
20
|
+
ui.separator()
|
|
21
|
+
|
|
22
|
+
# Mode selector
|
|
23
|
+
with ui.row().classes("items-center gap-4 q-mb-sm"):
|
|
24
|
+
mode = (
|
|
25
|
+
ui.select(["Payload list", "Race condition"], value="Payload list", label="Mode")
|
|
26
|
+
.props("dense outlined dark")
|
|
27
|
+
.classes("w-48")
|
|
28
|
+
)
|
|
29
|
+
count_input = (
|
|
30
|
+
ui.number(label="Count", value=10, min=1, max=500)
|
|
31
|
+
.props("dense outlined dark")
|
|
32
|
+
.classes("w-24")
|
|
33
|
+
)
|
|
34
|
+
concurrency_input = (
|
|
35
|
+
ui.number(label="Concurrency", value=10, min=1, max=100)
|
|
36
|
+
.props("dense outlined dark")
|
|
37
|
+
.classes("w-24")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Payload editor (for payload list mode)
|
|
41
|
+
ui.label("Payloads (one per line, JSON or plain text):").classes("text-caption")
|
|
42
|
+
payload_input = (
|
|
43
|
+
ui.textarea(placeholder='{"key": "value1"}\n{"key": "value2"}')
|
|
44
|
+
.props("outlined dense rows=6")
|
|
45
|
+
.classes("w-full font-mono text-xs")
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
send_btn = ui.button("Send", icon="send").props("color=primary")
|
|
49
|
+
ui.separator()
|
|
50
|
+
|
|
51
|
+
# Results table
|
|
52
|
+
results_label = ui.label("").classes("text-caption text-grey")
|
|
53
|
+
results_table = (
|
|
54
|
+
ui.table(
|
|
55
|
+
columns=[
|
|
56
|
+
{"name": "label", "label": "Label", "field": "label", "align": "left"},
|
|
57
|
+
{
|
|
58
|
+
"name": "status",
|
|
59
|
+
"label": "Status",
|
|
60
|
+
"field": "status_code",
|
|
61
|
+
"align": "center",
|
|
62
|
+
},
|
|
63
|
+
{"name": "ms", "label": "ms", "field": "duration_ms", "align": "right"},
|
|
64
|
+
{"name": "error", "label": "Error", "field": "error", "align": "left"},
|
|
65
|
+
],
|
|
66
|
+
rows=[],
|
|
67
|
+
row_key="label",
|
|
68
|
+
)
|
|
69
|
+
.classes("w-full")
|
|
70
|
+
.props("dense flat dark")
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def _send() -> None:
|
|
74
|
+
entry = state.get("entry")
|
|
75
|
+
if not entry:
|
|
76
|
+
ui.notify("No entry selected", type="warning")
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
send_btn.props("loading")
|
|
80
|
+
try:
|
|
81
|
+
if mode.value == "Race condition":
|
|
82
|
+
results = await race_send(
|
|
83
|
+
entry,
|
|
84
|
+
count=int(count_input.value),
|
|
85
|
+
timeout=30,
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
payloads: list[BulkPayload] = []
|
|
89
|
+
for i, line in enumerate(payload_input.value.splitlines()):
|
|
90
|
+
line = line.strip()
|
|
91
|
+
if not line:
|
|
92
|
+
continue
|
|
93
|
+
try:
|
|
94
|
+
obj = json.loads(line)
|
|
95
|
+
body = json.dumps(obj).encode()
|
|
96
|
+
except Exception:
|
|
97
|
+
body = line.encode()
|
|
98
|
+
payloads.append(BulkPayload(label=f"payload-{i}", override_body=body))
|
|
99
|
+
if not payloads:
|
|
100
|
+
ui.notify("No payloads entered", type="warning")
|
|
101
|
+
return
|
|
102
|
+
results = await bulk_send(
|
|
103
|
+
entry,
|
|
104
|
+
payloads,
|
|
105
|
+
concurrency=int(concurrency_input.value),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
results_table.rows = [r.to_dict() for r in results]
|
|
109
|
+
results_table.update()
|
|
110
|
+
status_counts: dict[int, int] = {}
|
|
111
|
+
for r in results:
|
|
112
|
+
status_counts[r.status_code] = status_counts.get(r.status_code, 0) + 1
|
|
113
|
+
summary = ", ".join(f"{s}: {c}" for s, c in sorted(status_counts.items()))
|
|
114
|
+
results_label.text = f"{len(results)} requests — {summary}"
|
|
115
|
+
ui.notify(f"Done: {len(results)} requests", type="positive")
|
|
116
|
+
finally:
|
|
117
|
+
send_btn.props(remove="loading")
|
|
118
|
+
|
|
119
|
+
send_btn.on("click", _send)
|
|
120
|
+
|
|
121
|
+
def open_entry(entry: Entry) -> None:
|
|
122
|
+
state["entry"] = entry
|
|
123
|
+
entry_label.text = f"#{entry.id} {entry.method} {entry.scheme}://{entry.host}{entry.path}"
|
|
124
|
+
|
|
125
|
+
return {"open_entry": open_entry}
|