Compare commits

...

72 Commits

Author SHA1 Message Date
0b5d72de28 v4.8.4 2026-03-17 11:47:33 +00:00
e8431c0174 fix(remoteingress-core): prevent stream stalls by guaranteeing flow-control updates and avoiding bounded per-stream channel overflows 2026-03-17 11:47:33 +00:00
d57d6395dd v4.8.3 2026-03-17 11:15:18 +00:00
2e5ceeaf5c fix(protocol,edge): optimize tunnel frame handling and zero-copy uploads in edge I/O 2026-03-17 11:15:18 +00:00
1979910f6f v4.8.2 2026-03-17 10:33:21 +00:00
edfad2dffe fix(rust-edge): refactor tunnel I/O to preserve TLS state and prioritize control frames 2026-03-17 10:33:21 +00:00
d907943ae5 v4.8.1 2026-03-17 01:48:06 +00:00
4bfb1244fc fix(remoteingress-core): remove tunnel writer timeouts from edge and hub buffered writes 2026-03-17 01:48:06 +00:00
e31c3421a6 v4.8.0 2026-03-17 00:58:08 +00:00
de8422966a feat(events): include disconnect reasons in edge and hub management events 2026-03-17 00:58:08 +00:00
a87e9578eb v4.7.2 2026-03-17 00:39:57 +00:00
b851bc7994 fix(remoteingress-core): add tunnel write timeouts and scale initial stream windows by active stream count 2026-03-17 00:39:57 +00:00
1284bb5b73 v4.7.1 2026-03-17 00:15:10 +00:00
1afd0e5347 fix(remoteingress-core): improve tunnel failure detection and reconnect handling 2026-03-17 00:15:10 +00:00
96e7ab00cf v4.7.0 2026-03-16 23:35:02 +00:00
17d1a795cd feat(edge,protocol,test): add configurable edge bind address and expand flow-control test coverage 2026-03-16 23:35:02 +00:00
982f648928 v4.6.1 2026-03-16 22:46:51 +00:00
3a2a060a85 fix(remoteingress-core): avoid spurious tunnel disconnect events and increase control channel capacity 2026-03-16 22:46:51 +00:00
e0c469147e v4.6.0 2026-03-16 19:37:06 +00:00
0fdcdf566e feat(remoteingress-core): add adaptive per-stream flow control based on active stream counts 2026-03-16 19:37:06 +00:00
a808d4c9de v4.5.12 2026-03-16 17:39:25 +00:00
f8a0171ef3 fix(remoteingress-core): improve tunnel liveness handling and enable TCP keepalive for accepted client sockets 2026-03-16 17:39:25 +00:00
1d59a48648 v4.5.11 2026-03-16 13:55:02 +00:00
af2ec11a2d fix(repo): no changes to commit 2026-03-16 13:55:02 +00:00
b6e66a7fa6 v4.5.10 2026-03-16 13:48:35 +00:00
1391b39601 fix(remoteingress-core): guard zero-window reads to avoid false EOF handling on stalled streams 2026-03-16 13:48:35 +00:00
e813c2f044 v4.5.9 2026-03-16 11:29:38 +00:00
0b8c1f0b57 fix(remoteingress-core): delay stream close until downstream response draining finishes to prevent truncated transfers 2026-03-16 11:29:38 +00:00
a63dbf2502 v4.5.8 2026-03-16 10:51:59 +00:00
4b95a3c999 fix(remoteingress-core): ensure upstream writes cancel promptly and reliably deliver CLOSE_BACK frames 2026-03-16 10:51:59 +00:00
51ab32f6c3 v4.5.7 2026-03-16 09:44:31 +00:00
ed52520d50 fix(remoteingress-core): improve tunnel reconnect and frame write efficiency 2026-03-16 09:44:31 +00:00
a08011d2da v4.5.6 2026-03-16 09:36:03 +00:00
679b247c8a fix(remoteingress-core): disable Nagle's algorithm on edge, hub, and upstream TCP sockets to reduce control-frame latency 2026-03-16 09:36:03 +00:00
32f9845495 v4.5.5 2026-03-16 09:02:02 +00:00
c0e1daa0e4 fix(remoteingress-core): wait for hub-to-client draining before cleanup and reliably send close frames 2026-03-16 09:02:02 +00:00
fd511c8a5c v4.5.4 2026-03-15 21:06:44 +00:00
c490e35a8f fix(remoteingress-core): preserve stream close ordering and add flow-control stall timeouts 2026-03-15 21:06:44 +00:00
579e553da0 v4.5.3 2026-03-15 19:26:39 +00:00
a8ee0b33d7 fix(remoteingress-core): prioritize control frames over data in edge and hub tunnel writers 2026-03-15 19:26:39 +00:00
43e320a36d v4.5.2 2026-03-15 18:16:10 +00:00
6ac4b37532 fix(remoteingress-core): improve stream flow control retries and increase channel buffer capacity 2026-03-15 18:16:10 +00:00
f456b0ba4f v4.5.1 2026-03-15 17:52:45 +00:00
69530f73aa fix(protocol): increase per-stream flow control window and channel buffers to improve high-RTT throughput 2026-03-15 17:52:45 +00:00
207b4a5cec v4.5.0 2026-03-15 17:33:59 +00:00
761551596b feat(remoteingress-core): add per-stream flow control for edge and hub tunnel data transfer 2026-03-15 17:33:59 +00:00
cf2d32bfe7 v4.4.1 2026-03-15 17:01:27 +00:00
4e9041c6a7 fix(remoteingress-core): prevent stream data loss by applying backpressure and closing saturated channels 2026-03-15 17:01:27 +00:00
86d4e9889a v4.4.0 2026-03-03 11:47:50 +00:00
45a2811f3e feat(remoteingress): add heartbeat PING/PONG and liveness timeouts; implement fast-reconnect/backoff reset and JS crash-recovery auto-restart 2026-03-03 11:47:50 +00:00
d6a07c28a0 v4.3.0 2026-02-26 23:47:16 +00:00
56a14aa7c5 feat(hub): add optional TLS certificate/key support to hub start config and bridge 2026-02-26 23:47:16 +00:00
417f62e646 v4.2.0 2026-02-26 23:02:23 +00:00
bda82f32ca feat(core): expose edge peer address in hub events and migrate writers to channel-based, non-blocking framing with stream limits and timeouts 2026-02-26 23:02:23 +00:00
4b06cb1b24 v4.1.0 2026-02-26 17:39:40 +00:00
1aae4b8c8e feat(remoteingress-bin): use mimalloc as the global allocator to reduce memory overhead and improve allocation performance 2026-02-26 17:39:40 +00:00
3474e8c310 v4.0.1 2026-02-26 12:37:40 +00:00
3df20df2a1 fix(hub): cancel per-stream tokens on stream close and avoid duplicate StreamClosed events; bump @types/node devDependency to ^25.3.0 2026-02-26 12:37:39 +00:00
929eec9825 v4.0.0 2026-02-19 08:45:32 +00:00
4e511b3350 BREAKING CHANGE(remoteingress-core): add cancellation tokens and cooperative shutdown; switch event channels to bounded mpsc and improve cleanup 2026-02-19 08:45:32 +00:00
a3af2487b7 v3.3.0 2026-02-18 18:41:25 +00:00
51de25d767 feat(readme): document dynamic port assignment and runtime port updates; clarify TLS multiplexing, frame format, and handshake sequence 2026-02-18 18:41:25 +00:00
7b8c4e1af5 v3.2.1 2026-02-18 18:35:53 +00:00
0459cd2af6 fix(tests): add comprehensive unit and async tests across Rust crates and TypeScript runtime 2026-02-18 18:35:53 +00:00
6fdc9ea918 v3.2.0 2026-02-18 18:20:53 +00:00
d869589663 feat(remoteingress (edge/hub/protocol)): add dynamic port configuration: handshake, FRAME_CONFIG frames, and hot-reloadable listeners 2026-02-18 18:20:53 +00:00
072362a8e6 v3.1.1 2026-02-18 06:01:33 +00:00
b628a5f964 fix(readme): update README: add issue reporting/security section, document connection tokens and token utilities, clarify architecture/API and improve examples/formatting 2026-02-18 06:01:33 +00:00
19e8003c77 v3.1.0 2026-02-17 19:36:40 +00:00
93592bf909 feat(edge): support connection tokens when starting an edge and add token encode/decode utilities 2026-02-17 19:36:40 +00:00
73fc4ea28e v3.0.4 2026-02-17 10:04:54 +00:00
5321e5f0e0 fix(build): bump dev dependencies, update build script, and refresh README docs 2026-02-17 10:04:54 +00:00
23 changed files with 4071 additions and 472 deletions

View File

