wse-server 2.0.0__tar.gz → 2.0.2__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.0.0 → wse_server-2.0.2}/PKG-INFO +7 -6
- {wse_server-2.0.0 → wse_server-2.0.2}/README.md +5 -4
- {wse_server-2.0.0 → wse_server-2.0.2}/pyproject.toml +2 -2
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/Cargo.lock +1 -1
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/Cargo.toml +1 -1
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/wse/server.rs +359 -26
- {wse_server-2.0.0 → wse_server-2.0.2}/wse_server/__init__.py +9 -1
- wse_server-2.0.2/wse_server/_wse_accel.pyi +455 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/LICENSE +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/.cargo/config.toml +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/jwt.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/lib.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/wse/cluster.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/wse/compression.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/wse/filters.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/wse/mod.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/wse/presence.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/wse/queue.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/wse/rate_limiter.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/wse/recovery.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/wse/reliability.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/wse/security.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/wse/sequencer.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/rust/src/wse/transformer.rs +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/wse_server/core/__init__.py +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/wse_server/core/filters.py +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/wse_server/core/types.py +0 -0
- {wse_server-2.0.0 → wse_server-2.0.2}/wse_server/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wse-server
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.2
|
|
4
4
|
Classifier: Development Status :: 5 - Production/Stable
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -27,7 +27,7 @@ Provides-Extra: dev
|
|
|
27
27
|
Provides-Extra: msgpack
|
|
28
28
|
Provides-Extra: prometheus
|
|
29
29
|
License-File: LICENSE
|
|
30
|
-
Summary: Rust-powered WebSocket engine for Python. Up to
|
|
30
|
+
Summary: Rust-powered WebSocket engine for Python. Up to 5M fan-out del/s. Zero-GIL JWT.
|
|
31
31
|
Keywords: websocket,engine,real-time,pubsub,cluster,encrypted,rust
|
|
32
32
|
Author: silvermpx
|
|
33
33
|
License: MIT
|
|
@@ -46,7 +46,7 @@ Project-URL: Repository, https://github.com/silvermpx/wse
|
|
|
46
46
|
[](https://www.npmjs.com/package/wse-client)
|
|
47
47
|
[](LICENSE)
|
|
48
48
|
|
|
49
|
-
High-performance WebSocket server built in Rust with native clustering, message recovery, presence tracking, and real-time fan-out. Exposed to Python via PyO3 with zero GIL overhead on the data path.
|
|
49
|
+
High-performance WebSocket server built in Rust with native clustering, E2E encryption, message recovery, presence tracking, and real-time fan-out. Exposed to Python via PyO3 with zero GIL overhead on the data path. Ships with Rust-accelerated utilities for application code: priority queues, rate limiters, event sequencing, compression, cryptography, and JWT.
|
|
50
50
|
|
|
51
51
|
## Features
|
|
52
52
|
|
|
@@ -504,7 +504,7 @@ client.run_forever()
|
|
|
504
504
|
|
|
505
505
|
Key features: 4 reconnect strategies (exponential, linear, fibonacci, adaptive), connection pool with health scoring and 3 load balancing strategies, circuit breaker, token bucket rate limiter, event sequencer with dedup and reorder buffering, network quality monitoring (latency/jitter/packet loss).
|
|
506
506
|
|
|
507
|
-
See [python-client/
|
|
507
|
+
See [python-client/](python-client/) for full source and examples.
|
|
508
508
|
|
|
509
509
|
### TypeScript / React
|
|
510
510
|
|
|
@@ -530,13 +530,13 @@ function App() {
|
|
|
530
530
|
|
|
531
531
|
Key features: offline queue with IndexedDB persistence, adaptive quality management, connection pool with health scoring, E2E encryption (Web Crypto API), message batching, 5 priority levels, Zustand store for external state access.
|
|
532
532
|
|
|
533
|
-
See [client/
|
|
533
|
+
See [client/](client/) for full source and examples.
|
|
534
534
|
|
|
535
535
|
---
|
|
536
536
|
|
|
537
537
|
## Performance
|
|
538
538
|
|
|
539
|
-
Benchmarked on AMD EPYC 7502P (64
|
|
539
|
+
Benchmarked on AMD EPYC 7502P (32 cores / 64 threads, 128 GB RAM), Ubuntu 24.04.
|
|
540
540
|
|
|
541
541
|
| Mode | Peak Throughput | Connections | Message Loss |
|
|
542
542
|
|------|----------------|-------------|--------------|
|
|
@@ -570,6 +570,7 @@ Working examples in the [`examples/`](examples/) directory:
|
|
|
570
570
|
- [Integration Guide](docs/INTEGRATION.md) - complete setup and API reference
|
|
571
571
|
- [Wire Protocol](docs/PROTOCOL.md) - message format specification
|
|
572
572
|
- [Cluster Protocol](docs/CLUSTER_PROTOCOL.md) - TCP mesh, frame format, gossip, interest routing
|
|
573
|
+
- [Rust Utilities](docs/RUST_UTILITIES.md) - priority queues, rate limiters, sequencing, compression, crypto, JWT
|
|
573
574
|
- [Security](docs/SECURITY.md) - JWT, encryption, mTLS, rate limiting, circuit breaker
|
|
574
575
|
- [Deployment](docs/DEPLOYMENT.md) - production setup, Docker, Kubernetes, cluster configuration
|
|
575
576
|
- [Migration Guide](docs/MIGRATION.md) - upgrading from v1.x to v2.0
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/wse-client)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
High-performance WebSocket server built in Rust with native clustering, message recovery, presence tracking, and real-time fan-out. Exposed to Python via PyO3 with zero GIL overhead on the data path.
|
|
8
|
+
High-performance WebSocket server built in Rust with native clustering, E2E encryption, message recovery, presence tracking, and real-time fan-out. Exposed to Python via PyO3 with zero GIL overhead on the data path. Ships with Rust-accelerated utilities for application code: priority queues, rate limiters, event sequencing, compression, cryptography, and JWT.
|
|
9
9
|
|
|
10
10
|
## Features
|
|
11
11
|
|
|
@@ -463,7 +463,7 @@ client.run_forever()
|
|
|
463
463
|
|
|
464
464
|
Key features: 4 reconnect strategies (exponential, linear, fibonacci, adaptive), connection pool with health scoring and 3 load balancing strategies, circuit breaker, token bucket rate limiter, event sequencer with dedup and reorder buffering, network quality monitoring (latency/jitter/packet loss).
|
|
465
465
|
|
|
466
|
-
See [python-client/
|
|
466
|
+
See [python-client/](python-client/) for full source and examples.
|
|
467
467
|
|
|
468
468
|
### TypeScript / React
|
|
469
469
|
|
|
@@ -489,13 +489,13 @@ function App() {
|
|
|
489
489
|
|
|
490
490
|
Key features: offline queue with IndexedDB persistence, adaptive quality management, connection pool with health scoring, E2E encryption (Web Crypto API), message batching, 5 priority levels, Zustand store for external state access.
|
|
491
491
|
|
|
492
|
-
See [client/
|
|
492
|
+
See [client/](client/) for full source and examples.
|
|
493
493
|
|
|
494
494
|
---
|
|
495
495
|
|
|
496
496
|
## Performance
|
|
497
497
|
|
|
498
|
-
Benchmarked on AMD EPYC 7502P (64
|
|
498
|
+
Benchmarked on AMD EPYC 7502P (32 cores / 64 threads, 128 GB RAM), Ubuntu 24.04.
|
|
499
499
|
|
|
500
500
|
| Mode | Peak Throughput | Connections | Message Loss |
|
|
501
501
|
|------|----------------|-------------|--------------|
|
|
@@ -529,6 +529,7 @@ Working examples in the [`examples/`](examples/) directory:
|
|
|
529
529
|
- [Integration Guide](docs/INTEGRATION.md) - complete setup and API reference
|
|
530
530
|
- [Wire Protocol](docs/PROTOCOL.md) - message format specification
|
|
531
531
|
- [Cluster Protocol](docs/CLUSTER_PROTOCOL.md) - TCP mesh, frame format, gossip, interest routing
|
|
532
|
+
- [Rust Utilities](docs/RUST_UTILITIES.md) - priority queues, rate limiters, sequencing, compression, crypto, JWT
|
|
532
533
|
- [Security](docs/SECURITY.md) - JWT, encryption, mTLS, rate limiting, circuit breaker
|
|
533
534
|
- [Deployment](docs/DEPLOYMENT.md) - production setup, Docker, Kubernetes, cluster configuration
|
|
534
535
|
- [Migration Guide](docs/MIGRATION.md) - upgrading from v1.x to v2.0
|
|
@@ -4,8 +4,8 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wse-server"
|
|
7
|
-
version = "2.0.
|
|
8
|
-
description = "Rust-powered WebSocket engine for Python. Up to
|
|
7
|
+
version = "2.0.2"
|
|
8
|
+
description = "Rust-powered WebSocket engine for Python. Up to 5M fan-out del/s. Zero-GIL JWT."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
11
11
|
license-files = ["LICENSE"]
|
|
@@ -217,6 +217,8 @@ pub(crate) struct SharedState {
|
|
|
217
217
|
pub(crate) recovery: Option<Arc<super::recovery::RecoveryManager>>,
|
|
218
218
|
// Per-connection last activity tracking for zombie detection
|
|
219
219
|
conn_last_activity: DashMap<String, std::time::Instant>,
|
|
220
|
+
// Per-connection AES-256-GCM cipher for E2E encryption (optional, client-initiated)
|
|
221
|
+
conn_encryption: DashMap<String, aes_gcm::Aes256Gcm>,
|
|
220
222
|
// Presence tracking (None when disabled)
|
|
221
223
|
pub(crate) presence: Option<Arc<super::presence::PresenceManager>>,
|
|
222
224
|
// Sender for presence broadcasts from disconnect cleanup paths
|
|
@@ -261,6 +263,7 @@ impl SharedState {
|
|
|
261
263
|
cluster_tls_enabled: AtomicBool::new(false),
|
|
262
264
|
recovery,
|
|
263
265
|
conn_last_activity: DashMap::new(),
|
|
266
|
+
conn_encryption: DashMap::new(),
|
|
264
267
|
presence: if presence_enabled {
|
|
265
268
|
Some(Arc::new(super::presence::PresenceManager::new(
|
|
266
269
|
presence_max_data_size,
|
|
@@ -520,13 +523,90 @@ fn build_error_json(code: &str, message: &str) -> String {
|
|
|
520
523
|
format!("WSE{}", payload)
|
|
521
524
|
}
|
|
522
525
|
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// E2E encryption helpers (ECDH P-256 + AES-GCM-256)
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
/// Perform ECDH key exchange: generate server keypair, derive AES-256 key.
|
|
531
|
+
/// Returns (server_public_key_base64, Aes256Gcm cipher).
|
|
532
|
+
fn derive_connection_key(client_pubkey_b64: &str) -> Result<(String, aes_gcm::Aes256Gcm), String> {
|
|
533
|
+
use aes_gcm::KeyInit;
|
|
534
|
+
use base64::Engine;
|
|
535
|
+
use hkdf::Hkdf;
|
|
536
|
+
use p256::ecdh::diffie_hellman;
|
|
537
|
+
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
|
538
|
+
use p256::{PublicKey, SecretKey};
|
|
539
|
+
use sha2::Sha256;
|
|
540
|
+
|
|
541
|
+
let client_pubkey_bytes = base64::engine::general_purpose::STANDARD
|
|
542
|
+
.decode(client_pubkey_b64)
|
|
543
|
+
.map_err(|e| format!("Invalid base64: {e}"))?;
|
|
544
|
+
|
|
545
|
+
let client_pk = PublicKey::from_sec1_bytes(&client_pubkey_bytes)
|
|
546
|
+
.map_err(|e| format!("Invalid ECDH public key: {e}"))?;
|
|
547
|
+
|
|
548
|
+
let server_sk = SecretKey::random(&mut aes_gcm::aead::OsRng);
|
|
549
|
+
let server_pk = server_sk.public_key();
|
|
550
|
+
|
|
551
|
+
let shared = diffie_hellman(server_sk.to_nonzero_scalar(), client_pk.as_affine());
|
|
552
|
+
|
|
553
|
+
let hkdf = Hkdf::<Sha256>::new(Some(b"wse-encryption"), shared.raw_secret_bytes());
|
|
554
|
+
let mut aes_key = [0u8; 32];
|
|
555
|
+
hkdf.expand(b"aes-gcm-key", &mut aes_key)
|
|
556
|
+
.map_err(|e| format!("HKDF expand failed: {e}"))?;
|
|
557
|
+
|
|
558
|
+
let cipher = aes_gcm::Aes256Gcm::new_from_slice(&aes_key)
|
|
559
|
+
.map_err(|e| format!("AES key init failed: {e}"))?;
|
|
560
|
+
|
|
561
|
+
let server_pk_point = server_pk.to_encoded_point(false);
|
|
562
|
+
let server_pk_b64 =
|
|
563
|
+
base64::engine::general_purpose::STANDARD.encode(server_pk_point.as_bytes());
|
|
564
|
+
|
|
565
|
+
Ok((server_pk_b64, cipher))
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/// Encrypt plaintext with AES-GCM-256: returns 12-byte IV + ciphertext + 16-byte tag.
|
|
569
|
+
fn encrypt_outbound(cipher: &aes_gcm::Aes256Gcm, plaintext: &[u8]) -> Result<Vec<u8>, String> {
|
|
570
|
+
use aes_gcm::AeadCore;
|
|
571
|
+
use aes_gcm::aead::Aead;
|
|
572
|
+
|
|
573
|
+
let nonce = aes_gcm::Aes256Gcm::generate_nonce(&mut aes_gcm::aead::OsRng);
|
|
574
|
+
let ciphertext = cipher
|
|
575
|
+
.encrypt(&nonce, plaintext)
|
|
576
|
+
.map_err(|e| format!("AES-GCM encrypt failed: {e}"))?;
|
|
577
|
+
let mut result = Vec::with_capacity(12 + ciphertext.len());
|
|
578
|
+
result.extend_from_slice(&nonce);
|
|
579
|
+
result.extend_from_slice(&ciphertext);
|
|
580
|
+
Ok(result)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/// Decrypt an AES-GCM-256 payload (12-byte IV + ciphertext + 16-byte tag).
|
|
584
|
+
fn decrypt_inbound(cipher: &aes_gcm::Aes256Gcm, data: &[u8]) -> Result<Vec<u8>, String> {
|
|
585
|
+
use aes_gcm::aead::Aead;
|
|
586
|
+
|
|
587
|
+
if data.len() < 28 {
|
|
588
|
+
return Err("Encrypted payload too short (min 28 bytes)".into());
|
|
589
|
+
}
|
|
590
|
+
let (nonce_bytes, ciphertext) = data.split_at(12);
|
|
591
|
+
let nonce: [u8; 12] = nonce_bytes.try_into().expect("split_at(12)");
|
|
592
|
+
cipher
|
|
593
|
+
.decrypt(&nonce.into(), ciphertext)
|
|
594
|
+
.map_err(|e| format!("AES-GCM decrypt failed: {e}"))
|
|
595
|
+
}
|
|
596
|
+
|
|
523
597
|
/// Build server_hello JSON response to client_hello (protocol negotiation).
|
|
524
|
-
fn build_server_hello(
|
|
598
|
+
fn build_server_hello(
|
|
599
|
+
conn_id: &str,
|
|
600
|
+
client_proto: u32,
|
|
601
|
+
state: &SharedState,
|
|
602
|
+
encryption_pubkey: Option<&str>,
|
|
603
|
+
) -> String {
|
|
525
604
|
let proto = client_proto.min(2);
|
|
526
605
|
let recovery_enabled = state.recovery.is_some();
|
|
527
606
|
let cluster_enabled = state.cluster_cmd_tx.read().unwrap().is_some();
|
|
607
|
+
let encryption_enabled = encryption_pubkey.is_some();
|
|
528
608
|
let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
|
529
|
-
let val = serde_json::json!({
|
|
609
|
+
let mut val = serde_json::json!({
|
|
530
610
|
"t": "server_hello",
|
|
531
611
|
"p": {
|
|
532
612
|
"server_version": env!("CARGO_PKG_VERSION"),
|
|
@@ -537,7 +617,8 @@ fn build_server_hello(conn_id: &str, client_proto: u32, state: &SharedState) ->
|
|
|
537
617
|
"cluster": cluster_enabled,
|
|
538
618
|
"batching": true,
|
|
539
619
|
"priority_queue": true,
|
|
540
|
-
"msgpack": true
|
|
620
|
+
"msgpack": true,
|
|
621
|
+
"encryption": encryption_enabled
|
|
541
622
|
},
|
|
542
623
|
"limits": {
|
|
543
624
|
"max_message_size": 1_048_576,
|
|
@@ -550,6 +631,9 @@ fn build_server_hello(conn_id: &str, client_proto: u32, state: &SharedState) ->
|
|
|
550
631
|
},
|
|
551
632
|
"v": 1
|
|
552
633
|
});
|
|
634
|
+
if let Some(pubkey) = encryption_pubkey {
|
|
635
|
+
val["p"]["encryption_public_key"] = serde_json::Value::String(pubkey.to_string());
|
|
636
|
+
}
|
|
553
637
|
format!("WSE{val}")
|
|
554
638
|
}
|
|
555
639
|
|
|
@@ -591,6 +675,12 @@ fn build_server_ready(conn_id: &str, user_id: &str, recovery_enabled: bool) -> S
|
|
|
591
675
|
// ---------------------------------------------------------------------------
|
|
592
676
|
|
|
593
677
|
async fn handle_connection(stream: TcpStream, addr: SocketAddr, state: Arc<SharedState>) {
|
|
678
|
+
// Early rejection before expensive handshake (best-effort, authoritative check below)
|
|
679
|
+
if state.connection_count.load(Ordering::Relaxed) >= state.max_connections {
|
|
680
|
+
eprintln!("[WSE] Max connections reached (early check), rejecting {addr}");
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
594
684
|
// Clone the TCP stream FD for pre-framed raw writes (bypasses tungstenite).
|
|
595
685
|
let (stream, mut raw_write) = match clone_tcp_stream(stream) {
|
|
596
686
|
Ok(pair) => pair,
|
|
@@ -693,7 +783,9 @@ async fn handle_connection(stream: TcpStream, addr: SocketAddr, state: Arc<Share
|
|
|
693
783
|
}
|
|
694
784
|
}
|
|
695
785
|
Err(e) => {
|
|
696
|
-
|
|
786
|
+
eprintln!("[WSE] JWT validation failed for {addr}: {e}");
|
|
787
|
+
reject_ws_auth(&tx, rx, write_half, "AUTH_FAILED", "Authentication failed")
|
|
788
|
+
.await;
|
|
697
789
|
return;
|
|
698
790
|
}
|
|
699
791
|
},
|
|
@@ -931,7 +1023,47 @@ async fn handle_connection(stream: TcpStream, addr: SocketAddr, state: Arc<Share
|
|
|
931
1023
|
p.and_then(|p| p.get("protocol_version"))
|
|
932
1024
|
.and_then(|v| v.as_u64())
|
|
933
1025
|
.unwrap_or(1) as u32;
|
|
934
|
-
|
|
1026
|
+
|
|
1027
|
+
// Check if client requests E2E encryption
|
|
1028
|
+
let wants_encryption = p
|
|
1029
|
+
.and_then(|p| p.get("features"))
|
|
1030
|
+
.and_then(|f| f.get("encryption"))
|
|
1031
|
+
.and_then(|v| v.as_bool())
|
|
1032
|
+
.unwrap_or(false);
|
|
1033
|
+
|
|
1034
|
+
let server_pubkey: Option<String> = if wants_encryption {
|
|
1035
|
+
p.and_then(|p| p.get("encryption_public_key"))
|
|
1036
|
+
.and_then(|v| v.as_str())
|
|
1037
|
+
.and_then(|b64| match derive_connection_key(b64) {
|
|
1038
|
+
Ok((server_pk_b64, cipher)) => {
|
|
1039
|
+
state
|
|
1040
|
+
.conn_encryption
|
|
1041
|
+
.insert((*conn_id).clone(), cipher);
|
|
1042
|
+
Some(server_pk_b64)
|
|
1043
|
+
}
|
|
1044
|
+
Err(e) => {
|
|
1045
|
+
eprintln!(
|
|
1046
|
+
"[WSE] ECDH key exchange failed for {}: {}",
|
|
1047
|
+
conn_id, e
|
|
1048
|
+
);
|
|
1049
|
+
let err_msg = format!(
|
|
1050
|
+
"WSE{{\"t\":\"error\",\"p\":{{\"code\":\"KEY_EXCHANGE_FAILED\",\"message\":\"ECDH key exchange failed: {}\",\"recoverable\":true}}}}",
|
|
1051
|
+
e.replace('\"', "\\\"")
|
|
1052
|
+
);
|
|
1053
|
+
let _ = tx.send(WsFrame::Msg(Message::Text(err_msg.into())));
|
|
1054
|
+
None
|
|
1055
|
+
}
|
|
1056
|
+
})
|
|
1057
|
+
} else {
|
|
1058
|
+
None
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
let hello = build_server_hello(
|
|
1062
|
+
&conn_id,
|
|
1063
|
+
client_proto,
|
|
1064
|
+
&state,
|
|
1065
|
+
server_pubkey.as_deref(),
|
|
1066
|
+
);
|
|
935
1067
|
let _ = tx.send(WsFrame::Msg(Message::Text(hello.into())));
|
|
936
1068
|
continue;
|
|
937
1069
|
}
|
|
@@ -947,7 +1079,7 @@ async fn handle_connection(stream: TcpStream, addr: SocketAddr, state: Arc<Share
|
|
|
947
1079
|
.as_millis()
|
|
948
1080
|
as i64;
|
|
949
1081
|
let pong = format!(
|
|
950
|
-
"WSE{{\"t\":\"PONG\",\"p\":{{\"client_timestamp\":{},\"server_timestamp\":{},\"latency\":{}}},\"v\":
|
|
1082
|
+
"WSE{{\"t\":\"PONG\",\"p\":{{\"client_timestamp\":{},\"server_timestamp\":{},\"latency\":{}}},\"v\":1}}",
|
|
951
1083
|
timestamp,
|
|
952
1084
|
server_ts,
|
|
953
1085
|
server_ts.saturating_sub(timestamp).max(0)
|
|
@@ -1010,6 +1142,70 @@ async fn handle_connection(stream: TcpStream, addr: SocketAddr, state: Arc<Share
|
|
|
1010
1142
|
state
|
|
1011
1143
|
.conn_last_activity
|
|
1012
1144
|
.insert((*conn_id).clone(), std::time::Instant::now());
|
|
1145
|
+
|
|
1146
|
+
// E2E encryption: detect E: prefix and decrypt before processing
|
|
1147
|
+
if data.len() >= 2 && data[0] == b'E' && data[1] == b':' {
|
|
1148
|
+
if let Some(cipher_ref) = state.conn_encryption.get(&*conn_id) {
|
|
1149
|
+
match decrypt_inbound(&cipher_ref, &data[2..]) {
|
|
1150
|
+
Ok(plaintext) => {
|
|
1151
|
+
// Decrypted payload is UTF-8 text (JSON with optional prefix)
|
|
1152
|
+
if let Ok(text) = std::str::from_utf8(&plaintext) {
|
|
1153
|
+
let json_str = if text.starts_with("WSE{") {
|
|
1154
|
+
&text[3..]
|
|
1155
|
+
} else if text.starts_with("S{") || text.starts_with("U{") {
|
|
1156
|
+
&text[1..]
|
|
1157
|
+
} else if text.starts_with('{') {
|
|
1158
|
+
text
|
|
1159
|
+
} else {
|
|
1160
|
+
if drain {
|
|
1161
|
+
state.push_inbound(InboundEvent::RawText {
|
|
1162
|
+
conn_id: (*conn_id).clone(),
|
|
1163
|
+
text: text.to_string(),
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
continue;
|
|
1167
|
+
};
|
|
1168
|
+
if let Ok(val) =
|
|
1169
|
+
serde_json::from_str::<serde_json::Value>(json_str)
|
|
1170
|
+
{
|
|
1171
|
+
let val = normalize_json_keys(val);
|
|
1172
|
+
if drain {
|
|
1173
|
+
state.push_inbound(InboundEvent::Message {
|
|
1174
|
+
conn_id: (*conn_id).clone(),
|
|
1175
|
+
value: val,
|
|
1176
|
+
});
|
|
1177
|
+
} else if let Some(ref cb) = cached_on_message {
|
|
1178
|
+
let cb_arc = cb.clone();
|
|
1179
|
+
let cid = Arc::clone(&conn_id);
|
|
1180
|
+
tokio::task::spawn_blocking(move || {
|
|
1181
|
+
Python::try_attach(|py| {
|
|
1182
|
+
let py_obj = json_to_pyobj(py, &val);
|
|
1183
|
+
if let Err(e) =
|
|
1184
|
+
cb_arc.call1(py, (&**cid, py_obj))
|
|
1185
|
+
{
|
|
1186
|
+
eprintln!(
|
|
1187
|
+
"[WSE] on_message (decrypted) error: {e}"
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
Err(e) => {
|
|
1197
|
+
eprintln!("[WSE] Decryption failed for {}: {}", conn_id, e);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
} else {
|
|
1201
|
+
eprintln!(
|
|
1202
|
+
"[WSE] Received E: frame but no encryption key for {}",
|
|
1203
|
+
conn_id
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1013
1209
|
// Check if this connection uses msgpack (lock-free DashMap read)
|
|
1014
1210
|
let is_msgpack = state
|
|
1015
1211
|
.conn_formats
|
|
@@ -1097,6 +1293,7 @@ async fn handle_connection(stream: TcpStream, addr: SocketAddr, state: Arc<Share
|
|
|
1097
1293
|
state.conn_formats.remove(&*conn_id);
|
|
1098
1294
|
state.conn_rates.remove(&*conn_id);
|
|
1099
1295
|
state.conn_last_activity.remove(&*conn_id);
|
|
1296
|
+
state.conn_encryption.remove(&*conn_id);
|
|
1100
1297
|
// If zombie detection already removed this connection and emitted
|
|
1101
1298
|
// a disconnect event, skip the rest to avoid duplicate events.
|
|
1102
1299
|
if already_removed {
|
|
@@ -1340,34 +1537,139 @@ async fn process_commands(
|
|
|
1340
1537
|
}
|
|
1341
1538
|
}
|
|
1342
1539
|
ServerCommand::BroadcastText { data } => {
|
|
1343
|
-
|
|
1344
|
-
|
|
1540
|
+
if state.conn_encryption.is_empty() {
|
|
1541
|
+
// Fast path: no encrypted connections, zero overhead
|
|
1542
|
+
let frame = WsFrame::PreFramed(pre_frame_text(&data));
|
|
1543
|
+
let senders: Vec<mpsc::UnboundedSender<WsFrame>> = {
|
|
1544
|
+
let guard = state.connections.read().await;
|
|
1545
|
+
guard.values().map(|h| h.tx.clone()).collect()
|
|
1546
|
+
};
|
|
1547
|
+
fanout_to_senders(senders, frame);
|
|
1548
|
+
} else {
|
|
1549
|
+
let preframed = pre_frame_text(&data);
|
|
1345
1550
|
let guard = state.connections.read().await;
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1551
|
+
let mut plain_senders = Vec::new();
|
|
1552
|
+
let mut enc_senders = Vec::new();
|
|
1553
|
+
for (cid, h) in guard.iter() {
|
|
1554
|
+
if let Some(cipher_ref) = state.conn_encryption.get(cid) {
|
|
1555
|
+
enc_senders.push((h.tx.clone(), cipher_ref.clone()));
|
|
1556
|
+
} else {
|
|
1557
|
+
plain_senders.push(h.tx.clone());
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
drop(guard);
|
|
1561
|
+
if !plain_senders.is_empty() {
|
|
1562
|
+
fanout_to_senders(plain_senders, WsFrame::PreFramed(preframed));
|
|
1563
|
+
}
|
|
1564
|
+
for (tx, cipher) in &enc_senders {
|
|
1565
|
+
if let Ok(enc) = encrypt_outbound(cipher, data.as_bytes()) {
|
|
1566
|
+
let mut buf = Vec::with_capacity(2 + enc.len());
|
|
1567
|
+
buf.extend_from_slice(b"E:");
|
|
1568
|
+
buf.extend_from_slice(&enc);
|
|
1569
|
+
let _ = tx.send(WsFrame::Msg(Message::Binary(buf.into())));
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1349
1573
|
}
|
|
1350
1574
|
ServerCommand::BroadcastBytes { data } => {
|
|
1351
|
-
|
|
1352
|
-
|
|
1575
|
+
if state.conn_encryption.is_empty() {
|
|
1576
|
+
let frame = WsFrame::PreFramed(pre_frame_binary(&data));
|
|
1577
|
+
let senders: Vec<mpsc::UnboundedSender<WsFrame>> = {
|
|
1578
|
+
let guard = state.connections.read().await;
|
|
1579
|
+
guard.values().map(|h| h.tx.clone()).collect()
|
|
1580
|
+
};
|
|
1581
|
+
fanout_to_senders(senders, frame);
|
|
1582
|
+
} else {
|
|
1583
|
+
let preframed = pre_frame_binary(&data);
|
|
1353
1584
|
let guard = state.connections.read().await;
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1585
|
+
let mut plain_senders = Vec::new();
|
|
1586
|
+
let mut enc_senders = Vec::new();
|
|
1587
|
+
for (cid, h) in guard.iter() {
|
|
1588
|
+
if let Some(cipher_ref) = state.conn_encryption.get(cid) {
|
|
1589
|
+
enc_senders.push((h.tx.clone(), cipher_ref.clone()));
|
|
1590
|
+
} else {
|
|
1591
|
+
plain_senders.push(h.tx.clone());
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
drop(guard);
|
|
1595
|
+
if !plain_senders.is_empty() {
|
|
1596
|
+
fanout_to_senders(plain_senders, WsFrame::PreFramed(preframed));
|
|
1597
|
+
}
|
|
1598
|
+
for (tx, cipher) in &enc_senders {
|
|
1599
|
+
if let Ok(enc) = encrypt_outbound(cipher, &data) {
|
|
1600
|
+
let mut buf = Vec::with_capacity(2 + enc.len());
|
|
1601
|
+
buf.extend_from_slice(b"E:");
|
|
1602
|
+
buf.extend_from_slice(&enc);
|
|
1603
|
+
let _ = tx.send(WsFrame::Msg(Message::Binary(buf.into())));
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1357
1607
|
}
|
|
1358
1608
|
ServerCommand::BroadcastLocal { topic, data } => {
|
|
1359
|
-
let
|
|
1609
|
+
let preframed = pre_frame_text(&data);
|
|
1360
1610
|
// Store in recovery buffer (zero-copy: Bytes is Arc-refcounted)
|
|
1361
|
-
if let Some(ref recovery) = state.recovery
|
|
1362
|
-
|
|
1363
|
-
{
|
|
1364
|
-
recovery.push(&topic, bytes.clone());
|
|
1611
|
+
if let Some(ref recovery) = state.recovery {
|
|
1612
|
+
recovery.push(&topic, preframed.clone());
|
|
1365
1613
|
}
|
|
1366
|
-
|
|
1614
|
+
if state.conn_encryption.is_empty() {
|
|
1615
|
+
// Fast path: no encrypted connections
|
|
1616
|
+
let senders = {
|
|
1617
|
+
let guard = state.connections.read().await;
|
|
1618
|
+
collect_topic_senders(&guard, &state.topic_subscribers, &topic)
|
|
1619
|
+
};
|
|
1620
|
+
fanout_to_senders(senders, WsFrame::PreFramed(preframed));
|
|
1621
|
+
} else {
|
|
1367
1622
|
let guard = state.connections.read().await;
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1623
|
+
let mut plain_senders = Vec::new();
|
|
1624
|
+
let mut enc_senders = Vec::new();
|
|
1625
|
+
let mut seen = AHashSet::new();
|
|
1626
|
+
if let Some(subs) = state.topic_subscribers.get(&topic) {
|
|
1627
|
+
for cid_ref in subs.iter() {
|
|
1628
|
+
let cid = cid_ref.key().clone();
|
|
1629
|
+
if seen.insert(cid.clone())
|
|
1630
|
+
&& let Some(h) = guard.get(&cid)
|
|
1631
|
+
{
|
|
1632
|
+
if let Some(cipher_ref) = state.conn_encryption.get(&cid) {
|
|
1633
|
+
enc_senders.push((h.tx.clone(), cipher_ref.clone()));
|
|
1634
|
+
} else {
|
|
1635
|
+
plain_senders.push(h.tx.clone());
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
// Glob pattern matching
|
|
1641
|
+
for entry in state.topic_subscribers.iter() {
|
|
1642
|
+
let pattern = entry.key();
|
|
1643
|
+
if (pattern.contains('*') || pattern.contains('?'))
|
|
1644
|
+
&& glob_match(pattern, &topic)
|
|
1645
|
+
{
|
|
1646
|
+
for cid_ref in entry.value().iter() {
|
|
1647
|
+
let cid = cid_ref.key().clone();
|
|
1648
|
+
if seen.insert(cid.clone())
|
|
1649
|
+
&& let Some(h) = guard.get(&cid)
|
|
1650
|
+
{
|
|
1651
|
+
if let Some(cipher_ref) = state.conn_encryption.get(&cid) {
|
|
1652
|
+
enc_senders.push((h.tx.clone(), cipher_ref.clone()));
|
|
1653
|
+
} else {
|
|
1654
|
+
plain_senders.push(h.tx.clone());
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
drop(guard);
|
|
1661
|
+
if !plain_senders.is_empty() {
|
|
1662
|
+
fanout_to_senders(plain_senders, WsFrame::PreFramed(preframed));
|
|
1663
|
+
}
|
|
1664
|
+
for (tx, cipher) in &enc_senders {
|
|
1665
|
+
if let Ok(enc) = encrypt_outbound(cipher, data.as_bytes()) {
|
|
1666
|
+
let mut buf = Vec::with_capacity(2 + enc.len());
|
|
1667
|
+
buf.extend_from_slice(b"E:");
|
|
1668
|
+
buf.extend_from_slice(&enc);
|
|
1669
|
+
let _ = tx.send(WsFrame::Msg(Message::Binary(buf.into())));
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1371
1673
|
}
|
|
1372
1674
|
ServerCommand::Disconnect { conn_id } => {
|
|
1373
1675
|
let guard = state.connections.read().await;
|
|
@@ -1753,6 +2055,7 @@ impl RustWSEServer {
|
|
|
1753
2055
|
ping_state.conn_formats.remove(cid);
|
|
1754
2056
|
ping_state.conn_rates.remove(cid);
|
|
1755
2057
|
ping_state.conn_last_activity.remove(cid);
|
|
2058
|
+
ping_state.conn_encryption.remove(cid);
|
|
1756
2059
|
}
|
|
1757
2060
|
ping_state.connection_count.store(
|
|
1758
2061
|
conns.len(),
|
|
@@ -2110,6 +2413,9 @@ impl RustWSEServer {
|
|
|
2110
2413
|
.map(|v| *v)
|
|
2111
2414
|
.unwrap_or(false);
|
|
2112
2415
|
|
|
2416
|
+
// Check if this connection has E2E encryption
|
|
2417
|
+
let conn_cipher = self.shared.conn_encryption.get(conn_id);
|
|
2418
|
+
|
|
2113
2419
|
let byte_count;
|
|
2114
2420
|
|
|
2115
2421
|
if use_msgpack {
|
|
@@ -2121,6 +2427,19 @@ impl RustWSEServer {
|
|
|
2121
2427
|
let mut final_bytes = Vec::with_capacity(buf.len() + 2);
|
|
2122
2428
|
final_bytes.extend_from_slice(b"M:");
|
|
2123
2429
|
final_bytes.extend_from_slice(&buf);
|
|
2430
|
+
|
|
2431
|
+
// Encrypt if E2E enabled for this connection
|
|
2432
|
+
let final_bytes = if let Some(ref cipher) = conn_cipher {
|
|
2433
|
+
let encrypted = encrypt_outbound(cipher, &final_bytes)
|
|
2434
|
+
.map_err(|e| PyRuntimeError::new_err(format!("Encrypt error: {e}")))?;
|
|
2435
|
+
let mut enc_buf = Vec::with_capacity(2 + encrypted.len());
|
|
2436
|
+
enc_buf.extend_from_slice(b"E:");
|
|
2437
|
+
enc_buf.extend_from_slice(&encrypted);
|
|
2438
|
+
enc_buf
|
|
2439
|
+
} else {
|
|
2440
|
+
final_bytes
|
|
2441
|
+
};
|
|
2442
|
+
|
|
2124
2443
|
byte_count = final_bytes.len();
|
|
2125
2444
|
cmd_tx
|
|
2126
2445
|
.send(ServerCommand::SendPrebuilt {
|
|
@@ -2137,7 +2456,21 @@ impl RustWSEServer {
|
|
|
2137
2456
|
let payload_str = format!("{}{}", category, json_str);
|
|
2138
2457
|
let payload_bytes = payload_str.as_bytes();
|
|
2139
2458
|
|
|
2140
|
-
if
|
|
2459
|
+
if let Some(ref cipher) = conn_cipher {
|
|
2460
|
+
// E2E encrypted: encrypt plaintext payload, send as E: prefix binary
|
|
2461
|
+
let encrypted = encrypt_outbound(cipher, payload_bytes)
|
|
2462
|
+
.map_err(|e| PyRuntimeError::new_err(format!("Encrypt error: {e}")))?;
|
|
2463
|
+
let mut final_bytes = Vec::with_capacity(2 + encrypted.len());
|
|
2464
|
+
final_bytes.extend_from_slice(b"E:");
|
|
2465
|
+
final_bytes.extend_from_slice(&encrypted);
|
|
2466
|
+
byte_count = final_bytes.len();
|
|
2467
|
+
cmd_tx
|
|
2468
|
+
.send(ServerCommand::SendPrebuilt {
|
|
2469
|
+
conn_id: conn_id.to_owned(),
|
|
2470
|
+
message: Message::Binary(final_bytes.into()),
|
|
2471
|
+
})
|
|
2472
|
+
.map_err(|_| PyRuntimeError::new_err("Channel closed"))?;
|
|
2473
|
+
} else if compression_threshold > 0 && payload_bytes.len() > compression_threshold {
|
|
2141
2474
|
// Compressed: C: prefix + zlib (binary frame)
|
|
2142
2475
|
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast());
|
|
2143
2476
|
encoder
|
|
@@ -4,6 +4,9 @@ from wse_server._wse_accel import (
|
|
|
4
4
|
RustCompressionManager,
|
|
5
5
|
RustEventSequencer,
|
|
6
6
|
RustPriorityMessageQueue,
|
|
7
|
+
RustPriorityQueue,
|
|
8
|
+
RustSequencer,
|
|
9
|
+
RustTokenBucket,
|
|
7
10
|
RustWSEServer,
|
|
8
11
|
rust_aes_gcm_decrypt,
|
|
9
12
|
rust_aes_gcm_encrypt,
|
|
@@ -16,19 +19,23 @@ from wse_server._wse_accel import (
|
|
|
16
19
|
rust_jwt_encode,
|
|
17
20
|
rust_match_event,
|
|
18
21
|
rust_sha256,
|
|
22
|
+
rust_should_compress,
|
|
19
23
|
rust_sign_message,
|
|
20
24
|
rust_transform_event,
|
|
21
25
|
)
|
|
22
26
|
from wse_server.core.filters import EventFilter
|
|
23
27
|
from wse_server.core.types import DeliveryGuarantee, EventPriority
|
|
24
28
|
|
|
25
|
-
__version__ = "2.0.
|
|
29
|
+
__version__ = "2.0.2"
|
|
26
30
|
|
|
27
31
|
__all__ = [
|
|
28
32
|
"RustWSEServer",
|
|
29
33
|
"RustCompressionManager",
|
|
30
34
|
"RustEventSequencer",
|
|
31
35
|
"RustPriorityMessageQueue",
|
|
36
|
+
"RustPriorityQueue",
|
|
37
|
+
"RustSequencer",
|
|
38
|
+
"RustTokenBucket",
|
|
32
39
|
"rust_jwt_encode",
|
|
33
40
|
"rust_jwt_decode",
|
|
34
41
|
"rust_aes_gcm_encrypt",
|
|
@@ -39,6 +46,7 @@ __all__ = [
|
|
|
39
46
|
"rust_sha256",
|
|
40
47
|
"rust_compress",
|
|
41
48
|
"rust_decompress",
|
|
49
|
+
"rust_should_compress",
|
|
42
50
|
"rust_sign_message",
|
|
43
51
|
"rust_match_event",
|
|
44
52
|
"rust_transform_event",
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
# =============================================================================
|
|
7
|
+
# Compression
|
|
8
|
+
# =============================================================================
|
|
9
|
+
|
|
10
|
+
def rust_compress(data: bytes, level: int = 6) -> bytes:
|
|
11
|
+
"""Compress data using zlib at the given compression level."""
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
def rust_decompress(data: bytes) -> bytes:
|
|
15
|
+
"""Decompress zlib-compressed data."""
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
def rust_should_compress(data: bytes, threshold: int = 1024) -> bool:
|
|
19
|
+
"""Return True if data length exceeds the compression threshold."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
# =============================================================================
|
|
23
|
+
# Event Transform & Filter
|
|
24
|
+
# =============================================================================
|
|
25
|
+
|
|
26
|
+
def rust_transform_event(
|
|
27
|
+
event: dict[str, Any], sequence: int, event_type_map: dict[str, Any]
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
"""Transform a raw event dict into a WSE wire envelope."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def rust_match_event(event: dict[str, Any], criteria: dict[str, Any]) -> bool:
|
|
33
|
+
"""Match an event against MongoDB-style filter criteria."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
# =============================================================================
|
|
37
|
+
# HMAC / SHA-256
|
|
38
|
+
# =============================================================================
|
|
39
|
+
|
|
40
|
+
def rust_hmac_sha256(key: bytes, data: bytes) -> bytes:
|
|
41
|
+
"""Compute HMAC-SHA256."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def rust_sha256(data: bytes) -> str:
|
|
45
|
+
"""Compute SHA-256 hash, returned as a hex string."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
def rust_sign_message(payload_json: str, secret: bytes) -> str:
|
|
49
|
+
"""Sign a JSON payload string with HMAC-SHA256."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
# =============================================================================
|
|
53
|
+
# AES-GCM-256 Encryption
|
|
54
|
+
# =============================================================================
|
|
55
|
+
|
|
56
|
+
def rust_aes_gcm_encrypt(key: bytes, plaintext: bytes) -> bytes:
|
|
57
|
+
"""Encrypt with AES-GCM-256. Returns 12-byte IV + ciphertext + 16-byte tag."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def rust_aes_gcm_decrypt(key: bytes, data: bytes) -> bytes:
|
|
61
|
+
"""Decrypt AES-GCM-256 ciphertext. Input: 12-byte IV + ciphertext + tag."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
# =============================================================================
|
|
65
|
+
# ECDH P-256 Key Exchange
|
|
66
|
+
# =============================================================================
|
|
67
|
+
|
|
68
|
+
def rust_ecdh_generate_keypair() -> tuple[bytes, bytes]:
|
|
69
|
+
"""Generate an ECDH P-256 keypair. Returns (private_key, public_key_sec1)."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
def rust_ecdh_derive_shared_secret(private_key: bytes, peer_public_key: bytes) -> bytes:
|
|
73
|
+
"""Derive a 32-byte AES key via ECDH + HKDF-SHA256."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
# =============================================================================
|
|
77
|
+
# JWT
|
|
78
|
+
# =============================================================================
|
|
79
|
+
|
|
80
|
+
def rust_jwt_encode(claims: dict[str, Any], secret: bytes, algorithm: str = "HS256") -> str:
|
|
81
|
+
"""Encode claims into a JWT (HS256)."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
def rust_jwt_decode(
|
|
85
|
+
token: str,
|
|
86
|
+
secret: bytes,
|
|
87
|
+
algorithm: str = "HS256",
|
|
88
|
+
issuer: str | None = None,
|
|
89
|
+
audience: str | None = None,
|
|
90
|
+
) -> dict[str, Any] | None:
|
|
91
|
+
"""Decode and validate a JWT. Returns None on failure."""
|
|
92
|
+
...
|
|
93
|
+
|
|
94
|
+
# =============================================================================
|
|
95
|
+
# RustCompressionManager
|
|
96
|
+
# =============================================================================
|
|
97
|
+
|
|
98
|
+
class RustCompressionManager:
|
|
99
|
+
"""Zlib compression manager with optional msgpack support."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, threshold: int = 1024, compression_level: int = 6) -> None: ...
|
|
102
|
+
def should_compress(self, data: bytes) -> bool:
|
|
103
|
+
"""Return True if data exceeds the compression threshold."""
|
|
104
|
+
...
|
|
105
|
+
def compress(self, data: bytes, level: int | None = None) -> bytes:
|
|
106
|
+
"""Compress data with zlib."""
|
|
107
|
+
...
|
|
108
|
+
def decompress(self, data: bytes) -> bytes:
|
|
109
|
+
"""Decompress zlib data."""
|
|
110
|
+
...
|
|
111
|
+
def pack_msgpack(self, data: dict[str, Any]) -> bytes:
|
|
112
|
+
"""Serialize dict to msgpack bytes."""
|
|
113
|
+
...
|
|
114
|
+
def unpack_msgpack(self, data: bytes) -> Any:
|
|
115
|
+
"""Deserialize msgpack bytes."""
|
|
116
|
+
...
|
|
117
|
+
def pack_event(self, event: dict[str, Any], use_msgpack: bool = True) -> bytes:
|
|
118
|
+
"""Pack event as msgpack (with JSON fallback)."""
|
|
119
|
+
...
|
|
120
|
+
def unpack_event(self, data: bytes, is_msgpack: bool = True) -> Any:
|
|
121
|
+
"""Unpack event from msgpack (with JSON fallback)."""
|
|
122
|
+
...
|
|
123
|
+
def get_stats(self) -> dict[str, int | float]:
|
|
124
|
+
"""Return compression statistics.
|
|
125
|
+
|
|
126
|
+
Keys: total_compressed, total_decompressed, compression_failures,
|
|
127
|
+
decompression_failures, total_bytes_saved (int),
|
|
128
|
+
compression_success_rate, decompression_success_rate,
|
|
129
|
+
average_bytes_saved (float).
|
|
130
|
+
"""
|
|
131
|
+
...
|
|
132
|
+
def reset_stats(self) -> None:
|
|
133
|
+
"""Reset all compression statistics to zero."""
|
|
134
|
+
...
|
|
135
|
+
|
|
136
|
+
# =============================================================================
|
|
137
|
+
# RustTokenBucket
|
|
138
|
+
# =============================================================================
|
|
139
|
+
|
|
140
|
+
class RustTokenBucket:
|
|
141
|
+
"""Token bucket rate limiter."""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
capacity: float,
|
|
146
|
+
refill_rate: float,
|
|
147
|
+
initial_tokens: float | None = None,
|
|
148
|
+
) -> None: ...
|
|
149
|
+
def acquire(self, tokens: float = 1.0) -> bool:
|
|
150
|
+
"""Try to acquire tokens. Returns True if successful."""
|
|
151
|
+
...
|
|
152
|
+
@property
|
|
153
|
+
def tokens(self) -> float:
|
|
154
|
+
"""Current token count after refill."""
|
|
155
|
+
...
|
|
156
|
+
def reset(self) -> None:
|
|
157
|
+
"""Reset bucket to full capacity."""
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
# =============================================================================
|
|
161
|
+
# RustSequencer
|
|
162
|
+
# =============================================================================
|
|
163
|
+
|
|
164
|
+
class RustSequencer:
|
|
165
|
+
"""Monotonic sequence counter with duplicate detection."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, window_size: int) -> None: ...
|
|
168
|
+
def next_seq(self) -> int:
|
|
169
|
+
"""Increment and return the next sequence number."""
|
|
170
|
+
...
|
|
171
|
+
def get_current_seq(self) -> int:
|
|
172
|
+
"""Get current sequence number without incrementing."""
|
|
173
|
+
...
|
|
174
|
+
def is_duplicate(self, event_id: str) -> bool:
|
|
175
|
+
"""Check if event ID has been seen."""
|
|
176
|
+
...
|
|
177
|
+
def cleanup(self, max_age_secs: float) -> None:
|
|
178
|
+
"""Remove entries older than max_age_secs."""
|
|
179
|
+
...
|
|
180
|
+
def seen_count(self) -> int:
|
|
181
|
+
"""Number of tracked event IDs."""
|
|
182
|
+
...
|
|
183
|
+
|
|
184
|
+
# =============================================================================
|
|
185
|
+
# RustEventSequencer
|
|
186
|
+
# =============================================================================
|
|
187
|
+
|
|
188
|
+
class RustEventSequencer:
|
|
189
|
+
"""Event sequencer with dedup and out-of-order buffering."""
|
|
190
|
+
|
|
191
|
+
def __init__(self, window_size: int = 10000, max_out_of_order: int = 100) -> None: ...
|
|
192
|
+
def next_seq(self) -> int:
|
|
193
|
+
"""Increment and return the next sequence number."""
|
|
194
|
+
...
|
|
195
|
+
def get_current_sequence(self) -> int:
|
|
196
|
+
"""Get current sequence number."""
|
|
197
|
+
...
|
|
198
|
+
def is_duplicate(self, event_id: str) -> bool:
|
|
199
|
+
"""Check if event ID has been seen."""
|
|
200
|
+
...
|
|
201
|
+
def process_sequenced_event(self, topic: str, sequence: int, event: Any) -> list[Any] | None:
|
|
202
|
+
"""Process a sequenced event. Returns deliverable events or None if buffered."""
|
|
203
|
+
...
|
|
204
|
+
def cleanup(self) -> None:
|
|
205
|
+
"""Clean up buffered events older than 5 minutes and trim seen_ids."""
|
|
206
|
+
...
|
|
207
|
+
def get_buffer_stats(self) -> dict[str, Any]:
|
|
208
|
+
"""Get statistics about buffered events per topic."""
|
|
209
|
+
...
|
|
210
|
+
def get_sequence_stats(self) -> dict[str, Any]:
|
|
211
|
+
"""Get detailed sequence statistics including per-topic info."""
|
|
212
|
+
...
|
|
213
|
+
def reset_sequence(self, topic: str | None = None) -> None:
|
|
214
|
+
"""Reset sequence tracking for a specific topic or all topics."""
|
|
215
|
+
...
|
|
216
|
+
|
|
217
|
+
# =============================================================================
|
|
218
|
+
# RustPriorityQueue
|
|
219
|
+
# =============================================================================
|
|
220
|
+
|
|
221
|
+
class RustPriorityQueue:
|
|
222
|
+
"""Bounded priority queue (binary heap)."""
|
|
223
|
+
|
|
224
|
+
def __init__(self, max_size: int) -> None: ...
|
|
225
|
+
def push(self, priority: int, message: Any) -> bool:
|
|
226
|
+
"""Push a message. Returns False if queue is full."""
|
|
227
|
+
...
|
|
228
|
+
def pop(self) -> Any | None:
|
|
229
|
+
"""Pop the highest-priority message."""
|
|
230
|
+
...
|
|
231
|
+
def drain(self, max_count: int) -> list[Any]:
|
|
232
|
+
"""Drain up to max_count messages in priority order."""
|
|
233
|
+
...
|
|
234
|
+
def len(self) -> int: ...
|
|
235
|
+
def is_full(self) -> bool: ...
|
|
236
|
+
def clear(self) -> None: ...
|
|
237
|
+
|
|
238
|
+
# =============================================================================
|
|
239
|
+
# RustPriorityMessageQueue
|
|
240
|
+
# =============================================================================
|
|
241
|
+
|
|
242
|
+
class RustPriorityMessageQueue:
|
|
243
|
+
"""Priority message queue with stats and batch dequeue."""
|
|
244
|
+
|
|
245
|
+
def __init__(self, max_size: int = 1000, batch_size: int = 10) -> None: ...
|
|
246
|
+
def enqueue(self, message: Any, priority: int = 5) -> bool:
|
|
247
|
+
"""Enqueue a message. Priorities: BACKGROUND=1, LOW=3, NORMAL=5, HIGH=8, CRITICAL=10."""
|
|
248
|
+
...
|
|
249
|
+
def dequeue_batch(self) -> list[tuple[int, Any]]:
|
|
250
|
+
"""Dequeue a batch of (priority, message) tuples."""
|
|
251
|
+
...
|
|
252
|
+
@property
|
|
253
|
+
def size(self) -> int:
|
|
254
|
+
"""Current total message count."""
|
|
255
|
+
...
|
|
256
|
+
def get_stats(self) -> dict[str, Any]:
|
|
257
|
+
"""Queue statistics.
|
|
258
|
+
|
|
259
|
+
Keys: size (int), capacity (int), utilization_percent (float),
|
|
260
|
+
priority_distribution (dict), priority_queue_depths (dict),
|
|
261
|
+
dropped_by_priority (dict), total_dropped (int),
|
|
262
|
+
backpressure (bool), oldest_message_age (float | None),
|
|
263
|
+
processing_rate (float).
|
|
264
|
+
"""
|
|
265
|
+
...
|
|
266
|
+
def clear(self) -> None: ...
|
|
267
|
+
|
|
268
|
+
# =============================================================================
|
|
269
|
+
# RustWSEServer
|
|
270
|
+
# =============================================================================
|
|
271
|
+
|
|
272
|
+
class RustWSEServer:
|
|
273
|
+
"""Standalone Rust WebSocket server controlled from Python.
|
|
274
|
+
|
|
275
|
+
All transport (TCP accept, framing, JWT auth, compression, encryption,
|
|
276
|
+
ping/pong) runs in Rust. Python handles application logic via drain mode
|
|
277
|
+
or callbacks.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
def __init__(
|
|
281
|
+
self,
|
|
282
|
+
host: str,
|
|
283
|
+
port: int,
|
|
284
|
+
max_connections: int = 1000,
|
|
285
|
+
jwt_secret: bytes | None = None,
|
|
286
|
+
jwt_issuer: str | None = None,
|
|
287
|
+
jwt_audience: str | None = None,
|
|
288
|
+
max_inbound_queue_size: int = 131072,
|
|
289
|
+
recovery_enabled: bool = False,
|
|
290
|
+
recovery_buffer_size: int = 128,
|
|
291
|
+
recovery_ttl: int = 300,
|
|
292
|
+
recovery_max_messages: int = 500,
|
|
293
|
+
recovery_memory_budget: int = 268435456,
|
|
294
|
+
presence_enabled: bool = False,
|
|
295
|
+
presence_max_data_size: int = 4096,
|
|
296
|
+
presence_max_members: int = 0,
|
|
297
|
+
) -> None: ...
|
|
298
|
+
|
|
299
|
+
# -- Lifecycle ------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
def start(self) -> None:
|
|
302
|
+
"""Start the WebSocket server."""
|
|
303
|
+
...
|
|
304
|
+
def stop(self) -> None:
|
|
305
|
+
"""Stop the server and close all connections."""
|
|
306
|
+
...
|
|
307
|
+
def is_running(self) -> bool:
|
|
308
|
+
"""Return True if the server is running."""
|
|
309
|
+
...
|
|
310
|
+
|
|
311
|
+
# -- Callbacks (alternative to drain mode) --------------------------------
|
|
312
|
+
|
|
313
|
+
def set_callbacks(
|
|
314
|
+
self,
|
|
315
|
+
on_connect: Callable[[str, str], Any],
|
|
316
|
+
on_message: Callable[[str, dict[str, Any]], Any],
|
|
317
|
+
on_disconnect: Callable[[str], Any],
|
|
318
|
+
) -> None:
|
|
319
|
+
"""Set callback functions for connect, message, and disconnect events."""
|
|
320
|
+
...
|
|
321
|
+
|
|
322
|
+
# -- Drain Mode -----------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
def enable_drain_mode(self) -> None:
|
|
325
|
+
"""Enable drain mode. Events are queued for batch retrieval."""
|
|
326
|
+
...
|
|
327
|
+
def drain_inbound(
|
|
328
|
+
self, max_count: int = 256, timeout_ms: int = 50
|
|
329
|
+
) -> list[tuple[str, str | None, Any]]:
|
|
330
|
+
"""Drain queued inbound events.
|
|
331
|
+
|
|
332
|
+
Returns list of (event_type, conn_id, data) tuples.
|
|
333
|
+
Event types: "connect", "auth_connect", "msg", "raw", "bin",
|
|
334
|
+
"disconnect", "presence_join", "presence_leave".
|
|
335
|
+
"""
|
|
336
|
+
...
|
|
337
|
+
def inbound_dropped_count(self) -> int:
|
|
338
|
+
"""Number of inbound events dropped due to queue overflow."""
|
|
339
|
+
...
|
|
340
|
+
def inbound_queue_depth(self) -> int:
|
|
341
|
+
"""Current number of events in the inbound queue."""
|
|
342
|
+
...
|
|
343
|
+
|
|
344
|
+
# -- Send -----------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def send(self, conn_id: str, data: str) -> None:
|
|
347
|
+
"""Send a text frame to a connection."""
|
|
348
|
+
...
|
|
349
|
+
def send_bytes(self, conn_id: str, data: bytes) -> None:
|
|
350
|
+
"""Send a binary frame to a connection."""
|
|
351
|
+
...
|
|
352
|
+
def send_event(
|
|
353
|
+
self,
|
|
354
|
+
conn_id: str,
|
|
355
|
+
event: dict[str, Any],
|
|
356
|
+
compression_threshold: int = 1024,
|
|
357
|
+
) -> int:
|
|
358
|
+
"""Send an event through the full outbound pipeline. Returns bytes sent."""
|
|
359
|
+
...
|
|
360
|
+
|
|
361
|
+
# -- Broadcast ------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
def broadcast_all(self, data: str) -> None:
|
|
364
|
+
"""Send text to all connected clients (no topic filtering)."""
|
|
365
|
+
...
|
|
366
|
+
def broadcast_all_bytes(self, data: bytes) -> None:
|
|
367
|
+
"""Send binary to all connected clients."""
|
|
368
|
+
...
|
|
369
|
+
def broadcast_local(self, topic: str, data: str) -> None:
|
|
370
|
+
"""Fan-out to local topic subscribers only (no cluster)."""
|
|
371
|
+
...
|
|
372
|
+
def broadcast(self, topic: str, data: str) -> None:
|
|
373
|
+
"""Fan-out to topic subscribers locally and across cluster peers."""
|
|
374
|
+
...
|
|
375
|
+
|
|
376
|
+
# -- Connection Management ------------------------------------------------
|
|
377
|
+
|
|
378
|
+
def get_connection_count(self) -> int:
|
|
379
|
+
"""Number of active connections (lock-free AtomicUsize read)."""
|
|
380
|
+
...
|
|
381
|
+
def get_connections(self) -> list[str]:
|
|
382
|
+
"""List of active connection IDs."""
|
|
383
|
+
...
|
|
384
|
+
def disconnect(self, conn_id: str) -> None:
|
|
385
|
+
"""Force-disconnect a connection."""
|
|
386
|
+
...
|
|
387
|
+
|
|
388
|
+
# -- Subscriptions --------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def subscribe_connection(
|
|
391
|
+
self,
|
|
392
|
+
conn_id: str,
|
|
393
|
+
topics: list[str],
|
|
394
|
+
presence_data: dict[str, Any] | None = None,
|
|
395
|
+
) -> None:
|
|
396
|
+
"""Subscribe a connection to topics. Optionally set initial presence data."""
|
|
397
|
+
...
|
|
398
|
+
def subscribe_with_recovery(
|
|
399
|
+
self,
|
|
400
|
+
conn_id: str,
|
|
401
|
+
topics: list[str],
|
|
402
|
+
recover: bool = False,
|
|
403
|
+
epoch: str | None = None,
|
|
404
|
+
offset: int | None = None,
|
|
405
|
+
) -> dict[str, Any]:
|
|
406
|
+
"""Subscribe with message recovery. Returns per-topic recovery status."""
|
|
407
|
+
...
|
|
408
|
+
def unsubscribe_connection(self, conn_id: str, topics: list[str] | None = None) -> None:
|
|
409
|
+
"""Unsubscribe from topics. If topics is None, unsubscribe from all."""
|
|
410
|
+
...
|
|
411
|
+
def get_topic_subscriber_count(self, topic: str) -> int:
|
|
412
|
+
"""Number of connections subscribed to a topic."""
|
|
413
|
+
...
|
|
414
|
+
|
|
415
|
+
# -- Presence -------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
def presence(self, topic: str) -> dict[str, dict[str, Any]]:
|
|
418
|
+
"""Get presence data for a topic. Returns {user_id: {data, connections}}."""
|
|
419
|
+
...
|
|
420
|
+
def presence_stats(self, topic: str) -> dict[str, int]:
|
|
421
|
+
"""Get presence counts for a topic. Returns {num_users, num_connections}."""
|
|
422
|
+
...
|
|
423
|
+
def update_presence(self, conn_id: str, data: dict[str, Any]) -> None:
|
|
424
|
+
"""Update presence metadata for a connection across all its topics."""
|
|
425
|
+
...
|
|
426
|
+
|
|
427
|
+
# -- Cluster --------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
def connect_cluster(
|
|
430
|
+
self,
|
|
431
|
+
peers: list[str],
|
|
432
|
+
tls_cert: str | None = None,
|
|
433
|
+
tls_key: str | None = None,
|
|
434
|
+
tls_ca: str | None = None,
|
|
435
|
+
cluster_port: int | None = None,
|
|
436
|
+
seeds: list[str] | None = None,
|
|
437
|
+
cluster_addr: str | None = None,
|
|
438
|
+
) -> None:
|
|
439
|
+
"""Connect to cluster peers via TCP mesh protocol."""
|
|
440
|
+
...
|
|
441
|
+
def cluster_connected(self) -> bool:
|
|
442
|
+
"""Return True if at least one cluster peer is connected."""
|
|
443
|
+
...
|
|
444
|
+
def cluster_peers_count(self) -> int:
|
|
445
|
+
"""Number of connected cluster peers."""
|
|
446
|
+
...
|
|
447
|
+
|
|
448
|
+
# -- Health / Monitoring --------------------------------------------------
|
|
449
|
+
|
|
450
|
+
def health_snapshot(self) -> dict[str, Any]:
|
|
451
|
+
"""Full server health metrics: connections, queue, cluster, recovery, presence, uptime."""
|
|
452
|
+
...
|
|
453
|
+
def get_cluster_dlq_entries(self) -> list[dict[str, Any]]:
|
|
454
|
+
"""Drain and return dead-letter-queue entries from failed cluster sends."""
|
|
455
|
+
...
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|