vortexpy 4.1.7__tar.gz → 4.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.
Files changed (123) hide show
  1. vortexpy-4.2.0/LICENSE +22 -0
  2. {vortexpy-4.1.7 → vortexpy-4.2.0}/PKG-INFO +1 -1
  3. {vortexpy-4.1.7 → vortexpy-4.2.0}/pyproject.toml +1 -1
  4. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/DeferUtil.py +24 -7
  5. vortexpy-4.2.0/vortex/DeferUtil_test.py +86 -0
  6. vortexpy-4.2.0/vortex/MtlsAuthorizedClientRegistry.py +150 -0
  7. vortexpy-4.2.0/vortex/MtlsAuthorizedClientRegistry_test.py +75 -0
  8. vortexpy-4.2.0/vortex/MtlsServerSslOptions.py +176 -0
  9. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/PayloadEndpoint.py +8 -26
  10. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/PayloadEndpointTest.py +3 -1
  11. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/PayloadEnvelope.py +9 -9
  12. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/PayloadIO.py +2 -9
  13. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/PayloadResponse.py +3 -7
  14. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/SerialiseUtil.py +0 -7
  15. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/Tuple.py +3 -2
  16. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/TuplePolymorphicTupleTypeFieldArgTest.py +1 -4
  17. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/TupleRestful.py +8 -25
  18. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/TupleRestfulTest.py +7 -15
  19. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexAccessControlContextABC.py +0 -1
  20. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexClientTcp.py +19 -4
  21. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexClientWebsocketFactory.py +29 -5
  22. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexFactory.py +56 -20
  23. vortexpy-4.2.0/vortex/VortexFactory_test.py +114 -0
  24. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexPayloadProtocol.py +2 -6
  25. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexPermission.py +0 -1
  26. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexPermissionError.py +0 -1
  27. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexServer.py +1 -0
  28. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexSession.py +2 -4
  29. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexTcpMemoryLeakPayloadTest.py +24 -24
  30. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexWritePushProducer.py +1 -0
  31. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/__init__.py +1 -1
  32. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/data_loader/TupleDataLiveLockManager.py +5 -1
  33. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/data_loader/TupleDataLoader.py +16 -28
  34. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/data_loader/TupleDataLoaderDelegate.py +4 -14
  35. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/event_bus/TupleChangeEventBus.py +8 -7
  36. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/handler/TupleActionProcessor.py +41 -74
  37. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/handler/TupleActionProcessorProxy.py +10 -8
  38. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/handler/TupleActionProcessorTestHandler.py +3 -3
  39. vortexpy-4.2.0/vortex/handler/TupleActionProcessor_shutdown_test.py +20 -0
  40. vortexpy-4.2.0/vortex/handler/TupleActionPushOffline.py +180 -0
  41. vortexpy-4.2.0/vortex/handler/TupleActionPushOffline_test.py +133 -0
  42. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/handler/TupleDataObservableCache.py +5 -3
  43. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/handler/TupleDataObservableHandler.py +20 -28
  44. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/handler/TupleDataObservableProxyHandler.py +5 -14
  45. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/handler/TupleDataObserverClient.py +6 -3
  46. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/handler/TupleDataProviderABC.py +1 -3
  47. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/rpc/RPC.py +38 -70
  48. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/sqla_orm/OrmCrudHandler.py +5 -2
  49. vortexpy-4.2.0/vortex/storage/TupleActionStorageSqlite.py +156 -0
  50. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/storage/TupleStorageSqlite.py +3 -3
  51. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/tuple_json_schema/tuple_json_schema_builder.py +1 -3
  52. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/tuple_json_schema/tuple_json_schema_test.py +1 -3
  53. vortexpy-4.2.0/vortex/twisted_runtime_metrics.py +252 -0
  54. vortexpy-4.2.0/vortex/twisted_runtime_metrics_test.py +132 -0
  55. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortexpy.egg-info/PKG-INFO +1 -1
  56. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortexpy.egg-info/SOURCES.txt +12 -3
  57. vortexpy-4.1.7/vortex/PayloadResponse_test.py +0 -42
  58. vortexpy-4.1.7/vortex/SerialiseUtil_test.py +0 -106
  59. vortexpy-4.1.7/vortex/rpc/RPC_test.py +0 -114
  60. {vortexpy-4.1.7 → vortexpy-4.2.0}/README.md +0 -0
  61. {vortexpy-4.1.7 → vortexpy-4.2.0}/setup.cfg +0 -0
  62. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/DataWrapTuple.py +0 -0
  63. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/Jsonable.py +0 -0
  64. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/Payload.py +0 -0
  65. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/PayloadFilterKeys.py +0 -0
  66. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/PayloadPriority.py +0 -0
  67. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/PayloadResponseTest.py +0 -0
  68. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/PayloadTest.py +0 -0
  69. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/PerformTestActionTuple.py +0 -0
  70. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/TestTuple.py +0 -0
  71. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/TupleAction.py +0 -0
  72. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/TupleDataForTest.py +0 -0
  73. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/TuplePerfTest.py +0 -0
  74. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/TupleSelector.py +0 -0
  75. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/TupleTest.py +0 -0
  76. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexABC.py +0 -0
  77. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexAuthenticatorABC.py +0 -0
  78. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexClientHttp.py +0 -0
  79. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexClientHttpTest.py +0 -0
  80. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexConnectionABC.py +0 -0
  81. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexEndpointPermission.py +0 -0
  82. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexServerConnection.py +0 -0
  83. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexServerHttpResource.py +0 -0
  84. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexServerTcp.py +0 -0
  85. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexServerWebsocket.py +0 -0
  86. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexTcpMemoryLeakRawTest.py +0 -0
  87. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexTest.py +0 -0
  88. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/VortexUtil.py +0 -0
  89. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/data_loader/TupleDataLoaderTupleABC.py +0 -0
  90. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/data_loader/TupleDataLoaderTuples.py +0 -0
  91. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/data_loader/__init__.py +0 -0
  92. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/event_bus/TupleChangeEventABC.py +0 -0
  93. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/event_bus/TupleChangeEventBusObserverABC.py +0 -0
  94. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/event_bus/__init__.py +0 -0
  95. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/handler/TupleDataAccessGranterABC.py +0 -0
  96. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/handler/TupleDataActionClient.py +0 -0
  97. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/handler/TupleDataObservableTestHandler.py +0 -0
  98. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/handler/VortexJSTupleLoaderTestHandler.py +0 -0
  99. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/handler/__init__.py +0 -0
  100. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/mypy_plugin.py +0 -0
  101. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/py.typed +0 -0
  102. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/restful/GzippedDataHttpClient.py +0 -0
  103. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/restful/GzippedDataHttpClientTest.py +0 -0
  104. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/restful/GzippedPayloadHttpClient.py +0 -0
  105. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/restful/RestfulHttpClient.py +0 -0
  106. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/restful/RestfulHttpClientTest.py +0 -0
  107. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/restful/RestfulResource.py +0 -0
  108. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/restful/RestfulResourceTest.py +0 -0
  109. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/restful/__init__.py +0 -0
  110. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/rpc/RPCTest.py +0 -0
  111. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/rpc/__init__.py +0 -0
  112. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/sqla_orm/TupleProviderForDb.py +0 -0
  113. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/sqla_orm/VortexTupleJson.py +0 -0
  114. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/sqla_orm/__init__.py +0 -0
  115. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/storage/__init__.py +0 -0
  116. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/tuple_json_schema/__init__.py +0 -0
  117. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/tuple_json_schema/constants.py +0 -0
  118. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/tuple_json_schema/json_schema_node_tuple.py +0 -0
  119. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortex/tuple_json_schema/tuple_json_schema_test_tuples.py +0 -0
  120. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortexpy.egg-info/dependency_links.txt +0 -0
  121. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortexpy.egg-info/entry_points.txt +0 -0
  122. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortexpy.egg-info/requires.txt +0 -0
  123. {vortexpy-4.1.7 → vortexpy-4.2.0}/vortexpy.egg-info/top_level.txt +0 -0
