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.
Files changed (72) hide show
  1. pypproxy/__init__.py +0 -0
  2. pypproxy/api/__init__.py +0 -0
  3. pypproxy/api/server.py +427 -0
  4. pypproxy/bulk/__init__.py +0 -0
  5. pypproxy/bulk/sender.py +97 -0
  6. pypproxy/cert/__init__.py +0 -0
  7. pypproxy/cert/ca.py +144 -0
  8. pypproxy/cert/client_cert.py +65 -0
  9. pypproxy/codec.py +176 -0
  10. pypproxy/config/__init__.py +0 -0
  11. pypproxy/config/config.py +106 -0
  12. pypproxy/dns/__init__.py +0 -0
  13. pypproxy/dns/server.py +149 -0
  14. pypproxy/exporter/__init__.py +0 -0
  15. pypproxy/exporter/exporter.py +122 -0
  16. pypproxy/exporter/importer.py +169 -0
  17. pypproxy/graphql/__init__.py +0 -0
  18. pypproxy/graphql/detector.py +76 -0
  19. pypproxy/graphql/introspection.py +217 -0
  20. pypproxy/graphql/modifier.py +98 -0
  21. pypproxy/graphql/schema_store.py +33 -0
  22. pypproxy/intercept/__init__.py +0 -0
  23. pypproxy/intercept/manager.py +142 -0
  24. pypproxy/interceptor/__init__.py +0 -0
  25. pypproxy/interceptor/interceptor.py +172 -0
  26. pypproxy/proto/__init__.py +0 -0
  27. pypproxy/proto/grpc.py +48 -0
  28. pypproxy/proto/mqtt.py +119 -0
  29. pypproxy/proto/ws.py +120 -0
  30. pypproxy/proto/ws_intercept.py +117 -0
  31. pypproxy/proxy/__init__.py +0 -0
  32. pypproxy/proxy/proxy.py +407 -0
  33. pypproxy/replay/__init__.py +0 -0
  34. pypproxy/replay/replay.py +77 -0
  35. pypproxy/rule/__init__.py +0 -0
  36. pypproxy/rule/rule.py +198 -0
  37. pypproxy/scan/__init__.py +0 -0
  38. pypproxy/scan/scanner.py +296 -0
  39. pypproxy/script/__init__.py +0 -0
  40. pypproxy/script/engine.py +49 -0
  41. pypproxy/security/__init__.py +0 -0
  42. pypproxy/security/header_checker.py +308 -0
  43. pypproxy/security/int_overflow.py +193 -0
  44. pypproxy/security/jwt_checker.py +273 -0
  45. pypproxy/security/plugin.py +152 -0
  46. pypproxy/security/randomness.py +165 -0
  47. pypproxy/store/__init__.py +0 -0
  48. pypproxy/store/db.py +189 -0
  49. pypproxy/store/filter_parser.py +181 -0
  50. pypproxy/store/fts.py +105 -0
  51. pypproxy/store/models.py +81 -0
  52. pypproxy/store/scope.py +63 -0
  53. pypproxy/store/store.py +120 -0
  54. pypproxy/ui/__init__.py +0 -0
  55. pypproxy/ui/app.py +386 -0
  56. pypproxy/ui/bulk_sender_ui.py +125 -0
  57. pypproxy/ui/cui.py +162 -0
  58. pypproxy/ui/detail.py +179 -0
  59. pypproxy/ui/diff_view.py +118 -0
  60. pypproxy/ui/graphql_tab.py +265 -0
  61. pypproxy/ui/import_tab.py +136 -0
  62. pypproxy/ui/intercept_dialog.py +74 -0
  63. pypproxy/ui/resender.py +140 -0
  64. pypproxy/ui/scan_tab.py +98 -0
  65. pypproxy/ui/security_tab.py +356 -0
  66. pypproxy/ui/settings.py +413 -0
  67. pypproxy/ui/theme.py +59 -0
  68. pypproxy-0.1.0.dist-info/METADATA +19 -0
  69. pypproxy-0.1.0.dist-info/RECORD +72 -0
  70. pypproxy-0.1.0.dist-info/WHEEL +4 -0
  71. pypproxy-0.1.0.dist-info/entry_points.txt +2 -0
  72. pypproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