@@ -1,5 +1,268 @@
# Changelog
## 2026-03-17 - 4.8.4 - fix(remoteingress-core)
prevent stream stalls by guaranteeing flow-control updates and avoiding bounded per-stream channel overflows
- Replace bounded per-stream data channels with unbounded channels on edge and hub, relying on existing WINDOW_UPDATE flow control to limit bytes in flight
- Use awaited sends for FRAME_WINDOW_UPDATE and FRAME_WINDOW_UPDATE_BACK so updates are not dropped and streams do not deadlock under backpressure
- Clean up stream state when channel receivers have already exited instead of closing active streams because a bounded queue filled
## 2026-03-17 - 4.8.3 - fix(protocol,edge)
optimize tunnel frame handling and zero-copy uploads in edge I/O
- extract hub frame processing into a shared edge handler to remove duplicated tunnel logic
- add zero-copy frame header encoding and read payloads directly into framed buffers for client-to-hub uploads
- refactor TunnelIo read/write state to avoid unsafe queue access and reduce buffer churn with incremental parsing
## 2026-03-17 - 4.8.2 - fix(rust-edge)
refactor tunnel I/O to preserve TLS state and prioritize control frames
- replace split TLS handling with a single-owner TunnelIo to avoid handshake and buffered read corruption
- prioritize control frames over data frames to prevent WINDOW_UPDATE starvation and flow-control deadlocks
- improve tunnel reliability with incremental frame parsing, liveness/error events, and corrupt frame header logging
## 2026-03-17 - 4.8.1 - fix(remoteingress-core)
remove tunnel writer timeouts from edge and hub buffered writes
- Drops the 30 second timeout wrapper around writer.write_all and writer.flush in both edge and hub tunnel writers.
- Updates error logging to report write failures without referring to stalled writes.
## 2026-03-17 - 4.8.0 - feat(events)
include disconnect reasons in edge and hub management events
- Add reason fields to tunnelDisconnected and edgeDisconnected events emitted from the Rust core and binary bridge
- Propagate specific disconnect causes such as EOF, liveness timeout, writer failure, handshake failure, and hub cancellation
- Update TypeScript edge and hub classes to log and forward disconnect reason data
- Extend serialization tests to cover the new reason fields
## 2026-03-17 - 4.7.2 - fix(remoteingress-core)
add tunnel write timeouts and scale initial stream windows by active stream count
- Wrap tunnel frame writes and flushes in a 30-second timeout on both edge and hub to detect stalled writers and trigger faster reconnect or cleanup.
- Compute each stream's initial send window from the current active stream count instead of using a fixed window to keep total in-flight data within the 32MB budget.
## 2026-03-17 - 4.7.1 - fix(remoteingress-core)
improve tunnel failure detection and reconnect handling
- Enable TCP keepalive on edge and hub connections to detect silent network failures sooner
- Trigger immediate reconnect or disconnect when tunnel writer tasks fail instead of waiting for liveness timeouts
- Prevent active stream counter underflow during concurrent connection cleanup
## 2026-03-16 - 4.7.0 - feat(edge,protocol,test)
add configurable edge bind address and expand flow-control test coverage
- adds an optional bindAddress configuration for edge TCP listeners, defaulting to 0.0.0.0 when not provided
- passes bindAddress through the TypeScript edge client and Rust edge runtime so local test setups can bind to localhost
- adds protocol unit tests for adaptive stream window sizing and window update frame encoding/decoding
- introduces end-to-end flow-control tests and updates the test script to build before running tests
## 2026-03-16 - 4.6.1 - fix(remoteingress-core)
avoid spurious tunnel disconnect events and increase control channel capacity
- Emit TunnelDisconnected only after an established connection is actually lost, preventing false disconnect events during failed reconnect attempts.
- Increase edge and hub control-channel buffer sizes from 64 to 256 to better prioritize control frames under load.
## 2026-03-16 - 4.6.0 - feat(remoteingress-core)
add adaptive per-stream flow control based on active stream counts
- Track active stream counts on edge and hub connections to size per-stream flow control windows dynamically.
- Cap WINDOW_UPDATE increments and read sizes to the adaptive window so bandwidth is shared more evenly across concurrent streams.
- Apply the adaptive logic to both upload and download paths on edge and hub stream handlers.
## 2026-03-16 - 4.5.12 - fix(remoteingress-core)
improve tunnel liveness handling and enable TCP keepalive for accepted client sockets
- Avoid disconnecting edges when PING or PONG frames cannot be queued because the control channel is temporarily full.
- Enable TCP_NODELAY and TCP keepalive on accepted client connections to help detect stale or dropped clients.
## 2026-03-16 - 4.5.11 - fix(repo)
no changes to commit
## 2026-03-16 - 4.5.10 - fix(remoteingress-core)
guard zero-window reads to avoid false EOF handling on stalled streams
- Prevent upload and download loops from calling read on an empty buffer when flow-control window remains at 0 after stall timeout
- Log a warning and close the affected stream instead of misinterpreting Ok(0) as end-of-file
## 2026-03-16 - 4.5.9 - fix(remoteingress-core)
delay stream close until downstream response draining finishes to prevent truncated transfers
- Waits for the hub-to-client download task to finish before sending the stream CLOSE frame
- Prevents upstream reads from being cancelled mid-response during asymmetric transfers such as git fetch
- Retains the existing timeout so stalled downloads still clean up safely
## 2026-03-16 - 4.5.8 - fix(remoteingress-core)
ensure upstream writes cancel promptly and reliably deliver CLOSE_BACK frames
- listen for stream cancellation while waiting on upstream write timeouts so FRAME_CLOSE does not block for up to 60 seconds
- replace try_send with send().await when emitting CLOSE_BACK frames to avoid silently dropping close notifications when the data channel is full
## 2026-03-16 - 4.5.7 - fix(remoteingress-core)
improve tunnel reconnect and frame write efficiency
- Reuse the TLS connector across edge reconnections to preserve session resumption state and reduce reconnect latency.
- Buffer hub and edge frame writes to coalesce small control and data frames into fewer TLS records and syscalls while still flushing each frame promptly.
## 2026-03-16 - 4.5.6 - fix(remoteingress-core)
disable Nagle's algorithm on edge, hub, and upstream TCP sockets to reduce control-frame latency
- Enable TCP_NODELAY on the edge connection to the hub for faster PING/PONG and WINDOW_UPDATE delivery
- Apply TCP_NODELAY on accepted hub streams before TLS handling
- Enable TCP_NODELAY on SmartProxy upstream connections before sending the PROXY header
## 2026-03-16 - 4.5.5 - fix(remoteingress-core)
wait for hub-to-client draining before cleanup and reliably send close frames
- switch CLOSE frame delivery on the data channel from try_send to send().await to avoid dropping it when the channel is full
- delay stream cleanup until the hub-to-client task finishes or times out so large downstream responses continue after upload EOF
- add a bounded 5-minute wait for download draining to prevent premature termination of asymmetric transfers such as git fetch
## 2026-03-15 - 4.5.4 - fix(remoteingress-core)
preserve stream close ordering and add flow-control stall timeouts
- Send CLOSE and CLOSE_BACK frames on the data channel so they arrive after the final stream data frames.
- Log and abort stalled upload and download paths when flow-control windows stay empty for 120 seconds.
- Apply a 60-second timeout when writing buffered stream data to the upstream connection to prevent hung streams.
## 2026-03-15 - 4.5.3 - fix(remoteingress-core)
prioritize control frames over data in edge and hub tunnel writers
- Split tunnel/frame writers into separate control and data channels in edge and hub
- Use biased select loops so PING, PONG, WINDOW_UPDATE, OPEN, and CLOSE frames are sent before data frames
- Route stream data through dedicated data channels while keeping OPEN, CLOSE, and flow-control updates on control channels to prevent keepalive starvation under load
## 2026-03-15 - 4.5.2 - fix(remoteingress-core)
improve stream flow control retries and increase channel buffer capacity
- increase per-stream mpsc channel capacity from 128 to 256 on both edge and hub paths
- only reset accumulated window update bytes after a successful try_send to avoid dropping flow-control credits when the update channel is busy
## 2026-03-15 - 4.5.1 - fix(protocol)
increase per-stream flow control window and channel buffers to improve high-RTT throughput
- raise the initial stream window from 256 KB to 4 MB to allow more in-flight data per stream
- increase edge and hub mpsc channel capacities from 16 to 128 to better absorb throughput under flow control
## 2026-03-15 - 4.5.0 - feat(remoteingress-core)
add per-stream flow control for edge and hub tunnel data transfer
- introduce WINDOW_UPDATE frame types and protocol helpers for per-stream flow control
- track per-stream send windows on both edge and hub to limit reads based on available capacity
- send window updates after downstream writes to reduce channel pressure during large transfers
## 2026-03-15 - 4.4.1 - fix(remoteingress-core)
prevent stream data loss by applying backpressure and closing saturated channels
- replace non-blocking frame writes with awaited sends in per-stream tasks so large transfers respect backpressure instead of dropping data
- close and remove streams when back-channel or data channels fill up to avoid TCP stream corruption from silently dropped frames
## 2026-03-03 - 4.4.0 - feat(remoteingress)
add heartbeat PING/PONG and liveness timeouts; implement fast-reconnect/backoff reset and JS crash-recovery auto-restart
- protocol: add FRAME_PING and FRAME_PONG and unit tests for ping/pong frames
- edge (Rust): reset backoff after successful connection, respond to PING with PONG, track liveness via deadline and reconnect on timeout, use Duration/Instant helpers
- hub (Rust): send periodic PING to edges, handle PONGs, enforce liveness timeout and disconnect inactive edges, use tokio interval and time utilities
- ts: RemoteIngressEdge and RemoteIngressHub: add crash-recovery auto-restart with exponential backoff and max attempts, save/restore config and allowed edges, register/remove exit handlers, ensure stop() marks stopping and cleans up listeners
- minor API/typing: introduce TAllowedEdge alias and persist allowed edges for restart recovery
## 2026-02-26 - 4.3.0 - feat(hub)
add optional TLS certificate/key support to hub start config and bridge
- TypeScript: add tls.certPem and tls.keyPem to IHubConfig and include tlsCertPem/tlsKeyPem in startHub bridge command when both are provided
- TypeScript: extend startHub params with tlsCertPem and tlsKeyPem and conditionally send them
- Rust: change HubConfig serde attributes for tls_cert_pem and tls_key_pem from skip to default so absent PEM fields deserialize as None
- Enables optional provisioning of TLS certificate and key to the hub when provided from the JS side
## 2026-02-26 - 4.2.0 - feat(core)
expose edge peer address in hub events and migrate writers to channel-based, non-blocking framing with stream limits and timeouts
- Add peerAddr to ConnectedEdgeStatus and HubEvent::EdgeConnected and surface it to the TS frontend event (management:edgeConnected).
- Replace Arc<Mutex<WriteHalf>> writers with dedicated mpsc channel writer tasks in both hub and edge crates to serialize writes off the main tasks.
- Use non-blocking try_send for data frames to avoid head-of-line blocking and drop frames with warnings when channels are full.
- Introduce MAX_STREAMS_PER_EDGE semaphore to limit concurrent streams per edge and reject excess opens with a CLOSE_BACK frame.
- Add a 10s timeout when connecting to SmartProxy to avoid hanging connections.
- Ensure writer tasks are aborted on shutdown/cleanup and propagate cancellation tokens appropriately.
## 2026-02-26 - 4.1.0 - feat(remoteingress-bin)
use mimalloc as the global allocator to reduce memory overhead and improve allocation performance
- added mimalloc = "0.1" dependency to rust/crates/remoteingress-bin/Cargo.toml
- registered mimalloc as the #[global_allocator] in rust/crates/remoteingress-bin/src/main.rs
- updated Cargo.lock with libmimalloc-sys and mimalloc package entries
## 2026-02-26 - 4.0.1 - fix(hub)
cancel per-stream tokens on stream close and avoid duplicate StreamClosed events; bump @types/node devDependency to ^25.3.0
- Add CancellationToken to per-stream entries so each stream can be cancelled independently.
- Ensure StreamClosed event is only emitted when a stream was actually present (guards against duplicate events).
- Cancel the stream-specific token on FRAME_CLOSE to stop associated tasks and free resources.
- DevDependency bump: @types/node updated from ^25.2.3 to ^25.3.0.
## 2026-02-19 - 4.0.0 - BREAKING CHANGE(remoteingress-core)
add cancellation tokens and cooperative shutdown; switch event channels to bounded mpsc and improve cleanup
- Introduce tokio-util::sync::CancellationToken for hub/edge and per-connection/stream cancellation, enabling cooperative shutdown of spawned tasks.
- Replace unbounded mpsc channels with bounded mpsc::channel(1024) and switch from UnboundedSender/Receiver to Sender/Receiver; use try_send where non-blocking sends are appropriate.
- Wire cancellation tokens through edge and hub codepaths: child tokens per connection, per-port, per-stream; cancel tokens in stop() and Drop impls to ensure deterministic task termination and cleanup.
- Reset stream id counters and clear listener state on reconnect; improved error handling around accept/read loops using tokio::select! and cancellation checks.
- Update Cargo.toml and Cargo.lock to add tokio-util (and related futures entries) as dependencies.
- BREAKING: public API/types changed — take_event_rx return types and event_tx/event_rx fields now use bounded mpsc::Sender/mpsc::Receiver instead of the unbounded variants; callers must adapt to the new types and bounded behavior.
## 2026-02-18 - 3.3.0 - feat(readme)
document dynamic port assignment and runtime port updates; clarify TLS multiplexing, frame format, and handshake sequence
- Adds documentation for dynamic port configuration: hub-assigned listen ports, hot-reloadable via FRAME_CONFIG frames
- Introduces new FRAME type CONFIG (0x06) and describes payload as JSON; notes immediate push of port changes to connected edges
- Clarifies that the tunnel is a single encrypted TLS multiplexed connection to the hub (preserves PROXY v1 behavior)
- Specifies frame integer fields are big-endian and that stream IDs are 32-bit unsigned integers
- Adds new events: portsAssigned and portsUpdated, and updates examples showing updateAllowedEdges usage and live port changes
## 2026-02-18 - 3.2.1 - fix(tests)
add comprehensive unit and async tests across Rust crates and TypeScript runtime
- Added IPC serialization tests in remoteingress-bin (IPC request/response/event)
- Added serde and async tests for Edge and Handshake configs and EdgeEvent/EdgeStatus in remoteingress-core (edge.rs)
- Added extensive Hub tests: constant_time_eq, PROXY header port parsing, serde/camelCase checks, Hub events and async TunnelHub behavior (hub.rs)
- Added STUN parser unit tests including XOR_MAPPED_ADDRESS, MAPPED_ADDRESS fallback, truncated attribute handling and other edge cases (stun.rs)
- Added protocol frame encoding and FrameReader tests covering all frame types, payload limits and EOF conditions (remoteingress-protocol)
- Added TypeScript Node tests for token encode/decode edge cases and RemoteIngressHub/RemoteIngressEdge class basics (test/*.node.ts)
## 2026-02-18 - 3.2.0 - feat(remoteingress (edge/hub/protocol))
add dynamic port configuration: handshake, FRAME_CONFIG frames, and hot-reloadable listeners
- Introduce a JSON handshake from hub -> edge with initial listen ports and stun interval so edges can configure listeners at connect time.
- Add FRAME_CONFIG (0x06) to the protocol and implement runtime config updates pushed from hub to connected edges.
- Edge now applies initial ports and supports hot-reloading: spawn/abort listeners when ports change, and emit PortsAssigned / PortsUpdated events.
- Hub now stores allowed edge metadata (listen_ports, stun_interval_secs), sends handshake responses on auth, and forwards config updates to connected edges.
- TypeScript bridge/client updated to emit new port events and periodically log status; updateAllowedEdges API accepts listenPorts and stunIntervalSecs.
- Stun interval handling moved to use handshake-provided/stored value instead of config.listen_ports being static.
## 2026-02-18 - 3.1.1 - fix(readme)
update README: add issue reporting/security section, document connection tokens and token utilities, clarify architecture/API and improve examples/formatting
- Added an 'Issue Reporting and Security' section linking to community.foss.global for bug/security reports and contributor onboarding.
- Documented connection tokens: encodeConnectionToken/decodeConnectionToken utilities, token format (base64url), and examples for hub and edge provisioning.
- Clarified Hub/Edge usage and examples: condensed event handlers, added token-based start() example, and provided explicit config alternative.
- Improved README formatting: added emojis, rephrased architecture descriptions, fixed wording and license path capitalization, and expanded example scenarios and interfaces.
## 2026-02-17 - 3.1.0 - feat(edge)
support connection tokens when starting an edge and add token encode/decode utilities
- Add classes.token.ts with encodeConnectionToken/decodeConnectionToken using a base64url compact JSON format
- Export token utilities from ts/index.ts
- RemoteIngressEdge.start now accepts a { token } option and decodes it to an IEdgeConfig before starting
- Add tests covering export availability, encode→decode roundtrip, malformed token, and missing fields
- Non-breaking change — recommend a minor version bump
## 2026-02-17 - 3.0.4 - fix(build)
bump dev dependencies, update build script, and refresh README docs
- Bumped devDependencies: @git.zone/tsbuild ^2.1.25 → ^4.1.2, @git.zone/tsbundle ^2.0.5 → ^2.8.3, @git.zone/tsrun ^1.2.46 → ^2.0.1, @git.zone/tstest ^1.0.44 → ^3.1.8, @push.rocks/tapbundle ^5.0.15 → ^6.0.3, @types/node ^20.8.7 → ^25.2.3
- Bumped runtime dependency: @push.rocks/qenv ^6.0.5 → ^6.1.3
- Changed build script: replaced "tsbuild --web --allowimplicitany" with "tsbuild tsfolders --allowimplicitany" (kept tsrust invocation)
- README updates: added RustBridge notes (localPaths must be full file paths), production binary naming conventions, rust core uses ring as rustls provider; removed emoji from example console output; clarified stunIntervalSecs is optional; renamed example status variable to edgeStatus; minor wire-protocol formatting and wording/legal text tweaks
## 2026-02-17 - 3.0.3 - fix(rust,ts)
initialize rustls ring CryptoProvider at startup; add rustls dependency and features; make native binary lookup platform-aware

View File

@@ -1,6 +1,6 @@
{
"name": "@serve.zone/remoteingress",
"version": "3.0.3",
"version": "4.8.4",
"private": false,
"description": "Edge ingress tunnel for DcRouter - accepts incoming TCP connections at network edge and tunnels them to DcRouter SmartProxy preserving client IP via PROXY protocol v1.",
"main": "dist_ts/index.js",
@@ -9,21 +9,21 @@
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/ --web)",
"build": "(tsbuild --web --allowimplicitany && tsrust)",
"test": "(pnpm run build && tstest test/ --verbose --logfile --timeout 60)",
"build": "(tsbuild tsfolders --allowimplicitany && tsrust)",
"buildDocs": "(tsdoc)"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.1.25",
"@git.zone/tsbundle": "^2.0.5",
"@git.zone/tsrun": "^1.2.46",
"@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsbundle": "^2.8.3",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tsrust": "^1.3.0",
"@git.zone/tstest": "^1.0.44",
"@push.rocks/tapbundle": "^5.0.15",
"@types/node": "^20.8.7"
"@git.zone/tstest": "^3.1.8",
"@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^25.3.0"
},
"dependencies": {
"@push.rocks/qenv": "^6.0.5",
"@push.rocks/qenv": "^6.1.3",
"@push.rocks/smartrust": "^1.2.1"
},
"repository": {

174
pnpm-lock.yaml generated
View File

@@ -9,33 +9,33 @@ importers:
.:
dependencies:
'@push.rocks/qenv':
specifier: ^6.0.5
specifier: ^6.1.3
version: 6.1.3
'@push.rocks/smartrust':
specifier: ^1.2.1
version: 1.2.1
devDependencies:
'@git.zone/tsbuild':
specifier: ^2.1.25
version: 2.7.3
specifier: ^4.1.2
version: 4.1.2
'@git.zone/tsbundle':
specifier: ^2.0.5
specifier: ^2.8.3
version: 2.8.3
'@git.zone/tsrun':
specifier: ^1.2.46
version: 1.6.2
specifier: ^2.0.1
version: 2.0.1
'@git.zone/tsrust':
specifier: ^1.3.0
version: 1.3.0
'@git.zone/tstest':
specifier: ^1.0.44
version: 1.11.5(socks@2.8.7)(typescript@5.9.3)
specifier: ^3.1.8
version: 3.1.8(socks@2.8.7)(typescript@5.9.3)
'@push.rocks/tapbundle':
specifier: ^5.0.15
version: 5.6.3(socks@2.8.7)
specifier: ^6.0.3
version: 6.0.3(socks@2.8.7)
'@types/node':
specifier: ^20.8.7
version: 20.19.33
specifier: ^25.3.0
version: 25.3.0
packages:
@@ -427,8 +427,8 @@ packages:
'@esm-bundle/chai@4.3.4-fix.0':
resolution: {integrity: sha512-26SKdM4uvDWlY8/OOOxSB1AqQWeBosCX3wRYUZO7enTAj03CtVxIiCimYVG2WpULcyV51qapK4qTovwkUr5Mlw==}
'@git.zone/tsbuild@2.7.3':
resolution: {integrity: sha512-GMM6VU6TcVvYINfV6b1ZVGZXYhdtriYyAHifvrn8IdRar6thIN3ig3N2J/S1kmX2KLrBbx0JyF3tNChHdNR+wA==}
'@git.zone/tsbuild@4.1.2':
resolution: {integrity: sha512-S518ulKveO76pS6jrAELrnFaCw5nDAIZD9j6QzVmLYDiZuJmlRwPK3/2E8ugQ+b7ffpkwJ9MT685ooEGDcWQ4Q==}
hasBin: true
'@git.zone/tsbundle@2.8.3':
@@ -439,16 +439,16 @@ packages:
resolution: {integrity: sha512-dkgaDBTzZJ53lAV72r7OW/W7l/KqpkncFuPojr11JO35OKAbjjDhZbAwPv4oGX9NplyXrhC5VJRPNX/orqNTHA==}
hasBin: true
'@git.zone/tsrun@1.6.2':
resolution: {integrity: sha512-SOHbQqBg3/769/jPQcdpPCmugdEtIJINiG0O6aWx+su91GvGhheha5dAhccsCutJYErr+aJcBqBYuUYfhOfkFQ==}
'@git.zone/tsrun@2.0.1':
resolution: {integrity: sha512-NEcnsjvlC1o3Z6SS3VhKCf6Ev+Sh4EAinmggslrIR/ppMrvjDbXNFXoyr3PB+GLeSAR0JRZ1fGvVYjpEzjBdIg==}
hasBin: true
'@git.zone/tsrust@1.3.0':
resolution: {integrity: sha512-dvmTAiM04Pkd7J1Gail3fu7aasmILQhC5vKL71/g6HYhpvl16/c+Dj3We5G4HsFr0jvAr+Xu570ZGEuZrtRcCg==}
hasBin: true
'@git.zone/tstest@1.11.5':
resolution: {integrity: sha512-7YHFNGMjUd3WOFXi0DlUieQcdxzwYqxL7n2XDE7SOUd8XpMxVsGsY2SuwBKXlbT10By/H3thQTsy+Hjy9ahGWA==}
'@git.zone/tstest@3.1.8':
resolution: {integrity: sha512-nmiLGeOkKMkLDyIk5BUBLx5ExskFbKHKlPdrWCARPVFkU4cAAiuIyJWVfLwISoS0TO/zSInLqArPwIc76yvaNw==}
hasBin: true
'@hapi/bourne@3.0.0':
@@ -693,6 +693,9 @@ packages:
'@push.rocks/smartbucket@3.3.10':
resolution: {integrity: sha512-0H2MioALspC8Aj0Q1FPCs2w4k2u9oJg7Q5yM8+1TZo7aRfrdxgM5HQ7z3apUaqC3ZEDewW6vSlttjHFHhMEC3A==}
'@push.rocks/smartbucket@4.4.1':
resolution: {integrity: sha512-68GFLgJKW+LXvuN+yuV8O/FozGMecraoT+PkI5whdRPFe7N3u2iYIHWAUjvQvVU4ygpdJv0kih2JDf5k3PYycw==}
'@push.rocks/smartbuffer@3.0.5':
resolution: {integrity: sha512-pWYF08Mn8s/KF/9nHRk7pZPzuMjmYVQay2c5gGexdayxn1W4eCSYYhWH73vR2JBfGeGq/izbRNuUuEaIEeTIKA==}
@@ -733,9 +736,6 @@ packages:
'@push.rocks/smartexit@1.1.0':
resolution: {integrity: sha512-GD8VLIbxQuwvhPXwK4eH162XAYSj+M3wGKWGNO3i1iY4bj8P3BARcgsWx6/ntN3aCo5ygWtrevrfD5iecYY2Ng==}
'@push.rocks/smartexpect@1.6.1':
resolution: {integrity: sha512-NFQXEPkGiMNxyvFwKyzDWe3ADYdf8KNvIcV7TGNZZT3uPQtk65te4Q+a1cWErjP/61yE9XdYiQA66QQp+TV9IQ==}
'@push.rocks/smartexpect@2.5.0':
resolution: {integrity: sha512-yoyuCoQ3tTiAriuvF+/09fNbVfFnacudL2SwHSzPhX/ugaE7VTSWXQ9A34eKOWvil0MPyDcOY36fVZDxvrPd8A==}
@@ -850,6 +850,9 @@ packages:
'@push.rocks/smarts3@2.2.7':
resolution: {integrity: sha512-9ZXGMlmUL2Wd+YJO0xOB8KyqPf4V++fWJvTq4s76bnqEuaCr9OLfq6czhban+i4cD3ZdIjehfuHqctzjuLw8Jw==}
'@push.rocks/smarts3@3.0.3':
resolution: {integrity: sha512-Y9nXMwurthJ9Z7yi0RwjhPFUC58aY8Mhia8kFo6Xj1tBM4LE8Oxg/ydejF7otHqQGr3QyqV5C4YrDEG17rUuzg==}
'@push.rocks/smartshell@3.3.0':
resolution: {integrity: sha512-m0w618H6YBs+vXGz1CgS4nPi5CUAnqRtckcS9/koGwfcIx1IpjqmiP47BoCTbdgcv0IPUxQVBG1IXTHPuZ8Z5g==}
@@ -892,8 +895,8 @@ packages:
'@push.rocks/smartyaml@3.0.4':
resolution: {integrity: sha512-1JRt+hnoc2zHw3AW+vXKlCdSVwqOmY/01fu+2HBviS0UDjoZCa+/rp6E3GaQb5lEEafKi8ENbffAfjXXp3N2xQ==}
'@push.rocks/tapbundle@5.6.3':
resolution: {integrity: sha512-hFzsf59rg1K70i45llj7PCyyCZp7JW19XRR+Q1gge1T0pBN8Wi53aYqP/2qtxdMiNVe2s3ESp6VJZv3sLOMYPQ==}
'@push.rocks/tapbundle@6.0.3':
resolution: {integrity: sha512-SuP14V6TPdtd1y1CYTvwTKJdpHa7EzY55NfaaEMxW4oRKvHgJiOiPEiR/IrtL9tSiDMSfrx12waTMgZheYaBug==}
'@push.rocks/taskbuffer@3.5.0':
resolution: {integrity: sha512-Y9WwIEIyp6oVFdj06j84tfrZIvjhbMb3DF52rYxlTeYLk3W7RPhSg1bGPCbtkXWeKdBrSe37V90BkOG7Qq8Pqg==}
@@ -1495,12 +1498,12 @@ packages:
'@types/node-forge@1.3.14':
resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==}
'@types/node@20.19.33':
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
'@types/node@22.19.11':
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
'@types/node@25.3.0':
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
'@types/parse5@6.0.3':
resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==}
@@ -3983,6 +3986,9 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -4916,13 +4922,14 @@ snapshots:
dependencies:
'@types/chai': 4.3.20
'@git.zone/tsbuild@2.7.3':
'@git.zone/tsbuild@4.1.2':
dependencies:
'@git.zone/tspublish': 1.11.0
'@push.rocks/early': 4.0.4
'@push.rocks/smartcli': 4.0.20
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartfile': 13.1.2
'@push.rocks/smartfs': 1.3.1
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
@@ -4984,9 +4991,9 @@ snapshots:
- supports-color
- vue
'@git.zone/tsrun@1.6.2':
'@git.zone/tsrun@2.0.1':
dependencies:
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartfile': 13.1.2
'@push.rocks/smartshell': 3.3.0
tsx: 4.21.0
@@ -5005,26 +5012,28 @@ snapshots:
- supports-color
- vue
'@git.zone/tstest@1.11.5(socks@2.8.7)(typescript@5.9.3)':
'@git.zone/tstest@3.1.8(socks@2.8.7)(typescript@5.9.3)':
dependencies:
'@api.global/typedserver': 3.0.80
'@git.zone/tsbundle': 2.8.3
'@git.zone/tsrun': 1.6.2
'@git.zone/tsrun': 2.0.1
'@push.rocks/consolecolor': 2.0.3
'@push.rocks/qenv': 6.1.3
'@push.rocks/smartbrowser': 2.0.8(typescript@5.9.3)
'@push.rocks/smartchok': 1.2.0
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartenv': 5.0.13
'@push.rocks/smartenv': 6.0.0
'@push.rocks/smartexpect': 2.5.0
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartlog': 3.1.11
'@push.rocks/smartmongo': 2.2.0(socks@2.8.7)
'@push.rocks/smartpath': 5.1.0
'@push.rocks/smartnetwork': 4.4.0
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 2.1.0
'@push.rocks/smarts3': 2.2.7
'@push.rocks/smartrequest': 5.0.1
'@push.rocks/smarts3': 3.0.3
'@push.rocks/smartshell': 3.3.0
'@push.rocks/smarttime': 4.2.3
'@types/ws': 8.18.1
@@ -5169,7 +5178,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -5549,6 +5558,21 @@ snapshots:
transitivePeerDependencies:
- aws-crt
'@push.rocks/smartbucket@4.4.1':
dependencies:
'@aws-sdk/client-s3': 3.990.0
'@push.rocks/smartmime': 2.0.4
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smartstream': 3.2.5
'@push.rocks/smartstring': 4.1.0
'@push.rocks/smartunique': 3.0.9
'@tsclass/tsclass': 9.3.0
minimatch: 10.2.0
transitivePeerDependencies:
- aws-crt
'@push.rocks/smartbuffer@3.0.5':
dependencies:
uint8array-extras: 1.5.0
@@ -5658,12 +5682,6 @@ snapshots:
'@push.rocks/smartpromise': 4.2.3
tree-kill: 1.2.2
'@push.rocks/smartexpect@1.6.1':
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartpromise': 4.2.3
fast-deep-equal: 3.1.3
'@push.rocks/smartexpect@2.5.0':
dependencies:
'@push.rocks/smartdelay': 3.0.5
@@ -5986,6 +6004,16 @@ snapshots:
- aws-crt
- supports-color
'@push.rocks/smarts3@3.0.3':
dependencies:
'@push.rocks/smartbucket': 4.4.1
'@push.rocks/smartfs': 1.3.1
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartxml': 2.0.0
'@tsclass/tsclass': 9.3.0
transitivePeerDependencies:
- aws-crt
'@push.rocks/smartshell@3.3.0':
dependencies:
'@push.rocks/smartdelay': 3.0.5
@@ -6095,7 +6123,7 @@ snapshots:
dependencies:
yaml: 2.8.2
'@push.rocks/tapbundle@5.6.3(socks@2.8.7)':
'@push.rocks/tapbundle@6.0.3(socks@2.8.7)':
dependencies:
'@open-wc/testing': 4.0.0
'@push.rocks/consolecolor': 2.0.3
@@ -6103,7 +6131,7 @@ snapshots:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartenv': 5.0.13
'@push.rocks/smartexpect': 1.6.1
'@push.rocks/smartexpect': 2.5.0
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartmongo': 2.2.0(socks@2.8.7)
@@ -6708,14 +6736,14 @@ snapshots:
'@types/accepts@1.3.7':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/babel__code-frame@7.27.0': {}
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/buffer-json@2.0.3': {}
@@ -6732,17 +6760,17 @@ snapshots:
'@types/clean-css@4.2.11':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
source-map: 0.6.1
'@types/co-body@6.1.3':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/qs': 6.14.0
'@types/connect@3.4.38':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/content-disposition@0.5.9': {}
@@ -6753,11 +6781,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/express': 5.0.6
'@types/keygrip': 1.0.6
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/cors@2.8.19':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/debounce@1.2.4': {}
@@ -6769,7 +6797,7 @@ snapshots:
'@types/express-serve-static-core@5.1.1':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -6783,7 +6811,7 @@ snapshots:
'@types/fs-extra@11.0.4':
dependencies:
'@types/jsonfile': 6.1.4
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/hast@3.0.4':
dependencies:
@@ -6817,7 +6845,7 @@ snapshots:
'@types/jsonfile@6.1.4':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/keygrip@1.0.6': {}
@@ -6834,7 +6862,7 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/keygrip': 1.0.6
'@types/koa-compose': 3.2.9
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/mdast@4.0.4':
dependencies:
@@ -6848,20 +6876,20 @@ snapshots:
'@types/mute-stream@0.0.4':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/node-forge@1.3.14':
dependencies:
'@types/node': 20.19.33
'@types/node@20.19.33':
dependencies:
undici-types: 6.21.0
'@types/node': 25.3.0
'@types/node@22.19.11':
dependencies:
undici-types: 6.21.0
'@types/node@25.3.0':
dependencies:
undici-types: 7.18.2
'@types/parse5@6.0.3': {}
'@types/ping@0.4.4': {}
@@ -6876,18 +6904,18 @@ snapshots:
'@types/s3rver@3.7.4':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/semver@7.7.1': {}
'@types/send@1.2.1':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/serve-static@2.2.0':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/sinon-chai@3.2.12':
dependencies:
@@ -6906,11 +6934,11 @@ snapshots:
'@types/tar-stream@3.1.4':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/through2@2.0.41':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/triple-beam@1.3.5': {}
@@ -6938,11 +6966,11 @@ snapshots:
'@types/ws@7.4.7':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/ws@8.18.1':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
'@types/yargs-parser@21.0.3': {}
@@ -6952,7 +6980,7 @@ snapshots:
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 20.19.33
'@types/node': 25.3.0
optional: true
'@ungap/structured-clone@1.3.0': {}
@@ -7557,7 +7585,7 @@ snapshots:
engine.io@6.6.4:
dependencies:
'@types/cors': 2.8.19
'@types/node': 20.19.33
'@types/node': 25.3.0
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.7.2
@@ -8271,7 +8299,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 20.19.33
'@types/node': 25.3.0
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -9779,6 +9807,8 @@ snapshots:
undici-types@6.21.0: {}
undici-types@7.18.2: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3

View File

@@ -7,3 +7,6 @@
* STUN-based public IP discovery at the edge (Cloudflare STUN server)
* Cross-compiled Rust binary for linux/amd64 and linux/arm64
* Old `ConnectorPublic`/`ConnectorPrivate` classes no longer exist (removed in v2.0.0/v3.0.0)
* `localPaths` in RustBridge config must be full file paths (not directories) — smartrust's `RustBinaryLocator` checks `isExecutable()` on each entry directly
* Production binaries are named with platform suffixes: `remoteingress-bin_linux_amd64`, `remoteingress-bin_linux_arm64`
* Rust core uses `ring` as the rustls CryptoProvider (explicitly installed in main.rs, aws-lc-rs disabled via default-features=false)

248
readme.md
View File

@@ -1,6 +1,6 @@
# @serve.zone/remoteingress
Edge ingress tunnel for DcRouter — accepts incoming TCP connections at the network edge and tunnels them to a DcRouter SmartProxy instance, preserving the original client IP via PROXY protocol v1.
Edge ingress tunnel for DcRouter — accepts incoming TCP connections at the network edge and tunnels them over a single encrypted TLS connection to a DcRouter SmartProxy instance, preserving the original client IP via PROXY protocol v1.
## Issue Reporting and Security
@@ -14,16 +14,16 @@ pnpm install @serve.zone/remoteingress
## 🏗️ Architecture
`@serve.zone/remoteingress` uses a **HubEdge** topology with a high-performance Rust core and a TypeScript API surface:
`@serve.zone/remoteingress` uses a **Hub/Edge** topology with a high-performance Rust core and a TypeScript API surface:
```
┌─────────────────────┐ TLS Tunnel ┌─────────────────────┐
│ Network Edge │ ◄══════════════════════════► │ Private Cluster │
│ │ (multiplexed frames + │ │
│ RemoteIngressEdge │ shared-secret auth) │ RemoteIngressHub │
Listens on :80,:443│ │ Forwards to │
Accepts client TCP │ │ SmartProxy on │
│ │ local ports │
Accepts client TCP │ │ Forwards to │
connections on │ │ SmartProxy on │
hub-assigned ports │ │ local ports │
└─────────────────────┘ └─────────────────────┘
▲ │
│ TCP from end users ▼
@@ -32,26 +32,28 @@ pnpm install @serve.zone/remoteingress
| Component | Role |
|-----------|------|
| **RemoteIngressEdge** | Deployed at the network edge (e.g. a VPS or cloud instance). Listens on public ports, accepts raw TCP connections, and multiplexes them over a single TLS tunnel to the hub. |
| **RemoteIngressHub** | Deployed alongside DcRouter/SmartProxy in a private cluster. Accepts edge connections, demuxes streams, and forwards each to SmartProxy with a PROXY protocol v1 header so the real client IP is preserved. |
| **RemoteIngressEdge** | Deployed at the network edge (e.g. a VPS or cloud instance). Listens on ports assigned by the hub, accepts raw TCP connections, and multiplexes them over a single TLS tunnel to the hub. Ports are hot-reloadable — the hub can change them at runtime. |
| **RemoteIngressHub** | Deployed alongside DcRouter/SmartProxy in a private cluster. Accepts edge connections, demuxes streams, and forwards each to SmartProxy with a PROXY protocol v1 header so the real client IP is preserved. Controls which ports each edge listens on. |
| **Rust Binary** (`remoteingress-bin`) | The performance-critical networking core. Managed via `@push.rocks/smartrust` RustBridge IPC — you never interact with it directly. Cross-compiled for `linux/amd64` and `linux/arm64`. |
### 🔑 Key Features
### Key Features
- 🔒 **TLS-encrypted tunnel** between edge and hub (auto-generated self-signed cert or bring your own)
- 🔀 **Multiplexed streams** — thousands of client connections flow over a single tunnel
- 🌐 **PROXY protocol v1** — SmartProxy sees the real client IP, not the tunnel IP
- 🔐 **Shared-secret authentication** — edges must present valid credentials to connect
- 🔑 **Shared-secret authentication** — edges must present valid credentials to connect
- 🎫 **Connection tokens** — encode all connection details into a single opaque string
- 📡 **STUN-based public IP discovery** — the edge automatically discovers its public IP via Cloudflare STUN
- 🔄 **Auto-reconnect** with exponential backoff if the tunnel drops
- 📢 **Event-driven** — both Hub and Edge extend `EventEmitter` for real-time monitoring
- 🎛️ **Dynamic port configuration** — the hub assigns listen ports per edge and can hot-reload them at runtime via `FRAME_CONFIG` frames
- 📣 **Event-driven** — both Hub and Edge extend `EventEmitter` for real-time monitoring
-**Rust core** — all frame encoding, TLS, and TCP proxying happen in native code for maximum throughput
## Usage
## 🚀 Usage
Both classes are imported from the package and communicate with the Rust binary under the hood. All you need to do is configure and start them.
### Setting up the Hub (private cluster side)
### Setting Up the Hub (Private Cluster Side)
```typescript
import { RemoteIngressHub } from '@serve.zone/remoteingress';
@@ -60,28 +62,46 @@ const hub = new RemoteIngressHub();
// Listen for events
hub.on('edgeConnected', ({ edgeId }) => {
console.log(`Edge ${edgeId} connected`);
console.log(`Edge ${edgeId} connected`);
});
hub.on('edgeDisconnected', ({ edgeId }) => {
console.log(`Edge ${edgeId} disconnected`);
console.log(`Edge ${edgeId} disconnected`);
});
hub.on('streamOpened', ({ edgeId, streamId }) => {
console.log(`🔗 Stream ${streamId} opened from edge ${edgeId}`);
console.log(`Stream ${streamId} opened from edge ${edgeId}`);
});
hub.on('streamClosed', ({ edgeId, streamId }) => {
console.log(`🔗 Stream ${streamId} closed from edge ${edgeId}`);
console.log(`Stream ${streamId} closed from edge ${edgeId}`);
});
// Start the hub — it will listen for incoming edge TLS connections
await hub.start({
tunnelPort: 8443, // port edges connect to (default: 8443)
tunnelPort: 8443, // port edges connect to (default: 8443)
targetHost: '127.0.0.1', // SmartProxy host to forward streams to (default: 127.0.0.1)
});
// Register which edges are allowed to connect
// Register which edges are allowed to connect, including their listen ports
await hub.updateAllowedEdges([
{ id: 'edge-nyc-01', secret: 'supersecrettoken1' },
{ id: 'edge-fra-02', secret: 'supersecrettoken2' },
{
id: 'edge-nyc-01',
secret: 'supersecrettoken1',
listenPorts: [80, 443], // ports the edge should listen on
stunIntervalSecs: 300, // STUN discovery interval (default: 300)
},
{
id: 'edge-fra-02',
secret: 'supersecrettoken2',
listenPorts: [443, 8080],
},
]);
// Dynamically update ports for a connected edge — changes are pushed instantly
await hub.updateAllowedEdges([
{
id: 'edge-nyc-01',
secret: 'supersecrettoken1',
listenPorts: [80, 443, 8443], // added port 8443 — edge picks it up in real time
},
]);
// Check status at any time
@@ -99,37 +119,54 @@ console.log(status);
await hub.stop();
```
### Setting up the Edge (network edge side)
### Setting Up the Edge (Network Edge Side)
The edge can be configured in two ways: with an **opaque connection token** (recommended) or with explicit config fields.
#### Option A: Connection Token (Recommended)
A single token encodes all connection details — ideal for provisioning edges at scale:
```typescript
import { RemoteIngressEdge } from '@serve.zone/remoteingress';
const edge = new RemoteIngressEdge();
// Listen for events
edge.on('tunnelConnected', () => {
console.log('🟢 Tunnel to hub established');
});
edge.on('tunnelDisconnected', () => {
console.log('🔴 Tunnel to hub lost — will auto-reconnect');
});
edge.on('publicIpDiscovered', ({ ip }) => {
console.log(`🌐 Public IP: ${ip}`);
});
edge.on('tunnelConnected', () => console.log('Tunnel established'));
edge.on('tunnelDisconnected', () => console.log('Tunnel lost — will auto-reconnect'));
edge.on('publicIpDiscovered', ({ ip }) => console.log(`Public IP: ${ip}`));
edge.on('portsAssigned', ({ listenPorts }) => console.log(`Listening on ports: ${listenPorts}`));
edge.on('portsUpdated', ({ listenPorts }) => console.log(`Ports updated: ${listenPorts}`));
// Single token contains hubHost, hubPort, edgeId, and secret
await edge.start({
token: 'eyJoIjoiaHViLmV4YW1wbGUuY29tIiwicCI6ODQ0MywiZSI6ImVkZ2UtbnljLTAxIiwicyI6InN1cGVyc2VjcmV0dG9rZW4xIn0',
});
```
#### Option B: Explicit Config
```typescript
import { RemoteIngressEdge } from '@serve.zone/remoteingress';
const edge = new RemoteIngressEdge();
edge.on('tunnelConnected', () => console.log('Tunnel established'));
edge.on('tunnelDisconnected', () => console.log('Tunnel lost — will auto-reconnect'));
edge.on('publicIpDiscovered', ({ ip }) => console.log(`Public IP: ${ip}`));
edge.on('portsAssigned', ({ listenPorts }) => console.log(`Listening on ports: ${listenPorts}`));
edge.on('portsUpdated', ({ listenPorts }) => console.log(`Ports updated: ${listenPorts}`));
// Start the edge — it connects to the hub and starts listening for clients
await edge.start({
hubHost: 'hub.example.com', // hostname or IP of the hub
hubPort: 8443, // must match hub's tunnelPort (default: 8443)
edgeId: 'edge-nyc-01', // unique edge identifier
secret: 'supersecrettoken1', // must match the hub's allowed edge secret
listenPorts: [80, 443], // public ports to accept TCP connections on
stunIntervalSecs: 300, // STUN refresh interval in seconds (default: 300)
});
// Check status at any time
const status = await edge.getStatus();
console.log(status);
const edgeStatus = await edge.getStatus();
console.log(edgeStatus);
// {
// running: true,
// connected: true,
@@ -142,37 +179,97 @@ console.log(status);
await edge.stop();
```
### API Reference
### 🎫 Connection Tokens
#### `RemoteIngressHub`
Connection tokens let you distribute a single opaque string instead of four separate config values. The hub operator generates the token; the edge operator just pastes it in.
```typescript
import { encodeConnectionToken, decodeConnectionToken } from '@serve.zone/remoteingress';
// Hub side: generate a token for a new edge
const token = encodeConnectionToken({
hubHost: 'hub.example.com',
hubPort: 8443,
edgeId: 'edge-nyc-01',
secret: 'supersecrettoken1',
});
console.log(token);
// => 'eyJoIjoiaHViLmV4YW1wbGUuY29tIiwi...'
// Edge side: inspect a token (optional — start() does this automatically)
const data = decodeConnectionToken(token);
console.log(data);
// {
// hubHost: 'hub.example.com',
// hubPort: 8443,
// edgeId: 'edge-nyc-01',
// secret: 'supersecrettoken1'
// }
```
Tokens are base64url-encoded (URL-safe, no padding) — safe to pass as environment variables, CLI arguments, or store in config files.
## 📖 API Reference
### `RemoteIngressHub`
| Method / Property | Description |
|-------------------|-------------|
| `start(config?)` | Spawns the Rust binary and starts the tunnel listener. Config: `{ tunnelPort?: number, targetHost?: string }` |
| `stop()` | Gracefully shuts down the hub and kills the Rust process. |
| `updateAllowedEdges(edges)` | Dynamically update which edges are authorized. Each edge: `{ id: string, secret: string }` |
| `updateAllowedEdges(edges)` | Dynamically update which edges are authorized and what ports they listen on. Each edge: `{ id: string, secret: string, listenPorts?: number[], stunIntervalSecs?: number }`. If ports change for a connected edge, the update is pushed immediately via a `FRAME_CONFIG` frame. |
| `getStatus()` | Returns current hub status including connected edges and active stream counts. |
| `running` | `boolean` — whether the Rust binary is alive. |
**Events:** `edgeConnected`, `edgeDisconnected`, `streamOpened`, `streamClosed`
#### `RemoteIngressEdge`
### `RemoteIngressEdge`
| Method / Property | Description |
|-------------------|-------------|
| `start(config)` | Spawns the Rust binary, connects to the hub, and starts listening on the specified ports. |
| `start(config)` | Spawns the Rust binary and connects to the hub. Accepts `{ token: string }` or `IEdgeConfig`. Listen ports are received from the hub during handshake. |
| `stop()` | Gracefully shuts down the edge and kills the Rust process. |
| `getStatus()` | Returns current edge status including connection state, public IP, and active streams. |
| `getStatus()` | Returns current edge status including connection state, public IP, listen ports, and active streams. |
| `running` | `boolean` — whether the Rust binary is alive. |
**Events:** `tunnelConnected`, `tunnelDisconnected`, `publicIpDiscovered`
**Events:** `tunnelConnected`, `tunnelDisconnected`, `publicIpDiscovered`, `portsAssigned`, `portsUpdated`
### 🔌 Wire Protocol
### Token Utilities
| Function | Description |
|----------|-------------|
| `encodeConnectionToken(data)` | Encodes `IConnectionTokenData` into a base64url token string. |
| `decodeConnectionToken(token)` | Decodes a token back into `IConnectionTokenData`. Throws on malformed or incomplete tokens. |
### Interfaces
```typescript
interface IHubConfig {
tunnelPort?: number; // default: 8443
targetHost?: string; // default: '127.0.0.1'
}
interface IEdgeConfig {
hubHost: string;
hubPort?: number; // default: 8443
edgeId: string;
secret: string;
}
interface IConnectionTokenData {
hubHost: string;
hubPort: number;
edgeId: string;
secret: string;
}
```
## 🔌 Wire Protocol
The tunnel uses a custom binary frame protocol over TLS:
```
[stream_id: 4 bytes][type: 1 byte][length: 4 bytes][payload: N bytes]
[stream_id: 4 bytes BE][type: 1 byte][length: 4 bytes BE][payload: N bytes]
```
| Frame Type | Value | Direction | Purpose |
@@ -182,16 +279,67 @@ The tunnel uses a custom binary frame protocol over TLS:
| `CLOSE` | `0x03` | Edge → Hub | Client closed the connection |
| `DATA_BACK` | `0x04` | Hub → Edge | Response data flowing downstream |
| `CLOSE_BACK` | `0x05` | Hub → Edge | Upstream (SmartProxy) closed the connection |
| `CONFIG` | `0x06` | Hub → Edge | Runtime configuration update (e.g. port changes); payload is JSON |
Max payload size per frame: **16 MB**.
Max payload size per frame: **16 MB**. Stream IDs are 32-bit unsigned integers.
### Example Scenarios
### Handshake Sequence
1. **Expose a private Kubernetes cluster to the internet** — Deploy an Edge on a public VPS, configure your DNS to point to the VPS IP. The Edge tunnels all traffic to the Hub running inside the cluster, which hands it off to SmartProxy/DcRouter. Your cluster stays fully private — no public-facing ports needed.
1. Edge opens a TLS connection to the hub
2. Edge sends: `EDGE <edgeId> <secret>\n`
3. Hub verifies credentials (constant-time comparison) and responds with JSON: `{"listenPorts":[...],"stunIntervalSecs":300}\n`
4. Edge starts TCP listeners on the assigned ports
5. Frame protocol begins — `OPEN`/`DATA`/`CLOSE` frames flow in both directions
6. Hub can push `CONFIG` frames at any time to update the edge's listen ports
2. **Multi-region edge ingress** — Run multiple Edges in different geographic regions (NYC, Frankfurt, Tokyo) all connecting to a single Hub. Use GeoDNS to route users to their nearest Edge. The Hub sees the real client IPs via PROXY protocol regardless of which edge they connected through.
## 💡 Example Scenarios
3. **Secure API exposure** — Your backend runs on a private network with no direct internet access. An Edge on a minimal cloud instance acts as the only public entry point. TLS tunnel + shared-secret auth ensure only your authorized Edge can forward traffic.
### 1. Expose a Private Kubernetes Cluster to the Internet
Deploy an Edge on a public VPS, point your DNS to the VPS IP. The Edge tunnels all traffic to the Hub running inside the cluster, which hands it off to SmartProxy/DcRouter. Your cluster stays fully private — no public-facing ports needed.
### 2. Multi-Region Edge Ingress
Run multiple Edges in different geographic regions (NYC, Frankfurt, Tokyo) all connecting to a single Hub. Use GeoDNS to route users to their nearest Edge. The Hub sees the real client IPs via PROXY protocol regardless of which edge they connected through.
### 3. Secure API Exposure
Your backend runs on a private network with no direct internet access. An Edge on a minimal cloud instance acts as the only public entry point. TLS tunnel + shared-secret auth ensure only your authorized Edge can forward traffic.
### 4. Token-Based Edge Provisioning
Generate connection tokens on the hub side and distribute them to edge operators. Each edge only needs a single token string to connect — no manual configuration of host, port, ID, and secret.
```typescript
// Hub operator generates token
const token = encodeConnectionToken({
hubHost: 'hub.prod.example.com',
hubPort: 8443,
edgeId: 'edge-tokyo-01',
secret: 'generated-secret-abc123',
});
// Send `token` to the edge operator via secure channel
// Edge operator starts with just the token
const edge = new RemoteIngressEdge();
await edge.start({ token });
```
### 5. Dynamic Port Management
The hub controls which ports each edge listens on. Ports can be changed at runtime without restarting the edge — the hub pushes a `CONFIG` frame and the edge hot-reloads its TCP listeners.
```typescript
// Initially assign ports 80 and 443
await hub.updateAllowedEdges([
{ id: 'edge-nyc-01', secret: 'secret', listenPorts: [80, 443] },
]);
// Later, add port 8080 — the connected edge picks it up instantly
await hub.updateAllowedEdges([
{ id: 'edge-nyc-01', secret: 'secret', listenPorts: [80, 443, 8080] },
]);
```
## License and Legal Information

61
rust/Cargo.lock generated
View File

@@ -234,6 +234,18 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -315,6 +327,16 @@ version = "0.2.182"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
[[package]]
name = "libmimalloc-sys"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -336,6 +358,15 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mimalloc"
version = "0.1.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "mio"
version = "1.1.1"
@@ -507,6 +538,7 @@ dependencies = [
"clap",
"env_logger",
"log",
"mimalloc",
"remoteingress-core",
"remoteingress-protocol",
"rustls",
@@ -526,15 +558,19 @@ dependencies = [
"rustls-pemfile",
"serde",
"serde_json",
"socket2 0.5.10",
"tokio",
"tokio-rustls",
"tokio-util",
]
[[package]]
name = "remoteingress-protocol"
version = "2.0.0"
dependencies = [
"log",
"tokio",
"tokio-util",
]
[[package]]
@@ -668,6 +704,16 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.2"
@@ -732,7 +778,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"socket2 0.6.2",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -758,6 +804,19 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"

View File

@@ -17,3 +17,4 @@ serde_json = "1"
log = "0.4"
env_logger = "0.11"
rustls = { version = "0.23", default-features = false, features = ["ring"] }
mimalloc = "0.1"

View File

@@ -1,3 +1,6 @@
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@@ -164,16 +167,16 @@ async fn handle_request(
tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
match &event {
HubEvent::EdgeConnected { edge_id } => {
HubEvent::EdgeConnected { edge_id, peer_addr } => {
send_event(
"edgeConnected",
serde_json::json!({ "edgeId": edge_id }),
serde_json::json!({ "edgeId": edge_id, "peerAddr": peer_addr }),
);
}
HubEvent::EdgeDisconnected { edge_id } => {
HubEvent::EdgeDisconnected { edge_id, reason } => {
send_event(
"edgeDisconnected",
serde_json::json!({ "edgeId": edge_id }),
serde_json::json!({ "edgeId": edge_id, "reason": reason }),
);
}
HubEvent::StreamOpened {
@@ -292,8 +295,8 @@ async fn handle_request(
EdgeEvent::TunnelConnected => {
send_event("tunnelConnected", serde_json::json!({}));
}
EdgeEvent::TunnelDisconnected => {
send_event("tunnelDisconnected", serde_json::json!({}));
EdgeEvent::TunnelDisconnected { reason } => {
send_event("tunnelDisconnected", serde_json::json!({ "reason": reason }));
}
EdgeEvent::PublicIpDiscovered { ip } => {
send_event(
@@ -301,6 +304,18 @@ async fn handle_request(
serde_json::json!({ "ip": ip }),
);
}
EdgeEvent::PortsAssigned { listen_ports } => {
send_event(
"portsAssigned",
serde_json::json!({ "listenPorts": listen_ports }),
);
}
EdgeEvent::PortsUpdated { listen_ports } => {
send_event(
"portsUpdated",
serde_json::json!({ "listenPorts": listen_ports }),
);
}
}
}
});
@@ -357,3 +372,58 @@ async fn handle_request(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ipc_request_deserialize() {
let json = r#"{"id": "1", "method": "ping", "params": {}}"#;
let req: IpcRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.id, "1");
assert_eq!(req.method, "ping");
assert!(req.params.is_object());
}
#[test]
fn test_ipc_response_skip_error_when_none() {
let resp = IpcResponse {
id: "1".to_string(),
success: true,
result: Some(serde_json::json!({"pong": true})),
error: None,
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["id"], "1");
assert_eq!(json["success"], true);
assert_eq!(json["result"]["pong"], true);
assert!(json.get("error").is_none());
}
#[test]
fn test_ipc_response_skip_result_when_none() {
let resp = IpcResponse {
id: "2".to_string(),
success: false,
result: None,
error: Some("something failed".to_string()),
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["id"], "2");
assert_eq!(json["success"], false);
assert_eq!(json["error"], "something failed");
assert!(json.get("result").is_none());
}
#[test]
fn test_ipc_event_serialize() {
let evt = IpcEvent {
event: "ready".to_string(),
data: serde_json::json!({"version": "2.0.0"}),
};
let json = serde_json::to_value(&evt).unwrap();
assert_eq!(json["event"], "ready");
assert_eq!(json["data"]["version"], "2.0.0");
}
}

View File

@@ -13,3 +13,5 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
rustls-pemfile = "2"
tokio-util = "0.7"
socket2 = "0.5"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -121,6 +121,133 @@ fn parse_stun_response(data: &[u8], _txn_id: &[u8; 12]) -> Option<String> {
None
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a synthetic STUN Binding Response with given attributes.
fn build_stun_response(attrs: &[(u16, &[u8])]) -> Vec<u8> {
let mut attrs_bytes = Vec::new();
for &(attr_type, attr_data) in attrs {
attrs_bytes.extend_from_slice(&attr_type.to_be_bytes());
attrs_bytes.extend_from_slice(&(attr_data.len() as u16).to_be_bytes());
attrs_bytes.extend_from_slice(attr_data);
// Pad to 4-byte boundary
let pad = (4 - (attr_data.len() % 4)) % 4;
attrs_bytes.extend(std::iter::repeat(0u8).take(pad));
}
let mut response = Vec::new();
// msg_type = 0x0101 (Binding Response)
response.extend_from_slice(&0x0101u16.to_be_bytes());
// message length
response.extend_from_slice(&(attrs_bytes.len() as u16).to_be_bytes());
// magic cookie
response.extend_from_slice(&STUN_MAGIC_COOKIE.to_be_bytes());
// transaction ID (12 bytes)
response.extend_from_slice(&[0u8; 12]);
// attributes
response.extend_from_slice(&attrs_bytes);
response
}
#[test]
fn test_xor_mapped_address_ipv4() {
// IP 203.0.113.1 = 0xCB007101, XOR'd with magic 0x2112A442 = 0xEA12D543
let attr_data: [u8; 8] = [
0x00, 0x01, // reserved + family (IPv4)
0x11, 0x2B, // port XOR'd with 0x2112 (port 0x3039 = 12345)
0xEA, 0x12, 0xD5, 0x43, // IP XOR'd
];
let data = build_stun_response(&[(ATTR_XOR_MAPPED_ADDRESS, &attr_data)]);
let txn_id = [0u8; 12];
let result = parse_stun_response(&data, &txn_id);
assert_eq!(result, Some("203.0.113.1".to_string()));
}
#[test]
fn test_mapped_address_fallback_ipv4() {
// IP 192.168.1.1 = 0xC0A80101 (no XOR)
let attr_data: [u8; 8] = [
0x00, 0x01, // reserved + family (IPv4)
0x00, 0x50, // port 80
0xC0, 0xA8, 0x01, 0x01, // IP
];
let data = build_stun_response(&[(ATTR_MAPPED_ADDRESS, &attr_data)]);
let txn_id = [0u8; 12];
let result = parse_stun_response(&data, &txn_id);
assert_eq!(result, Some("192.168.1.1".to_string()));
}
#[test]
fn test_response_too_short() {
let data = vec![0u8; 19]; // < 20 bytes
let txn_id = [0u8; 12];
assert_eq!(parse_stun_response(&data, &txn_id), None);
}
#[test]
fn test_wrong_msg_type() {
// Build with correct helper then overwrite msg_type to 0x0001 (Binding Request)
let mut data = build_stun_response(&[]);
data[0] = 0x00;
data[1] = 0x01;
let txn_id = [0u8; 12];
assert_eq!(parse_stun_response(&data, &txn_id), None);
}
#[test]
fn test_no_mapped_address_attributes() {
// Valid response with no attributes
let data = build_stun_response(&[]);
let txn_id = [0u8; 12];
assert_eq!(parse_stun_response(&data, &txn_id), None);
}
#[test]
fn test_xor_preferred_over_mapped() {
// XOR gives 203.0.113.1, MAPPED gives 192.168.1.1
let xor_data: [u8; 8] = [
0x00, 0x01,
0x11, 0x2B,
0xEA, 0x12, 0xD5, 0x43,
];
let mapped_data: [u8; 8] = [
0x00, 0x01,
0x00, 0x50,
0xC0, 0xA8, 0x01, 0x01,
];
// XOR listed first — should be preferred
let data = build_stun_response(&[
(ATTR_XOR_MAPPED_ADDRESS, &xor_data),
(ATTR_MAPPED_ADDRESS, &mapped_data),
]);
let txn_id = [0u8; 12];
let result = parse_stun_response(&data, &txn_id);
assert_eq!(result, Some("203.0.113.1".to_string()));
}
#[test]
fn test_truncated_attribute_data() {
// Attribute claims 8 bytes but only 4 are present
let mut data = build_stun_response(&[]);
// Manually append a truncated XOR_MAPPED_ADDRESS attribute
let attr_type = ATTR_XOR_MAPPED_ADDRESS.to_be_bytes();
let attr_len = 8u16.to_be_bytes(); // claims 8 bytes
let truncated = [0x00, 0x01, 0x11, 0x2B]; // only 4 bytes
// Update message length
let new_msg_len = (attr_type.len() + attr_len.len() + truncated.len()) as u16;
data[2..4].copy_from_slice(&new_msg_len.to_be_bytes());
data.extend_from_slice(&attr_type);
data.extend_from_slice(&attr_len);
data.extend_from_slice(&truncated);
let txn_id = [0u8; 12];
// Should return None, not panic
assert_eq!(parse_stun_response(&data, &txn_id), None);
}
}
/// Generate 12 random bytes for transaction ID.
fn rand_bytes() -> [u8; 12] {
let mut bytes = [0u8; 12];

View File

@@ -4,4 +4,9 @@ version = "2.0.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["io-util"] }
tokio = { version = "1", features = ["io-util", "sync", "time"] }
tokio-util = "0.7"
log = "0.4"
[dev-dependencies]
tokio = { version = "1", features = ["io-util", "macros", "rt"] }

View File

@@ -1,4 +1,8 @@
use tokio::io::{AsyncRead, AsyncReadExt};
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, ReadBuf};
// Frame type constants
pub const FRAME_OPEN: u8 = 0x01;
@@ -6,6 +10,11 @@ pub const FRAME_DATA: u8 = 0x02;
pub const FRAME_CLOSE: u8 = 0x03;
pub const FRAME_DATA_BACK: u8 = 0x04;
pub const FRAME_CLOSE_BACK: u8 = 0x05;
pub const FRAME_CONFIG: u8 = 0x06; // Hub -> Edge: configuration update
pub const FRAME_PING: u8 = 0x07; // Hub -> Edge: heartbeat probe
pub const FRAME_PONG: u8 = 0x08; // Edge -> Hub: heartbeat response
pub const FRAME_WINDOW_UPDATE: u8 = 0x09; // Edge -> Hub: per-stream flow control
pub const FRAME_WINDOW_UPDATE_BACK: u8 = 0x0A; // Hub -> Edge: per-stream flow control
// Frame header size: 4 (stream_id) + 1 (type) + 4 (length) = 9 bytes
pub const FRAME_HEADER_SIZE: usize = 9;
@@ -13,6 +22,37 @@ pub const FRAME_HEADER_SIZE: usize = 9;
// Maximum payload size (16 MB)
pub const MAX_PAYLOAD_SIZE: u32 = 16 * 1024 * 1024;
// Per-stream flow control constants
/// Initial per-stream window size (4 MB). Sized for full throughput at high RTT:
/// at 100ms RTT, this sustains ~40 MB/s per stream.
pub const INITIAL_STREAM_WINDOW: u32 = 4 * 1024 * 1024;
/// Send WINDOW_UPDATE after consuming this many bytes (half the initial window).
pub const WINDOW_UPDATE_THRESHOLD: u32 = INITIAL_STREAM_WINDOW / 2;
/// Maximum window size to prevent overflow.
pub const MAX_WINDOW_SIZE: u32 = 16 * 1024 * 1024;
/// Encode a WINDOW_UPDATE frame for a specific stream.
pub fn encode_window_update(stream_id: u32, frame_type: u8, increment: u32) -> Vec<u8> {
encode_frame(stream_id, frame_type, &increment.to_be_bytes())
}
/// Compute the target per-stream window size based on the number of active streams.
/// Total memory budget is ~32MB shared across all streams. As more streams are active,
/// each gets a smaller window. This adapts to current demand — few streams get high
/// throughput, many streams save memory and reduce control frame pressure.
pub fn compute_window_for_stream_count(active: u32) -> u32 {
let per_stream = (32 * 1024 * 1024u64) / (active.max(1) as u64);
per_stream.clamp(64 * 1024, INITIAL_STREAM_WINDOW as u64) as u32
}
/// Decode a WINDOW_UPDATE payload into a byte increment. Returns None if payload is malformed.
pub fn decode_window_update(payload: &[u8]) -> Option<u32> {
if payload.len() != 4 {
return None;
}
Some(u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]))
}
/// A single multiplexed frame.
#[derive(Debug, Clone)]
pub struct Frame {
@@ -32,6 +72,16 @@ pub fn encode_frame(stream_id: u32, frame_type: u8, payload: &[u8]) -> Vec<u8> {
buf
}
/// Write a frame header into `buf[0..FRAME_HEADER_SIZE]`.
/// The caller must ensure payload is already at `buf[FRAME_HEADER_SIZE..FRAME_HEADER_SIZE + payload_len]`.
/// This enables zero-copy encoding: read directly into `buf[FRAME_HEADER_SIZE..]`, then
/// prepend the header without copying the payload.
pub fn encode_frame_header(buf: &mut [u8], stream_id: u32, frame_type: u8, payload_len: usize) {
buf[0..4].copy_from_slice(&stream_id.to_be_bytes());
buf[4] = frame_type;
buf[5..9].copy_from_slice(&(payload_len as u32).to_be_bytes());
}
/// Build a PROXY protocol v1 header line.
/// Format: `PROXY TCP4 <client_ip> <edge_ip> <client_port> <dest_port>\r\n`
pub fn build_proxy_v1_header(
@@ -84,9 +134,13 @@ impl<R: AsyncRead + Unpin> FrameReader<R> {
]);
if length > MAX_PAYLOAD_SIZE {
log::error!(
"CORRUPT FRAME HEADER: raw={:02x?} stream_id={} type=0x{:02x} length={}",
self.header_buf, stream_id, frame_type, length
);
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("frame payload too large: {} bytes", length),
format!("frame payload too large: {} bytes (header={:02x?})", length, self.header_buf),
));
}
@@ -108,10 +162,303 @@ impl<R: AsyncRead + Unpin> FrameReader<R> {
}
}
// ---------------------------------------------------------------------------
// TunnelIo: single-owner I/O multiplexer for the TLS tunnel connection
// ---------------------------------------------------------------------------
/// Events produced by the TunnelIo event loop.
#[derive(Debug)]
pub enum TunnelEvent {
/// A complete frame was read from the remote side.
Frame(Frame),
/// The remote side closed the connection (EOF).
Eof,
/// A read error occurred.
ReadError(std::io::Error),
/// A write error occurred.
WriteError(std::io::Error),
/// No frames received for the liveness timeout duration.
LivenessTimeout,
/// The cancellation token was triggered.
Cancelled,
}
/// Write state extracted into a sub-struct so the borrow checker can see
/// disjoint field access between `self.write` and `self.stream`.
struct WriteState {
ctrl_queue: VecDeque<Vec<u8>>, // PONG, WINDOW_UPDATE, CLOSE, OPEN — always first
data_queue: VecDeque<Vec<u8>>, // DATA, DATA_BACK — only when ctrl is empty
offset: usize, // progress within current frame being written
flush_needed: bool,
}
impl WriteState {
fn has_work(&self) -> bool {
!self.ctrl_queue.is_empty() || !self.data_queue.is_empty()
}
}
/// Single-owner I/O engine for the tunnel TLS connection.
///
/// Owns the TLS stream directly — no `tokio::io::split()`, no mutex.
/// Uses two priority write queues: ctrl frames (PONG, WINDOW_UPDATE, CLOSE, OPEN)
/// are ALWAYS written before data frames (DATA, DATA_BACK). This prevents
/// WINDOW_UPDATE starvation that causes flow control deadlocks.
pub struct TunnelIo<S> {
stream: S,
// Read state: accumulate bytes, parse frames incrementally
read_buf: Vec<u8>,
read_pos: usize,
parse_pos: usize,
// Write state: extracted sub-struct for safe disjoint borrows
write: WriteState,
}
impl<S: AsyncRead + AsyncWrite + Unpin> TunnelIo<S> {
pub fn new(stream: S, initial_data: Vec<u8>) -> Self {
let read_pos = initial_data.len();
let mut read_buf = initial_data;
if read_buf.capacity() < 65536 {
read_buf.reserve(65536 - read_buf.len());
}
Self {
stream,
read_buf,
read_pos,
parse_pos: 0,
write: WriteState {
ctrl_queue: VecDeque::new(),
data_queue: VecDeque::new(),
offset: 0,
flush_needed: false,
},
}
}
/// Queue a high-priority control frame (PONG, WINDOW_UPDATE, CLOSE, OPEN).
pub fn queue_ctrl(&mut self, frame: Vec<u8>) {
self.write.ctrl_queue.push_back(frame);
}
/// Queue a lower-priority data frame (DATA, DATA_BACK).
pub fn queue_data(&mut self, frame: Vec<u8>) {
self.write.data_queue.push_back(frame);
}
/// Try to parse a complete frame from the read buffer.
/// Uses a parse_pos cursor to avoid drain() on every frame.
pub fn try_parse_frame(&mut self) -> Option<Result<Frame, std::io::Error>> {
let available = self.read_pos - self.parse_pos;
if available < FRAME_HEADER_SIZE {
return None;
}
let base = self.parse_pos;
let stream_id = u32::from_be_bytes([
self.read_buf[base], self.read_buf[base + 1],
self.read_buf[base + 2], self.read_buf[base + 3],
]);
let frame_type = self.read_buf[base + 4];
let length = u32::from_be_bytes([
self.read_buf[base + 5], self.read_buf[base + 6],
self.read_buf[base + 7], self.read_buf[base + 8],
]);
if length > MAX_PAYLOAD_SIZE {
let header = [
self.read_buf[base], self.read_buf[base + 1],
self.read_buf[base + 2], self.read_buf[base + 3],
self.read_buf[base + 4], self.read_buf[base + 5],
self.read_buf[base + 6], self.read_buf[base + 7],
self.read_buf[base + 8],
];
log::error!(
"CORRUPT FRAME HEADER: raw={:02x?} stream_id={} type=0x{:02x} length={}",
header, stream_id, frame_type, length
);
return Some(Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("frame payload too large: {} bytes (header={:02x?})", length, header),
)));
}
let total_frame_size = FRAME_HEADER_SIZE + length as usize;
if available < total_frame_size {
return None;
}
let payload = self.read_buf[base + FRAME_HEADER_SIZE..base + total_frame_size].to_vec();
self.parse_pos += total_frame_size;
// Compact when parse_pos > half the data to reclaim memory
if self.parse_pos > self.read_pos / 2 && self.parse_pos > 0 {
self.read_buf.drain(..self.parse_pos);
self.read_pos -= self.parse_pos;
self.parse_pos = 0;
}
Some(Ok(Frame { stream_id, frame_type, payload }))
}
/// Poll-based I/O step. Returns Ready on events, Pending when idle.
///
/// Order: write(ctrl→data) → flush → read → channels → timers
pub fn poll_step(
&mut self,
cx: &mut Context<'_>,
ctrl_rx: &mut tokio::sync::mpsc::Receiver<Vec<u8>>,
data_rx: &mut tokio::sync::mpsc::Receiver<Vec<u8>>,
liveness_deadline: &mut Pin<Box<tokio::time::Sleep>>,
cancel_token: &tokio_util::sync::CancellationToken,
) -> Poll<TunnelEvent> {
// 1. WRITE: drain ctrl queue first, then data queue.
// TLS poll_write writes plaintext to session buffer (always Ready).
// Batch up to 16 frames per poll cycle.
// Safe: `self.write` and `self.stream` are disjoint fields.
let mut writes = 0;
while self.write.has_work() && writes < 16 {
let from_ctrl = !self.write.ctrl_queue.is_empty();
let frame = if from_ctrl {
self.write.ctrl_queue.front().unwrap()
} else {
self.write.data_queue.front().unwrap()
};
let remaining = &frame[self.write.offset..];
match Pin::new(&mut self.stream).poll_write(cx, remaining) {
Poll::Ready(Ok(0)) => {
return Poll::Ready(TunnelEvent::WriteError(
std::io::Error::new(std::io::ErrorKind::WriteZero, "write zero"),
));
}
Poll::Ready(Ok(n)) => {
self.write.offset += n;
self.write.flush_needed = true;
if self.write.offset >= frame.len() {
if from_ctrl { self.write.ctrl_queue.pop_front(); }
else { self.write.data_queue.pop_front(); }
self.write.offset = 0;
writes += 1;
}
}
Poll::Ready(Err(e)) => return Poll::Ready(TunnelEvent::WriteError(e)),
Poll::Pending => break,
}
}
// 2. FLUSH: push encrypted data from TLS session to TCP.
if self.write.flush_needed {
match Pin::new(&mut self.stream).poll_flush(cx) {
Poll::Ready(Ok(())) => self.write.flush_needed = false,
Poll::Ready(Err(e)) => return Poll::Ready(TunnelEvent::WriteError(e)),
Poll::Pending => {} // TCP waker will notify us
}
}
// 3. READ: drain stream until Pending to ensure the TCP waker is always registered.
// Without this loop, a Ready return with partial frame data would consume
// the waker without re-registering it, causing the task to sleep until a
// timer or channel wakes it (potentially 15+ seconds of lost reads).
loop {
// Compact if needed to make room for reads
if self.parse_pos > 0 && self.read_buf.len() - self.read_pos < 32768 {
self.read_buf.drain(..self.parse_pos);
self.read_pos -= self.parse_pos;
self.parse_pos = 0;
}
if self.read_buf.len() < self.read_pos + 32768 {
self.read_buf.resize(self.read_pos + 32768, 0);
}
let mut rbuf = ReadBuf::new(&mut self.read_buf[self.read_pos..]);
match Pin::new(&mut self.stream).poll_read(cx, &mut rbuf) {
Poll::Ready(Ok(())) => {
let n = rbuf.filled().len();
if n == 0 {
return Poll::Ready(TunnelEvent::Eof);
}
self.read_pos += n;
if let Some(result) = self.try_parse_frame() {
return match result {
Ok(frame) => Poll::Ready(TunnelEvent::Frame(frame)),
Err(e) => Poll::Ready(TunnelEvent::ReadError(e)),
};
}
// Partial data — loop to call poll_read again so the TCP
// waker is re-registered when it finally returns Pending.
}
Poll::Ready(Err(e)) => return Poll::Ready(TunnelEvent::ReadError(e)),
Poll::Pending => break,
}
}
// 4. CHANNELS: drain ctrl into ctrl_queue, data into data_queue.
let mut got_new = false;
loop {
match ctrl_rx.poll_recv(cx) {
Poll::Ready(Some(frame)) => { self.write.ctrl_queue.push_back(frame); got_new = true; }
Poll::Ready(None) => {
return Poll::Ready(TunnelEvent::WriteError(
std::io::Error::new(std::io::ErrorKind::BrokenPipe, "ctrl channel closed"),
));
}
Poll::Pending => break,
}
}
loop {
match data_rx.poll_recv(cx) {
Poll::Ready(Some(frame)) => { self.write.data_queue.push_back(frame); got_new = true; }
Poll::Ready(None) => {
return Poll::Ready(TunnelEvent::WriteError(
std::io::Error::new(std::io::ErrorKind::BrokenPipe, "data channel closed"),
));
}
Poll::Pending => break,
}
}
// 5. TIMERS
if liveness_deadline.as_mut().poll(cx).is_ready() {
return Poll::Ready(TunnelEvent::LivenessTimeout);
}
if cancel_token.is_cancelled() {
return Poll::Ready(TunnelEvent::Cancelled);
}
// 6. SELF-WAKE: only when we have frames AND flush is done.
// If flush is pending, the TCP write-readiness waker will notify us.
// If we got new channel frames, wake to write them.
if got_new || (!self.write.flush_needed && self.write.has_work()) {
cx.waker().wake_by_ref();
}
Poll::Pending
}
pub fn into_inner(self) -> S {
self.stream
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_frame_header() {
let payload = b"hello";
let mut buf = vec![0u8; FRAME_HEADER_SIZE + payload.len()];
buf[FRAME_HEADER_SIZE..].copy_from_slice(payload);
encode_frame_header(&mut buf, 42, FRAME_DATA, payload.len());
assert_eq!(buf, encode_frame(42, FRAME_DATA, payload));
}
#[test]
fn test_encode_frame_header_empty_payload() {
let mut buf = vec![0u8; FRAME_HEADER_SIZE];
encode_frame_header(&mut buf, 99, FRAME_CLOSE, 0);
assert_eq!(buf, encode_frame(99, FRAME_CLOSE, &[]));
}
#[test]
fn test_encode_frame() {
let data = b"hello";
@@ -169,4 +516,274 @@ mod tests {
// EOF
assert!(reader.next_frame().await.unwrap().is_none());
}
#[test]
fn test_encode_frame_config_type() {
let payload = b"{\"listenPorts\":[443]}";
let encoded = encode_frame(0, FRAME_CONFIG, payload);
assert_eq!(encoded[4], FRAME_CONFIG);
assert_eq!(&encoded[0..4], &0u32.to_be_bytes());
assert_eq!(&encoded[9..], payload.as_slice());
}
#[test]
fn test_encode_frame_data_back_type() {
let payload = b"response data";
let encoded = encode_frame(7, FRAME_DATA_BACK, payload);
assert_eq!(encoded[4], FRAME_DATA_BACK);
assert_eq!(&encoded[0..4], &7u32.to_be_bytes());
assert_eq!(&encoded[5..9], &(payload.len() as u32).to_be_bytes());
assert_eq!(&encoded[9..], payload.as_slice());
}
#[test]
fn test_encode_frame_close_back_type() {
let encoded = encode_frame(99, FRAME_CLOSE_BACK, &[]);
assert_eq!(encoded[4], FRAME_CLOSE_BACK);
assert_eq!(&encoded[0..4], &99u32.to_be_bytes());
assert_eq!(&encoded[5..9], &0u32.to_be_bytes());
assert_eq!(encoded.len(), FRAME_HEADER_SIZE);
}
#[test]
fn test_encode_frame_large_stream_id() {
let encoded = encode_frame(u32::MAX, FRAME_DATA, b"x");
assert_eq!(&encoded[0..4], &u32::MAX.to_be_bytes());
assert_eq!(encoded[4], FRAME_DATA);
assert_eq!(&encoded[5..9], &1u32.to_be_bytes());
assert_eq!(encoded[9], b'x');
}
#[tokio::test]
async fn test_frame_reader_max_payload_rejection() {
let mut data = Vec::new();
data.extend_from_slice(&1u32.to_be_bytes());
data.push(FRAME_DATA);
data.extend_from_slice(&(MAX_PAYLOAD_SIZE + 1).to_be_bytes());
let cursor = std::io::Cursor::new(data);
let mut reader = FrameReader::new(cursor);
let result = reader.next_frame().await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn test_frame_reader_eof_mid_header() {
// Only 5 bytes — not enough for a 9-byte header
let data = vec![0u8; 5];
let cursor = std::io::Cursor::new(data);
let mut reader = FrameReader::new(cursor);
// Should return Ok(None) on partial header EOF
let result = reader.next_frame().await;
assert!(result.unwrap().is_none());
}
#[tokio::test]
async fn test_frame_reader_eof_mid_payload() {
// Full header claiming 100 bytes of payload, but only 10 bytes present
let mut data = Vec::new();
data.extend_from_slice(&1u32.to_be_bytes());
data.push(FRAME_DATA);
data.extend_from_slice(&100u32.to_be_bytes());
data.extend_from_slice(&[0xAB; 10]);
let cursor = std::io::Cursor::new(data);
let mut reader = FrameReader::new(cursor);
let result = reader.next_frame().await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_frame_reader_all_frame_types() {
let types = [
FRAME_OPEN,
FRAME_DATA,
FRAME_CLOSE,
FRAME_DATA_BACK,
FRAME_CLOSE_BACK,
FRAME_CONFIG,
FRAME_PING,
FRAME_PONG,
];
let mut data = Vec::new();
for (i, &ft) in types.iter().enumerate() {
let payload = format!("payload_{}", i);
data.extend_from_slice(&encode_frame(i as u32, ft, payload.as_bytes()));
}
let cursor = std::io::Cursor::new(data);
let mut reader = FrameReader::new(cursor);
for (i, &ft) in types.iter().enumerate() {
let frame = reader.next_frame().await.unwrap().unwrap();
assert_eq!(frame.stream_id, i as u32);
assert_eq!(frame.frame_type, ft);
assert_eq!(frame.payload, format!("payload_{}", i).as_bytes());
}
assert!(reader.next_frame().await.unwrap().is_none());
}
#[tokio::test]
async fn test_frame_reader_zero_length_payload() {
let data = encode_frame(42, FRAME_CLOSE, &[]);
let cursor = std::io::Cursor::new(data);
let mut reader = FrameReader::new(cursor);
let frame = reader.next_frame().await.unwrap().unwrap();
assert_eq!(frame.stream_id, 42);
assert_eq!(frame.frame_type, FRAME_CLOSE);
assert!(frame.payload.is_empty());
}
#[test]
fn test_encode_frame_ping_pong() {
// PING: stream_id=0, empty payload (control frame)
let ping = encode_frame(0, FRAME_PING, &[]);
assert_eq!(ping[4], FRAME_PING);
assert_eq!(&ping[0..4], &0u32.to_be_bytes());
assert_eq!(ping.len(), FRAME_HEADER_SIZE);
// PONG: stream_id=0, empty payload (control frame)
let pong = encode_frame(0, FRAME_PONG, &[]);
assert_eq!(pong[4], FRAME_PONG);
assert_eq!(&pong[0..4], &0u32.to_be_bytes());
assert_eq!(pong.len(), FRAME_HEADER_SIZE);
}
// --- compute_window_for_stream_count tests ---
#[test]
fn test_adaptive_window_zero_streams() {
// 0 streams treated as 1: 32MB/1 = 32MB → clamped to 4MB max
assert_eq!(compute_window_for_stream_count(0), INITIAL_STREAM_WINDOW);
}
#[test]
fn test_adaptive_window_one_stream() {
// 32MB/1 = 32MB → clamped to 4MB max
assert_eq!(compute_window_for_stream_count(1), INITIAL_STREAM_WINDOW);
}
#[test]
fn test_adaptive_window_at_max_boundary() {
// 32MB/8 = 4MB = exactly INITIAL_STREAM_WINDOW
assert_eq!(compute_window_for_stream_count(8), INITIAL_STREAM_WINDOW);
}
#[test]
fn test_adaptive_window_just_below_max() {
// 32MB/9 = 3,728,270 — first value below INITIAL_STREAM_WINDOW
let w = compute_window_for_stream_count(9);
assert!(w < INITIAL_STREAM_WINDOW);
assert_eq!(w, (32 * 1024 * 1024u64 / 9) as u32);
}
#[test]
fn test_adaptive_window_16_streams() {
// 32MB/16 = 2MB
assert_eq!(compute_window_for_stream_count(16), 2 * 1024 * 1024);
}
#[test]
fn test_adaptive_window_100_streams() {
// 32MB/100 = 335,544 bytes (~327KB)
let w = compute_window_for_stream_count(100);
assert_eq!(w, (32 * 1024 * 1024u64 / 100) as u32);
assert!(w > 64 * 1024); // above floor
assert!(w < INITIAL_STREAM_WINDOW as u32); // below ceiling
}
#[test]
fn test_adaptive_window_200_streams() {
// 32MB/200 = 167,772 bytes (~163KB), above 64KB floor
let w = compute_window_for_stream_count(200);
assert_eq!(w, (32 * 1024 * 1024u64 / 200) as u32);
assert!(w > 64 * 1024);
}
#[test]
fn test_adaptive_window_500_streams() {
// 32MB/500 = 67,108 bytes (~65.5KB), just above 64KB floor
let w = compute_window_for_stream_count(500);
assert_eq!(w, (32 * 1024 * 1024u64 / 500) as u32);
assert!(w > 64 * 1024);
}
#[test]
fn test_adaptive_window_at_min_boundary() {
// 32MB/512 = 65,536 = exactly 64KB floor
assert_eq!(compute_window_for_stream_count(512), 64 * 1024);
}
#[test]
fn test_adaptive_window_below_min_clamped() {
// 32MB/513 = 65,408 → clamped up to 64KB
assert_eq!(compute_window_for_stream_count(513), 64 * 1024);
}
#[test]
fn test_adaptive_window_1000_streams() {
// 32MB/1000 = 33,554 → clamped to 64KB
assert_eq!(compute_window_for_stream_count(1000), 64 * 1024);
}
#[test]
fn test_adaptive_window_max_u32() {
// Extreme: u32::MAX streams → tiny value → clamped to 64KB
assert_eq!(compute_window_for_stream_count(u32::MAX), 64 * 1024);
}
#[test]
fn test_adaptive_window_monotonically_decreasing() {
// Window should decrease (or stay same) as stream count increases
let mut prev = compute_window_for_stream_count(1);
for n in [2, 5, 10, 50, 100, 200, 500, 512, 1000] {
let w = compute_window_for_stream_count(n);
assert!(w <= prev, "window increased from {} to {} at n={}", prev, w, n);
prev = w;
}
}
#[test]
fn test_adaptive_window_total_budget_bounded() {
// active × per_stream_window should never exceed 32MB (+ clamp overhead for high N)
for n in [1, 10, 50, 100, 200, 500] {
let w = compute_window_for_stream_count(n);
let total = w as u64 * n as u64;
assert!(total <= 32 * 1024 * 1024, "total {}MB exceeds budget at n={}", total / (1024*1024), n);
}
}
// --- encode/decode window_update roundtrip ---
#[test]
fn test_window_update_roundtrip() {
for &increment in &[0u32, 1, 64 * 1024, INITIAL_STREAM_WINDOW, MAX_WINDOW_SIZE, u32::MAX] {
let frame = encode_window_update(42, FRAME_WINDOW_UPDATE, increment);
assert_eq!(frame[4], FRAME_WINDOW_UPDATE);
let decoded = decode_window_update(&frame[FRAME_HEADER_SIZE..]);
assert_eq!(decoded, Some(increment));
}
}
#[test]
fn test_window_update_back_roundtrip() {
let frame = encode_window_update(7, FRAME_WINDOW_UPDATE_BACK, 1234567);
assert_eq!(frame[4], FRAME_WINDOW_UPDATE_BACK);
assert_eq!(decode_window_update(&frame[FRAME_HEADER_SIZE..]), Some(1234567));
}
#[test]
fn test_decode_window_update_malformed() {
assert_eq!(decode_window_update(&[]), None);
assert_eq!(decode_window_update(&[0, 0, 0]), None);
assert_eq!(decode_window_update(&[0, 0, 0, 0, 0]), None);
}
}

35
test/test.classes.node.ts Normal file
View File

@@ -0,0 +1,35 @@
import { expect, tap } from '@push.rocks/tapbundle';
import { EventEmitter } from 'events';
import { RemoteIngressHub, RemoteIngressEdge } from '../ts/index.js';
tap.test('RemoteIngressHub constructor does not throw', async () => {
const hub = new RemoteIngressHub();
expect(hub).toBeTruthy();
});
tap.test('RemoteIngressHub is instanceof EventEmitter', async () => {
const hub = new RemoteIngressHub();
expect(hub).toBeInstanceOf(EventEmitter);
});
tap.test('RemoteIngressHub.running is false before start', async () => {
const hub = new RemoteIngressHub();
expect(hub.running).toBeFalse();
});
tap.test('RemoteIngressEdge constructor does not throw', async () => {
const edge = new RemoteIngressEdge();
expect(edge).toBeTruthy();
});
tap.test('RemoteIngressEdge is instanceof EventEmitter', async () => {
const edge = new RemoteIngressEdge();
expect(edge).toBeInstanceOf(EventEmitter);
});
tap.test('RemoteIngressEdge.running is false before start', async () => {
const edge = new RemoteIngressEdge();
expect(edge.running).toBeFalse();
});
export default tap.start();

View File

@@ -0,0 +1,475 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as net from 'net';
import * as crypto from 'crypto';
import { RemoteIngressHub, RemoteIngressEdge } from '../ts/index.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Find N free ports by binding to port 0 and collecting OS-assigned ports. */
async function findFreePorts(count: number): Promise<number[]> {
const servers: net.Server[] = [];
const ports: number[] = [];
for (let i = 0; i < count; i++) {
const server = net.createServer();
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
ports.push((server.address() as net.AddressInfo).port);
servers.push(server);
}
await Promise.all(servers.map((s) => new Promise<void>((resolve) => s.close(() => resolve()))));
return ports;
}
type TrackingServer = net.Server & { destroyAll: () => void };
/** Start a TCP echo server that tracks connections for force-close. */
function startEchoServer(port: number, host: string): Promise<TrackingServer> {
return new Promise((resolve, reject) => {
const connections = new Set<net.Socket>();
const server = net.createServer((socket) => {
connections.add(socket);
socket.on('close', () => connections.delete(socket));
// Skip PROXY protocol v1 header line before echoing
let proxyHeaderParsed = false;
let pendingBuf = Buffer.alloc(0);
socket.on('data', (data: Buffer) => {
if (!proxyHeaderParsed) {
pendingBuf = Buffer.concat([pendingBuf, data]);
const idx = pendingBuf.indexOf('\r\n');
if (idx !== -1) {
proxyHeaderParsed = true;
const remainder = pendingBuf.subarray(idx + 2);
if (remainder.length > 0) {
socket.write(remainder);
}
}
return;
}
socket.write(data);
});
socket.on('error', () => {});
}) as TrackingServer;
server.destroyAll = () => {
for (const conn of connections) conn.destroy();
connections.clear();
};
server.on('error', reject);
server.listen(port, host, () => resolve(server));
});
}
/**
* Start a server that sends a large response immediately on first data received.
* Does NOT wait for end (the tunnel protocol has no half-close).
* On receiving first data chunk after PROXY header, sends responseSize bytes then closes.
*/
function startLargeResponseServer(port: number, host: string, responseSize: number): Promise<TrackingServer> {
return new Promise((resolve, reject) => {
const connections = new Set<net.Socket>();
const server = net.createServer((socket) => {
connections.add(socket);
socket.on('close', () => connections.delete(socket));
let proxyHeaderParsed = false;
let pendingBuf = Buffer.alloc(0);
let responseSent = false;
socket.on('data', (data: Buffer) => {
if (!proxyHeaderParsed) {
pendingBuf = Buffer.concat([pendingBuf, data]);
const idx = pendingBuf.indexOf('\r\n');
if (idx !== -1) {
proxyHeaderParsed = true;
const remainder = pendingBuf.subarray(idx + 2);
if (remainder.length > 0 && !responseSent) {
responseSent = true;
sendLargeResponse(socket, responseSize);
}
}
return;
}
if (!responseSent) {
responseSent = true;
sendLargeResponse(socket, responseSize);
}
});
socket.on('error', () => {});
}) as TrackingServer;
server.destroyAll = () => {
for (const conn of connections) conn.destroy();
connections.clear();
};
server.on('error', reject);
server.listen(port, host, () => resolve(server));
});
}
function sendLargeResponse(socket: net.Socket, totalBytes: number) {
const chunkSize = 32 * 1024;
let sent = 0;
const writeChunk = () => {
while (sent < totalBytes) {
const toWrite = Math.min(chunkSize, totalBytes - sent);
// Use a deterministic pattern for verification
const chunk = Buffer.alloc(toWrite, (sent % 256) & 0xff);
const canContinue = socket.write(chunk);
sent += toWrite;
if (!canContinue) {
socket.once('drain', writeChunk);
return;
}
}
socket.end();
};
writeChunk();
}
/** Force-close a server: destroy all connections, then close. */
async function forceCloseServer(server: TrackingServer): Promise<void> {
server.destroyAll();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
interface TestTunnel {
hub: RemoteIngressHub;
edge: RemoteIngressEdge;
edgePort: number;
cleanup: () => Promise<void>;
}
/**
* Start a full hub + edge tunnel.
* Edge binds to 127.0.0.1, upstream server binds to 127.0.0.2.
* Hub targetHost = 127.0.0.2 so hub -> upstream doesn't loop back to edge.
*/
async function startTunnel(edgePort: number, hubPort: number): Promise<TestTunnel> {
const hub = new RemoteIngressHub();
const edge = new RemoteIngressEdge();
await hub.start({
tunnelPort: hubPort,
targetHost: '127.0.0.2',
});
await hub.updateAllowedEdges([
{ id: 'test-edge', secret: 'test-secret', listenPorts: [edgePort] },
]);
const connectedPromise = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Edge did not connect within 10s')), 10000);
edge.once('tunnelConnected', () => {
clearTimeout(timeout);
resolve();
});
});
await edge.start({
hubHost: '127.0.0.1',
hubPort,
edgeId: 'test-edge',
secret: 'test-secret',
bindAddress: '127.0.0.1',
});
await connectedPromise;
await new Promise((resolve) => setTimeout(resolve, 500));
return {
hub,
edge,
edgePort,
cleanup: async () => {
await edge.stop();
await hub.stop();
},
};
}
/**
* Send data through the tunnel and collect the echoed response.
*/
function sendAndReceive(port: number, data: Buffer, timeoutMs = 30000): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalReceived = 0;
const expectedLength = data.length;
let settled = false;
const client = net.createConnection({ host: '127.0.0.1', port }, () => {
client.write(data);
client.end();
});
const timer = setTimeout(() => {
if (!settled) {
settled = true;
client.destroy();
reject(new Error(`Timeout after ${timeoutMs}ms — received ${totalReceived}/${expectedLength} bytes`));
}
}, timeoutMs);
client.on('data', (chunk: Buffer) => {
chunks.push(chunk);
totalReceived += chunk.length;
if (totalReceived >= expectedLength && !settled) {
settled = true;
clearTimeout(timer);
client.destroy();
resolve(Buffer.concat(chunks));
}
});
client.on('end', () => {
if (!settled) {
settled = true;
clearTimeout(timer);
resolve(Buffer.concat(chunks));
}
});
client.on('error', (err) => {
if (!settled) {
settled = true;
clearTimeout(timer);
reject(err);
}
});
});
}
/**
* Connect to the tunnel, send a small request, and collect a large response.
* Does NOT call end() — the tunnel has no half-close.
* Instead, collects until expectedResponseSize bytes arrive.
*/
function sendAndReceiveLarge(
port: number,
data: Buffer,
expectedResponseSize: number,
timeoutMs = 60000,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalReceived = 0;
let settled = false;
const client = net.createConnection({ host: '127.0.0.1', port }, () => {
client.write(data);
// Do NOT call client.end() — the server will respond immediately
// and the tunnel CLOSE will happen when the download finishes
});
const timer = setTimeout(() => {
if (!settled) {
settled = true;
client.destroy();
reject(new Error(`Timeout after ${timeoutMs}ms — received ${totalReceived}/${expectedResponseSize} bytes`));
}
}, timeoutMs);
client.on('data', (chunk: Buffer) => {
chunks.push(chunk);
totalReceived += chunk.length;
if (totalReceived >= expectedResponseSize && !settled) {
settled = true;
clearTimeout(timer);
client.destroy();
resolve(Buffer.concat(chunks));
}
});
client.on('end', () => {
if (!settled) {
settled = true;
clearTimeout(timer);
resolve(Buffer.concat(chunks));
}
});
client.on('error', (err) => {
if (!settled) {
settled = true;
clearTimeout(timer);
reject(err);
}
});
});
}
function sha256(buf: Buffer): string {
return crypto.createHash('sha256').update(buf).digest('hex');
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
let tunnel: TestTunnel;
let echoServer: TrackingServer;
let hubPort: number;
let edgePort: number;
tap.test('setup: start echo server and tunnel', async () => {
[hubPort, edgePort] = await findFreePorts(2);
echoServer = await startEchoServer(edgePort, '127.0.0.2');
tunnel = await startTunnel(edgePort, hubPort);
expect(tunnel.hub.running).toBeTrue();
});
tap.test('single stream: 32MB transfer exceeding initial 4MB window', async () => {
const size = 32 * 1024 * 1024;
const data = crypto.randomBytes(size);
const expectedHash = sha256(data);
const received = await sendAndReceive(edgePort, data, 60000);
expect(received.length).toEqual(size);
expect(sha256(received)).toEqual(expectedHash);
});
tap.test('200 concurrent streams with 64KB each', async () => {
const streamCount = 200;
const payloadSize = 64 * 1024;
const promises = Array.from({ length: streamCount }, () => {
const data = crypto.randomBytes(payloadSize);
const hash = sha256(data);
return sendAndReceive(edgePort, data, 30000).then((received) => ({
sent: hash,
received: sha256(received),
sizeOk: received.length === payloadSize,
}));
});
const results = await Promise.all(promises);
const failures = results.filter((r) => !r.sizeOk || r.sent !== r.received);
expect(failures.length).toEqual(0);
});
tap.test('512 concurrent streams at minimum window boundary (16KB each)', async () => {
const streamCount = 512;
const payloadSize = 16 * 1024;
const promises = Array.from({ length: streamCount }, () => {
const data = crypto.randomBytes(payloadSize);
const hash = sha256(data);
return sendAndReceive(edgePort, data, 60000).then((received) => ({
sent: hash,
received: sha256(received),
sizeOk: received.length === payloadSize,
}));
});
const results = await Promise.all(promises);
const failures = results.filter((r) => !r.sizeOk || r.sent !== r.received);
expect(failures.length).toEqual(0);
});
tap.test('asymmetric transfer: 4KB request -> 4MB response', async () => {
// Swap to large-response server
await forceCloseServer(echoServer);
const responseSize = 4 * 1024 * 1024; // 4 MB
const largeServer = await startLargeResponseServer(edgePort, '127.0.0.2', responseSize);
try {
const requestData = crypto.randomBytes(4 * 1024); // 4 KB
const received = await sendAndReceiveLarge(edgePort, requestData, responseSize, 60000);
expect(received.length).toEqual(responseSize);
} finally {
// Always restore echo server even on failure
await forceCloseServer(largeServer);
echoServer = await startEchoServer(edgePort, '127.0.0.2');
}
});
tap.test('100 streams x 1MB each (100MB total exceeding 32MB budget)', async () => {
const streamCount = 100;
const payloadSize = 1 * 1024 * 1024;
const promises = Array.from({ length: streamCount }, () => {
const data = crypto.randomBytes(payloadSize);
const hash = sha256(data);
return sendAndReceive(edgePort, data, 120000).then((received) => ({
sent: hash,
received: sha256(received),
sizeOk: received.length === payloadSize,
}));
});
const results = await Promise.all(promises);
const failures = results.filter((r) => !r.sizeOk || r.sent !== r.received);
expect(failures.length).toEqual(0);
});
tap.test('active stream counter tracks concurrent connections', async () => {
const N = 50;
// Open N connections and keep them alive (send data but don't close)
const sockets: net.Socket[] = [];
const connectPromises = Array.from({ length: N }, () => {
return new Promise<net.Socket>((resolve, reject) => {
const sock = net.createConnection({ host: '127.0.0.1', port: edgePort }, () => {
resolve(sock);
});
sock.on('error', () => {});
setTimeout(() => reject(new Error('connect timeout')), 5000);
});
});
const connected = await Promise.all(connectPromises);
sockets.push(...connected);
// Brief delay for stream registration to propagate
await new Promise((resolve) => setTimeout(resolve, 500));
// Verify the edge reports >= N active streams.
// This counter is the input to compute_window_for_stream_count(),
// so its accuracy determines whether adaptive window sizing is correct.
const status = await tunnel.edge.getStatus();
expect(status.activeStreams).toBeGreaterThanOrEqual(N);
// Clean up: destroy all sockets (the tunnel's 300s stream timeout will handle cleanup)
for (const sock of sockets) {
sock.destroy();
}
});
tap.test('50 streams x 2MB each (forces multiple window refills per stream)', async () => {
// At 50 concurrent streams: adaptive window = 32MB/50 = 655KB per stream
// Each stream sends 2MB → needs ~3 WINDOW_UPDATE refill cycles per stream
const streamCount = 50;
const payloadSize = 2 * 1024 * 1024;
const promises = Array.from({ length: streamCount }, () => {
const data = crypto.randomBytes(payloadSize);
const hash = sha256(data);
return sendAndReceive(edgePort, data, 120000).then((received) => ({
sent: hash,
received: sha256(received),
sizeOk: received.length === payloadSize,
}));
});
const results = await Promise.all(promises);
const failures = results.filter((r) => !r.sizeOk || r.sent !== r.received);
expect(failures.length).toEqual(0);
});
tap.test('teardown: stop tunnel and echo server', async () => {
await tunnel.cleanup();
await forceCloseServer(echoServer);
});
export default tap.start();

152
test/test.token.node.ts Normal file
View File

@@ -0,0 +1,152 @@
import { expect, tap } from '@push.rocks/tapbundle';
import { encodeConnectionToken, decodeConnectionToken, type IConnectionTokenData } from '../ts/classes.token.js';
tap.test('token roundtrip with unicode chars in secret', async () => {
const data: IConnectionTokenData = {
hubHost: 'hub.example.com',
hubPort: 8443,
edgeId: 'edge-1',
secret: 'sécret-with-ünïcödé-日本語',
};
const token = encodeConnectionToken(data);
const decoded = decodeConnectionToken(token);
expect(decoded.secret).toEqual(data.secret);
});
tap.test('token roundtrip with empty edgeId', async () => {
const data: IConnectionTokenData = {
hubHost: 'hub.test',
hubPort: 443,
edgeId: '',
secret: 'key',
};
const token = encodeConnectionToken(data);
const decoded = decodeConnectionToken(token);
expect(decoded.edgeId).toEqual('');
});
tap.test('token roundtrip with port 0', async () => {
const data: IConnectionTokenData = {
hubHost: 'h',
hubPort: 0,
edgeId: 'e',
secret: 's',
};
const token = encodeConnectionToken(data);
const decoded = decodeConnectionToken(token);
expect(decoded.hubPort).toEqual(0);
});
tap.test('token roundtrip with port 65535', async () => {
const data: IConnectionTokenData = {
hubHost: 'h',
hubPort: 65535,
edgeId: 'e',
secret: 's',
};
const token = encodeConnectionToken(data);
const decoded = decodeConnectionToken(token);
expect(decoded.hubPort).toEqual(65535);
});
tap.test('token roundtrip with very long secret (10k chars)', async () => {
const longSecret = 'x'.repeat(10000);
const data: IConnectionTokenData = {
hubHost: 'host',
hubPort: 1234,
edgeId: 'edge',
secret: longSecret,
};
const token = encodeConnectionToken(data);
const decoded = decodeConnectionToken(token);
expect(decoded.secret).toEqual(longSecret);
expect(decoded.secret.length).toEqual(10000);
});
tap.test('token string is URL-safe', async () => {
const data: IConnectionTokenData = {
hubHost: 'hub.example.com',
hubPort: 8443,
edgeId: 'edge-001',
secret: 'super+secret/key==with+special/chars',
};
const token = encodeConnectionToken(data);
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
});
tap.test('decode empty string throws', async () => {
let error: Error | undefined;
try {
decodeConnectionToken('');
} catch (e) {
error = e as Error;
}
expect(error).toBeInstanceOf(Error);
});
tap.test('decode valid base64 but wrong JSON shape throws missing required fields', async () => {
// Encode { "a": 1, "b": 2 } — valid JSON but wrong shape
const token = Buffer.from(JSON.stringify({ a: 1, b: 2 }), 'utf-8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
let error: Error | undefined;
try {
decodeConnectionToken(token);
} catch (e) {
error = e as Error;
}
expect(error).toBeInstanceOf(Error);
expect(error!.message).toInclude('missing required fields');
});
tap.test('decode valid JSON but wrong field types throws missing required fields', async () => {
// h is number instead of string, p is string instead of number
const token = Buffer.from(JSON.stringify({ h: 123, p: 'notnum', e: 'e', s: 's' }), 'utf-8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
let error: Error | undefined;
try {
decodeConnectionToken(token);
} catch (e) {
error = e as Error;
}
expect(error).toBeInstanceOf(Error);
expect(error!.message).toInclude('missing required fields');
});
tap.test('decode with extra fields succeeds', async () => {
const token = Buffer.from(
JSON.stringify({ h: 'host', p: 443, e: 'edge', s: 'secret', extra: 'ignored' }),
'utf-8',
)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const decoded = decodeConnectionToken(token);
expect(decoded.hubHost).toEqual('host');
expect(decoded.hubPort).toEqual(443);
expect(decoded.edgeId).toEqual('edge');
expect(decoded.secret).toEqual('secret');
});
tap.test('encode is deterministic', async () => {
const data: IConnectionTokenData = {
hubHost: 'hub.test',
hubPort: 8443,
edgeId: 'edge-1',
secret: 'deterministic-key',
};
const token1 = encodeConnectionToken(data);
const token2 = encodeConnectionToken(data);
expect(token1).toEqual(token2);
});
export default tap.start();

View File

@@ -9,4 +9,53 @@ tap.test('should export RemoteIngressEdge', async () => {
expect(remoteingress.RemoteIngressEdge).toBeTypeOf('function');
});
tap.test('should export encodeConnectionToken and decodeConnectionToken', async () => {
expect(remoteingress.encodeConnectionToken).toBeTypeOf('function');
expect(remoteingress.decodeConnectionToken).toBeTypeOf('function');
});
tap.test('should roundtrip encode → decode a connection token', async () => {
const data: remoteingress.IConnectionTokenData = {
hubHost: 'hub.example.com',
hubPort: 8443,
edgeId: 'edge-001',
secret: 'super-secret-key',
};
const token = remoteingress.encodeConnectionToken(data);
const decoded = remoteingress.decodeConnectionToken(token);
expect(decoded.hubHost).toEqual(data.hubHost);
expect(decoded.hubPort).toEqual(data.hubPort);
expect(decoded.edgeId).toEqual(data.edgeId);
expect(decoded.secret).toEqual(data.secret);
});
tap.test('should throw on malformed token', async () => {
let error: Error | undefined;
try {
remoteingress.decodeConnectionToken('not-valid-json!!!');
} catch (e) {
error = e as Error;
}
expect(error).toBeInstanceOf(Error);
expect(error!.message).toInclude('Invalid connection token');
});
tap.test('should throw on token with missing fields', async () => {
// Encode a partial object (missing 'p' and 's')
const partial = Buffer.from(JSON.stringify({ h: 'host', e: 'edge' }), 'utf-8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
let error: Error | undefined;
try {
remoteingress.decodeConnectionToken(partial);
} catch (e) {
error = e as Error;
}
expect(error).toBeInstanceOf(Error);
expect(error!.message).toInclude('missing required fields');
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/remoteingress',
version: '3.0.3',
version: '4.8.4',
description: 'Edge ingress tunnel for DcRouter - accepts incoming TCP connections at network edge and tunnels them to DcRouter SmartProxy preserving client IP via PROXY protocol v1.'
}

View File

@@ -1,5 +1,6 @@
import * as plugins from './plugins.js';
import { EventEmitter } from 'events';
import { decodeConnectionToken } from './classes.token.js';
// Command map for the edge side of remoteingress-bin
type TEdgeCommands = {
@@ -13,8 +14,7 @@ type TEdgeCommands = {
hubPort: number;
edgeId: string;
secret: string;
listenPorts: number[];
stunIntervalSecs?: number;
bindAddress?: string;
};
result: { started: boolean };
};
@@ -39,13 +39,20 @@ export interface IEdgeConfig {
hubPort?: number;
edgeId: string;
secret: string;
listenPorts: number[];
stunIntervalSecs?: number;
bindAddress?: string;
}
const MAX_RESTART_ATTEMPTS = 10;
const MAX_RESTART_BACKOFF_MS = 30_000;
export class RemoteIngressEdge extends EventEmitter {
private bridge: InstanceType<typeof plugins.smartrust.RustBridge<TEdgeCommands>>;
private started = false;
private stopping = false;
private savedConfig: IEdgeConfig | null = null;
private restartBackoffMs = 1000;
private restartAttempts = 0;
private statusInterval: ReturnType<typeof setInterval> | undefined;
constructor() {
super();
@@ -76,45 +83,97 @@ export class RemoteIngressEdge extends EventEmitter {
this.bridge.on('management:tunnelConnected', () => {
this.emit('tunnelConnected');
});
this.bridge.on('management:tunnelDisconnected', () => {
this.emit('tunnelDisconnected');
this.bridge.on('management:tunnelDisconnected', (data: { reason?: string }) => {
const reason = data?.reason ?? 'unknown';
console.log(`[RemoteIngressEdge] Tunnel disconnected: ${reason}`);
this.emit('tunnelDisconnected', data);
});
this.bridge.on('management:publicIpDiscovered', (data: { ip: string }) => {
this.emit('publicIpDiscovered', data);
});
this.bridge.on('management:portsAssigned', (data: { listenPorts: number[] }) => {
console.log(`[RemoteIngressEdge] Ports assigned by hub: ${data.listenPorts.join(', ')}`);
this.emit('portsAssigned', data);
});
this.bridge.on('management:portsUpdated', (data: { listenPorts: number[] }) => {
console.log(`[RemoteIngressEdge] Ports updated by hub: ${data.listenPorts.join(', ')}`);
this.emit('portsUpdated', data);
});
}
/**
* Start the edge — spawns the Rust binary and connects to the hub.
* Accepts either a connection token or an explicit IEdgeConfig.
*/
public async start(config: IEdgeConfig): Promise<void> {
public async start(config: { token: string } | IEdgeConfig): Promise<void> {
let edgeConfig: IEdgeConfig;
if ('token' in config) {
const decoded = decodeConnectionToken(config.token);
edgeConfig = {
hubHost: decoded.hubHost,
hubPort: decoded.hubPort,
edgeId: decoded.edgeId,
secret: decoded.secret,
};
} else {
edgeConfig = config;
}
this.savedConfig = edgeConfig;
this.stopping = false;
const spawned = await this.bridge.spawn();
if (!spawned) {
throw new Error('Failed to spawn remoteingress-bin');
}
// Register crash recovery handler
this.bridge.on('exit', this.handleCrashRecovery);
await this.bridge.sendCommand('startEdge', {
hubHost: config.hubHost,
hubPort: config.hubPort ?? 8443,
edgeId: config.edgeId,
secret: config.secret,
listenPorts: config.listenPorts,
stunIntervalSecs: config.stunIntervalSecs,
hubHost: edgeConfig.hubHost,
hubPort: edgeConfig.hubPort ?? 8443,
edgeId: edgeConfig.edgeId,
secret: edgeConfig.secret,
...(edgeConfig.bindAddress ? { bindAddress: edgeConfig.bindAddress } : {}),
});
this.started = true;
this.restartAttempts = 0;
this.restartBackoffMs = 1000;
// Start periodic status logging
this.statusInterval = setInterval(async () => {
try {
const status = await this.getStatus();
console.log(
`[RemoteIngressEdge] Status: connected=${status.connected}, ` +
`streams=${status.activeStreams}, ports=[${status.listenPorts.join(',')}], ` +
`publicIp=${status.publicIp ?? 'unknown'}`
);
} catch {
// Bridge may be shutting down
}
}, 60_000);
}
/**
* Stop the edge and kill the Rust process.
*/
public async stop(): Promise<void> {
this.stopping = true;
if (this.statusInterval) {
clearInterval(this.statusInterval);
this.statusInterval = undefined;
}
if (this.started) {
try {
await this.bridge.sendCommand('stopEdge', {} as Record<string, never>);
} catch {
// Process may already be dead
}
this.bridge.removeListener('exit', this.handleCrashRecovery);
this.bridge.kill();
this.started = false;
}
@@ -133,4 +192,56 @@ export class RemoteIngressEdge extends EventEmitter {
public get running(): boolean {
return this.bridge.running;
}
/**
* Handle unexpected Rust binary crash — auto-restart with backoff.
*/
private handleCrashRecovery = async (code: number | null, signal: string | null) => {
if (this.stopping || !this.started || !this.savedConfig) {
return;
}
console.error(
`[RemoteIngressEdge] Rust binary crashed (code=${code}, signal=${signal}), ` +
`attempt ${this.restartAttempts + 1}/${MAX_RESTART_ATTEMPTS}`
);
this.started = false;
if (this.restartAttempts >= MAX_RESTART_ATTEMPTS) {
console.error('[RemoteIngressEdge] Max restart attempts reached, giving up');
this.emit('crashRecoveryFailed');
return;
}
await new Promise(resolve => setTimeout(resolve, this.restartBackoffMs));
this.restartBackoffMs = Math.min(this.restartBackoffMs * 2, MAX_RESTART_BACKOFF_MS);
this.restartAttempts++;
try {
const spawned = await this.bridge.spawn();
if (!spawned) {
console.error('[RemoteIngressEdge] Failed to respawn binary');
return;
}
this.bridge.on('exit', this.handleCrashRecovery);
await this.bridge.sendCommand('startEdge', {
hubHost: this.savedConfig.hubHost,
hubPort: this.savedConfig.hubPort ?? 8443,
edgeId: this.savedConfig.edgeId,
secret: this.savedConfig.secret,
...(this.savedConfig.bindAddress ? { bindAddress: this.savedConfig.bindAddress } : {}),
});
this.started = true;
this.restartAttempts = 0;
this.restartBackoffMs = 1000;
console.log('[RemoteIngressEdge] Successfully recovered from crash');
this.emit('crashRecovered');
} catch (err) {
console.error(`[RemoteIngressEdge] Crash recovery failed: ${err}`);
}
};
}

View File

@@ -11,6 +11,8 @@ type THubCommands = {
params: {
tunnelPort: number;
targetHost?: string;
tlsCertPem?: string;
tlsKeyPem?: string;
};
result: { started: boolean };
};
@@ -20,7 +22,7 @@ type THubCommands = {
};
updateAllowedEdges: {
params: {
edges: Array<{ id: string; secret: string }>;
edges: Array<{ id: string; secret: string; listenPorts?: number[]; stunIntervalSecs?: number }>;
};
result: { updated: boolean };
};
@@ -33,6 +35,7 @@ type THubCommands = {
edgeId: string;
connectedAt: number;
activeStreams: number;
peerAddr: string;
}>;
};
};
@@ -41,11 +44,25 @@ type THubCommands = {
export interface IHubConfig {
tunnelPort?: number;
targetHost?: string;
tls?: {
certPem?: string;
keyPem?: string;
};
}
type TAllowedEdge = { id: string; secret: string; listenPorts?: number[]; stunIntervalSecs?: number };
const MAX_RESTART_ATTEMPTS = 10;
const MAX_RESTART_BACKOFF_MS = 30_000;
export class RemoteIngressHub extends EventEmitter {
private bridge: InstanceType<typeof plugins.smartrust.RustBridge<THubCommands>>;
private started = false;
private stopping = false;
private savedConfig: IHubConfig | null = null;
private savedEdges: TAllowedEdge[] = [];
private restartBackoffMs = 1000;
private restartAttempts = 0;
constructor() {
super();
@@ -73,10 +90,12 @@ export class RemoteIngressHub extends EventEmitter {
});
// Forward events from Rust binary
this.bridge.on('management:edgeConnected', (data: { edgeId: string }) => {
this.bridge.on('management:edgeConnected', (data: { edgeId: string; peerAddr: string }) => {
this.emit('edgeConnected', data);
});
this.bridge.on('management:edgeDisconnected', (data: { edgeId: string }) => {
this.bridge.on('management:edgeDisconnected', (data: { edgeId: string; reason?: string }) => {
const reason = data?.reason ?? 'unknown';
console.log(`[RemoteIngressHub] Edge ${data.edgeId} disconnected: ${reason}`);
this.emit('edgeDisconnected', data);
});
this.bridge.on('management:streamOpened', (data: { edgeId: string; streamId: number }) => {
@@ -91,29 +110,42 @@ export class RemoteIngressHub extends EventEmitter {
* Start the hub — spawns the Rust binary and starts the tunnel server.
*/
public async start(config: IHubConfig = {}): Promise<void> {
this.savedConfig = config;
this.stopping = false;
const spawned = await this.bridge.spawn();
if (!spawned) {
throw new Error('Failed to spawn remoteingress-bin');
}
// Register crash recovery handler
this.bridge.on('exit', this.handleCrashRecovery);
await this.bridge.sendCommand('startHub', {
tunnelPort: config.tunnelPort ?? 8443,
targetHost: config.targetHost ?? '127.0.0.1',
...(config.tls?.certPem && config.tls?.keyPem
? { tlsCertPem: config.tls.certPem, tlsKeyPem: config.tls.keyPem }
: {}),
});
this.started = true;
this.restartAttempts = 0;
this.restartBackoffMs = 1000;
}
/**
* Stop the hub and kill the Rust process.
*/
public async stop(): Promise<void> {
this.stopping = true;
if (this.started) {
try {
await this.bridge.sendCommand('stopHub', {} as Record<string, never>);
} catch {
// Process may already be dead
}
this.bridge.removeListener('exit', this.handleCrashRecovery);
this.bridge.kill();
this.started = false;
}
@@ -122,7 +154,8 @@ export class RemoteIngressHub extends EventEmitter {
/**
* Update the list of allowed edges that can connect to this hub.
*/
public async updateAllowedEdges(edges: Array<{ id: string; secret: string }>): Promise<void> {
public async updateAllowedEdges(edges: TAllowedEdge[]): Promise<void> {
this.savedEdges = edges;
await this.bridge.sendCommand('updateAllowedEdges', { edges });
}
@@ -139,4 +172,62 @@ export class RemoteIngressHub extends EventEmitter {
public get running(): boolean {
return this.bridge.running;
}
/**
* Handle unexpected Rust binary crash — auto-restart with backoff.
*/
private handleCrashRecovery = async (code: number | null, signal: string | null) => {
if (this.stopping || !this.started || !this.savedConfig) {
return;
}
console.error(
`[RemoteIngressHub] Rust binary crashed (code=${code}, signal=${signal}), ` +
`attempt ${this.restartAttempts + 1}/${MAX_RESTART_ATTEMPTS}`
);
this.started = false;
if (this.restartAttempts >= MAX_RESTART_ATTEMPTS) {
console.error('[RemoteIngressHub] Max restart attempts reached, giving up');
this.emit('crashRecoveryFailed');
return;
}
await new Promise(resolve => setTimeout(resolve, this.restartBackoffMs));
this.restartBackoffMs = Math.min(this.restartBackoffMs * 2, MAX_RESTART_BACKOFF_MS);
this.restartAttempts++;
try {
const spawned = await this.bridge.spawn();
if (!spawned) {
console.error('[RemoteIngressHub] Failed to respawn binary');
return;
}
this.bridge.on('exit', this.handleCrashRecovery);
const config = this.savedConfig;
await this.bridge.sendCommand('startHub', {
tunnelPort: config.tunnelPort ?? 8443,
targetHost: config.targetHost ?? '127.0.0.1',
...(config.tls?.certPem && config.tls?.keyPem
? { tlsCertPem: config.tls.certPem, tlsKeyPem: config.tls.keyPem }
: {}),
});
// Restore allowed edges
if (this.savedEdges.length > 0) {
await this.bridge.sendCommand('updateAllowedEdges', { edges: this.savedEdges });
}
this.started = true;
this.restartAttempts = 0;
this.restartBackoffMs = 1000;
console.log('[RemoteIngressHub] Successfully recovered from crash');
this.emit('crashRecovered');
} catch (err) {
console.error(`[RemoteIngressHub] Crash recovery failed: ${err}`);
}
};
}

66
ts/classes.token.ts Normal file
View File

@@ -0,0 +1,66 @@
/**
* Connection token utilities for RemoteIngress edge connections.
* A token is a base64url-encoded compact JSON object carrying hub connection details.
*/
export interface IConnectionTokenData {
hubHost: string;
hubPort: number;
edgeId: string;
secret: string;
}
/**
* Encode connection data into a single opaque token string (base64url).
*/
export function encodeConnectionToken(data: IConnectionTokenData): string {
const compact = JSON.stringify({
h: data.hubHost,
p: data.hubPort,
e: data.edgeId,
s: data.secret,
});
// base64url: standard base64 with + → -, / → _, trailing = stripped
return Buffer.from(compact, 'utf-8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
/**
* Decode a connection token back into its constituent fields.
* Throws on malformed or incomplete tokens.
*/
export function decodeConnectionToken(token: string): IConnectionTokenData {
let parsed: { h?: unknown; p?: unknown; e?: unknown; s?: unknown };
try {
// Restore standard base64 from base64url
let base64 = token.replace(/-/g, '+').replace(/_/g, '/');
// Re-add padding
const remainder = base64.length % 4;
if (remainder === 2) base64 += '==';
else if (remainder === 3) base64 += '=';
const json = Buffer.from(base64, 'base64').toString('utf-8');
parsed = JSON.parse(json);
} catch {
throw new Error('Invalid connection token');
}
if (
typeof parsed.h !== 'string' ||
typeof parsed.p !== 'number' ||
typeof parsed.e !== 'string' ||
typeof parsed.s !== 'string'
) {
throw new Error('Invalid connection token: missing required fields');
}
return {
hubHost: parsed.h,
hubPort: parsed.p,
edgeId: parsed.e,
secret: parsed.s,
};
}

View File

@@ -1,2 +1,3 @@
export * from './classes.remoteingresshub.js';
export * from './classes.remoteingressedge.js';
export * from './classes.token.js';