vortexpy-4.2.0/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2016-2026 Flying Ion Pty Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vortexpy
3
- Version: 4.1.7
3
+ Version: 4.2.0
4
4
  Summary: Flying Ion's observable, routable, data serialisation and transport code.
5
5
  Author-email: Flying Ion Pty Ltd <contact@flyingion.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vortexpy"
7
- version = "4.1.7"
7
+ version = "4.2.0"
8
8
  license = {text = "MIT"}
9
9
  authors = [{name = "Flying Ion Pty Ltd", email = "contact@flyingion.com"}]
10
10
  description = "Flying Ion's observable, routable, data serialisation and transport code."
@@ -8,6 +8,7 @@
8
8
 
9
9
  import inspect
10
10
  import logging
11
+ import time
11
12
  from collections.abc import Callable
12
13
  from typing import ParamSpec, TypeVar
13
14
 
@@ -209,7 +210,27 @@ def deferToThreadWrapWithLogger(
209
210
  "Deferring to a thread can only be done from the main thread"
210
211
  )
211
212
 
212
- d: Deferred[_R] = deferToThread(funcToWrap, *args, **kwargs)
213
+ def timedFuncToRun(*runArgs: _P.args, **runKwargs: _P.kwargs) -> _R:
214
+ startMonotonic = time.monotonic()
215
+ try:
216
+ return funcToWrap(*runArgs, **runKwargs)
217
+ finally:
218
+ durationMs = (time.monotonic() - startMonotonic) * 1000.0
219
+ from vortex.twisted_runtime_metrics import (
220
+ STACK_CAPTURE_THRESHOLD_MS,
221
+ TwistedRuntimeMetrics,
222
+ )
223
+
224
+ methodFrame = None
225
+ if durationMs >= STACK_CAPTURE_THRESHOLD_MS:
226
+ methodFrame = TwistedRuntimeMetrics._captureWorkerThreadFrame()
227
+ TwistedRuntimeMetrics._collector()._recordThreadedCall(
228
+ funcToWrap.__qualname__,
229
+ durationMs,
230
+ methodFrame,
231
+ )
232
+
233
+ d: Deferred[_R] = deferToThread(timedFuncToRun, *args, **kwargs)
213
234
  d.addCallback(checkForInlineCallbackYield)