pypproxy/__init__.py ADDED
File without changes
File without changes
pypproxy/api/server.py ADDED
@@ -0,0 +1,427 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import json
5
+ import logging
6
+
7
+ import httpx
8
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import JSONResponse, PlainTextResponse, Response
11
+ from pydantic import BaseModel
12
+
13
+ from pypproxy.bulk.sender import BulkPayload, bulk_send, race_send
14
+ from pypproxy.exporter.exporter import export_all, export_har, import_rules
15
+ from pypproxy.exporter.importer import import_har, import_json
16
+ from pypproxy.replay.replay import ReplayOptions, replay_many
17
+ from pypproxy.rule.rule import Rule, RuleManager
18
+ from pypproxy.store.models import Filter
19
+ from pypproxy.store.scope import ScopeManager, ScopeRule
20
+ from pypproxy.store.store import Store
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ app = FastAPI(title="paxy API")
25
+ app.add_middleware(
26
+ CORSMiddleware,
27
+ allow_origins=["*"],
28
+ allow_methods=["*"],
29
+ allow_headers=["*"],
30
+ )
31
+
32
+ _store: Store | None = None
33
+ _rules: RuleManager | None = None
34
+ _scope: ScopeManager | None = None
35
+
36
+
37
+ def init(store: Store, rules: RuleManager, scope: ScopeManager | None = None) -> None:
38
+ global _store, _rules, _scope
39
+ _store = store
40
+ _rules = rules
41
+ _scope = scope
42
+
43
+
44
+ def register_routes(target_app: FastAPI) -> None:
45
+ """Include all API routes into another FastAPI/NiceGUI app (for GUI mode)."""
46
+ target_app.include_router(app.router)
47
+
48
+
49
+ # --- traffic ---
50
+
51
+
52
+ @app.get("/api/traffic")
53
+ async def list_traffic(
54
+ offset: int = 0,
55
+ limit: int = 100,
56
+ method: str = "",
57
+ host: str = "",
58
+ search: str = "",
59
+ protocol: str = "",
60
+ ) -> JSONResponse:
61
+ assert _store is not None
62
+ f = Filter(method=method, host=host, search=search, protocol=protocol)
63
+ entries, total = _store.list(f, offset, limit)
64
+ return JSONResponse(
65
+ {
66
+ "entries": [e.to_dict() for e in entries],
67
+ "total": total,
68
+ "offset": offset,
69
+ "limit": limit,
70
+ }
71
+ )
72
+
73
+
74
+ @app.get("/api/traffic/{entry_id}")
75
+ async def get_traffic(entry_id: int) -> JSONResponse:
76
+ assert _store is not None
77
+ entry = _store.get(entry_id)
78
+ if entry is None:
79
+ raise HTTPException(status_code=404, detail="not found")
80
+ return JSONResponse(entry.to_dict())
81
+
82
+
83
+ # --- rules ---
84
+
85
+
86
+ @app.get("/api/rules")
87
+ async def list_rules() -> JSONResponse:
88
+ assert _rules is not None
89
+ return JSONResponse([r.to_dict() for r in _rules.list()])
90
+
91
+
92
+ @app.post("/api/rules")
93
+ async def create_rule(data: dict) -> JSONResponse:
94
+ assert _rules is not None
95
+ rule = Rule.from_dict(data)
96
+ _rules.add(rule)
97
+ return JSONResponse(rule.to_dict())
98
+
99
+
100
+ @app.put("/api/rules/{rule_id}")
101
+ async def update_rule(rule_id: int, data: dict) -> JSONResponse:
102
+ assert _rules is not None
103
+ data["id"] = rule_id
104
+ rule = Rule.from_dict(data)
105
+ _rules.update(rule)
106
+ return JSONResponse(rule.to_dict())
107
+
108
+
109
+ @app.delete("/api/rules/{rule_id}")
110
+ async def delete_rule(rule_id: int) -> Response:
111
+ assert _rules is not None
112
+ _rules.delete(rule_id)
113
+ return Response(status_code=204)
114
+
115
+
116
+ # --- replay ---
117
+
118
+
119
+ class ReplayRequest(BaseModel):
120
+ entry_id: int
121
+ options: dict = {}
122
+
123
+
124
+ @app.post("/api/replay")
125
+ async def replay(req: ReplayRequest) -> JSONResponse:
126
+ assert _store is not None
127
+ entry = _store.get(req.entry_id)
128
+ if entry is None:
129
+ raise HTTPException(status_code=404, detail="entry not found")
130
+ opts = ReplayOptions(
131
+ override_host=req.options.get("override_host", ""),
132
+ extra_headers=req.options.get("extra_headers", {}),
133
+ timeout_seconds=req.options.get("timeout_seconds", 30),
134
+ count=req.options.get("count", 1),
135
+ )
136
+ results = await replay_many(entry, opts)
137
+ return JSONResponse([r.to_dict() for r in results])
138
+
139
+
140
+ # --- bulk sender ---
141
+
142
+
143
+ class BulkRequest(BaseModel):
144
+ entry_id: int
145
+ payloads: list[dict] = []
146
+ count: int = 10
147
+ concurrency: int = 10
148
+ mode: str = "payloads" # "payloads" or "race"
149
+
150
+
151
+ @app.post("/api/bulk")
152
+ async def bulk(req: BulkRequest) -> JSONResponse:
153
+ assert _store is not None
154
+ entry = _store.get(req.entry_id)
155
+ if entry is None:
156
+ raise HTTPException(status_code=404, detail="entry not found")
157
+ if req.mode == "race":
158
+ results = await race_send(entry, count=req.count)
159
+ else:
160
+ payloads = [
161
+ BulkPayload(
162
+ label=p.get("label", f"payload-{i}"),
163
+ override_body=p.get("body", "").encode() if p.get("body") else b"",
164
+ override_headers=p.get("headers", {}),
165
+ override_path=p.get("path", ""),
166
+ )
167
+ for i, p in enumerate(req.payloads)
168
+ ]
169
+ results = await bulk_send(entry, payloads, concurrency=req.concurrency)
170
+ return JSONResponse([r.to_dict() for r in results])
171
+
172
+
173
+ # --- export / import ---
174
+
175
+
176
+ @app.get("/api/export/json")
177
+ async def export_json() -> PlainTextResponse:
178
+ assert _store is not None and _rules is not None
179
+ entries, _ = _store.list(Filter(), 0, 0)
180
+ return PlainTextResponse(export_all(entries, _rules), media_type="application/json")
181
+
182
+
183
+ @app.get("/api/export/har")
184
+ async def export_har_endpoint() -> PlainTextResponse:
185
+ assert _store is not None
186
+ entries, _ = _store.list(Filter(), 0, 0)
187
+ return PlainTextResponse(export_har(entries), media_type="application/json")
188
+
189
+
190
+ @app.post("/api/import/rules")
191
+ async def import_rules_endpoint(data: dict) -> JSONResponse:
192
+ assert _rules is not None
193
+ import json as _json
194
+
195
+ count = import_rules(_json.dumps(data.get("rules", data)), _rules)
196
+ return JSONResponse({"imported": count})
197
+
198
+
199
+ @app.post("/api/import/har")
200
+ async def import_har_endpoint(data: dict) -> JSONResponse:
201
+ assert _store is not None
202
+ import json as _json
203
+
204
+ count = import_har(_json.dumps(data), _store)
205
+ return JSONResponse({"imported": count})
206
+
207
+
208
+ @app.post("/api/import/json")
209
+ async def import_json_endpoint(data: dict) -> JSONResponse:
210
+ assert _store is not None
211
+ import json as _json
212
+
213
+ count = import_json(_json.dumps(data.get("entries", data)), _store)
214
+ return JSONResponse({"imported": count})
215
+
216
+
217
+ # --- full-text search ---
218
+
219
+
220
+ @app.get("/api/search")
221
+ async def fts_search(q: str = "", limit: int = 50) -> JSONResponse:
222
+ assert _store is not None
223
+ if not q:
224
+ return JSONResponse([])
225
+ db = getattr(_store, "_db", None)
226
+ if db is None:
227
+ # fallback: in-memory filter
228
+ f = Filter(search=q)
229
+ entries, _ = _store.list(f, 0, limit)
230
+ return JSONResponse(
231
+ [{"entry_id": e.id, "rank": 0.0, "snippet": e.host + e.path} for e in entries]
232
+ )
233
+ results = await db.search(q, limit)
234
+ return JSONResponse([r.to_dict() for r in results])
235
+
236
+
237
+ # --- scope ---
238
+
239
+
240
+ @app.get("/api/scope")
241
+ async def list_scope() -> JSONResponse:
242
+ if _scope is None:
243
+ return JSONResponse({"enabled": False, "rules": []})
244
+ return JSONResponse(
245
+ {
246
+ "enabled": _scope.enabled,
247
+ "rules": [r.to_dict() for r in _scope.list()],
248
+ }
249
+ )
250
+
251
+
252
+ @app.post("/api/scope")
253
+ async def update_scope(data: dict) -> JSONResponse:
254
+ if _scope is None:
255
+ return JSONResponse({"error": "scope not initialized"}, status_code=503)
256
+ if "enabled" in data:
257
+ _scope.set_enabled(bool(data["enabled"]))
258
+ if "add" in data:
259
+ _scope.add(
260
+ ScopeRule(
261
+ pattern=data["add"].get("pattern", ""),
262
+ mode=data["add"].get("mode", "glob"),
263
+ )
264
+ )
265
+ if "remove" in data:
266
+ _scope.remove(data["remove"])
267
+ return JSONResponse({"enabled": _scope.enabled, "rules": [r.to_dict() for r in _scope.list()]})
268
+
269
+
270
+ # --- active scan ---
271
+
272
+
273
+ class ScanRequest(BaseModel):
274
+ entry_id: int
275
+ categories: list[str] = []
276
+ concurrency: int = 5
277
+
278
+
279
+ @app.post("/api/scan")
280
+ async def active_scan(req: ScanRequest) -> JSONResponse:
281
+ assert _store is not None
282
+ from pypproxy.scan.scanner import run_scan
283
+
284
+ entry = _store.get(req.entry_id)
285
+ if entry is None:
286
+ raise HTTPException(status_code=404, detail="entry not found")
287
+ cats = req.categories or None
288
+ results = await run_scan(entry, categories=cats, concurrency=req.concurrency)
289
+ return JSONResponse([r.to_dict() for r in results])
290
+
291
+
292
+ # --- GraphQL ---
293
+
294
+ _gql_schema_store = None
295
+
296
+
297
+ def init_graphql() -> None:
298
+ global _gql_schema_store
299
+ from pypproxy.graphql.schema_store import SchemaStore
300
+
301
+ _gql_schema_store = SchemaStore()
302
+
303
+
304
+ class IntrospectRequest(BaseModel):
305
+ url: str
306
+ headers: dict[str, str] = {}
307
+
308
+
309
+ @app.post("/api/graphql/introspect")
310
+ async def graphql_introspect(req: IntrospectRequest) -> JSONResponse:
311
+ from pypproxy.graphql.introspection import fetch_schema
312
+
313
+ schema = await fetch_schema(req.url, req.headers)
314
+ if schema is None:
315
+ raise HTTPException(status_code=502, detail="Introspection failed or not supported")
316
+ if _gql_schema_store is not None:
317
+ from urllib.parse import urlparse
318
+
319
+ host = urlparse(req.url).netloc
320
+ _gql_schema_store.set(host, schema)
321
+ return JSONResponse(schema.to_dict())
322
+
323
+
324
+ @app.get("/api/graphql/schemas")
325
+ async def graphql_list_schemas() -> JSONResponse:
326
+ if _gql_schema_store is None:
327
+ return JSONResponse([])
328
+ return JSONResponse(
329
+ [
330
+ {"host": host, "query_type": s.query_type, "mutation_type": s.mutation_type}
331
+ for host, s in _gql_schema_store.all().items()
332
+ ]
333
+ )
334
+
335
+
336
+ @app.get("/api/graphql/schema/{host}")
337
+ async def graphql_get_schema(host: str) -> JSONResponse:
338
+ if _gql_schema_store is None:
339
+ raise HTTPException(status_code=404, detail="no schema store")
340
+ schema = _gql_schema_store.get(host)
341
+ if schema is None:
342
+ raise HTTPException(status_code=404, detail=f"no schema for {host}")
343
+ return JSONResponse(schema.to_dict())
344
+
345
+
346
+ @app.delete("/api/graphql/schema/{host}")
347
+ async def graphql_delete_schema(host: str) -> Response:
348
+ if _gql_schema_store is not None:
349
+ _gql_schema_store.delete(host)
350
+ return Response(status_code=204)
351
+
352
+
353
+ class GQLReplayRequest(BaseModel):
354
+ entry_id: int
355
+ query: str = ""
356
+ variables: dict = {}
357
+ operation_name: str = ""
358
+
359
+
360
+ @app.post("/api/graphql/replay")
361
+ async def graphql_replay(req: GQLReplayRequest) -> JSONResponse:
362
+ assert _store is not None
363
+ import json as _json
364
+ import time
365
+
366
+ entry = _store.get(req.entry_id)
367
+ if entry is None:
368
+ raise HTTPException(status_code=404, detail="entry not found")
369
+
370
+ url = f"{entry.scheme}://{entry.host}{entry.path}"
371
+ body_dict: dict = {}
372
+ if entry.req_body:
373
+ with contextlib.suppress(Exception):
374
+ body_dict = _json.loads(entry.req_body)
375
+
376
+ if req.query:
377
+ body_dict["query"] = req.query
378
+ if req.variables:
379
+ body_dict["variables"] = req.variables
380
+ if req.operation_name:
381
+ body_dict["operationName"] = req.operation_name
382
+
383
+ req_headers = {k: ", ".join(v) for k, v in entry.req_headers.items()}
384
+ start = time.monotonic()
385
+ try:
386
+ async with httpx.AsyncClient(verify=False, timeout=30, http2=True) as client:
387
+ resp = await client.post(url, json=body_dict, headers=req_headers)
388
+ dur = int((time.monotonic() - start) * 1000)
389
+ return JSONResponse(
390
+ {
391
+ "status_code": resp.status_code,
392
+ "duration_ms": dur,
393
+ "body": resp.json()
394
+ if "json" in resp.headers.get("content-type", "")
395
+ else resp.text,
396
+ }
397
+ )
398
+ except Exception as e:
399
+ return JSONResponse({"error": str(e)}, status_code=502)
400
+
401
+
402
+ # --- clear ---
403
+
404
+
405
+ @app.post("/api/clear")
406
+ async def clear() -> Response:
407
+ assert _store is not None
408
+ _store.clear()
409
+ return Response(status_code=204)
410
+
411
+
412
+ # --- websocket ---
413
+
414
+
415
+ @app.websocket("/ws")
416
+ async def ws_endpoint(websocket: WebSocket) -> None:
417
+ assert _store is not None
418
+ await websocket.accept()
419
+ q = _store.subscribe()
420
+ try:
421
+ while True:
422
+ entry = await q.get()
423
+ await websocket.send_text(json.dumps(entry.to_dict()))
424
+ except WebSocketDisconnect:
425
+ pass
426
+ finally:
427
+ _store.unsubscribe(q)
File without changes
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from pypproxy.store.models import Entry
11
+
12
+
13
+ @dataclass
14
+ class BulkPayload:
15
+ label: str = ""
16
+ override_body: bytes = b""
17
+ override_headers: dict[str, str] = field(default_factory=dict)
18
+ override_path: str = ""
19
+
20
+
21
+ @dataclass
22
+ class BulkResult:
23
+ label: str
24
+ status_code: int = 0
25
+ body: bytes = b""
26
+ duration_ms: int = 0
27
+ error: str = ""
28
+
29
+ def to_dict(self) -> dict[str, Any]:
30
+ import base64
31
+
32
+ return {
33
+ "label": self.label,
34
+ "status_code": self.status_code,
35
+ "body": base64.b64encode(self.body).decode() if self.body else "",
36
+ "duration_ms": self.duration_ms,
37
+ "error": self.error,
38
+ }
39
+
40
+
41
+ async def bulk_send(
42
+ entry: Entry,
43
+ payloads: list[BulkPayload],
44
+ timeout: int = 30,
45
+ concurrency: int = 10,
46
+ ) -> list[BulkResult]:
47
+ """Send multiple variants of the same request concurrently."""
48
+ sem = asyncio.Semaphore(concurrency)
49
+
50
+ async def _one(payload: BulkPayload) -> BulkResult:
51
+ async with sem:
52
+ return await _send(entry, payload, timeout)
53
+
54
+ return await asyncio.gather(*[_one(p) for p in payloads])
55
+
56
+
57
+ async def _send(entry: Entry, payload: BulkPayload, timeout: int) -> BulkResult:
58
+ path = payload.override_path or entry.path
59
+ url = f"{entry.scheme}://{entry.host}{path}"
60
+ if entry.query:
61
+ url += f"?{entry.query}"
62
+
63
+ headers = {k: ", ".join(v) for k, v in entry.req_headers.items()}
64
+ headers.update(payload.override_headers)
65
+ body = payload.override_body if payload.override_body else entry.req_body
66
+
67
+ start = time.monotonic()
68
+ try:
69
+ async with httpx.AsyncClient(verify=False, timeout=timeout, http2=True) as client:
70
+ resp = await client.request(
71
+ method=entry.method,
72
+ url=url,
73
+ headers=headers,
74
+ content=body,
75
+ )
76
+ return BulkResult(
77
+ label=payload.label,
78
+ status_code=resp.status_code,
79
+ body=resp.content,
80
+ duration_ms=int((time.monotonic() - start) * 1000),
81
+ )
82
+ except Exception as e:
83
+ return BulkResult(
84
+ label=payload.label,
85
+ duration_ms=int((time.monotonic() - start) * 1000),
86
+ error=str(e),
87
+ )
88
+
89
+
90
+ async def race_send(
91
+ entry: Entry,
92
+ count: int = 10,
93
+ timeout: int = 30,
94
+ ) -> list[BulkResult]:
95
+ """Send the same request `count` times simultaneously (race condition test)."""
96
+ payloads = [BulkPayload(label=f"race-{i}") for i in range(count)]
97
+ return await bulk_send(entry, payloads, timeout=timeout, concurrency=count)
File without changes
pypproxy/cert/ca.py ADDED
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import ssl
5
+ import tempfile
6
+ import threading
7
+ from datetime import UTC, datetime, timedelta
8
+ from pathlib import Path
9
+
10
+ from cryptography import x509
11
+ from cryptography.hazmat.primitives import hashes, serialization
12
+ from cryptography.hazmat.primitives.asymmetric import rsa
13
+ from cryptography.x509.oid import NameOID
14
+
15
+
16
+ class CA:
17
+ def __init__(self, cert: x509.Certificate, key: rsa.RSAPrivateKey) -> None:
18
+ self._cert = cert
19
+ self._key = key
20
+ self._cache: dict[str, ssl.SSLContext] = {}
21
+ self._lock = threading.Lock()
22
+
23
+ @classmethod
24
+ def load_or_create(cls, cert_path: str, key_path: str) -> CA:
25
+ cp, kp = Path(cert_path).resolve(), Path(key_path).resolve()
26
+ if cp.exists() and kp.exists():
27
+ return cls._load(cp, kp)
28
+ return cls._generate(cp, kp)
29
+
30
+ @classmethod
31
+ def _load(cls, cert_path: Path, key_path: Path) -> CA:
32
+ cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
33
+ key = serialization.load_pem_private_key(key_path.read_bytes(), password=None)
34
+ return cls(cert, key) # type: ignore[arg-type]
35
+
36
+ @classmethod
37
+ def _generate(cls, cert_path: Path, key_path: Path) -> CA:
38
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
39
+ now = datetime.now(UTC)
40
+ cert = (
41
+ x509.CertificateBuilder()
42
+ .subject_name(
43
+ x509.Name(
44
+ [
45
+ x509.NameAttribute(NameOID.COMMON_NAME, "paxy CA"),
46
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, "paxy"),
47
+ ]
48
+ )
49
+ )
50
+ .issuer_name(
51
+ x509.Name(
52
+ [
53
+ x509.NameAttribute(NameOID.COMMON_NAME, "paxy CA"),
54
+ ]
55
+ )
56
+ )
57
+ .public_key(key.public_key())
58
+ .serial_number(x509.random_serial_number())
59
+ .not_valid_before(now - timedelta(hours=1))
60
+ .not_valid_after(now + timedelta(days=3650))
61
+ .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
62
+ .add_extension(
63
+ x509.KeyUsage(
64
+ digital_signature=False,
65
+ content_commitment=False,
66
+ key_encipherment=False,
67
+ data_encipherment=False,
68
+ key_agreement=False,
69
+ key_cert_sign=True,
70
+ crl_sign=True,
71
+ encipher_only=False,
72
+ decipher_only=False,
73
+ ),
74
+ critical=True,
75
+ )
76
+ .sign(key, hashes.SHA256())
77
+ )
78
+
79
+ cert_path.parent.mkdir(parents=True, exist_ok=True)
80
+ cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
81
+ key_path.write_bytes(
82
+ key.private_bytes(
83
+ serialization.Encoding.PEM,
84
+ serialization.PrivateFormat.TraditionalOpenSSL,
85
+ serialization.NoEncryption(),
86
+ )
87
+ )
88
+ return cls(cert, key)
89
+
90
+ def cert_pem(self) -> bytes:
91
+ return self._cert.public_bytes(serialization.Encoding.PEM)
92
+
93
+ def ssl_context_for(self, hostname: str) -> ssl.SSLContext:
94
+ with self._lock:
95
+ if hostname in self._cache:
96
+ return self._cache[hostname]
97
+ ctx = self._make_context(hostname)
98
+ self._cache[hostname] = ctx
99
+ return ctx
100
+
101
+ def _make_context(self, hostname: str) -> ssl.SSLContext:
102
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
103
+ now = datetime.now(UTC)
104
+
105
+ san: list[x509.GeneralName]
106
+ try:
107
+ san = [x509.IPAddress(ipaddress.ip_address(hostname))]
108
+ except ValueError:
109
+ san = [x509.DNSName(hostname)]
110
+
111
+ cert = (
112
+ x509.CertificateBuilder()
113
+ .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]))
114
+ .issuer_name(self._cert.subject)
115
+ .public_key(key.public_key())
116
+ .serial_number(x509.random_serial_number())
117
+ .not_valid_before(now - timedelta(hours=1))
118
+ .not_valid_after(now + timedelta(days=1))
119
+ .add_extension(x509.SubjectAlternativeName(san), critical=False)
120
+ .add_extension(
121
+ x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]),
122
+ critical=False,
123
+ )
124
+ .sign(self._key, hashes.SHA256())
125
+ )
126
+
127
+ cert_pem = cert.public_bytes(serialization.Encoding.PEM)
128
+ key_pem = key.private_bytes(
129
+ serialization.Encoding.PEM,
130
+ serialization.PrivateFormat.TraditionalOpenSSL,
131
+ serialization.NoEncryption(),
132
+ )
133
+
134
+ # Write to temp files because ssl.SSLContext requires file paths.
135
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as cf:
136
+ cf.write(cert_pem)
137
+ cert_file = cf.name
138
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pem") as kf:
139
+ kf.write(key_pem)
140
+ key_file = kf.name
141
+
142
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
143
+ ctx.load_cert_chain(cert_file, key_file)
144
+ return ctx