wse-server 2.2.1__tar.gz → 2.3.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.
- {wse_server-2.2.1 → wse_server-2.3.0}/PKG-INFO +30 -1
- {wse_server-2.2.1 → wse_server-2.3.0}/README.md +29 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/pyproject.toml +1 -1
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/Cargo.lock +129 -1
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/Cargo.toml +4 -1
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/jwt.rs +164 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/cluster.rs +674 -115
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/compression.rs +5 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/presence.rs +11 -6
- wse_server-2.3.0/rust/src/wse/recovery.rs +940 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/server.rs +708 -73
- {wse_server-2.2.1 → wse_server-2.3.0}/wse_server/__init__.py +1 -1
- {wse_server-2.2.1 → wse_server-2.3.0}/wse_server/_wse_accel.pyi +45 -1
- wse_server-2.2.1/rust/src/wse/recovery.rs +0 -520
- {wse_server-2.2.1 → wse_server-2.3.0}/LICENSE +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/.cargo/config.toml +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/lib.rs +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/filters.rs +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/mod.rs +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/queue.rs +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/rate_limiter.rs +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/reliability.rs +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/security.rs +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/sequencer.rs +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/rust/src/wse/transformer.rs +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/wse_server/core/__init__.py +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/wse_server/core/filters.py +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/wse_server/core/types.py +0 -0
- {wse_server-2.2.1 → wse_server-2.3.0}/wse_server/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wse-server
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Classifier: Development Status :: 5 - Production/Stable
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -70,6 +70,9 @@ High-performance WebSocket server built in Rust with native clustering, E2E encr
|
|
|
70
70
|
| **Compression** | zlib for client-facing messages above threshold (default 1024 bytes) |
|
|
71
71
|
| **MessagePack** | Opt-in binary transport via `?format=msgpack`, roughly 2x faster serialization, 30% smaller |
|
|
72
72
|
| **Message signing** | Selective HMAC-SHA256 signing for critical operations, nonce-based replay prevention |
|
|
73
|
+
| **Queue groups** | Round-robin dispatch within named groups for load-balanced worker pools |
|
|
74
|
+
| **Topic ACL** | Per-connection allow/deny glob patterns for topic access control |
|
|
75
|
+
| **Graceful drain** | `drain()` sends Close frame to all clients, rejects new connections, notifies cluster peers |
|
|
73
76
|
|
|
74
77
|
### End-to-End Encryption
|
|
75
78
|
|
|
@@ -96,6 +99,7 @@ High-performance WebSocket server built in Rust with native clustering, E2E encr
|
|
|
96
99
|
| **Circuit breaker** | 10 failures to open, 60s reset, 3 half-open probe calls |
|
|
97
100
|
| **Dead letter queue** | 1000-entry ring buffer for failed cluster sends |
|
|
98
101
|
| **Presence sync** | PresenceUpdate/PresenceFull frames, CRDT last-write-wins conflict resolution |
|
|
102
|
+
| **Topology API** | `cluster_info()` returns connected peer list with address, instance_id, status |
|
|
99
103
|
|
|
100
104
|
### Presence Tracking
|
|
101
105
|
|
|
@@ -295,6 +299,7 @@ server.broadcast(topic, text) # Fan-out to topic subscribers
|
|
|
295
299
|
```python
|
|
296
300
|
server.subscribe_connection(conn_id, ["prices", "news"]) # Subscribe to topics
|
|
297
301
|
server.subscribe_connection(conn_id, ["chat"], {"status": "online"}) # Subscribe with presence data
|
|
302
|
+
server.subscribe_connection(conn_id, ["tasks"], queue_group="workers") # Subscribe with queue group (round-robin)
|
|
298
303
|
server.unsubscribe_connection(conn_id, ["news"]) # Unsubscribe from specific topics
|
|
299
304
|
server.unsubscribe_connection(conn_id, None) # Unsubscribe from all topics
|
|
300
305
|
server.get_topic_subscriber_count("prices") # Subscriber count for a topic
|
|
@@ -302,6 +307,26 @@ server.get_topic_subscriber_count("prices") # Subscrib
|
|
|
302
307
|
|
|
303
308
|
Subscriptions are cleaned up automatically on disconnect. In cluster mode, interest changes are propagated to peers via SUB/UNSUB frames.
|
|
304
309
|
|
|
310
|
+
**Queue groups**: connections in the same `queue_group` receive messages round-robin instead of fanout. Normal subscribers (no queue group) still receive all messages. Useful for distributing work across a pool of consumers.
|
|
311
|
+
|
|
312
|
+
### Topic ACL
|
|
313
|
+
|
|
314
|
+
Per-connection topic access control with glob pattern matching.
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
# Allow only "user:*" topics, deny everything else
|
|
318
|
+
server.set_topic_acl(conn_id, allow=["user:*"])
|
|
319
|
+
|
|
320
|
+
# Allow "data:*" but deny "data:internal:*"
|
|
321
|
+
server.set_topic_acl(conn_id, allow=["data:*"], deny=["data:internal:*"])
|
|
322
|
+
|
|
323
|
+
# Must be called before subscribe_connection
|
|
324
|
+
server.subscribe_connection(conn_id, ["data:prices"]) # allowed
|
|
325
|
+
server.subscribe_connection(conn_id, ["data:internal:audit"]) # denied
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Deny patterns take precedence over allow patterns. Supports `*` (any characters) and `?` (single character) wildcards. Applied at subscribe time.
|
|
329
|
+
|
|
305
330
|
### Presence Tracking
|
|
306
331
|
|
|
307
332
|
Requires `presence_enabled=True` in the constructor.
|
|
@@ -362,6 +387,7 @@ server.connect_cluster(
|
|
|
362
387
|
|
|
363
388
|
server.cluster_connected() # True if connected to at least one peer
|
|
364
389
|
server.cluster_peers_count() # Number of active peer connections
|
|
390
|
+
server.cluster_info() # List of connected peers (address, instance_id, connected)
|
|
365
391
|
```
|
|
366
392
|
|
|
367
393
|
Nodes form a full TCP mesh automatically. The cluster protocol uses a custom binary frame format with an 8-byte header, 12 message types, and capability negotiation during handshake. Features:
|
|
@@ -409,11 +435,14 @@ health = server.health_snapshot()
|
|
|
409
435
|
server.get_connection_count() # Lock-free AtomicUsize read
|
|
410
436
|
server.get_connections() # List all connection IDs (snapshot)
|
|
411
437
|
server.disconnect(conn_id) # Force-disconnect a connection
|
|
438
|
+
server.drain(close_code=4300, close_reason="shutting down", timeout=10) # Graceful drain
|
|
412
439
|
server.inbound_queue_depth() # Events waiting to be drained
|
|
413
440
|
server.inbound_dropped_count() # Events dropped due to full queue
|
|
414
441
|
server.get_cluster_dlq_entries() # Retrieve failed cluster messages from dead letter queue
|
|
415
442
|
```
|
|
416
443
|
|
|
444
|
+
`drain()` sends a WebSocket Close frame to all connected clients and rejects new connections. The drain wait runs as a separate task, so the command processor stays responsive. Use for zero-downtime deployments and rolling restarts.
|
|
445
|
+
|
|
417
446
|
---
|
|
418
447
|
|
|
419
448
|
## Security
|
|
@@ -29,6 +29,9 @@ High-performance WebSocket server built in Rust with native clustering, E2E encr
|
|
|
29
29
|
| **Compression** | zlib for client-facing messages above threshold (default 1024 bytes) |
|
|
30
30
|
| **MessagePack** | Opt-in binary transport via `?format=msgpack`, roughly 2x faster serialization, 30% smaller |
|
|
31
31
|
| **Message signing** | Selective HMAC-SHA256 signing for critical operations, nonce-based replay prevention |
|
|
32
|
+
| **Queue groups** | Round-robin dispatch within named groups for load-balanced worker pools |
|
|
33
|
+
| **Topic ACL** | Per-connection allow/deny glob patterns for topic access control |
|
|
34
|
+
| **Graceful drain** | `drain()` sends Close frame to all clients, rejects new connections, notifies cluster peers |
|
|
32
35
|
|
|
33
36
|
### End-to-End Encryption
|
|
34
37
|
|
|
@@ -55,6 +58,7 @@ High-performance WebSocket server built in Rust with native clustering, E2E encr
|
|
|
55
58
|
| **Circuit breaker** | 10 failures to open, 60s reset, 3 half-open probe calls |
|
|
56
59
|
| **Dead letter queue** | 1000-entry ring buffer for failed cluster sends |
|
|
57
60
|
| **Presence sync** | PresenceUpdate/PresenceFull frames, CRDT last-write-wins conflict resolution |
|
|
61
|
+
| **Topology API** | `cluster_info()` returns connected peer list with address, instance_id, status |
|
|
58
62
|
|
|
59
63
|
### Presence Tracking
|
|
60
64
|
|
|
@@ -254,6 +258,7 @@ server.broadcast(topic, text) # Fan-out to topic subscribers
|
|
|
254
258
|
```python
|
|
255
259
|
server.subscribe_connection(conn_id, ["prices", "news"]) # Subscribe to topics
|
|
256
260
|
server.subscribe_connection(conn_id, ["chat"], {"status": "online"}) # Subscribe with presence data
|
|
261
|
+
server.subscribe_connection(conn_id, ["tasks"], queue_group="workers") # Subscribe with queue group (round-robin)
|
|
257
262
|
server.unsubscribe_connection(conn_id, ["news"]) # Unsubscribe from specific topics
|
|
258
263
|
server.unsubscribe_connection(conn_id, None) # Unsubscribe from all topics
|
|
259
264
|
server.get_topic_subscriber_count("prices") # Subscriber count for a topic
|
|
@@ -261,6 +266,26 @@ server.get_topic_subscriber_count("prices") # Subscrib
|
|
|
261
266
|
|
|
262
267
|
Subscriptions are cleaned up automatically on disconnect. In cluster mode, interest changes are propagated to peers via SUB/UNSUB frames.
|
|
263
268
|
|
|
269
|
+
**Queue groups**: connections in the same `queue_group` receive messages round-robin instead of fanout. Normal subscribers (no queue group) still receive all messages. Useful for distributing work across a pool of consumers.
|
|
270
|
+
|
|
271
|
+
### Topic ACL
|
|
272
|
+
|
|
273
|
+
Per-connection topic access control with glob pattern matching.
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
# Allow only "user:*" topics, deny everything else
|
|
277
|
+
server.set_topic_acl(conn_id, allow=["user:*"])
|
|
278
|
+
|
|
279
|
+
# Allow "data:*" but deny "data:internal:*"
|
|
280
|
+
server.set_topic_acl(conn_id, allow=["data:*"], deny=["data:internal:*"])
|
|
281
|
+
|
|
282
|
+
# Must be called before subscribe_connection
|
|
283
|
+
server.subscribe_connection(conn_id, ["data:prices"]) # allowed
|
|
284
|
+
server.subscribe_connection(conn_id, ["data:internal:audit"]) # denied
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Deny patterns take precedence over allow patterns. Supports `*` (any characters) and `?` (single character) wildcards. Applied at subscribe time.
|
|
288
|
+
|
|
264
289
|
### Presence Tracking
|
|
265
290
|
|
|
266
291
|
Requires `presence_enabled=True` in the constructor.
|
|
@@ -321,6 +346,7 @@ server.connect_cluster(
|
|
|
321
346
|
|
|
322
347
|
server.cluster_connected() # True if connected to at least one peer
|
|
323
348
|
server.cluster_peers_count() # Number of active peer connections
|
|
349
|
+
server.cluster_info() # List of connected peers (address, instance_id, connected)
|
|
324
350
|
```
|
|
325
351
|
|
|
326
352
|
Nodes form a full TCP mesh automatically. The cluster protocol uses a custom binary frame format with an 8-byte header, 12 message types, and capability negotiation during handshake. Features:
|
|
@@ -368,11 +394,14 @@ health = server.health_snapshot()
|
|
|
368
394
|
server.get_connection_count() # Lock-free AtomicUsize read
|
|
369
395
|
server.get_connections() # List all connection IDs (snapshot)
|
|
370
396
|
server.disconnect(conn_id) # Force-disconnect a connection
|
|
397
|
+
server.drain(close_code=4300, close_reason="shutting down", timeout=10) # Graceful drain
|
|
371
398
|
server.inbound_queue_depth() # Events waiting to be drained
|
|
372
399
|
server.inbound_dropped_count() # Events dropped due to full queue
|
|
373
400
|
server.get_cluster_dlq_entries() # Retrieve failed cluster messages from dead letter queue
|
|
374
401
|
```
|
|
375
402
|
|
|
403
|
+
`drain()` sends a WebSocket Close frame to all connected clients and rejects new connections. The drain wait runs as a separate task, so the command processor stays responsive. Use for zero-downtime deployments and rolling restarts.
|
|
404
|
+
|
|
376
405
|
---
|
|
377
406
|
|
|
378
407
|
## Security
|
|
@@ -80,6 +80,15 @@ version = "1.0.102"
|
|
|
80
80
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
81
81
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|
82
82
|
|
|
83
|
+
[[package]]
|
|
84
|
+
name = "arc-swap"
|
|
85
|
+
version = "1.8.2"
|
|
86
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
87
|
+
checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5"
|
|
88
|
+
dependencies = [
|
|
89
|
+
"rustversion",
|
|
90
|
+
]
|
|
91
|
+
|
|
83
92
|
[[package]]
|
|
84
93
|
name = "autocfg"
|
|
85
94
|
version = "1.5.0"
|
|
@@ -799,6 +808,15 @@ version = "0.4.29"
|
|
|
799
808
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
800
809
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
|
801
810
|
|
|
811
|
+
[[package]]
|
|
812
|
+
name = "matchers"
|
|
813
|
+
version = "0.2.0"
|
|
814
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
815
|
+
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
|
816
|
+
dependencies = [
|
|
817
|
+
"regex-automata",
|
|
818
|
+
]
|
|
819
|
+
|
|
802
820
|
[[package]]
|
|
803
821
|
name = "memchr"
|
|
804
822
|
version = "2.8.0"
|
|
@@ -835,6 +853,15 @@ dependencies = [
|
|
|
835
853
|
"windows-sys 0.61.2",
|
|
836
854
|
]
|
|
837
855
|
|
|
856
|
+
[[package]]
|
|
857
|
+
name = "nu-ansi-term"
|
|
858
|
+
version = "0.50.3"
|
|
859
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
860
|
+
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
|
861
|
+
dependencies = [
|
|
862
|
+
"windows-sys 0.61.2",
|
|
863
|
+
]
|
|
864
|
+
|
|
838
865
|
[[package]]
|
|
839
866
|
name = "num-bigint"
|
|
840
867
|
version = "0.4.6"
|
|
@@ -1483,6 +1510,15 @@ dependencies = [
|
|
|
1483
1510
|
"digest",
|
|
1484
1511
|
]
|
|
1485
1512
|
|
|
1513
|
+
[[package]]
|
|
1514
|
+
name = "sharded-slab"
|
|
1515
|
+
version = "0.1.7"
|
|
1516
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1517
|
+
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
|
1518
|
+
dependencies = [
|
|
1519
|
+
"lazy_static",
|
|
1520
|
+
]
|
|
1521
|
+
|
|
1486
1522
|
[[package]]
|
|
1487
1523
|
name = "shlex"
|
|
1488
1524
|
version = "1.3.0"
|
|
@@ -1598,6 +1634,15 @@ dependencies = [
|
|
|
1598
1634
|
"syn",
|
|
1599
1635
|
]
|
|
1600
1636
|
|
|
1637
|
+
[[package]]
|
|
1638
|
+
name = "thread_local"
|
|
1639
|
+
version = "1.1.9"
|
|
1640
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1641
|
+
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
|
1642
|
+
dependencies = [
|
|
1643
|
+
"cfg-if",
|
|
1644
|
+
]
|
|
1645
|
+
|
|
1601
1646
|
[[package]]
|
|
1602
1647
|
name = "time"
|
|
1603
1648
|
version = "0.3.47"
|
|
@@ -1691,6 +1736,80 @@ dependencies = [
|
|
|
1691
1736
|
"tokio",
|
|
1692
1737
|
]
|
|
1693
1738
|
|
|
1739
|
+
[[package]]
|
|
1740
|
+
name = "tracing"
|
|
1741
|
+
version = "0.1.44"
|
|
1742
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1743
|
+
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
|
1744
|
+
dependencies = [
|
|
1745
|
+
"pin-project-lite",
|
|
1746
|
+
"tracing-attributes",
|
|
1747
|
+
"tracing-core",
|
|
1748
|
+
]
|
|
1749
|
+
|
|
1750
|
+
[[package]]
|
|
1751
|
+
name = "tracing-attributes"
|
|
1752
|
+
version = "0.1.31"
|
|
1753
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1754
|
+
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
|
1755
|
+
dependencies = [
|
|
1756
|
+
"proc-macro2",
|
|
1757
|
+
"quote",
|
|
1758
|
+
"syn",
|
|
1759
|
+
]
|
|
1760
|
+
|
|
1761
|
+
[[package]]
|
|
1762
|
+
name = "tracing-core"
|
|
1763
|
+
version = "0.1.36"
|
|
1764
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1765
|
+
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
|
1766
|
+
dependencies = [
|
|
1767
|
+
"once_cell",
|
|
1768
|
+
"valuable",
|
|
1769
|
+
]
|
|
1770
|
+
|
|
1771
|
+
[[package]]
|
|
1772
|
+
name = "tracing-log"
|
|
1773
|
+
version = "0.2.0"
|
|
1774
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1775
|
+
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
|
1776
|
+
dependencies = [
|
|
1777
|
+
"log",
|
|
1778
|
+
"once_cell",
|
|
1779
|
+
"tracing-core",
|
|
1780
|
+
]
|
|
1781
|
+
|
|
1782
|
+
[[package]]
|
|
1783
|
+
name = "tracing-serde"
|
|
1784
|
+
version = "0.2.0"
|
|
1785
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1786
|
+
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
|
|
1787
|
+
dependencies = [
|
|
1788
|
+
"serde",
|
|
1789
|
+
"tracing-core",
|
|
1790
|
+
]
|
|
1791
|
+
|
|
1792
|
+
[[package]]
|
|
1793
|
+
name = "tracing-subscriber"
|
|
1794
|
+
version = "0.3.22"
|
|
1795
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1796
|
+
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
|
1797
|
+
dependencies = [
|
|
1798
|
+
"matchers",
|
|
1799
|
+
"nu-ansi-term",
|
|
1800
|
+
"once_cell",
|
|
1801
|
+
"regex-automata",
|
|
1802
|
+
"serde",
|
|
1803
|
+
"serde_json",
|
|
1804
|
+
"sharded-slab",
|
|
1805
|
+
"smallvec",
|
|
1806
|
+
"thread_local",
|
|
1807
|
+
"tracing",
|
|
1808
|
+
"tracing-core",
|
|
1809
|
+
"tracing-log",
|
|
1810
|
+
"tracing-serde",
|
|
1811
|
+
]
|
|
1812
|
+
|
|
1694
1813
|
[[package]]
|
|
1695
1814
|
name = "tungstenite"
|
|
1696
1815
|
version = "0.28.0"
|
|
@@ -1759,6 +1878,12 @@ dependencies = [
|
|
|
1759
1878
|
"wasm-bindgen",
|
|
1760
1879
|
]
|
|
1761
1880
|
|
|
1881
|
+
[[package]]
|
|
1882
|
+
name = "valuable"
|
|
1883
|
+
version = "0.1.1"
|
|
1884
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1885
|
+
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
|
1886
|
+
|
|
1762
1887
|
[[package]]
|
|
1763
1888
|
name = "version_check"
|
|
1764
1889
|
version = "0.9.5"
|
|
@@ -2099,10 +2224,11 @@ dependencies = [
|
|
|
2099
2224
|
|
|
2100
2225
|
[[package]]
|
|
2101
2226
|
name = "wse-accel"
|
|
2102
|
-
version = "2.
|
|
2227
|
+
version = "2.3.0"
|
|
2103
2228
|
dependencies = [
|
|
2104
2229
|
"aes-gcm",
|
|
2105
2230
|
"ahash",
|
|
2231
|
+
"arc-swap",
|
|
2106
2232
|
"base64",
|
|
2107
2233
|
"bytes",
|
|
2108
2234
|
"chrono",
|
|
@@ -2132,6 +2258,8 @@ dependencies = [
|
|
|
2132
2258
|
"tokio-rustls",
|
|
2133
2259
|
"tokio-tungstenite",
|
|
2134
2260
|
"tokio-util",
|
|
2261
|
+
"tracing",
|
|
2262
|
+
"tracing-subscriber",
|
|
2135
2263
|
"uuid",
|
|
2136
2264
|
"zstd",
|
|
2137
2265
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "wse-accel"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.3.0"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
|
|
6
6
|
[lib]
|
|
@@ -40,6 +40,9 @@ rustls = { version = "0.23", default-features = true }
|
|
|
40
40
|
rustls-pki-types = "1"
|
|
41
41
|
zstd = "0.13"
|
|
42
42
|
parking_lot = "0.12"
|
|
43
|
+
tracing = "0.1"
|
|
44
|
+
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
|
|
45
|
+
arc-swap = "1"
|
|
43
46
|
|
|
44
47
|
[dev-dependencies]
|
|
45
48
|
rcgen = "0.13"
|
|
@@ -502,6 +502,82 @@ fn json_value_to_py(py: Python<'_>, val: &serde_json::Value) -> Py<PyAny> {
|
|
|
502
502
|
}
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
// TopicAcl -- per-connection topic authorization from JWT claims
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
/// Topic-level access control list extracted from JWT `wse_topics` claim.
|
|
510
|
+
///
|
|
511
|
+
/// Format in JWT: `{"wse_topics": {"allow": ["chat.*"], "deny": ["admin.*"]}}`
|
|
512
|
+
///
|
|
513
|
+
/// Rules:
|
|
514
|
+
/// - Deny takes precedence over allow
|
|
515
|
+
/// - Empty allow list = allow all (backward compatible)
|
|
516
|
+
/// - Patterns use glob matching (*, ?)
|
|
517
|
+
#[derive(Debug, Clone)]
|
|
518
|
+
pub struct TopicAcl {
|
|
519
|
+
pub allow: Vec<String>,
|
|
520
|
+
pub deny: Vec<String>,
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
impl TopicAcl {
|
|
524
|
+
/// Check if a topic is allowed by this ACL.
|
|
525
|
+
///
|
|
526
|
+
/// Deny rules take precedence. Empty allow = allow all.
|
|
527
|
+
pub fn is_allowed(&self, topic: &str) -> bool {
|
|
528
|
+
// Check deny first (deny takes precedence)
|
|
529
|
+
for pattern in &self.deny {
|
|
530
|
+
if crate::wse::server::glob_match(pattern, topic) {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Empty allow = allow all
|
|
536
|
+
if self.allow.is_empty() {
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Check allow patterns
|
|
541
|
+
for pattern in &self.allow {
|
|
542
|
+
if crate::wse::server::glob_match(pattern, topic) {
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
false
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/// Extract optional topic ACL from JWT claims.
|
|
552
|
+
///
|
|
553
|
+
/// Returns `None` if no `wse_topics` claim is present (backward compatible).
|
|
554
|
+
pub fn extract_topic_acl(claims: &serde_json::Value) -> Option<TopicAcl> {
|
|
555
|
+
let topics = claims.get("wse_topics")?;
|
|
556
|
+
let obj = topics.as_object()?;
|
|
557
|
+
|
|
558
|
+
let allow = obj
|
|
559
|
+
.get("allow")
|
|
560
|
+
.and_then(|v| v.as_array())
|
|
561
|
+
.map(|arr| {
|
|
562
|
+
arr.iter()
|
|
563
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
564
|
+
.collect()
|
|
565
|
+
})
|
|
566
|
+
.unwrap_or_default();
|
|
567
|
+
|
|
568
|
+
let deny = obj
|
|
569
|
+
.get("deny")
|
|
570
|
+
.and_then(|v| v.as_array())
|
|
571
|
+
.map(|arr| {
|
|
572
|
+
arr.iter()
|
|
573
|
+
.filter_map(|v| v.as_str().map(String::from))
|
|
574
|
+
.collect()
|
|
575
|
+
})
|
|
576
|
+
.unwrap_or_default();
|
|
577
|
+
|
|
578
|
+
Some(TopicAcl { allow, deny })
|
|
579
|
+
}
|
|
580
|
+
|
|
505
581
|
// ---------------------------------------------------------------------------
|
|
506
582
|
// Rust-level unit tests
|
|
507
583
|
// ---------------------------------------------------------------------------
|
|
@@ -1222,4 +1298,92 @@ mod tests {
|
|
|
1222
1298
|
let result = jwt_decode(&token, &config);
|
|
1223
1299
|
assert!(result.is_err());
|
|
1224
1300
|
}
|
|
1301
|
+
|
|
1302
|
+
// --- TopicAcl tests ---
|
|
1303
|
+
|
|
1304
|
+
#[test]
|
|
1305
|
+
fn test_topic_acl_allow_only() {
|
|
1306
|
+
let acl = TopicAcl {
|
|
1307
|
+
allow: vec!["chat.*".to_string(), "events".to_string()],
|
|
1308
|
+
deny: vec![],
|
|
1309
|
+
};
|
|
1310
|
+
assert!(acl.is_allowed("chat.general"));
|
|
1311
|
+
assert!(acl.is_allowed("chat.private"));
|
|
1312
|
+
assert!(acl.is_allowed("events"));
|
|
1313
|
+
assert!(!acl.is_allowed("admin.settings"));
|
|
1314
|
+
assert!(!acl.is_allowed("other.topic"));
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
#[test]
|
|
1318
|
+
fn test_topic_acl_deny_precedence() {
|
|
1319
|
+
let acl = TopicAcl {
|
|
1320
|
+
allow: vec!["chat.*".to_string()],
|
|
1321
|
+
deny: vec!["chat.secret".to_string()],
|
|
1322
|
+
};
|
|
1323
|
+
assert!(acl.is_allowed("chat.general"));
|
|
1324
|
+
assert!(!acl.is_allowed("chat.secret"));
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
#[test]
|
|
1328
|
+
fn test_topic_acl_empty_allow() {
|
|
1329
|
+
// Empty allow = allow all (only deny filters)
|
|
1330
|
+
let acl = TopicAcl {
|
|
1331
|
+
allow: vec![],
|
|
1332
|
+
deny: vec!["admin.*".to_string()],
|
|
1333
|
+
};
|
|
1334
|
+
assert!(acl.is_allowed("chat.general"));
|
|
1335
|
+
assert!(acl.is_allowed("events"));
|
|
1336
|
+
assert!(!acl.is_allowed("admin.settings"));
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
#[test]
|
|
1340
|
+
fn test_topic_acl_glob_matching() {
|
|
1341
|
+
let acl = TopicAcl {
|
|
1342
|
+
allow: vec!["user.*.messages".to_string(), "broadcast".to_string()],
|
|
1343
|
+
deny: vec![],
|
|
1344
|
+
};
|
|
1345
|
+
assert!(acl.is_allowed("user.123.messages"));
|
|
1346
|
+
assert!(acl.is_allowed("user.abc.messages"));
|
|
1347
|
+
assert!(acl.is_allowed("broadcast"));
|
|
1348
|
+
assert!(!acl.is_allowed("user.123.settings"));
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
#[test]
|
|
1352
|
+
fn test_extract_topic_acl_present() {
|
|
1353
|
+
let claims = serde_json::json!({
|
|
1354
|
+
"sub": "user1",
|
|
1355
|
+
"wse_topics": {
|
|
1356
|
+
"allow": ["chat.*", "events"],
|
|
1357
|
+
"deny": ["admin.*"]
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
let acl = extract_topic_acl(&claims).unwrap();
|
|
1361
|
+
assert_eq!(acl.allow, vec!["chat.*", "events"]);
|
|
1362
|
+
assert_eq!(acl.deny, vec!["admin.*"]);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
#[test]
|
|
1366
|
+
fn test_extract_topic_acl_missing() {
|
|
1367
|
+
let claims = serde_json::json!({"sub": "user1"});
|
|
1368
|
+
assert!(extract_topic_acl(&claims).is_none());
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
#[test]
|
|
1372
|
+
fn test_extract_topic_acl_partial() {
|
|
1373
|
+
// Only allow, no deny
|
|
1374
|
+
let claims = serde_json::json!({
|
|
1375
|
+
"sub": "user1",
|
|
1376
|
+
"wse_topics": {"allow": ["chat.*"]}
|
|
1377
|
+
});
|
|
1378
|
+
let acl = extract_topic_acl(&claims).unwrap();
|
|
1379
|
+
assert_eq!(acl.allow, vec!["chat.*"]);
|
|
1380
|
+
assert!(acl.deny.is_empty());
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
#[test]
|
|
1384
|
+
fn test_no_acl_backward_compat() {
|
|
1385
|
+
// No wse_topics claim = no ACL = extract returns None
|
|
1386
|
+
let claims = serde_json::json!({"sub": "user1", "iss": "myapp"});
|
|
1387
|
+
assert!(extract_topic_acl(&claims).is_none());
|
|
1388
|
+
}
|
|
1225
1389
|
}
|