214
235
  d.addErrback(vortexLogFailure, loggerArg, consumeError=consumeError)
215
236
  return d
@@ -225,13 +246,9 @@ def isMainThread():
225
246
 
226
247
  def noMainThread():
227
248
  if isMainThread():
228
- raise Exception(
229
- "Blocking operations shouldn't occur in the reactors thread."
230
- )
249
+ raise Exception("Blocking operations shouldn't occur in the reactors thread.")
231
250
 
232
251
 
233
252
  def yesMainThread():
234
253
  if not isMainThread():
235
- raise Exception(
236
- "Async operations must occur in the reactors main thread."
237
- )
254
+ raise Exception("Async operations must occur in the reactors main thread.")
@@ -0,0 +1,86 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ # See LICENSE for the full license text.
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import time
8
+ from collections.abc import Generator
9
+
10
+ import pytest
11
+ from pytest_twisted import ensureDeferred # type: ignore[import-untyped]
12
+
13
+ from vortex.DeferUtil import deferToThreadWrapWithLogger
14
+ from vortex.twisted_runtime_metrics import TwistedRuntimeMetrics
15
+ from vortex.twisted_runtime_metrics_test import (
16
+ utilResetTwistedRuntimeMetricsSingletonForTests,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @deferToThreadWrapWithLogger(logger)
23
+ def _deferUtilTestBlockingWork() -> str:
24
+ time.sleep(0.01)
25
+ return "done"
26
+
27
+
28
+ @deferToThreadWrapWithLogger(logger)
29
+ def _deferUtilTestSlowBlockingWork() -> str:
30
+ time.sleep(0.06)
31
+ return "slow-done"
32
+
33
+
34
+ @pytest.fixture(autouse=True)
35
+ def resetMetricsSingleton() -> Generator[None, None, None]:
36
+ utilResetTwistedRuntimeMetricsSingletonForTests()
37
+ yield
38
+ utilResetTwistedRuntimeMetricsSingletonForTests()
39
+
40
+
41
+ @pytest.mark.twisted
42
+ @ensureDeferred
43
+ async def testDeferToThreadWrapWithLoggerRecordsThreadedCallTiming() -> None:
44
+ result = await _deferUtilTestBlockingWork()
45
+ assert result == "done", "deferred thread call must return worker result"
46
+
47
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
48
+ assert snapshot.threadedCallCount == 1, (
49
+ "defer hook must record one threaded call per invocation"
50
+ )
51
+ assert snapshot.longestThreadedMethodName == "_deferUtilTestBlockingWork", (
52
+ "defer hook must record wrapped function qualname"
53
+ )
54
+ assert snapshot.longestThreadedMethodMs >= 10.0, (
55
+ "defer hook must record at least sleep duration in ms"
56
+ )
57
+ assert snapshot.longestThreadedMethodFrame is None, (
58
+ "fast defer hook must not capture worker thread frame"
59
+ )
60
+
61
+
62
+ @pytest.mark.twisted
63
+ @ensureDeferred
64
+ async def testDeferToThreadWrapWithLoggerCapturesSlowCallFrame() -> None:
65
+ result = await _deferUtilTestSlowBlockingWork()
66
+ assert result == "slow-done", "deferred slow thread call must return worker result"
67
+
68
+ snapshot = TwistedRuntimeMetrics.snapshotAndResetWindow()
69
+ assert snapshot.threadedCallCount == 1, (
70
+ "slow defer hook must record one threaded call"
71
+ )
72
+ assert snapshot.longestThreadedMethodName == "_deferUtilTestSlowBlockingWork", (
73
+ "slow defer hook must record wrapped function qualname"
74
+ )
75
+ assert snapshot.longestThreadedMethodMs >= 50.0, (
76
+ "slow defer hook must exceed stack capture threshold in ms"
77
+ )
78
+ assert snapshot.longestThreadedMethodFrame is not None, (
79
+ "slow defer hook must capture worker thread frame"
80
+ )
81
+ assert "DeferUtil.py" not in snapshot.longestThreadedMethodFrame, (
82
+ "captured frame must skip defer hook internals"
83
+ )
84
+ assert "twisted_runtime_metrics.py" not in snapshot.longestThreadedMethodFrame, (
85
+ "captured frame must skip metrics collector internals"
86
+ )
@@ -0,0 +1,150 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ #
3
+ # This software is open source; the MIT license applies.
4
+ # See LICENSE for the full license text.
5
+ #
6
+ # Website : https://flyingion.com
7
+ # Support : contact@flyingion.com
8
+
9
+ """Mutable authorized-client certificate set for mTLS server verification."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import logging
15
+ import threading
16
+ from typing import NamedTuple
17
+
18
+ from OpenSSL import crypto
19
+ from txhttputil.util.PemUtil import parsePemBundleForTrustedPeers
20
+
21
+ logger = logging.getLogger(name=__name__)
22
+
23
+
24
+ class AuthorizedClientIdentity(NamedTuple):
25
+ """Metadata for one authorized mTLS client certificate."""
26
+
27
+ fingerprint: str
28
+ publicKeyAsn1: bytes
29
+ instanceId: str
30
+ ownerUsername: str
31
+ commonName: str
32
+
33
+
34
+ class MtlsAuthorizedClientRegistry:
35
+ """Runtime-mutable allowlist of client certificate public keys.
36
+
37
+ Static PEM bundles can be loaded at startup; ``addAuthorizedClientCertificate``
38
+ and ``revokeAuthorizedClientCertificate`` mutate the set without restarting
39
+ the TLS listener when used with ``buildMtlsServerCertificateOptions``.
40
+ """
41
+
42
+ def __init__(self) -> None:
43
+ self._lock = threading.RLock()
44
+ self._byPublicKey: dict[bytes, AuthorizedClientIdentity] = {}
45
+ self._byFingerprint: dict[str, AuthorizedClientIdentity] = {}
46
+
47
+ def loadTrustedPeerBundle(self, bundle_file_path: str) -> int:
48
+ """Load client certificates from a PEM bundle into the registry.
49
+
50
+ :param bundle_file_path: Path to a PEM file with one or more client certs.
51
+ :return: Number of certificates added.
52
+ """
53
+ trusted_peer_certificates = parsePemBundleForTrustedPeers(bundle_file_path)
54
+ added = 0
55
+ for trusted_peer in trusted_peer_certificates:
56
+ cert_pem = trusted_peer.as_bytes()
57
+ fingerprint = self.addAuthorizedClientCertificate(
58
+ cert_pem,
59
+ instanceId="",
60
+ ownerUsername="",
61
+ )
62
+ if fingerprint:
63
+ added += 1
64
+ return added
65
+
66
+ def addAuthorizedClientCertificate(
67
+ self,
68
+ certificate_pem: bytes | str,
69
+ *,
70
+ instanceId: str = "",
71
+ ownerUsername: str = "",
72
+ ) -> str:
73
+ """Authorize a client certificate for future mTLS handshakes.
74
+
75
+ :param certificate_pem: PEM-encoded X.509 client certificate.
76
+ :param instanceId: Codie instance id (CN when issued by hub CA).
77
+ :param ownerUsername: OAuth username or operator id for audit logs.
78
+ :return: SHA-256 fingerprint prefix (hex) for the certificate.
79
+ """
80
+ if isinstance(certificate_pem, str):
81
+ certificate_pem = certificate_pem.encode("utf-8")
82
+
83
+ x509_cert = crypto.load_certificate(crypto.FILETYPE_PEM, certificate_pem)
84
+ public_key_asn1 = crypto.dump_publickey(
85
+ crypto.FILETYPE_ASN1, x509_cert.get_pubkey()
86
+ )
87
+ fingerprint = hashlib.sha256(
88
+ crypto.dump_certificate(crypto.FILETYPE_ASN1, x509_cert)
89
+ ).hexdigest()[:16]
90
+ subject = x509_cert.get_subject()
91
+ common_name = (
92
+ subject.CN.decode("utf-8")
93
+ if isinstance(subject.CN, bytes)
94
+ else str(subject.CN or "")
95
+ )
96
+ resolved_instance_id = instanceId or common_name
97
+ identity = AuthorizedClientIdentity(
98
+ fingerprint=fingerprint,
99
+ publicKeyAsn1=public_key_asn1,
100
+ instanceId=resolved_instance_id,
101
+ ownerUsername=ownerUsername,
102
+ commonName=common_name,
103
+ )
104
+ with self._lock:
105
+ self._byPublicKey[public_key_asn1] = identity
106
+ self._byFingerprint[fingerprint] = identity
107
+ logger.info(
108
+ "Authorized mTLS client cert fingerprint=%s instanceId=%s owner=%s cn=%s",
109
+ fingerprint,
110
+ resolved_instance_id,
111
+ ownerUsername or "(unknown)",
112
+ common_name,
113
+ )
114
+ return fingerprint
115
+
116
+ def revokeAuthorizedClientCertificate(self, fingerprint: str) -> bool:
117
+ """Remove a client certificate from the authorized set.
118
+
119
+ :param fingerprint: Value returned from ``addAuthorizedClientCertificate``.
120
+ :return: ``True`` when a certificate was removed.
121
+ """
122
+ with self._lock:
123
+ identity = self._byFingerprint.pop(fingerprint, None)
124
+ if identity is None:
125
+ return False
126
+ self._byPublicKey.pop(identity.publicKeyAsn1, None)
127
+ logger.info(
128
+ "Revoked mTLS client cert fingerprint=%s instanceId=%s owner=%s",
129
+ identity.fingerprint,
130
+ identity.instanceId,
131
+ identity.ownerUsername or "(unknown)",
132
+ )
133
+ return True
134
+
135
+ def isPublicKeyAuthorized(self, public_key_asn1: bytes) -> bool:
136
+ """Return whether a TLS peer public key is currently authorized."""
137
+ with self._lock:
138
+ return public_key_asn1 in self._byPublicKey
139
+
140
+ def getIdentityForPublicKey(
141
+ self, public_key_asn1: bytes
142
+ ) -> AuthorizedClientIdentity | None:
143
+ """Return audit metadata for an authorized peer public key."""
144
+ with self._lock:
145
+ return self._byPublicKey.get(public_key_asn1)
146
+
147
+ def listAuthorizedIdentities(self) -> list[AuthorizedClientIdentity]:
148
+ """Return a snapshot of all authorized client identities."""
149
+ with self._lock:
150
+ return list(self._byFingerprint.values())
@@ -0,0 +1,75 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ #
3
+ # This software is open source; the MIT license applies.
4
+ # See LICENSE for the full license text.
5
+ #
6
+ # Website : https://flyingion.com
7
+ # Support : contact@flyingion.com
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ import pytest
14
+ from cryptography import x509
15
+ from cryptography.hazmat.primitives import hashes, serialization
16
+ from cryptography.hazmat.primitives.asymmetric import rsa
17
+ from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
18
+ from datetime import datetime, timedelta, timezone
19
+ from OpenSSL import crypto
20
+
21
+ from vortex.MtlsAuthorizedClientRegistry import MtlsAuthorizedClientRegistry
22
+
23
+
24
+ def _makeClientCertPem(common_name: str) -> bytes:
25
+ private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
26
+ subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
27
+ now = datetime.now(timezone.utc)
28
+ cert = (
29
+ x509.CertificateBuilder()
30
+ .subject_name(subject)
31
+ .issuer_name(subject)
32
+ .public_key(private_key.public_key())
33
+ .serial_number(x509.random_serial_number())
34
+ .not_valid_before(now)
35
+ .not_valid_after(now + timedelta(days=30))
36
+ .add_extension(
37
+ x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]),
38
+ critical=False,
39
+ )
40
+ .sign(private_key, hashes.SHA256())
41
+ )
42
+ return cert.public_bytes(serialization.Encoding.PEM)
43
+
44
+
45
+ def test_add_and_revoke_client_certificate() -> None:
46
+ registry = MtlsAuthorizedClientRegistry()
47
+ cert_pem = _makeClientCertPem("local-mac")
48
+ fingerprint = registry.addAuthorizedClientCertificate(
49
+ cert_pem,
50
+ instanceId="local-mac",
51
+ ownerUsername="operator",
52
+ )
53
+ assert fingerprint
54
+ x509_cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem)
55
+ public_key_asn1 = crypto.dump_publickey(
56
+ crypto.FILETYPE_ASN1, x509_cert.get_pubkey()
57
+ )
58
+ assert registry.isPublicKeyAuthorized(public_key_asn1)
59
+ identity = registry.getIdentityForPublicKey(public_key_asn1)
60
+ assert identity is not None
61
+ assert identity.instanceId == "local-mac"
62
+ assert identity.ownerUsername == "operator"
63
+ assert registry.revokeAuthorizedClientCertificate(fingerprint)
64
+ assert not registry.isPublicKeyAuthorized(public_key_asn1)
65
+
66
+
67
+ def test_load_trusted_peer_bundle(tmp_path) -> None:
68
+ cert_pem = _makeClientCertPem("cloud-instance")
69
+ bundle_path = os.path.join(str(tmp_path), "trusted-peers.pem")
70
+ with open(bundle_path, "wb") as handle:
71
+ handle.write(cert_pem)
72
+ registry = MtlsAuthorizedClientRegistry()
73
+ added = registry.loadTrustedPeerBundle(bundle_path)
74
+ assert added == 1
75
+ assert len(registry.listAuthorizedIdentities()) == 1
@@ -0,0 +1,176 @@
1
+ # Copyright (c) 2016-2026 Flying Ion Pty Ltd
2
+ #
3
+ # This software is open source; the MIT license applies.
4
+ # See LICENSE for the full license text.
5
+ #
6
+ # Website : https://flyingion.com
7
+ # Support : contact@flyingion.com
8
+
9
+ """Build Twisted server SSL options with a dynamic mTLS client allowlist."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Optional
15
+
16
+ from OpenSSL import SSL, crypto
17
+ from twisted.internet._sslverify import Certificate, KeyPair, TLSVersion
18
+ from twisted.internet.ssl import CertificateOptions
19
+ from twisted.python.randbytes import secureRandom
20
+ from txhttputil.util.PemUtil import PrivateKeyWithFullChain
21
+
22
+ from vortex.MtlsAuthorizedClientRegistry import MtlsAuthorizedClientRegistry
23
+
24
+ logger = logging.getLogger(name=__name__)
25
+
26
+
27
+ class _MtlsServerCertificateOptions(CertificateOptions):
28
+ """Server ``CertificateOptions`` with registry-backed client verification."""
29
+
30
+ def __init__(
31
+ self,
32
+ *,
33
+ authorized_client_registry: MtlsAuthorizedClientRegistry,
34
+ **kwargs,
35
+ ) -> None:
36
+ self._authorizedClientRegistry = authorized_client_registry
37
+ super().__init__(**kwargs)
38
+
39
+ def _makeContext(self):
40
+ ctx = self._contextFactory(self.method)
41
+ ctx.set_options(self._options)
42
+ ctx.set_mode(self._mode)
43
+
44
+ if self.certificate is not None and self.privateKey is not None:
45
+ ctx.use_certificate(self.certificate)
46
+ ctx.use_privatekey(self.privateKey)
47
+ for extra_cert in self.extraCertChain:
48
+ ctx.add_extra_chain_cert(extra_cert)
49
+ ctx.check_privatekey()
50
+
51
+ verify_flags = SSL.VERIFY_NONE
52
+ if self.verify:
53
+ verify_flags = SSL.VERIFY_PEER
54
+ if self.requireCertificate:
55
+ verify_flags |= SSL.VERIFY_FAIL_IF_NO_PEER_CERT
56
+ if self.verifyOnce:
57
+ verify_flags |= SSL.VERIFY_CLIENT_ONCE
58
+ self.trustRoot._addCACertsToContext(ctx)
59
+
60
+ registry = self._authorizedClientRegistry
61
+
62
+ def verifyCallback(
63
+ conn: SSL.Connection,
64
+ cert: crypto.X509,
65
+ errno: int,
66
+ depth: int,
67
+ preverify_ok: int,
68
+ ) -> int:
69
+ fail = 0
70
+
71
+ if depth != 0:
72
+ return preverify_ok
73
+
74
+ if preverify_ok == fail:
75
+ return preverify_ok
76
+
77
+ session_peer_public_key_bytes = crypto.dump_publickey(
78
+ crypto.FILETYPE_ASN1, cert.get_pubkey()
79
+ )
80
+ if not registry.isPublicKeyAuthorized(session_peer_public_key_bytes):
81
+ subject = cert.get_subject()
82
+ logger.error(
83
+ "mTLS peer verify failed: certificate not authorized subject=%s",
84
+ subject,
85
+ )
86
+ return fail
87
+
88
+ identity = registry.getIdentityForPublicKey(
89
+ session_peer_public_key_bytes
90
+ )
91
+ if identity is not None:
92
+ logger.info(
93
+ "mTLS peer verified instanceId=%s owner=%s fingerprint=%s",
94
+ identity.instanceId or "(unknown)",
95
+ identity.ownerUsername or "(unknown)",
96
+ identity.fingerprint,
97
+ )
98
+ return preverify_ok
99
+
100
+ ctx.set_verify(verify_flags, verifyCallback)
101
+ if self.verifyDepth is not None:
102
+ ctx.set_verify_depth(self.verifyDepth)
103
+
104
+ from binascii import hexlify
105
+
106
+ session_id_context = hexlify(secureRandom(7))
107
+ ctx.set_session_id(session_id_context)
108
+
109
+ if self.enableSessions:
110
+ ctx.set_session_cache_mode(SSL.SESS_CACHE_SERVER)
111
+ else:
112
+ ctx.set_session_cache_mode(SSL.SESS_CACHE_OFF)
113
+
114
+ if self.dhParameters:
115
+ ctx.load_tmp_dh(self.dhParameters._dhFile.path)
116
+ ctx.set_cipher_list(self._cipherString.encode("ascii"))
117
+
118
+ self._ecChooser.configureECDHCurve(ctx)
119
+
120
+ if self._acceptableProtocols:
121
+ from twisted.internet._sslverify import _setAcceptableProtocols
122
+
123
+ _setAcceptableProtocols(ctx, self._acceptableProtocols)
124
+
125
+ return ctx
126
+
127
+
128
+ def buildMtlsServerCertificateOptions(
129
+ private_key_with_full_chain: PrivateKeyWithFullChain,
130
+ trust_root,
131
+ authorized_client_registry: MtlsAuthorizedClientRegistry,
132
+ *,
133
+ acceptable_protocols: Optional[list[bytes]] = None,
134
+ dh_parameters=None,
135
+ ) -> CertificateOptions:
136
+ """Build server TLS options with dynamic authorized-client verification.
137
+
138
+ Existing callers that pass a static trusted-peer PEM bundle should load
139
+ that bundle into ``authorized_client_registry`` first; runtime ``add`` /
140
+ ``revoke`` on the registry updates verification without rebuilding options.
141
+
142
+ :param private_key_with_full_chain: Server key + certificate chain PEM bank.
143
+ :param trust_root: Twisted trust root for client certificate chain validation.
144
+ :param authorized_client_registry: Mutable authorized client public keys.
145
+ :return: Twisted ``CertificateOptions`` for ``reactor.listenSSL``.
146
+ """
147
+ if acceptable_protocols is None:
148
+ acceptable_protocols = [b"http/1.1"]
149
+
150
+ twisted_loaded_chain = PrivateKeyWithFullChain(
151
+ privateKey=KeyPair.load(
152
+ private_key_with_full_chain.privateKey.as_bytes(), crypto.FILETYPE_PEM
153
+ ).original,
154
+ certificate=Certificate.loadPEM(
155
+ private_key_with_full_chain.certificate.as_bytes()
156
+ ).original,
157
+ intermediateCAs=[
158
+ Certificate.loadPEM(intermediate_ca.as_bytes()).original
159
+ for intermediate_ca in private_key_with_full_chain.intermediateCAs
160
+ ],
161
+ rootCA=Certificate.loadPEM(
162
+ private_key_with_full_chain.rootCA.as_bytes()
163
+ ).original,
164
+ )
165
+
166
+ return _MtlsServerCertificateOptions(
167
+ authorized_client_registry=authorized_client_registry,
168
+ privateKey=twisted_loaded_chain.privateKey,
169
+ certificate=twisted_loaded_chain.certificate,
170
+ extraCertChain=twisted_loaded_chain.intermediateCAs,
171
+ trustRoot=trust_root,
172
+ raiseMinimumTo=TLSVersion.TLSv1_2,
173
+ lowerMaximumSecurityTo=TLSVersion.TLSv1_3,
174
+ acceptableProtocols=acceptable_protocols,
175
+ dhParameters=dh_parameters,
176
+ )
@@ -22,9 +22,7 @@ from vortex.VortexAccessControlContextABC import (
22
22
  NO_AUTH_ACCESS_CONTROL_CONTEXT,
23
23
  VortexAccessControlContextABC,
24
24
  )
25
- from vortex.VortexEndpointPermission import (
26
- EndpointPermissionsType as PermissionsType,
27
- )
25
+ from vortex.VortexEndpointPermission import EndpointPermissionsType as PermissionsType
28
26
  from vortex.VortexSession import (
29
27
  _callableLabel,
30
28
  checkAccessControlContextSignature,
@@ -71,7 +69,6 @@ class PayloadEndpoint:
71
69
  acceptOnlyFromVortex: Optional[Union[str, tuple]] = None,
72
70
  ignoreFromVortex: Optional[Union[str, tuple]] = None,
73
71
  requiredPermissions: Optional[PermissionsType] = None,
74
- logProcessException: bool = True,
75
72
  ) -> None:
76
73
  """
77
74
  :param filt: The filter to match against payloads
@@ -83,13 +80,8 @@ class PayloadEndpoint:
83
80
  the caller's session must hold. Pass an empty frozenset to
84
81
  explicitly declare "no permissions required". Omitting the
85
82
  parameter (``None``) emits a deprecation warning.
86
- :param logProcessException: When True, handler exceptions log at ERROR.
87
- When False, the same events log at DEBUG with neutral wording.
88
83
  """
89
- self._requiredPermissions: Optional[PermissionsType] = (
90
- requiredPermissions
91
- )
92
- self._logProcessException = logProcessException
84
+ self._requiredPermissions: Optional[PermissionsType] = requiredPermissions
93
85
 
94
86
  if requiredPermissions is None:
95
87
  logger.warning(self._NO_PERMISSIONS_WARNING, filt)
@@ -97,11 +89,9 @@ class PayloadEndpoint:
97
89
  callableDesc = (
98
90
  f"PayloadEndpoint filt={filt} callback={_callableLabel(callable_)}"
99
91
  )
100
- self._callableAcceptsAccessControlContext = (
101
- checkAccessControlContextSignature(
102
- callable_,
103
- callableDescription=callableDesc,
104
- )
92
+ self._callableAcceptsAccessControlContext = checkAccessControlContextSignature(
93
+ callable_,
94
+ callableDescription=callableDesc,
105
95
  )
106
96
  checkNoHttpSessionInSignature(callable_, callableDesc)
107
97
 
@@ -178,10 +168,7 @@ class PayloadEndpoint:
178
168
  return False
179
169
 
180
170
  # Filter for the backend vortexes.
181
- if (
182
- self._acceptOnlyFromVortex
183
- and vortexName not in self._acceptOnlyFromVortex
184
- ):
171
+ if self._acceptOnlyFromVortex and vortexName not in self._acceptOnlyFromVortex:
185
172
  return False
186
173
 
187
174
  def removeUnhashable(filt):
@@ -225,9 +212,7 @@ class PayloadEndpoint:
225
212
  if not self.check(payloadEnvelope, vortexName):
226
213
  return
227
214
 
228
- if self.PERMITTER and not self.PERMITTER.check(
229
- payloadEnvelope, vortexSession
230
- ):
215
+ if self.PERMITTER and not self.PERMITTER.check(payloadEnvelope, vortexSession):
231
216
  logger.debug("Permission failed for %s", payloadEnvelope.filt)
232
217
  return
233
218
 
@@ -265,10 +250,7 @@ class PayloadEndpoint:
265
250
  accessControlContext = NO_AUTH_ACCESS_CONTROL_CONTEXT
266
251
  if self._callableAcceptsAccessControlContext and auth and vortexSession:
267
252
  raw_ctx = yield maybeDeferred(
268
- auth.getAccessControlContext,
269
- vortexSession,
270
- vortexName,
271
- vortexUuid,
253
+ auth.getAccessControlContext, vortexSession, vortexName, vortexUuid
272
254
  )
273
255
  if not isinstance(raw_ctx, VortexAccessControlContextABC):
274
256
  logger.error(
@@ -143,7 +143,9 @@ class PayloadEndpointPyTest(unittest.TestCase):
143
143
 
144
144
  def subScope():
145
145
  inst = PayloadEndpointPyTestAssignPayload(self)
146
- PayloadEndpoint(filt, inst.process, requiredPermissions=frozenset())
146
+ PayloadEndpoint(
147
+ filt, inst.process, requiredPermissions=frozenset()
148
+ )
147
149
 
148
150
  self._processEnvelope(payload)
149
151
  yield self._flushReactor()