pypproxy 0.1.0__tar.gz → 0.2.0__tar.gz
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-0.1.0 → pypproxy-0.2.0}/PKG-INFO +3 -1
- {pypproxy-0.1.0 → pypproxy-0.2.0}/docs/api.md +1 -1
- {pypproxy-0.1.0 → pypproxy-0.2.0}/docs/architecture.md +40 -40
- {pypproxy-0.1.0 → pypproxy-0.2.0}/docs/configuration.md +1 -1
- {pypproxy-0.1.0 → pypproxy-0.2.0}/docs/getting-started.md +1 -1
- {pypproxy-0.1.0 → pypproxy-0.2.0}/docs/index.md +1 -1
- {pypproxy-0.1.0 → pypproxy-0.2.0}/docs/protocols.md +5 -5
- {pypproxy-0.1.0 → pypproxy-0.2.0}/docs/scripting.md +3 -3
- {pypproxy-0.1.0 → pypproxy-0.2.0}/docs/web-ui.md +2 -2
- pypproxy-0.2.0/pypproxy/ab_test/runner.py +136 -0
- pypproxy-0.2.0/pypproxy/analytics/stats.py +225 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/api/server.py +83 -0
- pypproxy-0.2.0/pypproxy/codec.py +427 -0
- pypproxy-0.2.0/pypproxy/codegen/generator.py +132 -0
- pypproxy-0.2.0/pypproxy/frida/device.py +203 -0
- pypproxy-0.2.0/pypproxy/frida/hook_generator.py +189 -0
- pypproxy-0.2.0/pypproxy/frida/pinning_bypass.py +285 -0
- pypproxy-0.2.0/pypproxy/macro/runner.py +193 -0
- pypproxy-0.2.0/pypproxy/openapi/generator.py +248 -0
- pypproxy-0.2.0/pypproxy/report/generator.py +148 -0
- pypproxy-0.2.0/pypproxy/rule/__init__.py +0 -0
- pypproxy-0.2.0/pypproxy/scan/__init__.py +0 -0
- pypproxy-0.2.0/pypproxy/script/__init__.py +0 -0
- pypproxy-0.2.0/pypproxy/security/__init__.py +0 -0
- pypproxy-0.2.0/pypproxy/security/advanced_checks.py +296 -0
- pypproxy-0.2.0/pypproxy/security/idor.py +207 -0
- pypproxy-0.2.0/pypproxy/session/__init__.py +0 -0
- pypproxy-0.2.0/pypproxy/session/manager.py +139 -0
- pypproxy-0.2.0/pypproxy/store/__init__.py +0 -0
- pypproxy-0.2.0/pypproxy/ui/__init__.py +0 -0
- pypproxy-0.2.0/pypproxy/ui/ab_tab.py +116 -0
- pypproxy-0.2.0/pypproxy/ui/advanced_security_tab.py +277 -0
- pypproxy-0.2.0/pypproxy/ui/analytics_tab.py +173 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/ui/app.py +87 -0
- pypproxy-0.2.0/pypproxy/ui/codegen_tab.py +67 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/ui/detail.py +127 -26
- pypproxy-0.2.0/pypproxy/ui/frida_tab.py +433 -0
- pypproxy-0.2.0/pypproxy/ui/macro_tab.py +171 -0
- pypproxy-0.2.0/pypproxy/ui/openapi_tab.py +77 -0
- pypproxy-0.2.0/pypproxy/ui/report_tab.py +75 -0
- pypproxy-0.2.0/pypproxy/ui/session_tab.py +95 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pyproject.toml +4 -1
- pypproxy-0.2.0/tests/__init__.py +0 -0
- pypproxy-0.2.0/tests/test_advanced_tools.py +254 -0
- pypproxy-0.2.0/tests/test_all_features.py +306 -0
- pypproxy-0.2.0/tests/test_decode.py +261 -0
- pypproxy-0.2.0/tests/test_frida.py +231 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/uv.lock +28 -0
- pypproxy-0.1.0/pypproxy/codec.py +0 -176
- {pypproxy-0.1.0 → pypproxy-0.2.0}/.github/dependabot.yml +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/.github/workflows/ci.yml +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/.github/workflows/docs.yml +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/.github/workflows/publish.yml +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/.gitignore +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/.pre-commit-config.yaml +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/LICENSE +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/Makefile +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/README.md +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/docs/replay.md +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/docs/rule-engine.md +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/main.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/mkdocs.yml +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/__init__.py +0 -0
- {pypproxy-0.1.0/pypproxy/api → pypproxy-0.2.0/pypproxy/ab_test}/__init__.py +0 -0
- {pypproxy-0.1.0/pypproxy/bulk → pypproxy-0.2.0/pypproxy/analytics}/__init__.py +0 -0
- {pypproxy-0.1.0/pypproxy/cert → pypproxy-0.2.0/pypproxy/api}/__init__.py +0 -0
- {pypproxy-0.1.0/pypproxy/config → pypproxy-0.2.0/pypproxy/bulk}/__init__.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/bulk/sender.py +0 -0
- {pypproxy-0.1.0/pypproxy/dns → pypproxy-0.2.0/pypproxy/cert}/__init__.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/cert/ca.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/cert/client_cert.py +0 -0
- {pypproxy-0.1.0/pypproxy/exporter → pypproxy-0.2.0/pypproxy/codegen}/__init__.py +0 -0
- {pypproxy-0.1.0/pypproxy/graphql → pypproxy-0.2.0/pypproxy/config}/__init__.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/config/config.py +0 -0
- {pypproxy-0.1.0/pypproxy/intercept → pypproxy-0.2.0/pypproxy/dns}/__init__.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/dns/server.py +0 -0
- {pypproxy-0.1.0/pypproxy/interceptor → pypproxy-0.2.0/pypproxy/exporter}/__init__.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/exporter/exporter.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/exporter/importer.py +0 -0
- {pypproxy-0.1.0/pypproxy/proto → pypproxy-0.2.0/pypproxy/frida}/__init__.py +0 -0
- {pypproxy-0.1.0/pypproxy/proxy → pypproxy-0.2.0/pypproxy/graphql}/__init__.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/graphql/detector.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/graphql/introspection.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/graphql/modifier.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/graphql/schema_store.py +0 -0
- {pypproxy-0.1.0/pypproxy/replay → pypproxy-0.2.0/pypproxy/intercept}/__init__.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/intercept/manager.py +0 -0
- {pypproxy-0.1.0/pypproxy/rule → pypproxy-0.2.0/pypproxy/interceptor}/__init__.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/interceptor/interceptor.py +0 -0
- {pypproxy-0.1.0/pypproxy/scan → pypproxy-0.2.0/pypproxy/macro}/__init__.py +0 -0
- {pypproxy-0.1.0/pypproxy/script → pypproxy-0.2.0/pypproxy/openapi}/__init__.py +0 -0
- {pypproxy-0.1.0/pypproxy/security → pypproxy-0.2.0/pypproxy/proto}/__init__.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/proto/grpc.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/proto/mqtt.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/proto/ws.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/proto/ws_intercept.py +0 -0
- {pypproxy-0.1.0/pypproxy/store → pypproxy-0.2.0/pypproxy/proxy}/__init__.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/proxy/proxy.py +0 -0
- {pypproxy-0.1.0/pypproxy/ui → pypproxy-0.2.0/pypproxy/replay}/__init__.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/replay/replay.py +0 -0
- {pypproxy-0.1.0/tests → pypproxy-0.2.0/pypproxy/report}/__init__.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/rule/rule.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/scan/scanner.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/script/engine.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/security/header_checker.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/security/int_overflow.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/security/jwt_checker.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/security/plugin.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/security/randomness.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/store/db.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/store/filter_parser.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/store/fts.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/store/models.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/store/scope.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/store/store.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/ui/bulk_sender_ui.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/ui/cui.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/ui/diff_view.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/ui/graphql_tab.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/ui/import_tab.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/ui/intercept_dialog.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/ui/resender.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/ui/scan_tab.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/ui/security_tab.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/ui/settings.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/pypproxy/ui/theme.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/tests/test_advanced.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/tests/test_bulk.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/tests/test_cert.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/tests/test_codec.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/tests/test_exporter.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/tests/test_filter_parser.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/tests/test_graphql.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/tests/test_interceptor.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/tests/test_mqtt.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/tests/test_rule.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/tests/test_security.py +0 -0
- {pypproxy-0.1.0 → pypproxy-0.2.0}/tests/test_store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pypproxy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: MITM HTTP/HTTPS proxy for inspecting and modifying traffic
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -17,3 +17,5 @@ Requires-Dist: pyyaml>=6.0.3
|
|
|
17
17
|
Requires-Dist: rich>=15.0.0
|
|
18
18
|
Requires-Dist: uvicorn[standard]>=0.30.0
|
|
19
19
|
Requires-Dist: websockets>=16.0
|
|
20
|
+
Provides-Extra: frida
|
|
21
|
+
Requires-Dist: frida>=16.0.0; extra == 'frida'
|
|
@@ -63,7 +63,7 @@ CRUD for intercept rules.
|
|
|
63
63
|
| `GET /api/export/json` | Export all entries + rules as JSON |
|
|
64
64
|
| `GET /api/export/har` | Export all entries as HAR 1.2 |
|
|
65
65
|
| `POST /api/import/har` | Import entries from HAR body |
|
|
66
|
-
| `POST /api/import/json` | Import entries from
|
|
66
|
+
| `POST /api/import/json` | Import entries from pypproxy JSON body |
|
|
67
67
|
| `POST /api/import/rules` | Import rules from JSON body |
|
|
68
68
|
|
|
69
69
|
---
|
|
@@ -8,24 +8,24 @@
|
|
|
8
8
|
└────────────────────────┬─────────────────────────────────────┘
|
|
9
9
|
│ HTTP / CONNECT
|
|
10
10
|
┌────────────────────────▼─────────────────────────────────────┐
|
|
11
|
-
│
|
|
11
|
+
│ pypproxy/proxy/proxy.py (port :8080) │
|
|
12
12
|
│ asyncio TCP server │
|
|
13
13
|
│ ├─ HTTP → intercept → forward upstream (httpx, HTTP/2) │
|
|
14
14
|
│ ├─ CONNECT → TLS termination (MITM) │
|
|
15
15
|
│ │ ├─ HTTP/HTTPS → intercept → forward upstream │
|
|
16
|
-
│ │ ├─ WebSocket →
|
|
17
|
-
│ │ ├─ gRPC →
|
|
18
|
-
│ │ └─ MQTT →
|
|
16
|
+
│ │ ├─ WebSocket → pypproxy/proto/ws.py │
|
|
17
|
+
│ │ ├─ gRPC → pypproxy/proto/grpc.py │
|
|
18
|
+
│ │ └─ MQTT → pypproxy/proto/mqtt.py │
|
|
19
19
|
│ └─ ignored hosts → raw TCP tunnel (passthrough) │
|
|
20
20
|
└─────────────┬────────────────────────┬───────────────────────┘
|
|
21
21
|
│ │
|
|
22
22
|
┌─────────────▼──────┐ ┌────────────▼──────────────────────┐
|
|
23
|
-
│
|
|
23
|
+
│ pypproxy/cert/ca.py │ │ pypproxy/interceptor/ │
|
|
24
24
|
│ CA + per-host TLS │ │ apply rules, record entries │
|
|
25
25
|
│ SSL Context cache │ └────────────┬───────────────────────┘
|
|
26
26
|
│ │ │
|
|
27
|
-
│
|
|
28
|
-
│ client_cert.py │ │
|
|
27
|
+
│ pypproxy/cert/ │ ┌────────────▼───────────────────────┐
|
|
28
|
+
│ client_cert.py │ │ pypproxy/store/store.py │
|
|
29
29
|
│ Mutual TLS certs │ │ in-memory store + SQLite persist │
|
|
30
30
|
└────────────────────┘ │ asyncio pub/sub │
|
|
31
31
|
└────────────┬───────────────────────┘
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
┌────────────────────────────┼──────────────────────┐
|
|
34
34
|
│ │ │
|
|
35
35
|
┌─────────▼──────────┐ ┌─────────────▼─────────┐ ┌────────▼──────────┐
|
|
36
|
-
│
|
|
36
|
+
│ pypproxy/api/ │ │ pypproxy/ui/app.py │ │ pypproxy/ui/cui.py │
|
|
37
37
|
│ FastAPI REST API │ │ NiceGUI 4-tab UI │ │ rich terminal UI │
|
|
38
38
|
│ + WebSocket /ws │ │ Traffic/Resender/ │ │ (CUI mode) │
|
|
39
39
|
│ Bulk/Export APIs │ │ Bulk/Diff │ └───────────────────┘
|
|
@@ -44,34 +44,34 @@
|
|
|
44
44
|
|
|
45
45
|
| Package | Responsibility |
|
|
46
46
|
|---------|----------------|
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
59
|
-
| `
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
47
|
+
| `pypproxy/proxy` | asyncio TCP server; HTTP forwarding; TLS MITM for CONNECT; raw tunnel for ignored hosts |
|
|
48
|
+
| `pypproxy/cert/ca` | CA certificate generation; per-host SSL Context cache |
|
|
49
|
+
| `pypproxy/cert/client_cert` | Client certificate management for mutual TLS |
|
|
50
|
+
| `pypproxy/interceptor` | Apply rules to requests and responses; record entries in the store |
|
|
51
|
+
| `pypproxy/intercept` | Manual intercept manager; pause requests for user review |
|
|
52
|
+
| `pypproxy/rule` | Rule evaluation engine; condition matching; priority ordering |
|
|
53
|
+
| `pypproxy/store/store` | Thread-safe in-memory traffic store; asyncio pub/sub |
|
|
54
|
+
| `pypproxy/store/db` | SQLite persistence via aiosqlite; load/save entries |
|
|
55
|
+
| `pypproxy/store/filter_parser` | Filter expression parser (`host == x && method == POST`) |
|
|
56
|
+
| `pypproxy/api` | FastAPI REST endpoints, WebSocket streaming, bulk/export APIs |
|
|
57
|
+
| `pypproxy/ui/app` | NiceGUI 4-tab browser UI (Traffic, Resender, Bulk Sender, Diff) |
|
|
58
|
+
| `pypproxy/ui/settings` | Settings page (rules, SSL passthrough, DNS, ports, client certs) |
|
|
59
|
+
| `pypproxy/ui/detail` | Request/response detail panel with body view selector |
|
|
60
|
+
| `pypproxy/ui/resender` | Resender tab — edit and re-send requests |
|
|
61
|
+
| `pypproxy/ui/bulk_sender_ui` | Bulk Sender tab — parallel payload sending and race testing |
|
|
62
|
+
| `pypproxy/ui/diff_view` | Diff tab — unified diff between two captured entries |
|
|
63
|
+
| `pypproxy/ui/intercept_dialog` | Intercept dialog — pause, edit, forward or drop requests |
|
|
64
|
+
| `pypproxy/ui/cui` | rich terminal UI (CUI mode) |
|
|
65
|
+
| `pypproxy/proto/ws` | WebSocket frame relay and logging |
|
|
66
|
+
| `pypproxy/proto/grpc` | gRPC length-prefix frame decoding |
|
|
67
|
+
| `pypproxy/proto/mqtt` | MQTT frame decoding and detection |
|
|
68
|
+
| `pypproxy/script` | Python script engine; `on_request` / `on_response` hooks |
|
|
69
|
+
| `pypproxy/replay` | Async HTTP replay and parallel fuzzing via httpx |
|
|
70
|
+
| `pypproxy/bulk` | Bulk sender and race condition test runner |
|
|
71
|
+
| `pypproxy/dns` | Built-in DNS server with domain spoofing |
|
|
72
|
+
| `pypproxy/exporter` | JSON/HAR export and rule import/export |
|
|
73
|
+
| `pypproxy/codec` | Content-encoding decode (gzip/br/deflate); binary format decode (Protobuf/MessagePack/CBOR) |
|
|
74
|
+
| `pypproxy/config` | YAML config loading |
|
|
75
75
|
|
|
76
76
|
## Key design decisions
|
|
77
77
|
|
|
@@ -82,11 +82,11 @@ This lets a single connection handle HTTP/1.1 keep-alive, CONNECT tunnels, WebSo
|
|
|
82
82
|
|
|
83
83
|
### TLS termination with `loop.start_tls()`
|
|
84
84
|
|
|
85
|
-
After responding `200 Connection Established` to a CONNECT request,
|
|
85
|
+
After responding `200 Connection Established` to a CONNECT request, pypproxy calls `loop.start_tls()` to upgrade the existing asyncio transport to TLS server-side. Per-host certificates are cached as `ssl.SSLContext` objects.
|
|
86
86
|
|
|
87
87
|
### SQLite persistence via aiosqlite
|
|
88
88
|
|
|
89
|
-
All captured traffic is stored in memory for fast access. Writes to SQLite are fire-and-forget via `asyncio.run_coroutine_threadsafe`. On startup, `store.load_from_db()` restores prior sessions. The DB path defaults to `~/.
|
|
89
|
+
All captured traffic is stored in memory for fast access. Writes to SQLite are fire-and-forget via `asyncio.run_coroutine_threadsafe`. On startup, `store.load_from_db()` restores prior sessions. The DB path defaults to `~/.pypproxy/pypproxy.db`.
|
|
90
90
|
|
|
91
91
|
### Store pub/sub
|
|
92
92
|
|
|
@@ -98,11 +98,11 @@ All upstream requests use `httpx` with `http2=True`. httpx negotiates HTTP/2 via
|
|
|
98
98
|
|
|
99
99
|
### Filter expression engine
|
|
100
100
|
|
|
101
|
-
The filter bar in the UI accepts a structured expression parsed by `
|
|
101
|
+
The filter bar in the UI accepts a structured expression parsed by `pypproxy/store/filter_parser.py`. The parser tokenizes `field op value` conditions and evaluates them with AND/OR short-circuit logic against `Entry` objects in memory.
|
|
102
102
|
|
|
103
103
|
### Binary format detection
|
|
104
104
|
|
|
105
|
-
`
|
|
105
|
+
`pypproxy/codec.py` implements `sniff_content_type()` which combines Content-Type inspection with a JSON parse attempt and a binary entropy heuristic to guess the best display mode. Protobuf decoding uses wire-type heuristics without requiring a `.proto` schema.
|
|
106
106
|
|
|
107
107
|
### GUI / CUI startup
|
|
108
108
|
|
|
@@ -21,7 +21,7 @@ Open the **GraphQL** tab or right-click a GraphQL entry → **Open in GraphQL ta
|
|
|
21
21
|
|
|
22
22
|
**Schema Introspection**
|
|
23
23
|
|
|
24
|
-
Enter the endpoint URL and click **Introspect**.
|
|
24
|
+
Enter the endpoint URL and click **Introspect**. pypproxy sends a full `__schema` introspection query and displays the type tree. The schema is cached per-host and available for query completion.
|
|
25
25
|
|
|
26
26
|
**Query Editor**
|
|
27
27
|
|
|
@@ -44,7 +44,7 @@ Shows the operation type (query/mutation/subscription), operation name, and top-
|
|
|
44
44
|
### Modifier utilities (`paxy.graphql.modifier`)
|
|
45
45
|
|
|
46
46
|
```python
|
|
47
|
-
from
|
|
47
|
+
from pypproxy.graphql.modifier import set_variable, build_query, build_mutation
|
|
48
48
|
|
|
49
49
|
# Replace a variable in a captured request body
|
|
50
50
|
new_body = set_variable(entry.req_body, "userId", "456")
|
|
@@ -65,8 +65,8 @@ WebSocket connections are detected automatically and intercepted as part of the
|
|
|
65
65
|
### How it works
|
|
66
66
|
|
|
67
67
|
1. The client sends a CONNECT request to the proxy.
|
|
68
|
-
2.
|
|
69
|
-
3. When
|
|
68
|
+
2. pypproxy terminates TLS (same as HTTPS MITM).
|
|
69
|
+
3. When pypproxy sees `Upgrade: websocket` in the decrypted stream, it switches to WebSocket relay mode.
|
|
70
70
|
4. Frames are relayed between client and server while being logged.
|
|
71
71
|
|
|
72
72
|
### Frame intercept
|
|
@@ -106,7 +106,7 @@ All upstream requests use `httpx` with HTTP/2 support enabled. Connections negot
|
|
|
106
106
|
|
|
107
107
|
## Certificate pinning
|
|
108
108
|
|
|
109
|
-
Add pinned hosts to the `ignore` list in config or the **SSL Passthrough** settings tab.
|
|
109
|
+
Add pinned hosts to the `ignore` list in config or the **SSL Passthrough** settings tab. pypproxy tunnels those hosts without TLS interception.
|
|
110
110
|
|
|
111
111
|
```yaml
|
|
112
112
|
proxy:
|
|
@@ -17,7 +17,7 @@ script:
|
|
|
17
17
|
|
|
18
18
|
## Hook functions
|
|
19
19
|
|
|
20
|
-
Define any of these functions in your script.
|
|
20
|
+
Define any of these functions in your script. pypproxy calls them automatically.
|
|
21
21
|
|
|
22
22
|
### `on_request(method, host, path, body)`
|
|
23
23
|
|
|
@@ -90,7 +90,7 @@ def on_response(status: int, body: bytes) -> bytes:
|
|
|
90
90
|
|
|
91
91
|
## Notes
|
|
92
92
|
|
|
93
|
-
- The script is loaded once at startup. Restart
|
|
93
|
+
- The script is loaded once at startup. Restart pypproxy to pick up changes.
|
|
94
94
|
- Both hooks may be called concurrently from multiple requests. Protect shared state with a lock if needed.
|
|
95
|
-
- Use `sys.stderr` for logging to avoid mixing output with
|
|
95
|
+
- Use `sys.stderr` for logging to avoid mixing output with pypproxy's own logs.
|
|
96
96
|
- The full Python standard library and any packages in the project's virtualenv are available.
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## GUI Mode
|
|
4
4
|
|
|
5
|
-
Start
|
|
5
|
+
Start pypproxy in GUI mode (the default) and open `http://localhost:8081`.
|
|
6
6
|
The UI is built with [NiceGUI](https://nicegui.io/) and runs in the same Python process as the proxy.
|
|
7
7
|
|
|
8
8
|
## Layout
|
|
9
9
|
|
|
10
10
|
```
|
|
11
11
|
┌──────────────────────────────────────────────────────────────┐
|
|
12
|
-
│ toolbar:
|
|
12
|
+
│ toolbar: pypproxy ● | Filter expression | Intercept | Clear | ⚙ │
|
|
13
13
|
├─────────────────────────────────────────────────────────────-┤
|
|
14
14
|
│ Traffic | Resender | Bulk Sender | Diff │
|
|
15
15
|
├──────────────────────────┬───────────────────────────────────┤
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from pypproxy.store.models import Entry
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ABResult:
|
|
14
|
+
endpoint_a: str
|
|
15
|
+
endpoint_b: str
|
|
16
|
+
method: str
|
|
17
|
+
status_a: int
|
|
18
|
+
status_b: int
|
|
19
|
+
body_a: bytes
|
|
20
|
+
body_b: bytes
|
|
21
|
+
duration_a_ms: int
|
|
22
|
+
duration_b_ms: int
|
|
23
|
+
error_a: str = ""
|
|
24
|
+
error_b: str = ""
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def status_diff(self) -> bool:
|
|
28
|
+
return self.status_a != self.status_b
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def body_diff(self) -> bool:
|
|
32
|
+
return self.body_a != self.body_b
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict:
|
|
35
|
+
import base64
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
"endpoint_a": self.endpoint_a,
|
|
39
|
+
"endpoint_b": self.endpoint_b,
|
|
40
|
+
"method": self.method,
|
|
41
|
+
"status_a": self.status_a,
|
|
42
|
+
"status_b": self.status_b,
|
|
43
|
+
"body_a": base64.b64encode(self.body_a).decode() if self.body_a else "",
|
|
44
|
+
"body_b": base64.b64encode(self.body_b).decode() if self.body_b else "",
|
|
45
|
+
"duration_a_ms": self.duration_a_ms,
|
|
46
|
+
"duration_b_ms": self.duration_b_ms,
|
|
47
|
+
"error_a": self.error_a,
|
|
48
|
+
"error_b": self.error_b,
|
|
49
|
+
"status_diff": self.status_diff,
|
|
50
|
+
"body_diff": self.body_diff,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def diff_summary(self) -> str:
|
|
54
|
+
lines: list[str] = []
|
|
55
|
+
if self.status_diff:
|
|
56
|
+
lines.append(f"Status: A={self.status_a} B={self.status_b}")
|
|
57
|
+
else:
|
|
58
|
+
lines.append(f"Status: both {self.status_a}")
|
|
59
|
+
if self.body_diff:
|
|
60
|
+
lines.append(f"Body differs ({len(self.body_a):,} B vs {len(self.body_b):,} B)")
|
|
61
|
+
# Try JSON diff summary
|
|
62
|
+
try:
|
|
63
|
+
da = json.loads(self.body_a)
|
|
64
|
+
db = json.loads(self.body_b)
|
|
65
|
+
if isinstance(da, dict) and isinstance(db, dict):
|
|
66
|
+
added = set(db) - set(da)
|
|
67
|
+
removed = set(da) - set(db)
|
|
68
|
+
changed = {k for k in da if k in db and da[k] != db[k]}
|
|
69
|
+
if added:
|
|
70
|
+
lines.append(f" + fields added: {', '.join(sorted(added)[:5])}")
|
|
71
|
+
if removed:
|
|
72
|
+
lines.append(f" - fields removed: {', '.join(sorted(removed)[:5])}")
|
|
73
|
+
if changed:
|
|
74
|
+
lines.append(f" ~ fields changed: {', '.join(sorted(changed)[:5])}")
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
else:
|
|
78
|
+
lines.append("Body: identical")
|
|
79
|
+
lines.append(f"Latency: A={self.duration_a_ms}ms B={self.duration_b_ms}ms")
|
|
80
|
+
return "\n".join(lines)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def run_ab_test(
|
|
84
|
+
entry: Entry,
|
|
85
|
+
override_host_b: str,
|
|
86
|
+
override_scheme_b: str = "",
|
|
87
|
+
timeout: int = 30,
|
|
88
|
+
) -> ABResult:
|
|
89
|
+
"""Send the same request to two different hosts and compare responses."""
|
|
90
|
+
scheme = entry.scheme
|
|
91
|
+
path = entry.path
|
|
92
|
+
query = entry.query
|
|
93
|
+
headers = {
|
|
94
|
+
k: ", ".join(v)
|
|
95
|
+
for k, v in entry.req_headers.items()
|
|
96
|
+
if k.lower() not in ("host", "content-length", "connection")
|
|
97
|
+
}
|
|
98
|
+
body = entry.req_body
|
|
99
|
+
|
|
100
|
+
url_a = f"{scheme}://{entry.host}{path}" + (f"?{query}" if query else "")
|
|
101
|
+
scheme_b = override_scheme_b or scheme
|
|
102
|
+
url_b = f"{scheme_b}://{override_host_b}{path}" + (f"?{query}" if query else "")
|
|
103
|
+
|
|
104
|
+
status_a = status_b = 0
|
|
105
|
+
body_a = body_b = b""
|
|
106
|
+
dur_a = dur_b = 0
|
|
107
|
+
err_a = err_b = ""
|
|
108
|
+
|
|
109
|
+
async def _fetch(url: str) -> tuple[int, bytes, int, str]:
|
|
110
|
+
start = time.monotonic()
|
|
111
|
+
try:
|
|
112
|
+
h = dict(headers)
|
|
113
|
+
h["host"] = url.split("/")[2].split(":")[0]
|
|
114
|
+
async with httpx.AsyncClient(verify=False, timeout=timeout, http2=True) as client:
|
|
115
|
+
resp = await client.request(method=entry.method, url=url, headers=h, content=body)
|
|
116
|
+
return resp.status_code, resp.content, int((time.monotonic() - start) * 1000), ""
|
|
117
|
+
except Exception as e:
|
|
118
|
+
return 0, b"", int((time.monotonic() - start) * 1000), str(e)
|
|
119
|
+
|
|
120
|
+
(status_a, body_a, dur_a, err_a), (status_b, body_b, dur_b, err_b) = await __import__(
|
|
121
|
+
"asyncio"
|
|
122
|
+
).gather(_fetch(url_a), _fetch(url_b))
|
|
123
|
+
|
|
124
|
+
return ABResult(
|
|
125
|
+
endpoint_a=url_a,
|
|
126
|
+
endpoint_b=url_b,
|
|
127
|
+
method=entry.method,
|
|
128
|
+
status_a=status_a,
|
|
129
|
+
status_b=status_b,
|
|
130
|
+
body_a=body_a,
|
|
131
|
+
body_b=body_b,
|
|
132
|
+
duration_a_ms=dur_a,
|
|
133
|
+
duration_b_ms=dur_b,
|
|
134
|
+
error_a=err_a,
|
|
135
|
+
error_b=err_b,
|
|
136
|
+
)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter, defaultdict
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pypproxy.store.models import Entry, Filter
|
|
8
|
+
from pypproxy.store.store import Store
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class HostStats:
|
|
13
|
+
host: str
|
|
14
|
+
count: int
|
|
15
|
+
methods: dict[str, int]
|
|
16
|
+
status_codes: dict[int, int]
|
|
17
|
+
avg_duration_ms: float
|
|
18
|
+
max_duration_ms: int
|
|
19
|
+
error_rate: float # 4xx+5xx / total
|
|
20
|
+
protocols: dict[str, int]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class EndpointStats:
|
|
25
|
+
method: str
|
|
26
|
+
path: str
|
|
27
|
+
count: int
|
|
28
|
+
avg_duration_ms: float
|
|
29
|
+
status_codes: dict[int, int]
|
|
30
|
+
error_rate: float
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class TrafficSummary:
|
|
35
|
+
total: int
|
|
36
|
+
hosts: list[HostStats]
|
|
37
|
+
top_endpoints: list[EndpointStats]
|
|
38
|
+
status_distribution: dict[str, int] # "2xx", "3xx", "4xx", "5xx"
|
|
39
|
+
method_distribution: dict[str, int]
|
|
40
|
+
protocol_distribution: dict[str, int]
|
|
41
|
+
avg_duration_ms: float
|
|
42
|
+
p95_duration_ms: int
|
|
43
|
+
p99_duration_ms: int
|
|
44
|
+
errors: list[dict] # top 5xx/4xx entries
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"total": self.total,
|
|
49
|
+
"hosts": [
|
|
50
|
+
{
|
|
51
|
+
"host": h.host,
|
|
52
|
+
"count": h.count,
|
|
53
|
+
"methods": h.methods,
|
|
54
|
+
"status_codes": {str(k): v for k, v in h.status_codes.items()},
|
|
55
|
+
"avg_duration_ms": round(h.avg_duration_ms, 1),
|
|
56
|
+
"max_duration_ms": h.max_duration_ms,
|
|
57
|
+
"error_rate": round(h.error_rate, 3),
|
|
58
|
+
"protocols": h.protocols,
|
|
59
|
+
}
|
|
60
|
+
for h in self.hosts
|
|
61
|
+
],
|
|
62
|
+
"top_endpoints": [
|
|
63
|
+
{
|
|
64
|
+
"method": e.method,
|
|
65
|
+
"path": e.path,
|
|
66
|
+
"count": e.count,
|
|
67
|
+
"avg_duration_ms": round(e.avg_duration_ms, 1),
|
|
68
|
+
"status_codes": {str(k): v for k, v in e.status_codes.items()},
|
|
69
|
+
"error_rate": round(e.error_rate, 3),
|
|
70
|
+
}
|
|
71
|
+
for e in self.top_endpoints
|
|
72
|
+
],
|
|
73
|
+
"status_distribution": self.status_distribution,
|
|
74
|
+
"method_distribution": self.method_distribution,
|
|
75
|
+
"protocol_distribution": self.protocol_distribution,
|
|
76
|
+
"avg_duration_ms": round(self.avg_duration_ms, 1),
|
|
77
|
+
"p95_duration_ms": self.p95_duration_ms,
|
|
78
|
+
"p99_duration_ms": self.p99_duration_ms,
|
|
79
|
+
"errors": self.errors,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def compute(store: Store, f: Filter | None = None) -> TrafficSummary:
|
|
84
|
+
entries, _ = store.list(f or Filter(), 0, 0)
|
|
85
|
+
return compute_from_entries(entries)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def compute_from_entries(entries: list[Entry]) -> TrafficSummary:
|
|
89
|
+
if not entries:
|
|
90
|
+
return TrafficSummary(
|
|
91
|
+
total=0,
|
|
92
|
+
hosts=[],
|
|
93
|
+
top_endpoints=[],
|
|
94
|
+
status_distribution={},
|
|
95
|
+
method_distribution={},
|
|
96
|
+
protocol_distribution={},
|
|
97
|
+
avg_duration_ms=0,
|
|
98
|
+
p95_duration_ms=0,
|
|
99
|
+
p99_duration_ms=0,
|
|
100
|
+
errors=[],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Per-host aggregation
|
|
104
|
+
host_data: dict[str, dict] = defaultdict(
|
|
105
|
+
lambda: {
|
|
106
|
+
"count": 0,
|
|
107
|
+
"methods": Counter(),
|
|
108
|
+
"status_codes": Counter(),
|
|
109
|
+
"durations": [],
|
|
110
|
+
"protocols": Counter(),
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
endpoint_data: dict[tuple[str, str], dict] = defaultdict(
|
|
114
|
+
lambda: {
|
|
115
|
+
"count": 0,
|
|
116
|
+
"durations": [],
|
|
117
|
+
"status_codes": Counter(),
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
status_dist: Counter = Counter()
|
|
121
|
+
method_dist: Counter = Counter()
|
|
122
|
+
proto_dist: Counter = Counter()
|
|
123
|
+
all_durations: list[int] = []
|
|
124
|
+
error_entries: list[Entry] = []
|
|
125
|
+
|
|
126
|
+
for e in entries:
|
|
127
|
+
h = host_data[e.host]
|
|
128
|
+
h["count"] += 1
|
|
129
|
+
h["methods"][e.method] += 1
|
|
130
|
+
if e.status_code:
|
|
131
|
+
h["status_codes"][e.status_code] += 1
|
|
132
|
+
if e.duration_ms:
|
|
133
|
+
h["durations"].append(e.duration_ms)
|
|
134
|
+
all_durations.append(e.duration_ms)
|
|
135
|
+
h["protocols"][e.protocol] += 1
|
|
136
|
+
|
|
137
|
+
ep_key = (e.method, e.path)
|
|
138
|
+
ep = endpoint_data[ep_key]
|
|
139
|
+
ep["count"] += 1
|
|
140
|
+
if e.duration_ms:
|
|
141
|
+
ep["durations"].append(e.duration_ms)
|
|
142
|
+
if e.status_code:
|
|
143
|
+
ep["status_codes"][e.status_code] += 1
|
|
144
|
+
|
|
145
|
+
if e.status_code:
|
|
146
|
+
bucket = f"{e.status_code // 100}xx"
|
|
147
|
+
status_dist[bucket] += 1
|
|
148
|
+
if e.status_code >= 400:
|
|
149
|
+
error_entries.append(e)
|
|
150
|
+
|
|
151
|
+
method_dist[e.method] += 1
|
|
152
|
+
proto_dist[e.protocol] += 1
|
|
153
|
+
|
|
154
|
+
# Build host stats
|
|
155
|
+
hosts = []
|
|
156
|
+
for host, d in sorted(host_data.items(), key=lambda x: -x[1]["count"])[:20]:
|
|
157
|
+
total_h = d["count"]
|
|
158
|
+
errors_h = sum(v for k, v in d["status_codes"].items() if k >= 400)
|
|
159
|
+
durations = d["durations"]
|
|
160
|
+
hosts.append(
|
|
161
|
+
HostStats(
|
|
162
|
+
host=host,
|
|
163
|
+
count=total_h,
|
|
164
|
+
methods=dict(d["methods"]),
|
|
165
|
+
status_codes=dict(d["status_codes"]),
|
|
166
|
+
avg_duration_ms=sum(durations) / len(durations) if durations else 0,
|
|
167
|
+
max_duration_ms=max(durations) if durations else 0,
|
|
168
|
+
error_rate=errors_h / total_h if total_h else 0,
|
|
169
|
+
protocols=dict(d["protocols"]),
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Build endpoint stats
|
|
174
|
+
endpoints = []
|
|
175
|
+
for (method, path), d in sorted(endpoint_data.items(), key=lambda x: -x[1]["count"])[:20]:
|
|
176
|
+
total_ep = d["count"]
|
|
177
|
+
errors_ep = sum(v for k, v in d["status_codes"].items() if k >= 400)
|
|
178
|
+
durations = d["durations"]
|
|
179
|
+
endpoints.append(
|
|
180
|
+
EndpointStats(
|
|
181
|
+
method=method,
|
|
182
|
+
path=path,
|
|
183
|
+
count=total_ep,
|
|
184
|
+
avg_duration_ms=sum(durations) / len(durations) if durations else 0,
|
|
185
|
+
status_codes=dict(d["status_codes"]),
|
|
186
|
+
error_rate=errors_ep / total_ep if total_ep else 0,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Percentiles
|
|
191
|
+
sorted_dur = sorted(all_durations)
|
|
192
|
+
n = len(sorted_dur)
|
|
193
|
+
p95 = sorted_dur[int(n * 0.95)] if n > 0 else 0
|
|
194
|
+
p99 = sorted_dur[int(n * 0.99)] if n > 0 else 0
|
|
195
|
+
avg = sum(all_durations) / n if n > 0 else 0
|
|
196
|
+
|
|
197
|
+
# Top errors
|
|
198
|
+
errors = sorted(
|
|
199
|
+
[
|
|
200
|
+
{
|
|
201
|
+
"id": e.id,
|
|
202
|
+
"method": e.method,
|
|
203
|
+
"host": e.host,
|
|
204
|
+
"path": e.path,
|
|
205
|
+
"status": e.status_code,
|
|
206
|
+
}
|
|
207
|
+
for e in error_entries
|
|
208
|
+
if e.status_code >= 500
|
|
209
|
+
],
|
|
210
|
+
key=lambda x: x["status"],
|
|
211
|
+
reverse=True,
|
|
212
|
+
)[:10]
|
|
213
|
+
|
|
214
|
+
return TrafficSummary(
|
|
215
|
+
total=len(entries),
|
|
216
|
+
hosts=hosts,
|
|
217
|
+
top_endpoints=endpoints,
|
|
218
|
+
status_distribution=dict(status_dist),
|
|
219
|
+
method_distribution=dict(method_dist),
|
|
220
|
+
protocol_distribution=dict(proto_dist),
|
|
221
|
+
avg_duration_ms=avg,
|
|
222
|
+
p95_duration_ms=p95,
|
|
223
|
+
p99_duration_ms=p99,
|
|
224
|
+
errors=errors,
|
|
225
|
+
)
|