Compare commits

...

208 Commits

Author SHA1 Message Date
5be93c8d38 v27.0.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-26 20:45:41 +00:00
788ccea81e BREAKING CHANGE(smart-proxy): remove route helper APIs and standardize route configuration on plain route objects 2026-03-26 20:45:41 +00:00
47140e5403 v26.3.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-26 13:11:57 +00:00
a6ffa24e36 feat(nftables): move NFTables forwarding management from the Rust engine to @push.rocks/smartnftables 2026-03-26 13:11:57 +00:00
c0e432fd9b v26.2.4
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-26 07:05:57 +00:00
a3d8a3a388 fix(rustproxy-http): improve HTTP/3 connection reuse and clean up stale proxy state 2026-03-26 07:05:57 +00:00
437d1a3329 v26.2.3
Some checks failed
Default (tags) / security (push) Failing after 3s
Default (tags) / test (push) Failing after 3s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-25 07:26:47 +00:00
746d93663d fix(repo): no changes to commit 2026-03-25 07:26:47 +00:00
a3f3fee253 v26.2.2
Some checks failed
Default (tags) / security (push) Failing after 4s
Default (tags) / test (push) Failing after 5s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-25 07:22:17 +00:00
53dee1fffc fix(proxy): improve connection cleanup and route validation handling 2026-03-25 07:22:17 +00:00
34dc0cb9b6 v26.2.1
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-23 11:11:55 +00:00
c83c43194b fix(rustproxy-http): include the upstream request URL when caching H3 Alt-Svc discoveries 2026-03-23 11:11:55 +00:00
d026d7c266 v26.2.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-23 09:42:07 +00:00
3b01144c51 feat(protocol-cache): add sliding TTL re-probing and eviction for backend protocol detection 2026-03-23 09:42:07 +00:00
56f5697e1b v26.1.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-22 10:20:00 +00:00
f04875885f feat(rustproxy-http): add protocol failure suppression, h3 fallback escalation, and protocol cache metrics exposure 2026-03-22 10:20:00 +00:00
d12812bb8d v26.0.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-21 22:23:38 +00:00
fc04a0210b BREAKING CHANGE(ts-api,rustproxy): remove deprecated TypeScript protocol and utility exports while hardening QUIC, HTTP/3, WebSocket, and rate limiter cleanup paths 2026-03-21 22:23:38 +00:00
33fdf42a70 v25.17.10
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-20 08:57:18 +00:00
fb1c59ac9a fix(rustproxy-http): reuse the shared HTTP proxy service for HTTP/3 request handling 2026-03-20 08:57:18 +00:00
ea8224c400 v25.17.9
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-20 08:30:09 +00:00
da1cc58a3d fix(rustproxy-http): correct HTTP/3 host extraction and avoid protocol filtering during UDP route lookup 2026-03-20 08:30:09 +00:00
606c620849 v25.17.8
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-20 08:06:32 +00:00
4ae09ac6ae fix(rustproxy): use SNI-based certificate resolution for QUIC TLS connections 2026-03-20 08:06:32 +00:00
2fce910795 v25.17.7
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-20 07:50:41 +00:00
ff09cef350 fix(readme): document QUIC and HTTP/3 compatibility caveats 2026-03-20 07:50:41 +00:00
d0148b2ac3 v25.17.6
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-20 07:48:26 +00:00
7217e15649 fix(rustproxy-http): disable HTTP/3 GREASE for client and server connections 2026-03-20 07:48:26 +00:00
bfcf92a855 v25.17.5
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-20 07:43:32 +00:00
8e0804cd20 fix(rustproxy): add HTTP/3 integration test for QUIC response stream FIN handling 2026-03-20 07:43:32 +00:00
c63f6fcd5f v25.17.4
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-20 03:19:57 +00:00
f3cd4d193e fix(rustproxy-http): prevent HTTP/3 response body streaming from hanging on backend completion 2026-03-20 03:19:57 +00:00
81de611255 v25.17.3
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-20 02:54:44 +00:00
91598b3be9 fix(repository): no changes detected 2026-03-20 02:54:44 +00:00
4e3c548012 v25.17.2
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-20 02:53:41 +00:00
1a2d7529db fix(rustproxy-http): enable TLS connections for HTTP/3 upstream requests when backend re-encryption or TLS is configured 2026-03-20 02:53:41 +00:00
31514f54ae v25.17.1
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-20 02:35:22 +00:00
247653c9d0 fix(rustproxy-routing): allow QUIC UDP TLS connections without SNI to match domain-restricted routes 2026-03-20 02:35:22 +00:00
07d88f6f6a v25.17.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 23:16:42 +00:00
4b64de2c67 feat(rustproxy-passthrough): add PROXY protocol v2 client IP handling for UDP and QUIC listeners 2026-03-19 23:16:42 +00:00
e8db7bc96d v25.16.3
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 22:00:07 +00:00
2621dea9fa fix(rustproxy): upgrade fallback UDP listeners to QUIC when TLS certificates become available 2026-03-19 22:00:07 +00:00
bb5b9b3d12 v25.16.2
Some checks failed
Default (tags) / security (push) Failing after 12s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 21:24:05 +00:00
d70c2d77ed fix(rustproxy-http): cache backend Alt-Svc only from original upstream responses during protocol auto-detection 2026-03-19 21:24:05 +00:00
4cf13c36f8 v25.16.1
Some checks failed
Default (tags) / security (push) Failing after 19s
Default (tags) / test (push) Failing after 18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 20:57:48 +00:00
37c7233780 fix(http-proxy): avoid repeated HTTP/3 recaching after QUIC fallback and document backend protocol selection 2026-03-19 20:57:48 +00:00
15d0a721d5 v25.16.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 20:27:57 +00:00
af970c447e feat(quic,http3): add HTTP/3 proxy handling and hot-reload QUIC TLS configuration 2026-03-19 20:27:57 +00:00
9e1103e7a7 v25.15.0
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 18:55:31 +00:00
2b990527ac feat(readme): document UDP, QUIC, and HTTP/3 support in the README 2026-03-19 18:55:31 +00:00
9595f0a9fc v25.14.1
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 16:21:37 +00:00
0fb3988123 fix(deps): update build and runtime dependencies and align route validation test expectations 2026-03-19 16:21:37 +00:00
53938df8db v25.14.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 2s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 16:09:51 +00:00
e890bda8fc feat(udp,http3): add UDP datagram handler relay support and stream HTTP/3 request bodies to backends 2026-03-19 16:09:51 +00:00
bbe8b729ea v25.13.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 15:06:27 +00:00
4fb91cd868 feat(smart-proxy): add UDP transport support with QUIC/HTTP3 routing and datagram handler relay 2026-03-19 15:06:27 +00:00
cfa958cf3d v25.12.0
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-19 12:41:26 +00:00
db2e586da2 feat(proxy-protocol): add PROXY protocol v2 support to the Rust passthrough listener and streamline TypeScript proxy protocol exports 2026-03-19 12:41:26 +00:00
91832c368d v25.11.24
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-17 16:47:57 +00:00
c9d0fccb2d fix(rustproxy-http): improve async static file serving, websocket handshake buffering, and shared metric metadata handling 2026-03-17 16:47:57 +00:00
5dccbbc9d1 v25.11.23
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-17 12:22:51 +00:00
92d7113c6c fix(rustproxy-http,rustproxy-metrics): reduce per-frame metrics overhead by batching body byte accounting 2026-03-17 12:22:51 +00:00
8f6bb30367 v25.11.22
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-17 12:12:24 +00:00
ef9bac80ff fix(rustproxy-http): reuse healthy HTTP/2 upstream connections after requests with bodies 2026-03-17 12:12:24 +00:00
9c78701038 v25.11.21
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-17 11:33:34 +00:00
26fd9409a7 fix(rustproxy-http): reuse pooled HTTP/2 connections for requests with and without bodies 2026-03-17 11:33:34 +00:00
cfff128499 v25.11.20
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-17 01:32:35 +00:00
3baff354bd fix(rustproxy-http): avoid downgrading cached backend protocol on H2 stream errors 2026-03-17 01:32:35 +00:00
c2eacd1b30 v25.11.19
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 2s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 20:53:39 +00:00
1fdbfcf0aa fix(rustproxy-http): avoid reusing pooled HTTP/2 connections for requests with bodies to prevent upload flow-control stalls 2026-03-16 20:53:39 +00:00
9b184acc8c v25.11.18
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 17:42:14 +00:00
b475968f4e fix(repo): no changes to commit 2026-03-16 17:42:14 +00:00
878eab6e88 v25.11.17
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 14:30:43 +00:00
77abe0804d fix(rustproxy-http): prevent stale HTTP/2 connection drivers from evicting newer pooled connections 2026-03-16 14:30:43 +00:00
ae0342d018 v25.11.16
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 13:58:22 +00:00
365981d9cf fix(repo): no changes to commit 2026-03-16 13:58:22 +00:00
2cc0ff0030 v25.11.15
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 13:54:56 +00:00
72935e7ee0 fix(rustproxy-http): implement vectored write support for backend streams 2026-03-16 13:54:56 +00:00
61db285e04 v25.11.14
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 13:44:56 +00:00
d165829022 fix(rustproxy-http): forward vectored write support in ShutdownOnDrop AsyncWrite wrapper 2026-03-16 13:44:56 +00:00
5e6cf391ab v25.11.13
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 13:17:02 +00:00
2b1a21c599 fix(rustproxy-http): remove hot-path debug logging from HTTP/1 connection pool hits 2026-03-16 13:17:02 +00:00
b8e1c9f3cf v25.11.12
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 13:12:24 +00:00
c65369540c fix(rustproxy-http): remove connection pool hit logging and keep logging limited to actual failures 2026-03-16 13:12:24 +00:00
59e108edbd v25.11.11
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 2s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 13:01:32 +00:00
1e2ca68fc7 fix(rustproxy-http): improve HTTP/2 proxy error logging with warning-level connection failures and debug error details 2026-03-16 13:01:32 +00:00
4c76a9f9f3 v25.11.10
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 12:29:15 +00:00
8e76c42cea fix(rustproxy-http): validate pooled HTTP/2 connections asynchronously before reuse and evict stale senders 2026-03-16 12:29:15 +00:00
b1f4181139 v25.11.9
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 09:38:55 +00:00
a1b8d40011 fix(rustproxy-routing): reduce hot-path allocations in routing, metrics, and proxy protocol handling 2026-03-16 09:38:55 +00:00
246b44913e v25.11.8
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 08:58:11 +00:00
b3d4949225 fix(rustproxy-http): prevent premature idle timeouts during streamed HTTP responses and ensure TLS close_notify is sent on dropped connections 2026-03-16 08:58:11 +00:00
0475e6b442 v25.11.7
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 03:01:16 +00:00
8cdb95a853 fix(rustproxy): prevent TLS route reload certificate mismatches and tighten passthrough connection handling 2026-03-16 03:01:16 +00:00
8cefe9d66a v25.11.6
Some checks failed
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 02:05:47 +00:00
d5e08c83fc fix(rustproxy-http,rustproxy-passthrough): improve upstream connection cleanup and graceful tunnel shutdown 2026-03-16 02:05:47 +00:00
1247f48856 v25.11.5
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-16 00:03:10 +00:00
e3bae4c399 fix(repo): no changes to commit 2026-03-16 00:03:10 +00:00
0930f7e10c v25.11.4
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-15 21:44:32 +00:00
aa9e6dfd94 fix(rustproxy-http): report streamed HTTP and WebSocket bytes per chunk for real-time throughput metrics 2026-03-15 21:44:32 +00:00
211d5cf835 v25.11.3
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-15 17:00:33 +00:00
2ce1899337 fix(repo): no changes to commit 2026-03-15 17:00:33 +00:00
2e2ffc4485 v25.11.2
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-15 16:58:41 +00:00
da26816af5 fix(rustproxy-http): avoid reusing HTTP/1 senders during streaming responses and relax HTTP/2 keep-alive timeouts 2026-03-15 16:58:41 +00:00
d598bffec3 v25.11.1
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-15 16:24:41 +00:00
a9dbccfaff fix(rustproxy-http): keep connection idle tracking alive during streaming and tune HTTP/2 connection lifetimes 2026-03-15 16:24:41 +00:00
386859a2bd v25.11.0
Some checks failed
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-15 16:00:26 +00:00
2b58615d24 feat(rustproxy-http): add HTTP/2 Extended CONNECT WebSocket proxy support 2026-03-15 16:00:26 +00:00
95adf56e52 v25.10.7
Some checks failed
Default (tags) / security (push) Successful in 1m4s
Default (tags) / test (push) Failing after 4m5s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-12 22:41:20 +00:00
c96a493fb6 fix(rustproxy-http): remove Host header from HTTP/2 upstream requests while preserving it for HTTP/1 retries 2026-03-12 22:41:20 +00:00
b92587cc16 v25.10.6
Some checks failed
Default (tags) / security (push) Successful in 52s
Default (tags) / test (push) Failing after 4m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-12 22:06:11 +00:00
b3dc0a6db2 fix(rustproxy-http): use the requested domain as HTTP/2 authority instead of the backend host and port 2026-03-12 22:06:11 +00:00
de3b8d3f58 v25.10.5
Some checks failed
Default (tags) / security (push) Successful in 1m12s
Default (tags) / test (push) Failing after 4m5s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-12 21:53:04 +00:00
75089ec975 fix(rustproxy-http): configure HTTP/2 client builders with a Tokio timer for keep-alive handling 2026-03-12 21:53:04 +00:00
b106db932b v25.10.4
Some checks failed
Default (tags) / security (push) Successful in 42s
Default (tags) / test (push) Failing after 4m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-12 21:41:54 +00:00
fb0c0dcc31 fix(rustproxy-http): stabilize upstream HTTP/2 forwarding and fallback behavior 2026-03-12 21:41:54 +00:00
61b67b91a0 v25.10.3
Some checks failed
Default (tags) / security (push) Successful in 42s
Default (tags) / test (push) Failing after 4m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-12 21:04:58 +00:00
fc64f5a95e fix(rustproxy-http): include request domain in backend proxy error and protocol detection logs 2026-03-12 21:04:58 +00:00
90b83a9dbe v25.10.2
Some checks failed
Default (tags) / security (push) Successful in 41s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-12 20:11:55 +00:00
508621e231 fix(repo): no code changes to release 2026-03-12 20:11:55 +00:00
9ef21dcb41 v25.10.1
Some checks failed
Default (tags) / security (push) Successful in 44s
Default (tags) / test (push) Failing after 4m2s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-12 20:00:56 +00:00
0acd907431 fix(repo): no changes to commit 2026-03-12 20:00:56 +00:00
80276a70e8 v25.10.0
Some checks failed
Default (tags) / security (push) Successful in 48s
Default (tags) / test (push) Failing after 4m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-12 15:16:11 +00:00
0d4399d7f1 feat(metrics): add per-backend connection, error, protocol, and pool metrics with stale backend pruning 2026-03-12 15:16:11 +00:00
0380a957d0 v25.9.3
Some checks failed
Default (tags) / security (push) Successful in 41s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-11 11:28:57 +00:00
5271447264 fix(rustproxy-http): Evict stale HTTP/2 pooled senders and retry bodyless requests with fresh backend connections to avoid 502s 2026-03-11 11:28:57 +00:00
be9898805f v25.9.2
Some checks failed
Default (tags) / security (push) Successful in 41s
Default (tags) / test (push) Failing after 4m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-08 15:24:18 +00:00
d4aa46aed7 fix(protocol-cache): Include requested_host in protocol detection cache key to avoid cache oscillation when multiple frontend domains share the same backend 2026-03-08 15:24:18 +00:00
4f1c5c919f v25.9.1
Some checks failed
Default (tags) / security (push) Successful in 48s
Default (tags) / test (push) Failing after 4m3s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-03 16:14:16 +00:00
d51b2c5890 fix(rustproxy): Cancel connections for routes removed/disabled by adding per-route cancellation tokens and make RouteManager swappable (ArcSwap) for runtime updates 2026-03-03 16:14:16 +00:00
bb471a8cc9 v25.9.0
Some checks failed
Default (tags) / security (push) Successful in 41s
Default (tags) / test (push) Failing after 4m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-03 11:04:01 +00:00
c52128f12d feat(rustproxy-http): add HTTP/2 auto-detection via ALPN with TTL-backed protocol cache and h1-only/h2 ALPN client configs 2026-03-03 11:04:01 +00:00
e69de246e9 v25.8.5
Some checks failed
Default (tags) / security (push) Successful in 43s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-26 21:31:38 +00:00
5126049ae6 fix(release): bump patch version (no source changes) 2026-02-26 21:31:38 +00:00
8db621657f fix(proxy): close connection buildup vectors in HTTP idle, WebSocket, socket relay, and TLS forwarding paths
- Add HTTP keep-alive idle timeout (60s default) with periodic watchdog that
  skips active requests (panic-safe via RAII ActiveRequestGuard)
- Make WebSocket inactivity/max-lifetime timeouts configurable from ConnectionConfig
  instead of hardcoded 1h/24h
- Replace bare copy_bidirectional in socket handler relay with timeout+cancel-aware
  split forwarding (inactivity, max lifetime, graceful shutdown)
- Add CancellationToken to forward_bidirectional_split_with_timeouts so TLS-terminated
  TCP connections respond to graceful shutdown
- Fix graceful_stop to actually abort listener tasks that exceed the shutdown deadline
  (previously they detached and ran forever)
- Add 10s metadata parsing timeout on TS socket-handler-server to prevent stuck sockets
2026-02-26 21:29:19 +00:00
ef060d5e79 v25.8.4
Some checks failed
Default (tags) / security (push) Successful in 40s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-26 17:32:35 +00:00
cd7f3f7f75 fix(proxy): adjust default proxy timeouts and keep-alive behavior to shorter, more consistent values 2026-02-26 17:32:35 +00:00
8df18728d4 v25.8.3
Some checks failed
Default (tags) / security (push) Successful in 29s
Default (tags) / test (push) Failing after 4m2s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-26 17:01:57 +00:00
bedecc6b6b fix(smartproxy): no code or dependency changes detected; no version bump required 2026-02-26 17:01:57 +00:00
b5f166bc92 v25.8.2
Some checks failed
Default (tags) / security (push) Successful in 31s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-26 16:58:30 +00:00
94266222fe fix(connection): improve connection handling and timeouts 2026-02-26 16:58:30 +00:00
697d51a9d4 v25.8.1
Some checks failed
Default (tags) / security (push) Successful in 42s
Default (tags) / test (push) Failing after 4m4s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-25 00:12:41 +00:00
7e5fe2bec3 fix(allocator): switch global allocator from tikv-jemallocator to mimalloc 2026-02-25 00:12:41 +00:00
f592bf627f v25.8.0
Some checks failed
Default (tags) / security (push) Successful in 42s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-24 23:22:49 +00:00
6114a00fb8 feat(rustproxy): use tikv-jemallocator as the global allocator to reduce glibc fragmentation and slow RSS growth; add allocator dependency and enable it in rustproxy, update lockfile, and run tsrust before tests 2026-02-24 23:22:49 +00:00
98089b0351 v25.7.10
Some checks failed
Default (tags) / security (push) Successful in 41s
Default (tags) / test (push) Failing after 4m2s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-24 20:56:37 +00:00
33cd5330c4 fix(rustproxy): Use cooperative cancellation for background tasks, prune stale caches and metric entries, and switch tests to dynamic port allocation to avoid port conflicts 2026-02-24 20:56:37 +00:00
755c81c042 v25.7.9
Some checks failed
Default (tags) / security (push) Successful in 44s
Default (tags) / test (push) Failing after 4m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-21 13:27:55 +00:00
9368226ce0 fix(tests): use high non-privileged ports in tests to avoid conflicts and CI failures 2026-02-21 13:27:55 +00:00
d4739045cd feat: enhance HTTP/2 support by ensuring Host header is set and adding multiplexed request tests 2026-02-20 18:30:57 +00:00
9521f2e044 feat: add TCP keepalive options and connection pooling for improved performance
- Added `socket2` dependency for socket options.
- Introduced `keep_alive`, `keep_alive_initial_delay_ms`, and `max_connections` fields in `ConnectionConfig`.
- Implemented TCP keepalive settings in `TcpListenerManager` for both client and backend connections.
- Created a new `ConnectionPool` for managing idle HTTP/1.1 and HTTP/2 connections to reduce overhead.
- Enhanced TLS configuration to support ALPN for HTTP/2.
- Added performance tests for connection pooling, stability, and concurrent connections.
2026-02-20 18:16:09 +00:00
0f6752b9a7 v25.7.8
Some checks failed
Default (tags) / security (push) Successful in 12m17s
Default (tags) / test (push) Failing after 4m14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-19 14:21:05 +00:00
b8b7490d44 fix(no-changes): no changes detected; nothing to release 2026-02-19 14:21:05 +00:00
8c2042a2f5 v25.7.7
Some checks failed
Default (tags) / security (push) Successful in 12m19s
Default (tags) / test (push) Failing after 4m16s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-19 14:03:31 +00:00
3514260316 fix(proxy): restrict PROXY protocol parsing to configured trusted proxy IPs and parse PROXY headers before metrics/fast-path so client IPs reflect the real source 2026-02-19 14:03:31 +00:00
f171cc8c5d v25.7.6
Some checks failed
Default (tags) / security (push) Successful in 12m20s
Default (tags) / test (push) Failing after 4m18s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-19 09:12:50 +00:00
c7722c30f3 fix(throughput): add tests for per-IP connection tracking and throughput history; assert per-IP eviction after connection close to prevent memory leak 2026-02-19 09:12:50 +00:00
0ae882731a v25.7.5
Some checks failed
Default (tags) / security (push) Successful in 12m22s
Default (tags) / test (push) Failing after 4m16s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-19 08:48:46 +00:00
53d73c7dc6 fix(rustproxy): prune stale per-route metrics, add per-route rate limiter caching and regex cache, and improve connection tracking cleanup to prevent memory growth 2026-02-19 08:48:46 +00:00
b4b8bd925d v25.7.4
Some checks failed
Default (tags) / security (push) Successful in 12m5s
Default (tags) / test (push) Failing after 4m5s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-19 08:07:34 +00:00
5ac44b898b fix(smart-proxy): include proxy IPs in smart proxy configuration 2026-02-19 08:07:34 +00:00
9b4393b5ac v25.7.3
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-16 14:35:26 +00:00
02b4ed8018 fix(metrics): centralize connection-closed reporting via ConnectionGuard and remove duplicate explicit metrics.connection_closed calls 2026-02-16 14:35:26 +00:00
e4e4b4f1ec v25.7.2
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 4m4s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-16 13:43:22 +00:00
d361a21543 fix(rustproxy-http): preserve original Host header when proxying and add X-Forwarded-* headers; add TLS WebSocket echo backend helper and integration test for terminate-and-reencrypt websocket 2026-02-16 13:43:22 +00:00
106713a546 v25.7.1
Some checks failed
Default (tags) / security (push) Successful in 34s
Default (tags) / test (push) Failing after 4m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-16 13:29:45 +00:00
101675b5f8 fix(proxy): use TLS to backends for terminate-and-reencrypt routes 2026-02-16 13:29:45 +00:00
9fac17bc39 v25.7.0
Some checks failed
Default (tags) / security (push) Successful in 30s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-16 12:11:49 +00:00
2e3cf515a4 feat(routes): add protocol-based route matching and ensure terminate-and-reencrypt routes HTTP through the full HTTP proxy; update docs and tests 2026-02-16 12:11:49 +00:00
754d32fd34 v25.6.0
Some checks failed
Default (tags) / security (push) Successful in 1m39s
Default (tags) / test (push) Failing after 5m7s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-16 12:02:36 +00:00
f0b7c27996 feat(rustproxy): add protocol-based routing and backend TLS re-encryption support 2026-02-16 12:02:36 +00:00
db932e8acc v25.5.0
Some checks failed
Default (tags) / security (push) Successful in 1m1s
Default (tags) / test (push) Failing after 5m5s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-16 03:00:39 +00:00
455d5bb757 feat(tls): add shared TLS acceptor with SNI resolver and session resumption; prefer shared acceptor and fall back to per-connection when routes specify custom TLS versions 2026-02-16 03:00:39 +00:00
fa2a27df6d v25.4.0
Some checks failed
Default (tags) / security (push) Successful in 30s
Default (tags) / test (push) Failing after 5m5s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-16 01:37:43 +00:00
7b2ccbdd11 feat(rustproxy): support dynamically loaded TLS certificates via loadCertificate IPC and include them in listener TLS configs for rebuilds and hot-swap 2026-02-16 01:37:43 +00:00
8409984fcc v25.3.1
Some checks failed
Default (tags) / security (push) Successful in 1m44s
Default (tags) / test (push) Failing after 5m8s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-15 15:05:03 +00:00
af10d189a3 fix(plugins): remove unused dependencies and simplify plugin exports 2026-02-15 15:05:03 +00:00
0b4d180cdf v25.3.0
Some checks failed
Default (tags) / security (push) Has been cancelled
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-02-14 14:02:25 +00:00
7b3545d1b5 feat(smart-proxy): add background concurrent certificate provisioning with per-domain timeouts and concurrency control 2026-02-14 14:02:25 +00:00
e837419d5d v25.2.2
Some checks failed
Default (tags) / security (push) Has been cancelled
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-02-14 12:42:20 +00:00
487a603fa3 fix(smart-proxy): start metrics polling before certificate provisioning to avoid blocking metrics collection 2026-02-14 12:42:20 +00:00
d6fdd3fc86 v25.2.1
Some checks failed
Default (tags) / security (push) Has been cancelled
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-02-14 12:28:42 +00:00
344f224c89 fix(smartproxy): no changes detected in git diff 2026-02-14 12:28:42 +00:00
6bbd2b3ee1 test(metrics): add v25.2.0 end-to-end assertions for per-IP, history, and HTTP request metrics 2026-02-14 12:24:48 +00:00
c44216df28 v25.2.0
Some checks failed
Default (tags) / security (push) Has been cancelled
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-02-14 11:15:17 +00:00
f80cdcf41c feat(metrics): add per-IP and HTTP-request metrics, propagate source IP through proxy paths, and expose new metrics to the TS adapter 2026-02-14 11:15:17 +00:00
6c84aedee1 v25.1.0
Some checks failed
Default (tags) / security (push) Has been cancelled
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-02-13 23:18:22 +00:00
1f95d2b6c4 feat(metrics): add real-time throughput sampling and byte-counting metrics 2026-02-13 23:18:22 +00:00
37372353d7 v25.0.0
Some checks failed
Default (tags) / security (push) Has been cancelled
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-02-13 21:24:16 +00:00
7afa4c4c58 BREAKING CHANGE(certs): accept a second eventComms argument in certProvisionFunction, add cert provisioning event types, and emit certificate lifecycle events 2026-02-13 21:24:16 +00:00
998662e137 v24.0.1
Some checks failed
Default (tags) / security (push) Has been cancelled
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-02-13 16:57:46 +00:00
a8f8946a4d fix(proxy): improve proxy robustness: add connect timeouts, graceful shutdown, WebSocket watchdog, and metrics guard 2026-02-13 16:57:46 +00:00
07e464fdac v24.0.0
Some checks failed
Default (tags) / security (push) Has been cancelled
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-02-13 16:32:02 +00:00
0e058594c9 BREAKING CHANGE(smart-proxy): move certificate persistence to an in-memory store and introduce consumer-managed certStore API; add default self-signed fallback cert and change ACME account handling 2026-02-13 16:32:02 +00:00
e0af82c1ef v23.1.6
Some checks failed
Default (tags) / security (push) Has been cancelled
Default (tags) / test (push) Has been cancelled
Default (tags) / release (push) Has been cancelled
Default (tags) / metadata (push) Has been cancelled
2026-02-13 13:08:30 +00:00
efe3d80713 fix(smart-proxy): disable built-in Rust ACME when a certProvisionFunction is provided and improve certificate provisioning flow 2026-02-13 13:08:30 +00:00
6b04bc612b v23.1.5
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-13 12:02:47 +00:00
e774ec87ca fix(smart-proxy): provision certificates for wildcard domains instead of skipping them 2026-02-13 12:02:47 +00:00
cbde778f09 v23.1.4
Some checks failed
Default (tags) / security (push) Successful in 43s
Default (tags) / test (push) Failing after 4m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-12 22:35:25 +00:00
bc2bc874a5 fix(tests): make tests more robust and bump small dependencies 2026-02-12 22:35:25 +00:00
fdabf807b0 v23.1.3
Some checks failed
Default (tags) / security (push) Successful in 44s
Default (tags) / test (push) Failing after 4m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-12 20:17:32 +00:00
81e0e6b4d8 fix(rustproxy): install default rustls crypto provider early; detect and skip raw fast-path for HTTP connections and return proper HTTP 502 when no route matches 2026-02-12 20:17:32 +00:00
28fa69bf59 v23.1.2
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-11 13:48:30 +00:00
5019658032 fix(core): use node: scoped builtin imports and add route unit tests 2026-02-11 13:48:30 +00:00
a9fe365c78 v23.1.1
Some checks failed
Default (tags) / security (push) Successful in 39s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-11 12:52:45 +00:00
32e0410227 fix(rust-proxy): increase rust proxy bridge maxPayloadSize to 100 MB and bump dependencies 2026-02-11 12:52:45 +00:00
fd56064495 v23.1.0
Some checks failed
Default (tags) / security (push) Successful in 35s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-02-10 09:43:40 +00:00
3b7e6a6ed7 feat(rust-bridge): integrate tsrust to build and locate cross-compiled Rust binaries; refactor rust-proxy bridge to use typed IPC and streamline process handling; add @push.rocks/smartrust and update build/dev dependencies 2026-02-10 09:43:40 +00:00
193 changed files with 26109 additions and 19968 deletions

View File

@@ -40,5 +40,8 @@
}, },
"@ship.zone/szci": { "@ship.zone/szci": {
"npmGlobalTools": [] "npmGlobalTools": []
},
"@git.zone/tsrust": {
"targets": ["linux_amd64", "linux_arm64"]
} }
} }

View File

@@ -1,19 +1,20 @@
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIDCzCCAfOgAwIBAgIUPU4tviz3ZvsMDjCz1NZRT16b0Y4wDQYJKoZIhvcNAQEL MIIDQTCCAimgAwIBAgIUJm+igT1AVSuwNzjvqjSF6cysw6MwDQYJKoZIhvcNAQEL
BQAwFTETMBEGA1UEAwwKcHVzaC5yb2NrczAeFw0yNTAyMDMyMzA5MzRaFw0yNjAy BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDIxMzIyMzI1MloXDTM2MDIx
MDMyMzA5MzRaMBUxEzARBgNVBAMMCnB1c2gucm9ja3MwggEiMA0GCSqGSIb3DQEB MTIyMzI1MlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AQUAA4IBDwAwggEKAoIBAQCZMkBYD/pYLBv9MiyHTLRT24kQyPeJBtZqryibi1jk AAOCAQ8AMIIBCgKCAQEAyjitkDd4DdlVk4TfVxKUqdxnJCGj9uyrUPAqR8hB6+bR
BT1ZgNl3yo5U6kjj/nYBU/oy7M4OFC0xyaJQ4wpvLHu7xzREqwT9N9WcDcxaahUi +8rW63ryBNYNRizxOGw41E19ascNuyA98mUF4oqjid1K4VqDiKzv1Uq/3NUunCw/
P8+PsjGyznPrtXa1ASzGAYMNvXyWWp3351UWZHMEs6eY/Y7i8m4+0NwP5h8RNBCF rEddR5hCoVkTsBJjzNgBJqncS606v0hfA00cCkpGR+Te7Q/E8T8lApioz1zFQ05Y
KSFS41Ee9rNAMCnQSHZv1vIzKeVYPmYnCVmL7X2kQb+gS6Rvq5sEGLLKMC5QtTwI C69oeJHIrJcrIkIFAgmXDgRF0Z4ErUeu+wVOWT267uVAYn5AdFMxCSIBsYtPseqy
rdkPGpx4xZirIyf8KANbt0sShwUDpiCSuOCtpze08jMzoHLG9Nv97cJQjb/BhiES cC5EQ6BCBtsIGitlRgzLRg957ZZa+SF38ao+/ijYmOLHpQT0mFaUyLT7BKgxguGs
hLL+YjfAUFjq0rQ38zFKLJ87QB9Jym05mY6IadGQLXVXAgMBAAGjUzBRMB0GA1Ud 8CHcIxN5Qo27J3sC5ymnrv2uk5DcAOUcxklXUbVCeQIDAQABo4GKMIGHMB0GA1Ud
DgQWBBQjpowWjrql/Eo2EVjl29xcjuCgkTAfBgNVHSMEGDAWgBQjpowWjrql/Eo2 DgQWBBShZhz7aX/KhleAfYKvTgyG5ANuDjAfBgNVHSMEGDAWgBShZhz7aX/KhleA
EVjl29xcjuCgkTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAY fYKvTgyG5ANuDjAPBgNVHRMBAf8EBTADAQH/MDQGA1UdEQQtMCuCCWxvY2FsaG9z
44vqbaf6ewFrZC0f3Kk4A10lC6qjWkcDFfw+JE8nzt+4+xPqp1eWgZKF2rONyAv2 dIIKcHVzaC5yb2Nrc4IMKi5wdXNoLnJvY2tzhwR/AAABMA0GCSqGSIb3DQEBCwUA
nG41Xygt19ByancXLU44KB24LX8F1GV5Oo7CGBA+xtoSPc0JulXw9fGclZDC6XiR A4IBAQAyUvjUszQp4riqa3CfBFFtjh+7DKNuQPOlYAwSEis4l+YK06Glx4fJBHcx
P/+vhGgCHicbfP2O+N00pOifrTtf2tmOT4iPXRRo4TxmPzuCd+ZJTlBhPlKCmICq eCPhQ/0wnPzi6CZe3vVRXd5fX27nVs+lMQD6Oc47F8OmTU6NXnb/1AcvrycDsP8D
yGdAiEo6HsSiP+M5qVlNx8s57MhQYk5TpgmI6FU4mO7zfDfSatFonlg+aDbrnaqJ 9Y9qecekbpegrN1W4D46goBAwvrd6Qy0EHi0Z5z02rfyXAdxm0OmdpuWoIMcEgUQ
v/+km02M+oB460GmKwsSTnThHZgLNCLiKqD8bdziiCQjx5u0GjLI6468o+Aehb8l YyXIq3zSFE6uoO61WdLvBcXN6iaiSTVy0605WncDe2+UT9MeNq6zi1JD34jsgUrd
l/x9vWTTk/QKq41X5hFk xq0WRUk2C6C4Irkf00Q12rXeL+Jv5OwyrUUZRvz0gLgG02UUbB/6Ca5GYNXniEuI
Py/EHTqbtjLIs7HxYjQH86FI9fUj
-----END CERTIFICATE----- -----END CERTIFICATE-----

View File

@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY----- -----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCZMkBYD/pYLBv9 MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDKOK2QN3gN2VWT
MiyHTLRT24kQyPeJBtZqryibi1jkBT1ZgNl3yo5U6kjj/nYBU/oy7M4OFC0xyaJQ hN9XEpSp3GckIaP27KtQ8CpHyEHr5tH7ytbrevIE1g1GLPE4bDjUTX1qxw27ID3y
4wpvLHu7xzREqwT9N9WcDcxaahUiP8+PsjGyznPrtXa1ASzGAYMNvXyWWp3351UW ZQXiiqOJ3UrhWoOIrO/VSr/c1S6cLD+sR11HmEKhWROwEmPM2AEmqdxLrTq/SF8D
ZHMEs6eY/Y7i8m4+0NwP5h8RNBCFKSFS41Ee9rNAMCnQSHZv1vIzKeVYPmYnCVmL TRwKSkZH5N7tD8TxPyUCmKjPXMVDTlgLr2h4kcislysiQgUCCZcOBEXRngStR677
7X2kQb+gS6Rvq5sEGLLKMC5QtTwIrdkPGpx4xZirIyf8KANbt0sShwUDpiCSuOCt BU5ZPbru5UBifkB0UzEJIgGxi0+x6rJwLkRDoEIG2wgaK2VGDMtGD3ntllr5IXfx
pze08jMzoHLG9Nv97cJQjb/BhiEShLL+YjfAUFjq0rQ38zFKLJ87QB9Jym05mY6I qj7+KNiY4selBPSYVpTItPsEqDGC4azwIdwjE3lCjbsnewLnKaeu/a6TkNwA5RzG
adGQLXVXAgMBAAECggEARGCBBq1PBHbfoUH5TQSIAlvdEEBa9+602lZG7jIioVfT SVdRtUJ5AgMBAAECggEAEM8piTp9I5yQVxr1RCv+mMiB99BGjHrTij6uawlXxnPJ
W7Uem5Ctuan+kcDcY9hbNsqqZ+9KgsvoJmlIGXoF2jjeE/4vUmRO9AHWoc5yk2Be Ol574zbU6Yc/8vh/JB8l0arvzQmHCAoX8B9K4BABZE81X1paYujqJi8ImAMN9Owe
4NjcxN3QMLdEfiLBnLlFCOd4CdX1ZxZ6TG3WRpV3a1pVIeeqHGB1sKT6Xd/atcwG LlQ/yhjbWAVbJDiBHHjLjrLRpaY8gwQxZqk5FpdiNG1vROIZzypeKZM2PAdke9HA
RvpiXzu0SutGxVb6WE9r6hovZ4fVERCyCRczUGrUH5ICbxf6E7L4u8xjEYR4uEKK PvJtsyfXdEz+jb5EUgaadyn7aquR6y607a8m55y34POLOcssteUOje4GdrTekHK0
/8ZkDqrWdRASDAdPPMNqnHUEAho/WnxpNeb6B4lvvv2QWxIS9H1OikF/NzWPgVNS 62E+iEnawBjIs7gBzJf0j1XjFNq3aAeLrn8gFCEb+yK7X++8FJ8YjwsqS5V1aMsR
oPpvtJgjyo5xdgLm3zE4lcSPNVSrh1TBXuAn9TG4WQKBgQDScPFkUNBqjC5iPMof 1PZguW0jCzYHATc2OcIozlvdBriPxy7eX8Y3MFvNMQKBgQD22ReUyKX5TKA/fg3z
bqDHlhlptrHmiv9LC0lgjEDPgIEQfjLfdCugwDk32QyAcb5B60upDYeqCFDkfV/C S/QGfYqd4T35jkwK1MaXOuFOBzNyTMT6ZJkbjxPOYPB0uakcfIlva8bI77mE5vRe
T536qxevYPjPAjahLPHqMxkWpjvtY6NOTgbbcpVtblU2Fj8R8qbyPNADG31LicU9 PWYlvitp9Zz3v2kt/WgnwG32ZdVedPjEoi9aitUXmiiIoxdPVAUAgLPFFN65Sr2G
GVPtQ4YcVaMWCYbg5107+9dFWQKBgQC6XK+foKK+81RFdrqaNNgebTWTsANnBcZe 2NM/vduZcAPUr0UWnFx4dlpo8QKBgQDRuAV44Y+1396511oW4OR8ofWFECMc5uEV
xl0bj6oL5yY0IzroxHvgcNS7UMriWCu+K2xfkUBdMmxU773VN5JQ5k15ezjgtrvc wQ26EpbilEYhRSBty+1PAK5AcEGybeVtUn9RSmx0Ef1L15wnzP/C886LIzkaig/9
8oAaEsxYP4su12JSTC/zsBANUgrNbFj8++qqKYWt2aQc2O/kbZ4MNfekIVFc8AjM xs0yudXgOFdBAzYQKnK2lZmSKkzcUFJtifat3E+ZMCo/duhzXpzecg/lVNGh6gcx
2X9PxvxKLwKBgHXL7QO3TQLnVyt8VbQEjBFMzwriznB7i+4o8jkOKVU93IEr8zQr xbtphJCyCQKBgEO8zvvFE8aVgGPr82gQL6aYTLGGXbtdkQBn4xcc0TbYQwXaizMq
5iQElcLSR3I6uUJTALYvsaoXH5jXKVwujwL69LYiNQRDe+r6qqvrUHbiNJdsd8Rk 59joKkc30sQ1LnLiudQZfzMklYQi3Gv/7UfuJ3usKqbRn8s+/pXp+ELlLuf8sUdE
XuhGGqj34tD04Pcd+h+MtO+YWqmHBBZwcA9XBeIkebbjPFH2kLT8AwN5AoGAYQy9 OjpeXptbckQMfRkHtVet+abbU0MFf3zBgza6osg4NNToQ80wmy9zStwBAoGAGLeD
hMJxnkE3hIkk+gNE/OtgeE20J+Vw/ZANkrnJEzPHyGUEW41e+W2oyvdzAFZsSTdx jZeoBFt6OJT0/TVMOJQuB5y7RrC/Xnz+TSvbtKCdE1a+V7JtKZ5+6wFP/OOO4q+S
037f5ujIU58Z27x53NliRT4vS4693H0Iyws5EUfeIoGVuUflvODWKymraHjhCrXh adZHqfZk0Ad9VAOJMUTi1usz07jp4ZMIpC3a0y5QukzSll0qX/KJwvxRSrX8wQQ9
6cV/0R5DAabTnsCbCr7b/MRBC8YQvyUQ0KnOXo8CgYBQYGpvJnSWyvsCjtb6apTP mogYqYlPsWMmSlKgUmdHEFRK0LZwWqFfUTRaiWECgYEA6KR6KMbqnYn5CglHeD42
drjcBhVd0aSBpLGtDdtUCV4oLl9HPy+cLzcGaqckBqCwEq5DKruhMEf7on56bUMd NmOgFYXljRLIxS1coTiWWQZM/nUyx/tSk+MAS7770USHoZhAfh6lmEa/AeKSoLVl
m/3ItFk1TnhysAeJHb3zLqmJ9CKBitpqLlsOE7MEXVNmbTYeXU10Uo9yOfyt1i7T Su3yzgtKk1/doAtbiWD8TasHAhacwWmzTuZtH5cZUgW3QIVJg6ADi6m8zswqKxIS
su+nT5VtyPkmF/l4wZl5+g== qfU/1N4aHp832v4ggRe/Og0=
-----END PRIVATE KEY----- -----END PRIVATE KEY-----

View File

@@ -1,5 +1,764 @@
# Changelog # Changelog
## 2026-03-26 - 27.0.0 - BREAKING CHANGE(smart-proxy)
remove route helper APIs and standardize route configuration on plain route objects
- Removes TypeScript route helper exports and related Rust config helpers in favor of defining routes directly with match and action properties.
- Updates documentation and tests to use plain IRouteConfig objects and SocketHandlers imports instead of helper factory functions.
- Moves socket handlers to a top-level utils export and keeps direct socket-handler route configuration as the supported pattern.
## 2026-03-26 - 26.3.0 - feat(nftables)
move NFTables forwarding management from the Rust engine to @push.rocks/smartnftables
- add @push.rocks/smartnftables as a runtime dependency and export it via the plugin layer
- remove the internal rustproxy-nftables crate along with Rust-side NFTables rule application and status management
- apply and clean up NFTables port-forwarding rules in the TypeScript SmartProxy lifecycle and route update flow
- change getNfTablesStatus to return local smartnftables status instead of querying the Rust bridge
- update README documentation to describe NFTables support as provided through @push.rocks/smartnftables
## 2026-03-26 - 26.2.4 - fix(rustproxy-http)
improve HTTP/3 connection reuse and clean up stale proxy state
- Reuse pooled HTTP/3 SendRequest handles to skip repeated SETTINGS handshakes and reduce request overhead on QUIC pool hits
- Add periodic cleanup for per-route rate limiters and orphaned backend metrics to prevent unbounded memory growth after traffic or backend errors stop
- Enforce HTTP max connection lifetime alongside idle timeouts and apply configured lifetime values from the TCP listener
- Reduce HTTP/3 body copying by using owned Bytes paths for request and response streaming, and replace the custom response body adapter with a stream-based implementation
- Harden auxiliary proxy components by capping datagram handler buffer growth and removing duplicate RustProxy exit listeners
## 2026-03-25 - 26.2.3 - fix(repo)
no changes to commit
## 2026-03-25 - 26.2.2 - fix(proxy)
improve connection cleanup and route validation handling
- add timeouts for HTTP/1 upstream connection drivers to prevent lingering tasks
- ensure QUIC relay sessions cancel and abort background tasks on drop
- avoid registering unnamed routes as duplicates and label unnamed catch-all conflicts clearly
- fix offset mapping route helper to forward only remaining route options without overriding derived values
- update project config filename and toolchain versions for the current build setup
## 2026-03-23 - 26.2.1 - fix(rustproxy-http)
include the upstream request URL when caching H3 Alt-Svc discoveries
- Tracks the request path that triggered Alt-Svc discovery in connection activity state
- Adds request URL context to Alt-Svc debug logging and protocol cache insertion reasons for better traceability
## 2026-03-23 - 26.2.0 - feat(protocol-cache)
add sliding TTL re-probing and eviction for backend protocol detection
- extend protocol cache entries and metrics with last accessed and last probed timestamps
- trigger periodic ALPN re-probes for cached H1/H2 entries while keeping active entries alive with a sliding 1 day TTL
- log protocol transitions with reasons and evict cache entries when all protocol fallback attempts fail
## 2026-03-22 - 26.1.0 - feat(rustproxy-http)
add protocol failure suppression, h3 fallback escalation, and protocol cache metrics exposure
- introduces escalating cooldowns for failed H2/H3 protocol detection to prevent repeated upgrades to unstable backends
- adds within-request escalation to cached HTTP/3 when TCP or TLS backend connections fail in auto-detect mode
- exposes detected protocol cache entries and suppression state through Rust metrics and the TypeScript metrics adapter
## 2026-03-21 - 26.0.0 - BREAKING CHANGE(ts-api,rustproxy)
remove deprecated TypeScript protocol and utility exports while hardening QUIC, HTTP/3, WebSocket, and rate limiter cleanup paths
- Removes large parts of the public TypeScript surface including detection, TLS, router, websocket, proxy/common protocol, and multiple core utility exports and files.
- Adds parent-child cancellation handling for HTTP/3 and QUIC stream forwarding to stop orphaned tasks and close idle or overlong streams.
- Improves cleanup reliability with RAII guards for WebSocket upstream tracking and QUIC connection metrics, plus periodic cleanup for rate limiter and proxy address maps.
- Cleans backend metrics state when active backend connections drop to zero and tracks passthrough backend sockets for shutdown cleanup.
## 2026-03-20 - 25.17.10 - fix(rustproxy-http)
reuse the shared HTTP proxy service for HTTP/3 request handling
- Refactors H3ProxyService to delegate requests to the shared HttpProxyService instead of maintaining separate routing and backend forwarding logic.
- Aligns HTTP/3 with the TCP/HTTP path for route matching, connection pooling, and ALPN-based upstream protocol detection.
- Generalizes request handling and filters to accept boxed/generic HTTP bodies so both HTTP/3 and existing HTTP paths share the same proxy pipeline.
- Updates the HTTP/3 integration route matcher to allow transport matching across shared HTTP and QUIC handling.
## 2026-03-20 - 25.17.9 - fix(rustproxy-http)
correct HTTP/3 host extraction and avoid protocol filtering during UDP route lookup
- Use the URI host or strip the port from the Host header so HTTP/3 requests match routes consistently with TCP/HTTP handling.
- Remove protocol filtering from HTTP/3 route lookup because QUIC transport already constrains routing to UDP and protocol validation happens earlier.
## 2026-03-20 - 25.17.8 - fix(rustproxy)
use SNI-based certificate resolution for QUIC TLS connections
- Replaces static first-certificate selection with the shared CertResolver used by the TCP/TLS path.
- Ensures QUIC connections can present the correct certificate per requested domain.
- Keeps HTTP/3 ALPN configuration while improving multi-domain TLS handling.
## 2026-03-20 - 25.17.7 - fix(readme)
document QUIC and HTTP/3 compatibility caveats
- Add notes explaining that GREASE frames are disabled on both server and client HTTP/3 paths to avoid interoperability issues
- Document that the current HTTP/3 stack depends on pre-1.0 h3 ecosystem components and may still have rough edges
## 2026-03-20 - 25.17.6 - fix(rustproxy-http)
disable HTTP/3 GREASE for client and server connections
- Switch the HTTP/3 server connection setup to use the builder API with send_grease(false)
- Switch the HTTP/3 client handshake to use the builder API with send_grease(false) to improve compatibility
## 2026-03-20 - 25.17.5 - fix(rustproxy)
add HTTP/3 integration test for QUIC response stream FIN handling
- adds an integration test covering HTTP/3 proxying over QUIC with TLS termination
- verifies response bodies fully arrive and the client receives stream termination instead of hanging
- adds test-only dependencies for quinn, h3, h3-quinn, rustls, bytes, and http
## 2026-03-20 - 25.17.4 - fix(rustproxy-http)
prevent HTTP/3 response body streaming from hanging on backend completion
- extract and track Content-Length before consuming the response body
- stop the HTTP/3 body loop when the stream reports end-of-stream or the expected byte count has been sent
- add a per-frame idle timeout to avoid indefinite waits on stalled or close-delimited backend bodies
## 2026-03-20 - 25.17.3 - fix(repository)
no changes detected
## 2026-03-20 - 25.17.2 - fix(rustproxy-http)
enable TLS connections for HTTP/3 upstream requests when backend re-encryption or TLS is configured
- Pass backend TLS client configuration into the HTTP/3 request handler.
- Detect TLS-required upstream targets using route and target TLS settings before connecting.
- Build backend request URIs with the correct http or https scheme to match the upstream connection.
## 2026-03-20 - 25.17.1 - fix(rustproxy-routing)
allow QUIC UDP TLS connections without SNI to match domain-restricted routes
- Exempts UDP transport from the no-SNI rejection logic because QUIC encrypts the TLS ClientHello and SNI is unavailable at accept time
- Adds regression tests to confirm QUIC route matching succeeds without SNI while TCP TLS without SNI remains rejected
## 2026-03-19 - 25.17.0 - feat(rustproxy-passthrough)
add PROXY protocol v2 client IP handling for UDP and QUIC listeners
- propagate trusted proxy IP configuration into UDP and QUIC listener managers
- extract and preserve real client addresses from PROXY protocol v2 headers for HTTP/3 and QUIC stream handling
- apply rate limiting, session limits, routing, and metrics using the resolved client IP while preserving correct proxy return-path routing
## 2026-03-19 - 25.16.3 - fix(rustproxy)
upgrade fallback UDP listeners to QUIC when TLS certificates become available
- Rebuild and apply QUIC TLS configuration during route and certificate updates instead of only when adding new UDP ports.
- Add logic to drain UDP sessions, stop raw fallback listeners, and start QUIC endpoints on existing ports once TLS is available.
- Retry QUIC endpoint creation during upgrade and fall back to rebinding raw UDP if the upgrade cannot complete.
## 2026-03-19 - 25.16.2 - fix(rustproxy-http)
cache backend Alt-Svc only from original upstream responses during protocol auto-detection
- Moves Alt-Svc discovery into streaming response construction so it reads backend headers before response filters inject client-facing Alt-Svc values
- Stores the protocol cache key in connection activity during auto-detect mode and clears it after HTTP/3 connection failure to avoid re-caching failed H3 routes
- Prevents fallback requests from reintroducing stale or self-injected Alt-Svc entries that could cause repeated H3 retry loops
## 2026-03-19 - 25.16.1 - fix(http-proxy)
avoid repeated HTTP/3 recaching after QUIC fallback and document backend protocol selection
- Suppress Alt-Svc HTTP/3 recaching after a failed QUIC backend connection to prevent repeated H3 timeout fallback loops
- Force an ALPN probe on TCP fallback so auto detection correctly reselects HTTP/2 or HTTP/1.1 after H3 connection failure
- Add README documentation for best-effort backendProtocol selection and supported protocol modes
## 2026-03-19 - 25.16.0 - feat(quic,http3)
add HTTP/3 proxy handling and hot-reload QUIC TLS configuration
- initialize and wire H3ProxyService into QUIC listeners so HTTP/3 requests are handled instead of being kept as placeholder connections
- add backend HTTP/3 support with protocol caching that stores Alt-Svc advertised H3 ports for auto-detection
- hot-swap TLS certificates across active QUIC endpoints and require terminating TLS for QUIC route validation
- document QUIC route setup with required TLS and ACME configuration
## 2026-03-19 - 25.15.0 - feat(readme)
document UDP, QUIC, and HTTP/3 support in the README
- Adds README examples for UDP datagram handlers, QUIC/HTTP3 forwarding, and dual-stack TCP/UDP routes
- Expands configuration and API reference sections to cover transport matching, UDP/QUIC options, backend transport selection, and UDP metrics
- Updates architecture and feature descriptions to reflect UDP, QUIC, HTTP/3, and datagram handler capabilities
## 2026-03-19 - 25.14.1 - fix(deps)
update build and runtime dependencies and align route validation test expectations
- split the test preparation step into a dedicated test:before script while keeping test execution separate
- bump development tooling and runtime package versions in package.json
- adjust the route validation test to match the current generic handler error message
## 2026-03-19 - 25.14.0 - feat(udp,http3)
add UDP datagram handler relay support and stream HTTP/3 request bodies to backends
- establish a persistent Unix socket relay for UDP datagram handlers and process handler replies back to clients
- update route validation and smart proxy route reload logic to support datagramHandler routes
- record UDP, QUIC, and HTTP/3 byte metrics more accurately, including request bytes in and UDP session cleanup connection tracking
- add integration tests for UDP forwarding, datagram handlers, and UDP metrics
## 2026-03-19 - 25.13.0 - feat(smart-proxy)
add UDP transport support with QUIC/HTTP3 routing and datagram handler relay
- adds UDP listener and session tracking infrastructure in the Rust proxy, including UDP metrics and hot-reload support for transport-specific ports
- introduces QUIC and HTTP/3 support in routing and HTTP handling, including Alt-Svc advertisement and QUIC TLS configuration
- extends route configuration types in Rust and TypeScript with transport, UDP, QUIC, backend transport, and mixed port range support
- adds a TypeScript datagram handler relay server and bridge command so UDP socket-handler routes can dispatch datagrams to application callbacks
- updates nftables rule generation so protocol=all creates both TCP and UDP rules
## 2026-03-19 - 25.12.0 - feat(proxy-protocol)
add PROXY protocol v2 support to the Rust passthrough listener and streamline TypeScript proxy protocol exports
- detect and parse PROXY protocol v2 headers in the Rust TCP listener, including TCP and UDP address families
- add Rust v2 header generation, incomplete-header handling, and broader parser test coverage
- remove deprecated TypeScript proxy protocol parser exports and tests, leaving shared type definitions only
## 2026-03-17 - 25.11.24 - fix(rustproxy-http)
improve async static file serving, websocket handshake buffering, and shared metric metadata handling
- convert static file serving to async filesystem operations and await directory/file checks
- preserve and forward bytes read past the WebSocket handshake header terminator to avoid dropping buffered upstream data
- reuse Arc<str> values for route and source identifiers across counting bodies and metric reporting
- standardize backend key propagation across H1/H2 forwarding, retry, and fallback paths for consistent logging and metrics
## 2026-03-17 - 25.11.23 - fix(rustproxy-http,rustproxy-metrics)
reduce per-frame metrics overhead by batching body byte accounting
- Buffer HTTP body byte counts and flush them every 64 KB, at end of stream, and on drop to keep totals accurate while preserving throughput sampling.
- Skip zero-value counter updates in metrics collection to avoid unnecessary atomic and DashMap operations for the unused direction.
## 2026-03-17 - 25.11.22 - fix(rustproxy-http)
reuse healthy HTTP/2 upstream connections after requests with bodies
- Registers successful HTTP/2 connections in the pool regardless of whether the proxied request included a body
- Continues to avoid pooling upstream connections that returned 502 Bad Gateway responses
## 2026-03-17 - 25.11.21 - fix(rustproxy-http)
reuse pooled HTTP/2 connections for requests with and without bodies
- remove the bodyless-request restriction from HTTP/2 pool checkout
- always return successful HTTP/2 senders to the connection pool after requests
## 2026-03-17 - 25.11.20 - fix(rustproxy-http)
avoid downgrading cached backend protocol on H2 stream errors
- Treat HTTP/2 stream-level failures as retryable request errors instead of evidence that the backend only supports HTTP/1.1
- Keep protocol cache entries unchanged after successful H2 handshakes so future requests continue using HTTP/2
- Lower log severity for this fallback path from warning to debug while still recording backend H2 failure metrics
## 2026-03-16 - 25.11.19 - fix(rustproxy-http)
avoid reusing pooled HTTP/2 connections for requests with bodies to prevent upload flow-control stalls
- Limit HTTP/2 pool checkout to bodyless requests such as GET, HEAD, and DELETE
- Skip re-registering HTTP/2 connections in the pool after requests that send a body
- Prevent stalled uploads caused by depleted connection-level flow control windows on reused HTTP/2 connections
## 2026-03-16 - 25.11.18 - fix(repo)
no changes to commit
## 2026-03-16 - 25.11.17 - fix(rustproxy-http)
prevent stale HTTP/2 connection drivers from evicting newer pooled connections
- add generation IDs to pooled HTTP/2 senders so pool removal only affects the matching connection
- update HTTP/2 proxy and retry paths to register generation-tagged connections and skip eviction before registration completes
## 2026-03-16 - 25.11.16 - fix(repo)
no changes to commit
## 2026-03-16 - 25.11.15 - fix(rustproxy-http)
implement vectored write support for backend streams
- Add poll_write_vectored forwarding for both plain and TLS backend stream variants
- Expose is_write_vectored so the proxy can correctly report vectored write capability
## 2026-03-16 - 25.11.14 - fix(rustproxy-http)
forward vectored write support in ShutdownOnDrop AsyncWrite wrapper
- Implements poll_write_vectored by delegating to the wrapped writer
- Exposes is_write_vectored so the wrapper preserves underlying AsyncWrite capabilities
## 2026-03-16 - 25.11.13 - fix(rustproxy-http)
remove hot-path debug logging from HTTP/1 connection pool hits
- Stops emitting debug logs when reusing HTTP/1 idle connections in the connection pool.
- Keeps pool hit behavior unchanged while reducing overhead on a frequently executed path.
## 2026-03-16 - 25.11.12 - fix(rustproxy-http)
remove connection pool hit logging and keep logging limited to actual failures
- Removes debug and warning logs for HTTP/2 connection pool hits and age checks.
- Keeps pool behavior unchanged while reducing noisy per-request logging in the Rust HTTP proxy layer.
## 2026-03-16 - 25.11.11 - fix(rustproxy-http)
improve HTTP/2 proxy error logging with warning-level connection failures and debug error details
- Adds debug-formatted error fields to HTTP/2 handshake, retry, fallback, and request failure logs
- Promotes upstream HTTP/2 connection error logs from debug to warn to improve operational visibility
## 2026-03-16 - 25.11.10 - fix(rustproxy-http)
validate pooled HTTP/2 connections asynchronously before reuse and evict stale senders
- Add an async ready() check with a 500ms timeout before reusing pooled HTTP/2 senders to catch GOAWAY/RST states before forwarding requests
- Return connection age from the HTTP/2 pool checkout path and log warnings for older pooled connections
- Evict pooled HTTP/2 senders when they are closed, exceed max age, fail readiness validation, or time out during readiness checks
## 2026-03-16 - 25.11.9 - fix(rustproxy-routing)
reduce hot-path allocations in routing, metrics, and proxy protocol handling
- skip HTTP header map construction unless a route on the current port uses header matching
- reuse computed client IP strings during HTTP route matching to avoid redundant allocations
- optimize per-route and per-IP metric updates with get-first lookups to avoid unnecessary String creation on existing entries
- replace heap-allocated PROXY protocol peek and discard buffers with stack-allocated buffers in the TCP listener
- improve domain matcher case-insensitive wildcard checks while preserving glob fallback behavior
## 2026-03-16 - 25.11.8 - fix(rustproxy-http)
prevent premature idle timeouts during streamed HTTP responses and ensure TLS close_notify is sent on dropped connections
- track active streaming response bodies so the HTTP idle watchdog does not close connections mid-transfer
- add a ShutdownOnDrop wrapper for TLS-terminated HTTP connections to send shutdown on drop and avoid improperly terminated TLS sessions
- apply the shutdown wrapper in passthrough TLS terminate and terminate+reencrypt HTTP handling
## 2026-03-16 - 25.11.7 - fix(rustproxy)
prevent TLS route reload certificate mismatches and tighten passthrough connection handling
- Load updated TLS configs before swapping the route manager so newly visible routes always have their certificates available.
- Add timeouts when peeking initial decrypted data after TLS handshake to avoid leaked idle connections.
- Raise dropped, blocked, unmatched, and errored passthrough connection events from debug to warn for better operational visibility.
## 2026-03-16 - 25.11.6 - fix(rustproxy-http,rustproxy-passthrough)
improve upstream connection cleanup and graceful tunnel shutdown
- Evict pooled HTTP/2 connections when their driver exits and shorten the maximum pooled H2 age to reduce reuse of stale upstream connections.
- Strip hop-by-hop headers from backend responses before forwarding to HTTP/2 clients to avoid invalid H2 response handling.
- Replace immediate task aborts in WebSocket and TCP tunnel watchdogs with cancellation-driven graceful shutdown plus timed fallback aborts.
- Use non-blocking semaphore acquisition in the TCP listener so connection limits do not stall the accept loop for the entire port.
## 2026-03-16 - 25.11.5 - fix(repo)
no changes to commit
## 2026-03-15 - 25.11.4 - fix(rustproxy-http)
report streamed HTTP and WebSocket bytes per chunk for real-time throughput metrics
- Update CountingBody to record bytes immediately on each data frame instead of aggregating until completion or drop
- Record WebSocket tunnel traffic inside both copy loops and remove the final aggregate byte report to keep throughput metrics current
## 2026-03-15 - 25.11.3 - fix(repo)
no changes to commit
## 2026-03-15 - 25.11.2 - fix(rustproxy-http)
avoid reusing HTTP/1 senders during streaming responses and relax HTTP/2 keep-alive timeouts
- Stop returning HTTP/1 senders to the connection pool before upstream response bodies finish streaming to prevent unsafe reuse on active connections.
- Increase HTTP/2 keep-alive timeout from 5 seconds to 30 seconds in proxy connection builders to better support longer-lived backend streams.
- Improves reliability for large streaming payloads and backend fallback request handling.
## 2026-03-15 - 25.11.1 - fix(rustproxy-http)
keep connection idle tracking alive during streaming and tune HTTP/2 connection lifetimes
- Propagate connection activity tracking through HTTP/1, HTTP/2, and WebSocket forwarding so active request and response body streams do not trigger the idle watchdog.
- Update CountingBody to refresh connection activity timestamps while data frames are polled during uploads and downloads.
- Increase pooled HTTP/2 max age and set explicit HTTP/2 connection window sizes to improve long-lived streaming behavior.
## 2026-03-15 - 25.11.0 - feat(rustproxy-http)
add HTTP/2 Extended CONNECT WebSocket proxy support
- Enable HTTP/2 CONNECT protocol support on the Hyper auto connection builder
- Detect WebSocket requests for both HTTP/1 Upgrade and HTTP/2 Extended CONNECT flows
- Translate HTTP/2 WebSocket requests to an HTTP/1.1 backend handshake and return RFC-compliant client responses
## 2026-03-12 - 25.10.7 - fix(rustproxy-http)
remove Host header from HTTP/2 upstream requests while preserving it for HTTP/1 retries
- strips the Host header before sending HTTP/2 upstream requests so :authority from the URI is used instead
- avoids 400 responses from nginx caused by sending both Host and :authority headers
- keeps a cloned header set for bodyless request retries so HTTP/1 fallback still retains the Host header
## 2026-03-12 - 25.10.6 - fix(rustproxy-http)
use the requested domain as HTTP/2 authority instead of the backend host and port
- build HTTP/2 absolute URIs from the client-facing domain so the :authority pseudo-header matches the Host header
- remove backend port from generated HTTP/2 request URIs and fall back to the upstream host only when no domain is available
- apply the authority handling consistently across pooled, inline, and generic upstream request paths
## 2026-03-12 - 25.10.5 - fix(rustproxy-http)
configure HTTP/2 client builders with a Tokio timer for keep-alive handling
- Adds TokioTimer to all HTTP/2 client builder instances in proxy_service.
- Ensures configured HTTP/2 keep-alive interval and timeout settings have the required timer runtime support.
## 2026-03-12 - 25.10.4 - fix(rustproxy-http)
stabilize upstream HTTP/2 forwarding and fallback behavior
- Remove hop-by-hop headers before forwarding requests to HTTP/2 backends to comply with RFC 9113.
- Use ALPN-enabled TLS configuration whenever HTTP/2 is possible, including explicit H2 connections and retries.
- Add HTTP/2 handshake timeouts, tuned connection settings, and fallback to HTTP/1 when H2 negotiation times out or fails.
- Register pooled HTTP/2 senders only after a successful first request to avoid reusing broken connections.
- Build absolute URIs for HTTP/2 upstream requests so pseudo-headers such as scheme and authority are derived correctly.
## 2026-03-12 - 25.10.3 - fix(rustproxy-http)
include request domain in backend proxy error and protocol detection logs
- Adds domain context to backend TCP/TLS connect, handshake, request failure, retry, and fallback log entries in the Rust HTTP proxy service.
- Propagates the resolved host/domain through H1, H2, pooled, and fallback forwarding paths so backend-level diagnostics can be correlated with the original request domain.
## 2026-03-12 - 25.10.2 - fix(repo)
no code changes to release
## 2026-03-12 - 25.10.1 - fix(repo)
no changes to commit
## 2026-03-12 - 25.10.0 - feat(metrics)
add per-backend connection, error, protocol, and pool metrics with stale backend pruning
- tracks backend connection lifecycle, connect timing, protocol detection, pool hit/miss rates, handshake/request errors, and h2 fallback failures in Rust metrics
- exposes backend metrics through the TypeScript metrics adapter with backend listings, protocol lookup, and top error summaries
- prunes backend metrics for backends no longer referenced by active routes, including preserved-port targets expanded across listening ports
## 2026-03-11 - 25.9.3 - fix(rustproxy-http)
Evict stale HTTP/2 pooled senders and retry bodyless requests with fresh backend connections to avoid 502s
- Introduce MAX_H2_AGE (120s) and evict HTTP/2 senders older than this or closed
- Check MAX_H2_AGE on checkout and during background eviction to prevent reuse of stale h2 connections
- Add connection_pool.remove_h2() to explicitly remove dead H2 senders from the pool
- When a pooled H2 request returns a 502 and the original request had an empty body, retry using a fresh H2 connection (retry_h2_with_fresh_connection)
- On H2 auto-detect failures, retry as HTTP/1.1 for bodyless requests via forward_h1_empty_body; return 502 for requests with bodies
- Evict dead H2 senders on backend request failures in reconnect_backend so subsequent attempts create fresh connections
## 2026-03-08 - 25.9.2 - fix(protocol-cache)
Include requested_host in protocol detection cache key to avoid cache oscillation when multiple frontend domains share the same backend
- Add ProtocolCacheKey.requested_host: Option<String> to distinguish cache entries by incoming request Host/:authority
- Update protocol cache lookups/inserts in proxy_service to populate requested_host
- Enhance debug logging to show requested_host on cache hits
- Fixes repeated ALPN probing / cache oscillation when different frontend domains share a backend with differing HTTP/2 support
## 2026-03-03 - 25.9.1 - fix(rustproxy)
Cancel connections for routes removed/disabled by adding per-route cancellation tokens and make RouteManager swappable (ArcSwap) for runtime updates
- Add per-route CancellationToken map (DashMap) to TcpListenerManager and call token.cancel() when routes are removed (invalidate_removed_routes)
- Propagate Arc<ArcSwap<RouteManager>> into HttpProxyService and passthrough listener so the route manager can be hot-swapped without restarting listeners
- Use per-route child cancellation tokens in accept/connection handling and forwarders to terminate existing connections when a route is removed
- Prune HTTP proxy caches and retain/cleanup per-route tokens when routes are active/removed
- Update test.test.sni-requirement.node.ts to allocate unique free ports via findFreePorts to avoid port conflicts during tests
## 2026-03-03 - 25.9.0 - feat(rustproxy-http)
add HTTP/2 auto-detection via ALPN with TTL-backed protocol cache and h1-only/h2 ALPN client configs
- Add protocol_cache module: bounded, TTL-based cache (5min TTL), max entries (4096), background cleanup task and clear() to discard stale detections.
- Introduce BackendProtocol::Auto and expose 'auto' in TypeScript route types to allow ALPN-based protocol auto-detection.
- Add build_tls_acceptor_h1_only() to create a TLS acceptor that advertises only http/1.1 (used for backends/tests that speak plain HTTP/1.1).
- Add shared_backend_tls_config_alpn() and default_backend_tls_config_with_alpn() to provide client TLS configs advertising h2+http/1.1 for auto-detection.
- Wire backend_tls_config_alpn and protocol_cache into proxy_service, tcp_listener and passthrough paths; add set_backend_tls_config_alpn() and prune protocol_cache on route updates.
- Update passthrough tests to use h1-only acceptor to avoid false HTTP/2 detection when backends speak plain HTTP/1.1.
- Include reconnection/fallback handling and ensure ALPN-enabled client config is used for auto-detection mode.
## 2026-02-26 - 25.8.5 - fix(release)
bump patch version (no source changes)
- No changes detected in git diff
- Current version: 25.8.4
- Recommend patch bump to 25.8.5 to record release without code changes
## 2026-02-26 - 25.8.4 - fix(proxy)
adjust default proxy timeouts and keep-alive behavior to shorter, more consistent values
- Increase connection timeout default from 30,000ms to 60,000ms (30s -> 60s).
- Reduce socket timeout default from 3,600,000ms to 60,000ms (1h -> 60s).
- Reduce max connection lifetime default from 86,400,000ms to 3,600,000ms (24h -> 1h).
- Change inactivity timeout default from 14,400,000ms to 75,000ms (4h -> 75s).
- Update keep-alive defaults: keepAliveTreatment 'extended' -> 'standard', keepAliveInactivityMultiplier 6 -> 4, extendedKeepAliveLifetime 604800000 -> 3,600,000ms (7d -> 1h).
- Apply these consistent default values across Rust crates (rustproxy-config, rustproxy-passthrough) and the TypeScript smart-proxy implementation.
- Update unit test expectations to match the new defaults.
## 2026-02-26 - 25.8.3 - fix(smartproxy)
no code or dependency changes detected; no version bump required
- No files changed in the provided diff (No changes).
- package.json version remains 25.8.2.
- No dependency or source updates detected; skip release.
## 2026-02-26 - 25.8.2 - fix(connection)
improve connection handling and timeouts
- Flush logs on process beforeExit and avoid calling process.exit in SIGINT/SIGTERM handlers to preserve host graceful shutdown
- Store protocol entries with a createdAt timestamp in ProtocolDetector and remove stale entries older than 30s to prevent leaked state from abandoned handshakes or port scanners
- Add backend connect timeout (30s) and idle timeouts (5 minutes) for dynamic forwards; destroy sockets on timeout and emit logs for timeout events
## 2026-02-25 - 25.8.1 - fix(allocator)
switch global allocator from tikv-jemallocator to mimalloc
- Replaced tikv-jemallocator with mimalloc in rust/Cargo.toml workspace dependencies.
- Updated rust/crates/rustproxy/Cargo.toml to use mimalloc as a workspace dependency.
- Updated rust/Cargo.lock: added mimalloc and libmimalloc-sys entries and removed tikv-jemallocator and tikv-jemalloc-sys entries.
- Changed the global allocator in crates/rustproxy/src/main.rs from tikv_jemallocator::Jemalloc to mimalloc::MiMalloc.
- Impact: runtime memory allocator is changed which may affect memory usage and performance; no public API changes but recommend testing memory/performance in deployments.
## 2026-02-24 - 25.8.0 - feat(rustproxy)
use tikv-jemallocator as the global allocator to reduce glibc fragmentation and slow RSS growth; add allocator dependency and enable it in rustproxy, update lockfile, and run tsrust before tests
- Added tikv-jemallocator dependency to rust/Cargo.toml and rust/crates/rustproxy/Cargo.toml
- Enabled tikv_jemallocator as the global allocator in rust/crates/rustproxy/src/main.rs
- Updated rust/Cargo.lock with tikv-jemallocator and tikv-jemalloc-sys entries
- Modified package.json test script to run tsrust before tstest
## 2026-02-24 - 25.7.10 - fix(rustproxy)
Use cooperative cancellation for background tasks, prune stale caches and metric entries, and switch tests to dynamic port allocation to avoid port conflicts
- Introduce tokio_util::sync::CancellationToken to coordinate graceful shutdown of sampling and renewal tasks; await handles on stop and reset the token so the proxy can be restarted.
- Add safety Drop impls (RustProxy, TcpListenerManager) as a last-resort abort path when stop() is not called.
- MetricsCollector: avoid creating per-IP metric entries when the IP has no active connections; prune orphaned per-IP metric maps during sampling; add tests covering late record_bytes races and pruning behavior.
- Passthrough/ConnectionTracker: remove per-connection record/zombie-scanner complexity, add cleanup_stale_timestamps to prune rate-limit timestamp entries, and add an RAII ConnectionTrackerGuard to guarantee connection_closed is invoked.
- HTTP proxy improvements: add prune_stale_routes and reset_round_robin to clear caches (rate limiters, regex cache, round-robin counters) on route updates.
- Tests: add test/helpers/port-allocator.ts and update many tests to use findFreePorts/assertPortsFree (dynamic ports + post-test port assertions) to avoid flakiness and port collisions in CI.
## 2026-02-21 - 25.7.9 - fix(tests)
use high non-privileged ports in tests to avoid conflicts and CI failures
- Updated multiple test files to use high-range, non-privileged ports instead of well-known or conflicting ports.
- Files changed: test/test.acme-http01-challenge.ts, test/test.connection-forwarding.ts, test/test.forwarding-regression.ts, test/test.http-port8080-forwarding.ts, test/test.port-mapping.ts, test/test.smartproxy.ts, test/test.socket-handler.ts.
- Notable port remappings: 8080/8081 -> 47730/47731 (and other proxy ports like 47710), 8443 -> 47711, 7001/7002 -> 47712/47713, 9090 -> 47721, 8181/8182 -> 47732/47733, 9999 -> 47780, TEST_PORT_START/PROXY_PORT_START -> 47750/48750, and TEST_SERVER_PORT/PROXY_PORT -> 47770/47771.
## 2026-02-19 - 25.7.8 - fix(no-changes)
no changes detected; nothing to release
- Current package version: 25.7.7
- Git diff: no changes
- No files modified; no release necessary
## 2026-02-19 - 25.7.7 - fix(proxy)
restrict PROXY protocol parsing to configured trusted proxy IPs and parse PROXY headers before metrics/fast-path so client IPs reflect the real source
- Add proxy_ips: Vec<std::net::IpAddr> to ConnectionConfig with a default empty Vec
- Populate proxy_ips from options.proxy_ips strings in rust/crates/rustproxy/src/lib.rs, parsing each to IpAddr
- Only peek for and parse PROXY v1 headers when the remote IP is contained in proxy_ips (prevents untrusted clients from injecting PROXY headers)
- Move PROXY protocol parsing earlier so metrics and fast-path logic use the effective (real client) IP after PROXY parsing
- If proxy_ips is empty, behavior remains unchanged (no PROXY parsing)
## 2026-02-19 - 25.7.6 - fix(throughput)
add tests for per-IP connection tracking and throughput history; assert per-IP eviction after connection close to prevent memory leak
- Adds runtime assertions for per-IP TCP connection tracking (m.connections.byIP) while a connection is active
- Adds checks for throughput history (m.throughput.history) to ensure history length and timestamps are recorded
- Asserts that per-IP tracking data is evicted after connection close (byIP.size === 0) to verify memory leak fix
- Reorders test checks so per-IP and history metrics are validated during the active connection and totals are validated after close
## 2026-02-19 - 25.7.5 - fix(rustproxy)
prune stale per-route metrics, add per-route rate limiter caching and regex cache, and improve connection tracking cleanup to prevent memory growth
- Prune per-route metrics for routes removed from configuration via MetricsCollector::retain_routes invoked during route table updates
- Introduce per-route shared RateLimiter instances (DashMap) with a request-count-triggered periodic cleanup to avoid stale limiters
- Cache compiled URL-rewrite regexes (regex_cache) to avoid recompiling patterns on every request and insert compiled regex on first use
- Improve upstream connection tracking to remove zero-count entries and guard against underflow, preventing unbounded DashMap growth
- Evict per-IP metrics and timestamps when the last connection for an IP closes so per-IP DashMap entries are fully freed
- Add unit tests validating connection tracking cleanup, per-IP eviction, and route-metrics retention behavior
## 2026-02-19 - 25.7.4 - fix(smart-proxy)
include proxy IPs in smart proxy configuration
- Add proxyIps: this.settings.proxyIPs to proxy options in ts/proxies/smart-proxy/smart-proxy.ts
- Ensures proxy IPs from settings are passed into the proxy implementation (enables proxy IP filtering/whitelisting)
## 2026-02-16 - 25.7.3 - fix(metrics)
centralize connection-closed reporting via ConnectionGuard and remove duplicate explicit metrics.connection_closed calls
- Removed numerous explicit metrics.connection_closed calls from rust/crates/rustproxy-http/src/proxy_service.rs so connection teardown and byte counting are handled by the connection guard / counting body instead of ad-hoc calls.
- Simplified ConnectionGuard in rust/crates/rustproxy-passthrough/src/tcp_listener.rs: removed the disarm flag and disarm() method so Drop always reports connection_closed.
- Stopped disarming the TCP-level guard when handing connections off to HTTP proxy paths (HTTP/WebSocket/streaming flows) to avoid missing or double-reporting metrics.
- Fixes incorrect/duplicate connection-closed metric emission and ensures consistent byte/connection accounting during streaming and WebSocket upgrades.
## 2026-02-16 - 25.7.2 - fix(rustproxy-http)
preserve original Host header when proxying and add X-Forwarded-* headers; add TLS WebSocket echo backend helper and integration test for terminate-and-reencrypt websocket
- Preserve the client's original Host header instead of replacing it with backend host:port when proxying requests.
- Add standard reverse-proxy headers: X-Forwarded-For (appends client IP), X-Forwarded-Host, and X-Forwarded-Proto for upstream requests.
- Ensure raw TCP/HTTP upstream requests copy original headers and skip X-Forwarded-* (which are added explicitly).
- Add start_tls_ws_echo_backend test helper to start a TLS WebSocket echo backend for tests.
- Add integration test test_terminate_and_reencrypt_websocket to verify WS upgrade through terminate-and-reencrypt TLS path.
- Rename unused parameter upstream to _upstream in proxy_service functions to avoid warnings.
## 2026-02-16 - 25.7.1 - fix(proxy)
use TLS to backends for terminate-and-reencrypt routes
- Set upstream.use_tls = true when a route's TLS mode is TerminateAndReencrypt so the proxy re-encrypts to backend servers.
- Add start_tls_http_backend test helper and update integration tests to run TLS-enabled backend servers validating re-encryption behavior.
- Make the selected upstream mutable to allow toggling the use_tls flag during request handling.
## 2026-02-16 - 25.7.0 - feat(routes)
add protocol-based route matching and ensure terminate-and-reencrypt routes HTTP through the full HTTP proxy; update docs and tests
- Introduce a new 'protocol' match field for routes (supports 'http' and 'tcp') and preserve it through cloning/merging.
- Add Rust integration test verifying terminate-and-reencrypt decrypts TLS and routes HTTP traffic via the HTTP proxy (per-request Host/path routing) instead of raw tunneling.
- Add TypeScript unit tests covering protocol field validation, preservation, interaction with terminate-and-reencrypt, cloning, merging, and matching behavior.
- Update README with a Protocol-Specific Routing section and clarify terminate-and-reencrypt behavior (HTTP routed via HTTP proxy; non-HTTP uses raw TLS-to-TLS tunnel).
- Example config: include health check thresholds (unhealthyThreshold and healthyThreshold) in the sample healthCheck settings.
## 2026-02-16 - 25.6.0 - feat(rustproxy)
add protocol-based routing and backend TLS re-encryption support
- Introduce optional route_match.protocol ("http" | "tcp") in Rust and TypeScript route types to allow protocol-restricted routing.
- RouteManager: respect protocol field during matching and treat TLS connections without SNI as not matching domain-restricted routes (except wildcard-only routes).
- HTTP proxy: add BackendStream abstraction to unify plain TCP and tokio-rustls TLS backend streams, and support connecting to upstreams over TLS (upstream.use_tls) with an InsecureBackendVerifier for internal/self-signed backends.
- WebSocket and HTTP forwarding updated to use BackendStream so upstream TLS is handled transparently.
- Passthrough listener: perform post-termination protocol detection for TerminateAndReencrypt; route HTTP flows into HttpProxyService and handle non-HTTP as TLS-to-TLS tunnel.
- Add tests for protocol matching, TLS/no-SNI behavior, and other routing edge cases.
- Add rustls and tokio-rustls dependencies (Cargo.toml/Cargo.lock updates).
## 2026-02-16 - 25.5.0 - feat(tls)
add shared TLS acceptor with SNI resolver and session resumption; prefer shared acceptor and fall back to per-connection when routes specify custom TLS versions
- Add CertResolver that pre-parses PEM certs/keys into CertifiedKey instances for SNI-based lookup and cheap runtime resolution
- Introduce build_shared_tls_acceptor to create a shared ServerConfig with session cache (4096) and Ticketer for session ticket resumption
- Add ArcSwap<Option<TlsAcceptor>> shared_tls_acceptor to tcp_listener for hot-reloadable, pre-built acceptor and update accept loop/handlers to use it
- set_tls_configs now attempts to build and store the shared TLS acceptor, falling back to per-connection acceptors on failure; raw PEM configs are still retained for route-level fallbacks
- Add get_tls_acceptor helper: prefer shared acceptor for performance and session resumption, but build per-connection acceptor when a route requests custom TLS versions
## 2026-02-16 - 25.4.0 - feat(rustproxy)
support dynamically loaded TLS certificates via loadCertificate IPC and include them in listener TLS configs for rebuilds and hot-swap
- Adds loaded_certs: HashMap<String, TlsCertConfig> to RustProxy to store certificates loaded at runtime
- Merge loaded_certs into tls_configs in rebuild and listener hot-swap paths so dynamically loaded certs are served immediately
- Persist loaded certificates on loadCertificate so future rebuilds include them
## 2026-02-15 - 25.3.1 - fix(plugins)
remove unused dependencies and simplify plugin exports
- Removed multiple dependencies from package.json to reduce dependency footprint: @push.rocks/lik, @push.rocks/smartacme, @push.rocks/smartdelay, @push.rocks/smartfile, @push.rocks/smartnetwork, @push.rocks/smartpromise, @push.rocks/smartrequest, @push.rocks/smartrx, @push.rocks/smartstring, @push.rocks/taskbuffer, @types/minimatch, @types/ws, pretty-ms, ws
- ts/plugins.ts: stopped importing/exporting node:https and many push.rocks and third-party modules; plugins now only re-export core node modules (without https), tsclass, smartcrypto, smartlog (+ destination-local), smartrust, and minimatch
- Intended effect: trim surface area and remove unused/optional integrations; patch-level change (no feature/API additions)
## 2026-02-14 - 25.3.0 - feat(smart-proxy)
add background concurrent certificate provisioning with per-domain timeouts and concurrency control
- Add ISmartProxyOptions settings: certProvisionTimeout (ms) and certProvisionConcurrency (default 4)
- Run certProvisionFunction as fire-and-forget background tasks (stores promise on start/route-update and awaited on stop)
- Provision certificates in parallel with a concurrency limit using a new ConcurrencySemaphore utility
- Introduce per-domain timeout handling (default 300000ms) via withTimeout and surface timeout errors as certificate-failed events
- Refactor provisioning into provisionSingleDomain to isolate domain handling, ACME fallback preserved
- Run provisioning outside route update mutex so route updates are not blocked by slow provisioning
## 2026-02-14 - 25.2.2 - fix(smart-proxy)
start metrics polling before certificate provisioning to avoid blocking metrics collection
- Start metrics polling immediately after Rust engine startup so metrics are available without waiting for certificate provisioning.
- Run certProvisionFunction after startup because ACME/DNS-01 provisioning can hang or be slow and must not block observability.
- Code change in ts/proxies/smart-proxy/smart-proxy.ts: metricsAdapter.startPolling() moved to run before provisionCertificatesViaCallback().
## 2026-02-14 - 25.2.1 - fix(smartproxy)
no changes detected in git diff
- The provided diff contains no file changes; no code or documentation updates to release.
## 2026-02-14 - 25.2.0 - feat(metrics)
add per-IP and HTTP-request metrics, propagate source IP through proxy paths, and expose new metrics to the TS adapter
- Add per-IP tracking and IpMetrics in MetricsCollector (active/total connections, bytes, throughput).
- Add HTTP request counters and tracking (total_http_requests, http_requests_per_sec, recent counters and tests).
- Include throughput history (ThroughputSample serialization, retention and snapshotting) and expose history in snapshots.
- Propagate source IP through HTTP and passthrough code paths: CountingBody.record_bytes and MetricsCollector methods now accept source_ip; connection_opened/closed calls include source IP.
- Introduce ForwardMetricsCtx to carry metrics context (collector, route_id, source_ip) into passthrough forwarding routines; update ConnectionGuard to include source_ip.
- TypeScript adapter (rust-metrics-adapter.ts) updated to return per-IP counts, top IPs, per-IP throughput, throughput history mapping, and HTTP request rates/total where available.
- Numerous unit tests added for per-IP tracking, HTTP request tracking, throughput history and ThroughputTracker.history behavior.
## 2026-02-13 - 25.1.0 - feat(metrics)
add real-time throughput sampling and byte-counting metrics
- Add CountingBody wrapper to count HTTP request and response bytes and report them to MetricsCollector.
- Implement lock-free hot-path byte recording and a cold-path sampling API (sample_all) in MetricsCollector with throughput history and configurable retention (default 3600s).
- Spawn a background sampling task in RustProxy (configurable sample_interval_ms) and tear it down on stop so throughput trackers are regularly sampled.
- Instrument passthrough TCP forwarding and socket-relay paths to record per-chunk bytes (lock-free) so long-lived connections contribute to throughput measurements.
- Wrap HTTP request/response bodies with CountingBody in proxy_service to capture bytes_in/bytes_out and report on body completion; connection_closed handling updated accordingly.
- Expose recent throughput metrics to the TypeScript adapter (throughputRecentIn/Out) and pass metrics settings from the TS SmartProxy into Rust.
- Add http-body dependency and update Cargo.toml/Cargo.lock entries for the new body wrapper usage.
- Add unit tests for MetricsCollector throughput tracking and a new end-to-end throughput test (test.throughput.ts).
- Update test certificates (assets/certs cert.pem and key.pem) used by TLS tests.
## 2026-02-13 - 25.0.0 - BREAKING CHANGE(certs)
accept a second eventComms argument in certProvisionFunction, add cert provisioning event types, and emit certificate lifecycle events
- Breaking API change: certProvisionFunction signature changed from (domain: string) => Promise<TSmartProxyCertProvisionObject> to (domain: string, eventComms: ICertProvisionEventComms) => Promise<TSmartProxyCertProvisionObject>. Custom provisioners must accept (or safely ignore) the new second argument.
- New types added and exported: ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent.
- smart-proxy now constructs an eventComms channel that allows provisioners to log/warn/error and set expiry date and source for the issued event.
- Emits 'certificate-issued' (domain, expiryDate, source, isRenewal?) on successful provisioning and 'certificate-failed' (domain, error, source) on failures.
- Updated public exports to include the new types so they are available to consumers.
- Removed readme.byte-counting-audit.md (documentation file deleted).
## 2026-02-13 - 24.0.1 - fix(proxy)
improve proxy robustness: add connect timeouts, graceful shutdown, WebSocket watchdog, and metrics guard
- Add tokio-util CancellationToken to HTTP handlers to support graceful shutdown (stop accepting new requests while letting in-flight requests finish).
- Introduce configurable upstream connect timeout (DEFAULT_CONNECT_TIMEOUT) and return 504 Gateway Timeout on connect timeouts to avoid hanging connections.
- Add WebSocket watchdog with inactivity and max-lifetime checks, activity tracking via AtomicU64, and cancellation-driven tunnel aborts.
- Add ConnectionGuard RAII in passthrough listener to ensure metrics.connection_closed() is called on all exit paths and disarm the guard when handing off to the HTTP proxy.
- Expose HttpProxyService::with_connect_timeout and wire connection timeout from ConnectionConfig into listeners.
- Add tokio-util workspace dependency (CancellationToken) and related code changes across rustproxy-http and rustproxy-passthrough.
## 2026-02-13 - 24.0.0 - BREAKING CHANGE(smart-proxy)
move certificate persistence to an in-memory store and introduce consumer-managed certStore API; add default self-signed fallback cert and change ACME account handling
- Cert persistence removed from Rust side: CertStore is now an in-memory cache (no filesystem reads/writes). Rust no longer persists or loads certs from disk.
- ACME account credentials are no longer persisted by the library; AcmeClient uses ephemeral accounts only and account persistence APIs were removed.
- TypeScript API changes: removed certificateStore option and added ISmartProxyCertStore + certStore option for consumer-provided persistence (loadAll, save, optional remove).
- Default self-signed fallback certificate added (generateDefaultCertificate) and loaded as '*' unless disableDefaultCert is set.
- SmartProxy now pre-loads certificates from consumer certStore on startup and persists certificates by calling certStore.save() after provisioning.
- provisionCertificatesViaCallback signature changed to accept preloaded domains (prevents re-provisioning), and ACME fallback behavior adjusted with clearer logging.
- Rust cert manager methods made infallible for cache-only operations (load_static/store no longer return errors for cache insertions); removed store-backed load_all/remove/base_dir APIs.
- TCP listener tls_configs concurrency improved: switched to ArcSwap<HashMap<...>> so accept loops see hot-reloads immediately.
- Removed dependencies related to filesystem cert persistence from the tls crate (serde_json, tempfile) and corresponding Cargo.lock changes and test updates.
## 2026-02-13 - 23.1.6 - fix(smart-proxy)
disable built-in Rust ACME when a certProvisionFunction is provided and improve certificate provisioning flow
- Pass an optional ACME override into buildRustConfig so Rust ACME can be disabled per-run
- Disable Rust ACME when certProvisionFunction is configured to avoid provisioning race conditions
- Normalize routing glob patterns into concrete domain identifiers for certificate provisioning (expand leading-star globs and warn on unsupported patterns)
- Deduplicate domains during provisioning to avoid repeated attempts
- When the callback returns 'http01', explicitly trigger Rust ACME for the route via bridge.provisionCertificate and log success/failure
## 2026-02-13 - 23.1.5 - fix(smart-proxy)
provision certificates for wildcard domains instead of skipping them
- Removed early continue that skipped domains containing '*' in the domain loop
- Now calls provisionFn for wildcard domains so certificate provisioning can proceed for wildcard hosts
- Fixes cases where wildcard domains never had certificates requested
## 2026-02-12 - 23.1.4 - fix(tests)
make tests more robust and bump small dependencies
- Bump dependencies: @push.rocks/smartrust ^1.2.1 and minimatch ^10.2.0
- Replace hardcoded ports with named constants (ECHO_PORT, PROXY_PORT, PROXY_PORT_1/2) to avoid collisions between tests
- Add server 'error' handlers and reject listen promises on server errors to prevent silent hangs
- Reduce test timeouts and intervals (shorter test durations, more frequent pings) to speed up test runs
- Ensure proxy is stopped between tests and remove forced process.exit; export tap.start() consistently
- Adjust assertions to match the new shorter ping/response counts
## 2026-02-12 - 23.1.3 - fix(rustproxy)
install default rustls crypto provider early; detect and skip raw fast-path for HTTP connections and return proper HTTP 502 when no route matches
- Install ring-based rustls crypto provider at startup to prevent panics from instant-acme/hyper-rustls calling ClientConfig::builder() before TLS listeners are initialized
- Add a non-blocking 10ms peek to detect HTTP traffic in the TCP passthrough fast-path to avoid misrouting HTTP and ensure HTTP proxy handles CORS, errors, and request-level routing
- Skip the fast-path and fall back to the HTTP proxy when HTTP is detected (with a debug log)
- When no route matches for detected HTTP connections, send an HTTP 502 Bad Gateway response and close the connection instead of silently dropping it
## 2026-02-11 - 23.1.2 - fix(core)
use node: scoped builtin imports and add route unit tests
- Replaced bare Node built-in imports (events, fs, http, https, net, path, tls, url, http2, buffer, crypto) with 'node:' specifiers for ESM/bundler compatibility (files updated include ts/plugins.ts, ts/core/models/socket-types.ts, ts/core/utils/enhanced-connection-pool.ts, ts/core/utils/socket-tracker.ts, ts/protocols/common/fragment-handler.ts, ts/protocols/tls/sni/client-hello-parser.ts, ts/protocols/tls/sni/sni-extraction.ts, ts/protocols/websocket/utils.ts, ts/tls/sni/sni-handler.ts).
- Added new unit tests (test/test.bun.ts and test/test.deno.ts) covering route helpers, validators, matching, merging and cloning to improve test coverage.
## 2026-02-11 - 23.1.1 - fix(rust-proxy)
increase rust proxy bridge maxPayloadSize to 100 MB and bump dependencies
- Set maxPayloadSize to 100 * 1024 * 1024 (100 MB) in ts/proxies/smart-proxy/rust-proxy-bridge.ts to support large route configs
- Bump devDependency @types/node from ^25.2.2 to ^25.2.3
- Bump dependency @push.rocks/smartrust from ^1.1.1 to ^1.2.0
## 2026-02-10 - 23.1.0 - feat(rust-bridge)
integrate tsrust to build and locate cross-compiled Rust binaries; refactor rust-proxy bridge to use typed IPC and streamline process handling; add @push.rocks/smartrust and update build/dev dependencies
- Add tsrust to the build script and include dist_rust candidates when locating the Rust binary (enables cross-compiled artifacts produced by tsrust).
- Remove the old rust-binary-locator and refactor rust-proxy-bridge to use explicit, typed IPC command definitions and improved process spawn/cleanup logic.
- Introduce @push.rocks/smartrust for type-safe JSON IPC and export it via plugins; update README with expanded metrics documentation and change initialDataTimeout default from 60s to 120s.
- Add rust/.cargo/config.toml with aarch64 linker configuration to support cross-compilation for arm64.
- Bump several devDependencies and runtime dependencies (e.g. @git.zone/tsbuild, @git.zone/tstest, @push.rocks/smartserve, @push.rocks/taskbuffer, ws, minimatch, etc.).
- Update runtime message guiding local builds to use 'pnpm build' (tsrust) instead of direct cargo invocation.
## 2026-02-09 - 23.0.0 - BREAKING CHANGE(proxies/nftables-proxy) ## 2026-02-09 - 23.0.0 - BREAKING CHANGE(proxies/nftables-proxy)
remove nftables-proxy implementation, models, and utilities from the repository remove nftables-proxy implementation, models, and utilities from the repository

6752
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
Copyright (c) 2019 Lossless GmbH (hello@lossless.com) Copyright (c) 2019 Task Venture Capital GmbH (hello@task.vc)
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "23.0.0", "version": "27.0.0",
"private": false, "private": false,
"description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.", "description": "A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
@@ -9,39 +9,29 @@
"author": "Lossless GmbH", "author": "Lossless GmbH",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"test:before": "(tsrust)",
"test": "(tstest test/**/test*.ts --verbose --timeout 60 --logfile)", "test": "(tstest test/**/test*.ts --verbose --timeout 60 --logfile)",
"build": "(tsbuild tsfolders --allowimplicitany)", "build": "(tsbuild tsfolders --allowimplicitany) && (tsrust)",
"format": "(gitzone format)", "format": "(gitzone format)",
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^3.1.2", "@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsrun": "^2.0.0", "@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.1.3", "@git.zone/tsrust": "^1.3.2",
"@push.rocks/smartserve": "^1.4.0", "@git.zone/tstest": "^3.6.0",
"@types/node": "^24.10.2", "@push.rocks/smartserve": "^2.0.3",
"typescript": "^5.9.3", "@types/node": "^25.5.0",
"typescript": "^6.0.2",
"why-is-node-running": "^3.2.2" "why-is-node-running": "^3.2.2"
}, },
"dependencies": { "dependencies": {
"@push.rocks/lik": "^6.2.2",
"@push.rocks/smartacme": "^8.0.0",
"@push.rocks/smartcrypto": "^2.0.4", "@push.rocks/smartcrypto": "^2.0.4",
"@push.rocks/smartdelay": "^3.0.5", "@push.rocks/smartlog": "^3.2.1",
"@push.rocks/smartfile": "^13.1.0", "@push.rocks/smartnftables": "^1.0.1",
"@push.rocks/smartlog": "^3.1.10", "@push.rocks/smartrust": "^1.3.2",
"@push.rocks/smartnetwork": "^4.4.0", "@tsclass/tsclass": "^9.5.0",
"@push.rocks/smartpromise": "^4.2.3", "minimatch": "^10.2.4"
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstring": "^4.1.0",
"@push.rocks/taskbuffer": "^3.5.0",
"@tsclass/tsclass": "^9.3.0",
"@types/minimatch": "^6.0.0",
"@types/ws": "^8.18.1",
"minimatch": "^10.1.1",
"pretty-ms": "^9.3.0",
"ws": "^8.18.3"
}, },
"files": [ "files": [
"ts/**/*", "ts/**/*",
@@ -52,7 +42,7 @@
"dist_ts_web/**/*", "dist_ts_web/**/*",
"assets/**/*", "assets/**/*",
"cli.js", "cli.js",
"npmextra.json", ".smartconfig.json",
"readme.md", "readme.md",
"changelog.md" "changelog.md"
], ],

5801
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,169 +0,0 @@
# SmartProxy Byte Counting Audit Report
## Executive Summary
After a comprehensive audit of the SmartProxy codebase, I can confirm that **byte counting is implemented correctly** with no instances of double counting. Each byte transferred through the proxy is counted exactly once in each direction.
## Byte Counting Implementation
### 1. Core Tracking Mechanisms
SmartProxy uses two complementary tracking systems:
1. **Connection Records** (`IConnectionRecord`):
- `bytesReceived`: Total bytes received from client
- `bytesSent`: Total bytes sent to client
2. **MetricsCollector**:
- Global throughput tracking via `ThroughputTracker`
- Per-connection byte tracking for route/IP metrics
- Called via `recordBytes(connectionId, bytesIn, bytesOut)`
### 2. Where Bytes Are Counted
Bytes are counted in only two files:
#### a) `route-connection-handler.ts`
- **Line 351**: TLS alert bytes when no SNI is provided
- **Lines 1286-1301**: Data forwarding callbacks in `setupBidirectionalForwarding()`
#### b) `http-proxy-bridge.ts`
- **Line 127**: Initial TLS chunk for HttpProxy connections
- **Lines 142-154**: Data forwarding callbacks in `setupBidirectionalForwarding()`
## Connection Flow Analysis
### 1. Direct TCP Connection (No TLS)
```
Client → SmartProxy → Target Server
```
1. Connection arrives at `RouteConnectionHandler.handleConnection()`
2. For non-TLS ports, immediately routes via `routeConnection()`
3. `setupDirectConnection()` creates target connection
4. `setupBidirectionalForwarding()` handles all data transfer:
- `onClientData`: `bytesReceived += chunk.length` + `recordBytes(chunk.length, 0)`
- `onServerData`: `bytesSent += chunk.length` + `recordBytes(0, chunk.length)`
**Result**: ✅ Each byte counted exactly once
### 2. TLS Passthrough Connection
```
Client (TLS) → SmartProxy → Target Server (TLS)
```
1. Connection waits for initial data to detect TLS
2. TLS handshake detected, SNI extracted
3. Route matched, `setupDirectConnection()` called
4. Initial chunk stored in `pendingData` (NOT counted yet)
5. On target connect, `pendingData` written to target (still not counted)
6. `setupBidirectionalForwarding()` counts ALL bytes including initial chunk
**Result**: ✅ Each byte counted exactly once
### 3. TLS Termination via HttpProxy
```
Client (TLS) → SmartProxy → HttpProxy (localhost) → Target Server
```
1. TLS connection detected with `tls.mode = "terminate"`
2. `forwardToHttpProxy()` called:
- Initial chunk: `bytesReceived += chunk.length` + `recordBytes(chunk.length, 0)`
3. Proxy connection created to HttpProxy on localhost
4. `setupBidirectionalForwarding()` handles subsequent data
**Result**: ✅ Each byte counted exactly once
### 4. HTTP Connection via HttpProxy
```
Client (HTTP) → SmartProxy → HttpProxy (localhost) → Target Server
```
1. Connection on configured HTTP port (`useHttpProxy` ports)
2. Same flow as TLS termination
3. All byte counting identical to TLS termination
**Result**: ✅ Each byte counted exactly once
### 5. NFTables Forwarding
```
Client → [Kernel NFTables] → Target Server
```
1. Connection detected, route matched with `forwardingEngine: 'nftables'`
2. Connection marked as `usingNetworkProxy = true`
3. NO application-level forwarding (kernel handles packet routing)
4. NO byte counting in application layer
**Result**: ✅ No counting (correct - kernel handles everything)
## Special Cases
### PROXY Protocol
- PROXY protocol headers sent to backend servers are NOT counted in client metrics
- Only actual client data is counted
- **Correct behavior**: Protocol overhead is not client data
### TLS Alerts
- TLS alerts (e.g., for missing SNI) are counted as sent bytes
- **Correct behavior**: Alerts are actual data sent to the client
### Initial Chunks
- **Direct connections**: Stored in `pendingData`, counted when forwarded
- **HttpProxy connections**: Counted immediately upon receipt
- **Both approaches**: Count each byte exactly once
## Verification Methodology
1. **Code Analysis**: Searched for all instances of:
- `bytesReceived +=` and `bytesSent +=`
- `recordBytes()` calls
- Data forwarding implementations
2. **Flow Tracing**: Followed data path for each connection type from entry to exit
3. **Handler Review**: Examined all forwarding handlers to ensure no additional counting
## Findings
### ✅ No Double Counting Detected
- Each byte is counted exactly once in the direction it flows
- Connection records and metrics are updated consistently
- No overlapping or duplicate counting logic found
### Areas of Excellence
1. **Centralized Counting**: All byte counting happens in just two files
2. **Consistent Pattern**: Uses `setupBidirectionalForwarding()` with callbacks
3. **Clear Separation**: Forwarding handlers don't interfere with proxy metrics
## Recommendations
1. **Debug Logging**: Add optional debug logging to verify byte counts in production:
```typescript
if (settings.debugByteCount) {
logger.log('debug', `Bytes counted: ${connectionId} +${bytes} (total: ${record.bytesReceived})`);
}
```
2. **Unit Tests**: Create specific tests to ensure byte counting accuracy:
- Test initial chunk handling
- Test PROXY protocol overhead exclusion
- Test HttpProxy forwarding accuracy
3. **Protocol Overhead Tracking**: Consider separately tracking:
- PROXY protocol headers
- TLS handshake bytes
- HTTP headers vs body
4. **NFTables Documentation**: Clearly document that NFTables-forwarded connections are not included in application metrics
## Conclusion
SmartProxy's byte counting implementation is **robust and accurate**. The design ensures that each byte is counted exactly once, with clear separation between connection tracking and metrics collection. No remediation is required.

View File

@@ -462,35 +462,57 @@ For TLS termination modes (`terminate` and `terminate-and-reencrypt`), SmartProx
**HTTP to HTTPS Redirect**: **HTTP to HTTPS Redirect**:
```typescript ```typescript
import { createHttpToHttpsRedirect } from '@push.rocks/smartproxy'; import { SocketHandlers } from '@push.rocks/smartproxy';
const redirectRoute = createHttpToHttpsRedirect(['example.com', 'www.example.com']); const redirectRoute = {
name: 'http-to-https',
match: { ports: 80, domains: ['example.com', 'www.example.com'] },
action: {
type: 'socket-handler' as const,
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
}
};
``` ```
**Complete HTTPS Server (with redirect)**: **Complete HTTPS Server (with redirect)**:
```typescript ```typescript
import { createCompleteHttpsServer } from '@push.rocks/smartproxy'; const routes = [
{
const routes = createCompleteHttpsServer( name: 'https-server',
'example.com', match: { ports: 443, domains: 'example.com' },
{ host: 'localhost', port: 8080 }, action: {
{ certificate: 'auto' } type: 'forward' as const,
); targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate' as const, certificate: 'auto' as const }
}
},
{
name: 'http-redirect',
match: { ports: 80, domains: 'example.com' },
action: {
type: 'socket-handler' as const,
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
}
}
];
``` ```
**Load Balancer with Health Checks**: **Load Balancer with Health Checks**:
```typescript ```typescript
import { createLoadBalancerRoute } from '@push.rocks/smartproxy'; const lbRoute = {
name: 'load-balancer',
const lbRoute = createLoadBalancerRoute( match: { ports: 443, domains: 'api.example.com' },
'api.example.com', action: {
[ type: 'forward' as const,
{ host: 'backend1', port: 8080 }, targets: [
{ host: 'backend2', port: 8080 }, { host: 'backend1', port: 8080 },
{ host: 'backend3', port: 8080 } { host: 'backend2', port: 8080 },
], { host: 'backend3', port: 8080 }
{ tls: { mode: 'terminate', certificate: 'auto' } } ],
); tls: { mode: 'terminate' as const, certificate: 'auto' as const },
loadBalancing: { algorithm: 'round-robin' as const }
}
};
``` ```
### Smart SNI Requirement (v22.3+) ### Smart SNI Requirement (v22.3+)

766
readme.md

File diff suppressed because it is too large Load Diff

2
rust/.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

522
rust/Cargo.lock generated
View File

@@ -157,12 +157,24 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.57" version = "4.5.57"
@@ -218,6 +230,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -285,6 +307,18 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "fastbloom"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4"
dependencies = [
"getrandom 0.3.4",
"libm",
"rand",
"siphasher",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
@@ -309,6 +343,21 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -316,6 +365,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -324,6 +374,34 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.31" version = "0.3.31"
@@ -342,10 +420,16 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
"slab",
] ]
[[package]] [[package]]
@@ -368,9 +452,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi", "r-efi",
"wasip2", "wasip2",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -398,6 +484,34 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "h3"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be"
dependencies = [
"bytes",
"fastrand",
"futures-util",
"http",
"pin-project-lite",
"tokio",
]
[[package]]
name = "h3-quinn"
version = "0.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4"
dependencies = [
"bytes",
"futures",
"h3",
"quinn",
"tokio",
"tokio-util",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -515,7 +629,7 @@ dependencies = [
"hyper", "hyper",
"libc", "libc",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2 0.6.2",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -571,6 +685,28 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]] [[package]]
name = "jobserver" name = "jobserver"
version = "0.1.34" version = "0.1.34"
@@ -619,10 +755,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]] [[package]]
name = "linux-raw-sys" name = "libm"
version = "0.11.0" version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870"
dependencies = [
"cc",
"libc",
]
[[package]] [[package]]
name = "lock_api" name = "lock_api"
@@ -639,6 +785,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -654,6 +806,15 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 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]] [[package]]
name = "mio" name = "mio"
version = "1.1.1" version = "1.1.1"
@@ -777,6 +938,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -786,6 +956,64 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"futures-io",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"fastbloom",
"getrandom 0.3.4",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.2",
"tracing",
"windows-sys 0.60.2",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.44" version = "1.0.44"
@@ -801,6 +1029,35 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rcgen" name = "rcgen"
version = "0.13.2" version = "0.13.2"
@@ -867,17 +1124,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "rustix" name = "rustc-hash"
version = "1.1.3" version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
@@ -922,9 +1172,37 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [ dependencies = [
"web-time",
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]] [[package]]
name = "rustls-webpki" name = "rustls-webpki"
version = "0.103.9" version = "0.103.9"
@@ -946,15 +1224,20 @@ dependencies = [
"bytes", "bytes",
"clap", "clap",
"dashmap", "dashmap",
"h3",
"h3-quinn",
"http",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-util", "hyper-util",
"mimalloc",
"quinn",
"rcgen", "rcgen",
"rustls", "rustls",
"rustls-pemfile",
"rustproxy-config", "rustproxy-config",
"rustproxy-http", "rustproxy-http",
"rustproxy-metrics", "rustproxy-metrics",
"rustproxy-nftables",
"rustproxy-passthrough", "rustproxy-passthrough",
"rustproxy-routing", "rustproxy-routing",
"rustproxy-security", "rustproxy-security",
@@ -986,16 +1269,25 @@ dependencies = [
"arc-swap", "arc-swap",
"bytes", "bytes",
"dashmap", "dashmap",
"futures",
"h3",
"h3-quinn",
"http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-util", "hyper-util",
"quinn",
"regex", "regex",
"rustls",
"rustproxy-config", "rustproxy-config",
"rustproxy-metrics", "rustproxy-metrics",
"rustproxy-routing", "rustproxy-routing",
"rustproxy-security", "rustproxy-security",
"socket2 0.5.10",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-rustls",
"tokio-util",
"tracing", "tracing",
] ]
@@ -1011,27 +1303,16 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "rustproxy-nftables"
version = "0.1.0"
dependencies = [
"anyhow",
"libc",
"rustproxy-config",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]] [[package]]
name = "rustproxy-passthrough" name = "rustproxy-passthrough"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
"base64",
"dashmap", "dashmap",
"quinn",
"rcgen",
"rustls", "rustls",
"rustls-pemfile", "rustls-pemfile",
"rustproxy-config", "rustproxy-config",
@@ -1040,6 +1321,7 @@ dependencies = [
"rustproxy-routing", "rustproxy-routing",
"serde", "serde",
"serde_json", "serde_json",
"socket2 0.5.10",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
@@ -1084,8 +1366,6 @@ dependencies = [
"rustls", "rustls",
"rustproxy-config", "rustproxy-config",
"serde", "serde",
"serde_json",
"tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -1097,6 +1377,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.28" version = "0.1.28"
@@ -1215,6 +1504,12 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "siphasher"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -1227,6 +1522,16 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 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]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.2" version = "0.6.2"
@@ -1260,19 +1565,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "tempfile"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -1353,6 +1645,21 @@ dependencies = [
"time-core", "time-core",
] ]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.49.0" version = "1.49.0"
@@ -1365,7 +1672,7 @@ dependencies = [
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",
"socket2", "socket2 0.6.2",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1416,6 +1723,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
@@ -1501,6 +1809,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"
@@ -1570,12 +1888,49 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@@ -1603,6 +1958,21 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@@ -1636,6 +2006,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.1", "windows_x86_64_msvc 0.53.1",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -1648,6 +2024,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -1660,6 +2042,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -1684,6 +2072,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
@@ -1696,6 +2090,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
@@ -1708,6 +2108,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -1720,6 +2126,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -1747,6 +2159,26 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "zerocopy"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zeroize" name = "zeroize"
version = "1.8.2" version = "1.8.2"

View File

@@ -7,7 +7,6 @@ members = [
"crates/rustproxy-tls", "crates/rustproxy-tls",
"crates/rustproxy-passthrough", "crates/rustproxy-passthrough",
"crates/rustproxy-http", "crates/rustproxy-http",
"crates/rustproxy-nftables",
"crates/rustproxy-metrics", "crates/rustproxy-metrics",
"crates/rustproxy-security", "crates/rustproxy-security",
] ]
@@ -29,6 +28,7 @@ serde_json = "1"
# HTTP proxy engine (hyper-based) # HTTP proxy engine (hyper-based)
hyper = { version = "1", features = ["http1", "http2", "server", "client"] } hyper = { version = "1", features = ["http1", "http2", "server", "client"] }
hyper-util = { version = "0.1", features = ["tokio", "http1", "http2", "client-legacy", "server-auto"] } hyper-util = { version = "0.1", features = ["tokio", "http1", "http2", "client-legacy", "server-auto"] }
http-body = "1"
http-body-util = "0.1" http-body-util = "0.1"
bytes = "1" bytes = "1"
@@ -87,12 +87,24 @@ async-trait = "0.1"
# libc for uid checks # libc for uid checks
libc = "0.2" libc = "0.2"
# Socket-level options (keepalive, etc.)
socket2 = { version = "0.5", features = ["all"] }
# QUIC transport
quinn = "0.11"
# HTTP/3 protocol
h3 = "0.0.8"
h3-quinn = "0.0.10"
# mimalloc allocator (prevents glibc fragmentation / slow RSS growth)
mimalloc = "0.1"
# Internal crates # Internal crates
rustproxy-config = { path = "crates/rustproxy-config" } rustproxy-config = { path = "crates/rustproxy-config" }
rustproxy-routing = { path = "crates/rustproxy-routing" } rustproxy-routing = { path = "crates/rustproxy-routing" }
rustproxy-tls = { path = "crates/rustproxy-tls" } rustproxy-tls = { path = "crates/rustproxy-tls" }
rustproxy-passthrough = { path = "crates/rustproxy-passthrough" } rustproxy-passthrough = { path = "crates/rustproxy-passthrough" }
rustproxy-http = { path = "crates/rustproxy-http" } rustproxy-http = { path = "crates/rustproxy-http" }
rustproxy-nftables = { path = "crates/rustproxy-nftables" }
rustproxy-metrics = { path = "crates/rustproxy-metrics" } rustproxy-metrics = { path = "crates/rustproxy-metrics" }
rustproxy-security = { path = "crates/rustproxy-security" } rustproxy-security = { path = "crates/rustproxy-security" }

View File

@@ -1,334 +0,0 @@
use crate::route_types::*;
use crate::tls_types::*;
/// Create a simple HTTP forwarding route.
/// Equivalent to SmartProxy's `createHttpRoute()`.
pub fn create_http_route(
domains: impl Into<DomainSpec>,
target_host: impl Into<String>,
target_port: u16,
) -> RouteConfig {
RouteConfig {
id: None,
route_match: RouteMatch {
ports: PortRange::Single(80),
domains: Some(domains.into()),
path: None,
client_ip: None,
tls_version: None,
headers: None,
},
action: RouteAction {
action_type: RouteActionType::Forward,
targets: Some(vec![RouteTarget {
target_match: None,
host: HostSpec::Single(target_host.into()),
port: PortSpec::Fixed(target_port),
tls: None,
websocket: None,
load_balancing: None,
send_proxy_protocol: None,
headers: None,
advanced: None,
priority: None,
}]),
tls: None,
websocket: None,
load_balancing: None,
advanced: None,
options: None,
forwarding_engine: None,
nftables: None,
send_proxy_protocol: None,
},
headers: None,
security: None,
name: None,
description: None,
priority: None,
tags: None,
enabled: None,
}
}
/// Create an HTTPS termination route.
/// Equivalent to SmartProxy's `createHttpsTerminateRoute()`.
pub fn create_https_terminate_route(
domains: impl Into<DomainSpec>,
target_host: impl Into<String>,
target_port: u16,
) -> RouteConfig {
let mut route = create_http_route(domains, target_host, target_port);
route.route_match.ports = PortRange::Single(443);
route.action.tls = Some(RouteTls {
mode: TlsMode::Terminate,
certificate: Some(CertificateSpec::Auto("auto".to_string())),
acme: None,
versions: None,
ciphers: None,
honor_cipher_order: None,
session_timeout: None,
});
route
}
/// Create a TLS passthrough route.
/// Equivalent to SmartProxy's `createHttpsPassthroughRoute()`.
pub fn create_https_passthrough_route(
domains: impl Into<DomainSpec>,
target_host: impl Into<String>,
target_port: u16,
) -> RouteConfig {
let mut route = create_http_route(domains, target_host, target_port);
route.route_match.ports = PortRange::Single(443);
route.action.tls = Some(RouteTls {
mode: TlsMode::Passthrough,
certificate: None,
acme: None,
versions: None,
ciphers: None,
honor_cipher_order: None,
session_timeout: None,
});
route
}
/// Create an HTTP-to-HTTPS redirect route.
/// Equivalent to SmartProxy's `createHttpToHttpsRedirect()`.
pub fn create_http_to_https_redirect(
domains: impl Into<DomainSpec>,
) -> RouteConfig {
let domains = domains.into();
RouteConfig {
id: None,
route_match: RouteMatch {
ports: PortRange::Single(80),
domains: Some(domains),
path: None,
client_ip: None,
tls_version: None,
headers: None,
},
action: RouteAction {
action_type: RouteActionType::Forward,
targets: None,
tls: None,
websocket: None,
load_balancing: None,
advanced: Some(RouteAdvanced {
timeout: None,
headers: None,
keep_alive: None,
static_files: None,
test_response: Some(RouteTestResponse {
status: 301,
headers: {
let mut h = std::collections::HashMap::new();
h.insert("Location".to_string(), "https://{domain}{path}".to_string());
h
},
body: String::new(),
}),
url_rewrite: None,
}),
options: None,
forwarding_engine: None,
nftables: None,
send_proxy_protocol: None,
},
headers: None,
security: None,
name: Some("HTTP to HTTPS Redirect".to_string()),
description: None,
priority: None,
tags: None,
enabled: None,
}
}
/// Create a complete HTTPS server with HTTP redirect.
/// Equivalent to SmartProxy's `createCompleteHttpsServer()`.
pub fn create_complete_https_server(
domain: impl Into<String>,
target_host: impl Into<String>,
target_port: u16,
) -> Vec<RouteConfig> {
let domain = domain.into();
let target_host = target_host.into();
vec![
create_http_to_https_redirect(DomainSpec::Single(domain.clone())),
create_https_terminate_route(
DomainSpec::Single(domain),
target_host,
target_port,
),
]
}
/// Create a load balancer route.
/// Equivalent to SmartProxy's `createLoadBalancerRoute()`.
pub fn create_load_balancer_route(
domains: impl Into<DomainSpec>,
targets: Vec<(String, u16)>,
tls: Option<RouteTls>,
) -> RouteConfig {
let route_targets: Vec<RouteTarget> = targets
.into_iter()
.map(|(host, port)| RouteTarget {
target_match: None,
host: HostSpec::Single(host),
port: PortSpec::Fixed(port),
tls: None,
websocket: None,
load_balancing: None,
send_proxy_protocol: None,
headers: None,
advanced: None,
priority: None,
})
.collect();
let port = if tls.is_some() { 443 } else { 80 };
RouteConfig {
id: None,
route_match: RouteMatch {
ports: PortRange::Single(port),
domains: Some(domains.into()),
path: None,
client_ip: None,
tls_version: None,
headers: None,
},
action: RouteAction {
action_type: RouteActionType::Forward,
targets: Some(route_targets),
tls,
websocket: None,
load_balancing: Some(RouteLoadBalancing {
algorithm: LoadBalancingAlgorithm::RoundRobin,
health_check: None,
}),
advanced: None,
options: None,
forwarding_engine: None,
nftables: None,
send_proxy_protocol: None,
},
headers: None,
security: None,
name: Some("Load Balancer".to_string()),
description: None,
priority: None,
tags: None,
enabled: None,
}
}
// Convenience conversions for DomainSpec
impl From<&str> for DomainSpec {
fn from(s: &str) -> Self {
DomainSpec::Single(s.to_string())
}
}
impl From<String> for DomainSpec {
fn from(s: String) -> Self {
DomainSpec::Single(s)
}
}
impl From<Vec<String>> for DomainSpec {
fn from(v: Vec<String>) -> Self {
DomainSpec::List(v)
}
}
impl From<Vec<&str>> for DomainSpec {
fn from(v: Vec<&str>) -> Self {
DomainSpec::List(v.into_iter().map(|s| s.to_string()).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tls_types::TlsMode;
#[test]
fn test_create_http_route() {
let route = create_http_route("example.com", "localhost", 8080);
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
let domains = route.route_match.domains.as_ref().unwrap().to_vec();
assert_eq!(domains, vec!["example.com"]);
let target = &route.action.targets.as_ref().unwrap()[0];
assert_eq!(target.host.first(), "localhost");
assert_eq!(target.port.resolve(80), 8080);
assert!(route.action.tls.is_none());
}
#[test]
fn test_create_https_terminate_route() {
let route = create_https_terminate_route("api.example.com", "backend", 3000);
assert_eq!(route.route_match.ports.to_ports(), vec![443]);
let tls = route.action.tls.as_ref().unwrap();
assert_eq!(tls.mode, TlsMode::Terminate);
assert!(tls.certificate.as_ref().unwrap().is_auto());
}
#[test]
fn test_create_https_passthrough_route() {
let route = create_https_passthrough_route("secure.example.com", "backend", 443);
assert_eq!(route.route_match.ports.to_ports(), vec![443]);
let tls = route.action.tls.as_ref().unwrap();
assert_eq!(tls.mode, TlsMode::Passthrough);
assert!(tls.certificate.is_none());
}
#[test]
fn test_create_http_to_https_redirect() {
let route = create_http_to_https_redirect("example.com");
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
assert!(route.action.targets.is_none());
let test_response = route.action.advanced.as_ref().unwrap().test_response.as_ref().unwrap();
assert_eq!(test_response.status, 301);
assert!(test_response.headers.contains_key("Location"));
}
#[test]
fn test_create_complete_https_server() {
let routes = create_complete_https_server("example.com", "backend", 8080);
assert_eq!(routes.len(), 2);
// First route is HTTP redirect
assert_eq!(routes[0].route_match.ports.to_ports(), vec![80]);
// Second route is HTTPS terminate
assert_eq!(routes[1].route_match.ports.to_ports(), vec![443]);
}
#[test]
fn test_create_load_balancer_route() {
let targets = vec![
("backend1".to_string(), 8080),
("backend2".to_string(), 8080),
("backend3".to_string(), 8080),
];
let route = create_load_balancer_route("*.example.com", targets, None);
assert_eq!(route.route_match.ports.to_ports(), vec![80]);
assert_eq!(route.action.targets.as_ref().unwrap().len(), 3);
let lb = route.action.load_balancing.as_ref().unwrap();
assert_eq!(lb.algorithm, LoadBalancingAlgorithm::RoundRobin);
}
#[test]
fn test_domain_spec_from_str() {
let spec: DomainSpec = "example.com".into();
assert_eq!(spec.to_vec(), vec!["example.com"]);
}
#[test]
fn test_domain_spec_from_vec() {
let spec: DomainSpec = vec!["a.com", "b.com"].into();
assert_eq!(spec.to_vec(), vec!["a.com", "b.com"]);
}
}

View File

@@ -8,7 +8,6 @@ pub mod proxy_options;
pub mod tls_types; pub mod tls_types;
pub mod security_types; pub mod security_types;
pub mod validation; pub mod validation;
pub mod helpers;
// Re-export all primary types // Re-export all primary types
pub use route_types::*; pub use route_types::*;
@@ -16,4 +15,3 @@ pub use proxy_options::*;
pub use tls_types::*; pub use tls_types::*;
pub use security_types::*; pub use security_types::*;
pub use validation::*; pub use validation::*;
pub use helpers::*;

View File

@@ -29,9 +29,6 @@ pub struct AcmeOptions {
/// Enable automatic renewal (default: true) /// Enable automatic renewal (default: true)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub auto_renew: Option<bool>, pub auto_renew: Option<bool>,
/// Directory to store certificates (default: './certs')
#[serde(skip_serializing_if = "Option::is_none")]
pub certificate_store: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub skip_configured_certs: Option<bool>, pub skip_configured_certs: Option<bool>,
/// How often to check for renewals (default: 24) /// How often to check for renewals (default: 24)
@@ -211,6 +208,10 @@ pub struct RustProxyOptions {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub connection_rate_limit_per_minute: Option<u64>, pub connection_rate_limit_per_minute: Option<u64>,
/// Global maximum simultaneous connections (default: 100000)
#[serde(skip_serializing_if = "Option::is_none")]
pub max_connections: Option<u64>,
// ─── Keep-Alive Settings ───────────────────────────────────────── // ─── Keep-Alive Settings ─────────────────────────────────────────
/// How to treat keep-alive connections /// How to treat keep-alive connections
@@ -275,6 +276,7 @@ impl Default for RustProxyOptions {
enable_randomized_timeouts: None, enable_randomized_timeouts: None,
max_connections_per_ip: None, max_connections_per_ip: None,
connection_rate_limit_per_minute: None, connection_rate_limit_per_minute: None,
max_connections: None,
keep_alive_treatment: None, keep_alive_treatment: None,
keep_alive_inactivity_multiplier: None, keep_alive_inactivity_multiplier: None,
extended_keep_alive_lifetime: None, extended_keep_alive_lifetime: None,
@@ -296,7 +298,7 @@ impl RustProxyOptions {
/// Get the effective connection timeout in milliseconds. /// Get the effective connection timeout in milliseconds.
pub fn effective_connection_timeout(&self) -> u64 { pub fn effective_connection_timeout(&self) -> u64 {
self.connection_timeout.unwrap_or(30_000) self.connection_timeout.unwrap_or(60_000)
} }
/// Get the effective initial data timeout in milliseconds. /// Get the effective initial data timeout in milliseconds.
@@ -306,12 +308,12 @@ impl RustProxyOptions {
/// Get the effective socket timeout in milliseconds. /// Get the effective socket timeout in milliseconds.
pub fn effective_socket_timeout(&self) -> u64 { pub fn effective_socket_timeout(&self) -> u64 {
self.socket_timeout.unwrap_or(3_600_000) self.socket_timeout.unwrap_or(60_000)
} }
/// Get the effective max connection lifetime in milliseconds. /// Get the effective max connection lifetime in milliseconds.
pub fn effective_max_connection_lifetime(&self) -> u64 { pub fn effective_max_connection_lifetime(&self) -> u64 {
self.max_connection_lifetime.unwrap_or(86_400_000) self.max_connection_lifetime.unwrap_or(3_600_000)
} }
/// Get all unique ports that routes listen on. /// Get all unique ports that routes listen on.
@@ -329,12 +331,48 @@ impl RustProxyOptions {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::helpers::*; use crate::route_types::*;
use crate::tls_types::*;
fn make_route(domain: &str, host: &str, port: u16, listen_port: u16) -> RouteConfig {
RouteConfig {
id: None,
route_match: RouteMatch {
ports: PortRange::Single(listen_port),
domains: Some(DomainSpec::Single(domain.to_string())),
path: None, client_ip: None, transport: None, tls_version: None, headers: None, protocol: None,
},
action: RouteAction {
action_type: RouteActionType::Forward,
targets: Some(vec![RouteTarget {
target_match: None,
host: HostSpec::Single(host.to_string()),
port: PortSpec::Fixed(port),
tls: None, websocket: None, load_balancing: None, send_proxy_protocol: None,
headers: None, advanced: None, backend_transport: None, priority: None,
}]),
tls: None, websocket: None, load_balancing: None, advanced: None,
options: None, send_proxy_protocol: None, udp: None,
},
headers: None, security: None, name: None, description: None,
priority: None, tags: None, enabled: None,
}
}
fn make_passthrough_route(domain: &str, host: &str, port: u16) -> RouteConfig {
let mut route = make_route(domain, host, port, 443);
route.action.tls = Some(RouteTls {
mode: TlsMode::Passthrough,
certificate: None, acme: None, versions: None, ciphers: None,
honor_cipher_order: None, session_timeout: None,
});
route
}
#[test] #[test]
fn test_serde_roundtrip_minimal() { fn test_serde_roundtrip_minimal() {
let options = RustProxyOptions { let options = RustProxyOptions {
routes: vec![create_http_route("example.com", "localhost", 8080)], routes: vec![make_route("example.com", "localhost", 8080, 80)],
..Default::default() ..Default::default()
}; };
let json = serde_json::to_string(&options).unwrap(); let json = serde_json::to_string(&options).unwrap();
@@ -346,8 +384,8 @@ mod tests {
fn test_serde_roundtrip_full() { fn test_serde_roundtrip_full() {
let options = RustProxyOptions { let options = RustProxyOptions {
routes: vec![ routes: vec![
create_http_route("a.com", "backend1", 8080), make_route("a.com", "backend1", 8080, 80),
create_https_passthrough_route("b.com", "backend2", 443), make_passthrough_route("b.com", "backend2", 443),
], ],
connection_timeout: Some(5000), connection_timeout: Some(5000),
socket_timeout: Some(60000), socket_timeout: Some(60000),
@@ -361,7 +399,6 @@ mod tests {
use_production: None, use_production: None,
renew_threshold_days: None, renew_threshold_days: None,
auto_renew: None, auto_renew: None,
certificate_store: None,
skip_configured_certs: None, skip_configured_certs: None,
renew_check_interval_hours: None, renew_check_interval_hours: None,
}), }),
@@ -376,10 +413,10 @@ mod tests {
#[test] #[test]
fn test_default_timeouts() { fn test_default_timeouts() {
let options = RustProxyOptions::default(); let options = RustProxyOptions::default();
assert_eq!(options.effective_connection_timeout(), 30_000); assert_eq!(options.effective_connection_timeout(), 60_000);
assert_eq!(options.effective_initial_data_timeout(), 60_000); assert_eq!(options.effective_initial_data_timeout(), 60_000);
assert_eq!(options.effective_socket_timeout(), 3_600_000); assert_eq!(options.effective_socket_timeout(), 60_000);
assert_eq!(options.effective_max_connection_lifetime(), 86_400_000); assert_eq!(options.effective_max_connection_lifetime(), 3_600_000);
} }
#[test] #[test]
@@ -401,9 +438,9 @@ mod tests {
fn test_all_listening_ports() { fn test_all_listening_ports() {
let options = RustProxyOptions { let options = RustProxyOptions {
routes: vec![ routes: vec![
create_http_route("a.com", "backend", 8080), // port 80 make_route("a.com", "backend", 8080, 80), // port 80
create_https_passthrough_route("b.com", "backend", 443), // port 443 make_passthrough_route("b.com", "backend", 443), // port 443
create_http_route("c.com", "backend", 9090), // port 80 (duplicate) make_route("c.com", "backend", 9090, 80), // port 80 (duplicate)
], ],
..Default::default() ..Default::default()
}; };

View File

@@ -7,16 +7,24 @@ use crate::security_types::RouteSecurity;
// ─── Port Range ────────────────────────────────────────────────────── // ─── Port Range ──────────────────────────────────────────────────────
/// Port range specification format. /// Port range specification format.
/// Matches TypeScript: `type TPortRange = number | number[] | Array<{ from: number; to: number }>` /// Matches TypeScript: `type TPortRange = number | Array<number | { from: number; to: number }>`
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum PortRange { pub enum PortRange {
/// Single port number /// Single port number
Single(u16), Single(u16),
/// Array of port numbers /// Array of port numbers, ranges, or mixed
List(Vec<u16>), List(Vec<PortRangeItem>),
/// Array of port ranges }
Ranges(Vec<PortRangeSpec>),
/// A single item in a port range array: either a number or a from-to range.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PortRangeItem {
/// Single port number
Port(u16),
/// A from-to port range
Range(PortRangeSpec),
} }
impl PortRange { impl PortRange {
@@ -24,9 +32,11 @@ impl PortRange {
pub fn to_ports(&self) -> Vec<u16> { pub fn to_ports(&self) -> Vec<u16> {
match self { match self {
PortRange::Single(p) => vec![*p], PortRange::Single(p) => vec![*p],
PortRange::List(ports) => ports.clone(), PortRange::List(items) => {
PortRange::Ranges(ranges) => { items.iter().flat_map(|item| match item {
ranges.iter().flat_map(|r| r.from..=r.to).collect() PortRangeItem::Port(p) => vec![*p],
PortRangeItem::Range(r) => (r.from..=r.to).collect(),
}).collect()
} }
} }
} }
@@ -50,16 +60,6 @@ pub enum RouteActionType {
SocketHandler, SocketHandler,
} }
// ─── Forwarding Engine ───────────────────────────────────────────────
/// Forwarding engine specification.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ForwardingEngine {
Node,
Nftables,
}
// ─── Route Match ───────────────────────────────────────────────────── // ─── Route Match ─────────────────────────────────────────────────────
/// Domain specification: single string or array. /// Domain specification: single string or array.
@@ -79,6 +79,31 @@ impl DomainSpec {
} }
} }
// Convenience conversions for DomainSpec
impl From<&str> for DomainSpec {
fn from(s: &str) -> Self {
DomainSpec::Single(s.to_string())
}
}
impl From<String> for DomainSpec {
fn from(s: String) -> Self {
DomainSpec::Single(s)
}
}
impl From<Vec<String>> for DomainSpec {
fn from(v: Vec<String>) -> Self {
DomainSpec::List(v)
}
}
impl From<Vec<&str>> for DomainSpec {
fn from(v: Vec<&str>) -> Self {
DomainSpec::List(v.into_iter().map(|s| s.to_string()).collect())
}
}
/// Header match value: either exact string or regex pattern. /// Header match value: either exact string or regex pattern.
/// In JSON, all values come as strings. Regex patterns are prefixed with `/` and suffixed with `/`. /// In JSON, all values come as strings. Regex patterns are prefixed with `/` and suffixed with `/`.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -95,6 +120,10 @@ pub struct RouteMatch {
/// Listen on these ports (required) /// Listen on these ports (required)
pub ports: PortRange, pub ports: PortRange,
/// Transport protocol: tcp (default), udp, or all (both TCP and UDP)
#[serde(skip_serializing_if = "Option::is_none")]
pub transport: Option<TransportProtocol>,
/// Optional domain patterns to match (default: all domains) /// Optional domain patterns to match (default: all domains)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub domains: Option<DomainSpec>, pub domains: Option<DomainSpec>,
@@ -114,6 +143,10 @@ pub struct RouteMatch {
/// Match specific HTTP headers /// Match specific HTTP headers
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>, pub headers: Option<HashMap<String, String>>,
/// Match specific protocol: "http", "tcp", "udp", "quic", "http3"
#[serde(skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
} }
// ─── Target Match ──────────────────────────────────────────────────── // ─── Target Match ────────────────────────────────────────────────────
@@ -323,38 +356,6 @@ pub struct RouteAdvanced {
pub url_rewrite: Option<RouteUrlRewrite>, pub url_rewrite: Option<RouteUrlRewrite>,
} }
// ─── NFTables Options ────────────────────────────────────────────────
/// NFTables protocol type.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NfTablesProtocol {
Tcp,
Udp,
All,
}
/// NFTables-specific configuration options.
/// Matches TypeScript: `INfTablesOptions`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NfTablesOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub preserve_source_ip: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub protocol: Option<NfTablesProtocol>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_rate: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub table_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_ip_sets: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_advanced_nat: Option<bool>,
}
// ─── Backend Protocol ──────────────────────────────────────────────── // ─── Backend Protocol ────────────────────────────────────────────────
/// Backend protocol. /// Backend protocol.
@@ -363,6 +364,17 @@ pub struct NfTablesOptions {
pub enum BackendProtocol { pub enum BackendProtocol {
Http1, Http1,
Http2, Http2,
Http3,
Auto,
}
/// Transport protocol for route matching.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TransportProtocol {
Tcp,
Udp,
All,
} }
/// Action options. /// Action options.
@@ -465,6 +477,10 @@ pub struct RouteTarget {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub advanced: Option<RouteAdvanced>, pub advanced: Option<RouteAdvanced>,
/// Override transport for backend connection (e.g., receive QUIC but forward as TCP)
#[serde(skip_serializing_if = "Option::is_none")]
pub backend_transport: Option<TransportProtocol>,
/// Priority for matching (higher values checked first, default: 0) /// Priority for matching (higher values checked first, default: 0)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i32>, pub priority: Option<i32>,
@@ -508,17 +524,71 @@ pub struct RouteAction {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub options: Option<ActionOptions>, pub options: Option<ActionOptions>,
/// Forwarding engine specification
#[serde(skip_serializing_if = "Option::is_none")]
pub forwarding_engine: Option<ForwardingEngine>,
/// NFTables-specific options
#[serde(skip_serializing_if = "Option::is_none")]
pub nftables: Option<NfTablesOptions>,
/// PROXY protocol support (default for all targets) /// PROXY protocol support (default for all targets)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub send_proxy_protocol: Option<bool>, pub send_proxy_protocol: Option<bool>,
/// UDP-specific settings (session tracking, datagram limits, QUIC config)
#[serde(skip_serializing_if = "Option::is_none")]
pub udp: Option<RouteUdp>,
}
// ─── UDP & QUIC Config ──────────────────────────────────────────────
/// UDP-specific settings for route actions.
/// Matches TypeScript: `IRouteUdp`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RouteUdp {
/// Idle timeout for a UDP session/flow in ms. Default: 60000
#[serde(skip_serializing_if = "Option::is_none")]
pub session_timeout: Option<u64>,
/// Max concurrent UDP sessions per source IP. Default: 1000
#[serde(skip_serializing_if = "Option::is_none")]
pub max_sessions_per_ip: Option<u32>,
/// Max accepted datagram size in bytes. Default: 65535
#[serde(skip_serializing_if = "Option::is_none")]
pub max_datagram_size: Option<u32>,
/// QUIC-specific configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub quic: Option<RouteQuic>,
}
/// QUIC and HTTP/3 settings.
/// Matches TypeScript: `IRouteQuic`
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RouteQuic {
/// QUIC connection idle timeout in ms. Default: 30000
#[serde(skip_serializing_if = "Option::is_none")]
pub max_idle_timeout: Option<u64>,
/// Max concurrent bidirectional streams per QUIC connection. Default: 100
#[serde(skip_serializing_if = "Option::is_none")]
pub max_concurrent_bidi_streams: Option<u32>,
/// Max concurrent unidirectional streams per QUIC connection. Default: 100
#[serde(skip_serializing_if = "Option::is_none")]
pub max_concurrent_uni_streams: Option<u32>,
/// Enable HTTP/3 over this QUIC endpoint. Default: false
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_http3: Option<bool>,
/// Port to advertise in Alt-Svc header on TCP HTTP responses
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_svc_port: Option<u16>,
/// Max age for Alt-Svc advertisement in seconds. Default: 86400
#[serde(skip_serializing_if = "Option::is_none")]
pub alt_svc_max_age: Option<u64>,
/// Initial congestion window size in bytes
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_congestion_window: Option<u32>,
} }
// ─── Route Config ──────────────────────────────────────────────────── // ─── Route Config ────────────────────────────────────────────────────

View File

@@ -104,7 +104,49 @@ mod tests {
use crate::route_types::*; use crate::route_types::*;
fn make_valid_route() -> RouteConfig { fn make_valid_route() -> RouteConfig {
crate::helpers::create_http_route("example.com", "localhost", 8080) RouteConfig {
id: None,
route_match: RouteMatch {
ports: PortRange::Single(80),
domains: Some(DomainSpec::Single("example.com".to_string())),
path: None,
client_ip: None,
transport: None,
tls_version: None,
headers: None,
protocol: None,
},
action: RouteAction {
action_type: RouteActionType::Forward,
targets: Some(vec![RouteTarget {
target_match: None,
host: HostSpec::Single("localhost".to_string()),
port: PortSpec::Fixed(8080),
tls: None,
websocket: None,
load_balancing: None,
send_proxy_protocol: None,
headers: None,
advanced: None,
backend_transport: None,
priority: None,
}]),
tls: None,
websocket: None,
load_balancing: None,
advanced: None,
options: None,
send_proxy_protocol: None,
udp: None,
},
headers: None,
security: None,
name: None,
description: None,
priority: None,
tags: None,
enabled: None,
}
} }
#[test] #[test]

View File

@@ -14,11 +14,20 @@ rustproxy-metrics = { workspace = true }
hyper = { workspace = true } hyper = { workspace = true }
hyper-util = { workspace = true } hyper-util = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
http-body = { workspace = true }
http-body-util = { workspace = true } http-body-util = { workspace = true }
bytes = { workspace = true } bytes = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
rustls = { workspace = true }
tokio-rustls = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
arc-swap = { workspace = true } arc-swap = { workspace = true }
dashmap = { workspace = true } dashmap = { workspace = true }
tokio-util = { workspace = true }
socket2 = { workspace = true }
quinn = { workspace = true }
h3 = { workspace = true }
h3-quinn = { workspace = true }
futures = { version = "0.3", default-features = false, features = ["std"] }

View File

@@ -0,0 +1,313 @@
//! Backend connection pool for HTTP/1.1, HTTP/2, and HTTP/3 (QUIC).
//!
//! Reuses idle keep-alive connections to avoid per-request TCP+TLS handshakes.
//! HTTP/2 and HTTP/3 connections are multiplexed (clone the sender / share the connection).
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use bytes::Bytes;
use dashmap::DashMap;
use http_body_util::combinators::BoxBody;
use hyper::client::conn::{http1, http2};
/// Maximum idle connections per backend key.
const MAX_IDLE_PER_KEY: usize = 16;
/// Default idle timeout — connections not used within this window are evicted.
const IDLE_TIMEOUT: Duration = Duration::from_secs(90);
/// Background eviction interval.
const EVICTION_INTERVAL: Duration = Duration::from_secs(30);
/// Maximum age for pooled HTTP/2 connections before proactive eviction.
const MAX_H2_AGE: Duration = Duration::from_secs(120);
/// Maximum age for pooled QUIC/HTTP/3 connections.
const MAX_H3_AGE: Duration = Duration::from_secs(120);
/// Protocol for pool key discrimination.
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
pub enum PoolProtocol {
H1,
H2,
H3,
}
/// Identifies a unique backend endpoint.
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
pub struct PoolKey {
pub host: String,
pub port: u16,
pub use_tls: bool,
pub protocol: PoolProtocol,
}
/// An idle HTTP/1.1 sender with a timestamp for eviction.
struct IdleH1 {
sender: http1::SendRequest<BoxBody<Bytes, hyper::Error>>,
idle_since: Instant,
}
/// A pooled HTTP/2 sender (multiplexed, Clone-able) with a generation tag.
struct PooledH2 {
sender: http2::SendRequest<BoxBody<Bytes, hyper::Error>>,
created_at: Instant,
/// Unique generation ID. Connection drivers use this to only remove their OWN
/// entry, preventing phantom eviction when multiple connections share the same key.
generation: u64,
}
/// A pooled QUIC/HTTP/3 connection (multiplexed like H2).
/// Stores the h3 `SendRequest` handle so pool hits skip the h3 SETTINGS handshake.
pub struct PooledH3 {
/// Multiplexed h3 request handle — clone to open a new stream.
pub send_request: h3::client::SendRequest<h3_quinn::OpenStreams, Bytes>,
/// Raw QUIC connection — kept for liveness probing (close_reason) only.
pub connection: quinn::Connection,
pub created_at: Instant,
pub generation: u64,
}
/// Backend connection pool.
pub struct ConnectionPool {
/// HTTP/1.1 idle connections indexed by backend key.
h1_pool: Arc<DashMap<PoolKey, Vec<IdleH1>>>,
/// HTTP/2 multiplexed connections indexed by backend key.
h2_pool: Arc<DashMap<PoolKey, PooledH2>>,
/// HTTP/3 (QUIC) connections indexed by backend key.
h3_pool: Arc<DashMap<PoolKey, PooledH3>>,
/// Monotonic generation counter for H2/H3 pool entries.
h2_generation: AtomicU64,
/// Handle for the background eviction task.
eviction_handle: Option<tokio::task::JoinHandle<()>>,
}
impl ConnectionPool {
/// Create a new pool and start the background eviction task.
pub fn new() -> Self {
let h1_pool: Arc<DashMap<PoolKey, Vec<IdleH1>>> = Arc::new(DashMap::new());
let h2_pool: Arc<DashMap<PoolKey, PooledH2>> = Arc::new(DashMap::new());
let h3_pool: Arc<DashMap<PoolKey, PooledH3>> = Arc::new(DashMap::new());
let h1_clone = Arc::clone(&h1_pool);
let h2_clone = Arc::clone(&h2_pool);
let h3_clone = Arc::clone(&h3_pool);
let eviction_handle = tokio::spawn(async move {
Self::eviction_loop(h1_clone, h2_clone, h3_clone).await;
});
Self {
h1_pool,
h2_pool,
h3_pool,
h2_generation: AtomicU64::new(0),
eviction_handle: Some(eviction_handle),
}
}
/// Try to check out an idle HTTP/1.1 sender for the given key.
/// Returns `None` if no usable idle connection exists.
pub fn checkout_h1(&self, key: &PoolKey) -> Option<http1::SendRequest<BoxBody<Bytes, hyper::Error>>> {
let mut entry = self.h1_pool.get_mut(key)?;
let idles = entry.value_mut();
while let Some(idle) = idles.pop() {
// Check if the connection is still alive and ready
if idle.idle_since.elapsed() < IDLE_TIMEOUT && idle.sender.is_ready() && !idle.sender.is_closed() {
// H1 pool hit — no logging on hot path
return Some(idle.sender);
}
// Stale or closed — drop it
}
// Clean up empty entry
if idles.is_empty() {
drop(entry);
self.h1_pool.remove(key);
}
None
}
/// Return an HTTP/1.1 sender to the pool after the response body has been prepared.
/// The caller should NOT call this if the sender is closed or not ready.
pub fn checkin_h1(&self, key: PoolKey, sender: http1::SendRequest<BoxBody<Bytes, hyper::Error>>) {
if sender.is_closed() || !sender.is_ready() {
return; // Don't pool broken connections
}
let mut entry = self.h1_pool.entry(key).or_insert_with(Vec::new);
if entry.value().len() < MAX_IDLE_PER_KEY {
entry.value_mut().push(IdleH1 {
sender,
idle_since: Instant::now(),
});
}
// If at capacity, just drop the sender
}
/// Try to get a cloned HTTP/2 sender for the given key.
/// HTTP/2 senders are Clone-able (multiplexed), so we clone rather than remove.
pub fn checkout_h2(&self, key: &PoolKey) -> Option<(http2::SendRequest<BoxBody<Bytes, hyper::Error>>, Duration)> {
let entry = self.h2_pool.get(key)?;
let pooled = entry.value();
let age = pooled.created_at.elapsed();
if pooled.sender.is_closed() || age >= MAX_H2_AGE {
drop(entry);
self.h2_pool.remove(key);
return None;
}
if pooled.sender.is_ready() {
return Some((pooled.sender.clone(), age));
}
None
}
/// Remove a dead HTTP/2 sender from the pool (unconditional).
/// Called when `send_request` fails to prevent subsequent requests from reusing the stale sender.
pub fn remove_h2(&self, key: &PoolKey) {
self.h2_pool.remove(key);
}
/// Remove an HTTP/2 sender ONLY if the current entry has the expected generation.
/// This prevents phantom eviction: when multiple connections share the same key,
/// an old connection's driver won't accidentally remove a newer connection's entry.
pub fn remove_h2_if_generation(&self, key: &PoolKey, expected_gen: u64) {
if let Some(entry) = self.h2_pool.get(key) {
if entry.value().generation == expected_gen {
drop(entry); // release DashMap ref before remove
self.h2_pool.remove(key);
}
// else: a newer connection replaced ours — don't touch it
}
}
/// Register an HTTP/2 sender in the pool. Returns the generation ID for this entry.
/// The caller should pass this generation to the connection driver so it can use
/// `remove_h2_if_generation` instead of `remove_h2` to avoid phantom eviction.
pub fn register_h2(&self, key: PoolKey, sender: http2::SendRequest<BoxBody<Bytes, hyper::Error>>) -> u64 {
let gen = self.h2_generation.fetch_add(1, Ordering::Relaxed);
if sender.is_closed() {
return gen;
}
self.h2_pool.insert(key, PooledH2 {
sender,
created_at: Instant::now(),
generation: gen,
});
gen
}
// ── HTTP/3 (QUIC) pool methods ──
/// Try to get a pooled QUIC connection for the given key.
/// QUIC connections are multiplexed — the connection is shared, not removed.
pub fn checkout_h3(
&self,
key: &PoolKey,
) -> Option<(h3::client::SendRequest<h3_quinn::OpenStreams, Bytes>, quinn::Connection, Duration)> {
let entry = self.h3_pool.get(key)?;
let pooled = entry.value();
let age = pooled.created_at.elapsed();
if age >= MAX_H3_AGE {
drop(entry);
self.h3_pool.remove(key);
return None;
}
// Check if QUIC connection is still alive
if pooled.connection.close_reason().is_some() {
drop(entry);
self.h3_pool.remove(key);
return None;
}
Some((pooled.send_request.clone(), pooled.connection.clone(), age))
}
/// Register a QUIC connection and its h3 SendRequest handle in the pool.
/// Returns the generation ID.
pub fn register_h3(
&self,
key: PoolKey,
connection: quinn::Connection,
send_request: h3::client::SendRequest<h3_quinn::OpenStreams, Bytes>,
) -> u64 {
let gen = self.h2_generation.fetch_add(1, Ordering::Relaxed);
self.h3_pool.insert(key, PooledH3 {
send_request,
connection,
created_at: Instant::now(),
generation: gen,
});
gen
}
/// Remove a QUIC connection only if generation matches.
pub fn remove_h3_if_generation(&self, key: &PoolKey, expected_gen: u64) {
if let Some(entry) = self.h3_pool.get(key) {
if entry.value().generation == expected_gen {
drop(entry);
self.h3_pool.remove(key);
}
}
}
/// Background eviction loop — runs every EVICTION_INTERVAL to remove stale connections.
async fn eviction_loop(
h1_pool: Arc<DashMap<PoolKey, Vec<IdleH1>>>,
h2_pool: Arc<DashMap<PoolKey, PooledH2>>,
h3_pool: Arc<DashMap<PoolKey, PooledH3>>,
) {
let mut interval = tokio::time::interval(EVICTION_INTERVAL);
loop {
interval.tick().await;
// Evict stale H1 connections
let mut empty_keys = Vec::new();
for mut entry in h1_pool.iter_mut() {
entry.value_mut().retain(|idle| {
idle.idle_since.elapsed() < IDLE_TIMEOUT && !idle.sender.is_closed()
});
if entry.value().is_empty() {
empty_keys.push(entry.key().clone());
}
}
for key in empty_keys {
h1_pool.remove(&key);
}
// Evict dead or aged-out H2 connections
let mut dead_h2 = Vec::new();
for entry in h2_pool.iter() {
if entry.value().sender.is_closed() || entry.value().created_at.elapsed() >= MAX_H2_AGE {
dead_h2.push(entry.key().clone());
}
}
for key in dead_h2 {
h2_pool.remove(&key);
}
// Evict dead or aged-out H3 (QUIC) connections
let mut dead_h3 = Vec::new();
for entry in h3_pool.iter() {
if entry.value().connection.close_reason().is_some()
|| entry.value().created_at.elapsed() >= MAX_H3_AGE
{
dead_h3.push(entry.key().clone());
}
}
for key in dead_h3 {
h3_pool.remove(&key);
}
}
}
}
impl Drop for ConnectionPool {
fn drop(&mut self) {
if let Some(handle) = self.eviction_handle.take() {
handle.abort();
}
}
}

View File

@@ -0,0 +1,172 @@
//! A body wrapper that counts bytes flowing through and reports them to MetricsCollector.
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::task::{Context, Poll};
use bytes::Bytes;
use http_body::Frame;
use rustproxy_metrics::MetricsCollector;
/// Flush accumulated bytes to the metrics collector every 64 KB.
/// This reduces per-frame DashMap shard-locked reads from ~15 to ~1 per 4 frames
/// (assuming typical 16 KB upload frames). The 1 Hz throughput sampler still sees
/// data within one sampling period even at low transfer rates.
const BYTE_FLUSH_THRESHOLD: u64 = 65_536;
/// Wraps any `http_body::Body` and counts data bytes passing through.
///
/// Bytes are accumulated and flushed to the `MetricsCollector` every
/// [`BYTE_FLUSH_THRESHOLD`] bytes (and on Drop) so the throughput tracker
/// (sampled at 1 Hz) reflects real-time data flow without per-frame overhead.
///
/// The inner body is pinned on the heap to support `!Unpin` types like `hyper::body::Incoming`.
pub struct CountingBody<B> {
inner: Pin<Box<B>>,
metrics: Arc<MetricsCollector>,
route_id: Option<Arc<str>>,
source_ip: Option<Arc<str>>,
/// Whether we count bytes as "in" (request body) or "out" (response body).
direction: Direction,
/// Accumulated bytes not yet flushed to the metrics collector.
pending_bytes: u64,
/// Optional connection-level activity tracker. When set, poll_frame updates this
/// to keep the idle watchdog alive during active body streaming (uploads/downloads).
connection_activity: Option<Arc<AtomicU64>>,
/// Start instant for computing elapsed ms for connection_activity.
activity_start: Option<std::time::Instant>,
/// Optional active-request counter. When set, CountingBody increments on creation
/// and decrements on Drop, keeping the HTTP idle watchdog aware that a response
/// body is still streaming (even after the request handler has returned).
active_requests: Option<Arc<AtomicU64>>,
}
/// Which direction the bytes flow.
#[derive(Clone, Copy)]
pub enum Direction {
/// Request body: bytes flowing from client → upstream (counted as bytes_in)
In,
/// Response body: bytes flowing from upstream → client (counted as bytes_out)
Out,
}
impl<B> CountingBody<B> {
/// Create a new CountingBody wrapping an inner body.
pub fn new(
inner: B,
metrics: Arc<MetricsCollector>,
route_id: Option<Arc<str>>,
source_ip: Option<Arc<str>>,
direction: Direction,
) -> Self {
Self {
inner: Box::pin(inner),
metrics,
route_id,
source_ip,
direction,
pending_bytes: 0,
connection_activity: None,
activity_start: None,
active_requests: None,
}
}
/// Set the connection-level activity tracker. When set, each data frame
/// updates this timestamp to prevent the idle watchdog from killing the
/// connection during active body streaming.
pub fn with_connection_activity(mut self, activity: Arc<AtomicU64>, start: std::time::Instant) -> Self {
self.connection_activity = Some(activity);
self.activity_start = Some(start);
self
}
/// Set the active-request counter for the HTTP idle watchdog.
/// CountingBody increments on creation and decrements on Drop, ensuring the
/// idle watchdog sees an "active request" while the response body streams.
pub fn with_active_requests(mut self, counter: Arc<AtomicU64>) -> Self {
counter.fetch_add(1, Ordering::Relaxed);
self.active_requests = Some(counter);
self
}
/// Flush accumulated bytes to the metrics collector.
#[inline]
fn flush_pending(&mut self) {
if self.pending_bytes == 0 {
return;
}
let bytes = self.pending_bytes;
self.pending_bytes = 0;
let route_id = self.route_id.as_deref();
let source_ip = self.source_ip.as_deref();
match self.direction {
Direction::In => self.metrics.record_bytes(bytes, 0, route_id, source_ip),
Direction::Out => self.metrics.record_bytes(0, bytes, route_id, source_ip),
}
}
}
// CountingBody is Unpin because inner is Pin<Box<B>> (always Unpin).
impl<B> Unpin for CountingBody<B> {}
impl<B> http_body::Body for CountingBody<B>
where
B: http_body::Body<Data = Bytes>,
{
type Data = Bytes;
type Error = B::Error;
fn poll_frame(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
let this = self.get_mut();
match this.inner.as_mut().poll_frame(cx) {
Poll::Ready(Some(Ok(frame))) => {
if let Some(data) = frame.data_ref() {
let len = data.len() as u64;
this.pending_bytes += len;
if this.pending_bytes >= BYTE_FLUSH_THRESHOLD {
this.flush_pending();
}
// Keep the connection-level idle watchdog alive on every frame
// (this is just one atomic store — cheap enough per-frame)
if let (Some(activity), Some(start)) = (&this.connection_activity, &this.activity_start) {
activity.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
}
}
Poll::Ready(Some(Ok(frame)))
}
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Ready(None) => {
// End of stream — flush any remaining bytes
this.flush_pending();
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
}
}
fn is_end_stream(&self) -> bool {
self.inner.is_end_stream()
}
fn size_hint(&self) -> http_body::SizeHint {
self.inner.size_hint()
}
}
impl<B> Drop for CountingBody<B> {
fn drop(&mut self) {
// Flush any remaining accumulated bytes so totals stay accurate
self.flush_pending();
// Decrement the active-request counter so the HTTP idle watchdog
// knows this response body is no longer streaming.
if let Some(ref counter) = self.active_requests {
counter.fetch_sub(1, Ordering::Relaxed);
}
}
}

View File

@@ -0,0 +1,222 @@
//! HTTP/3 proxy service.
//!
//! Accepts QUIC connections via quinn, runs h3 server to handle HTTP/3 requests,
//! and delegates backend forwarding to the shared `HttpProxyService` — same
//! route matching, connection pool, and protocol auto-detection as TCP/HTTP.
use std::net::SocketAddr;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use bytes::{Buf, Bytes};
use http_body::Frame;
use http_body_util::BodyExt;
use http_body_util::combinators::BoxBody;
use tracing::{debug, warn};
use rustproxy_config::RouteConfig;
use tokio_util::sync::CancellationToken;
use crate::proxy_service::{ConnActivity, HttpProxyService};
/// HTTP/3 proxy service.
///
/// Accepts QUIC connections, parses HTTP/3 requests, and delegates backend
/// forwarding to the shared `HttpProxyService`.
pub struct H3ProxyService {
http_proxy: Arc<HttpProxyService>,
}
impl H3ProxyService {
pub fn new(http_proxy: Arc<HttpProxyService>) -> Self {
Self { http_proxy }
}
/// Handle an accepted QUIC connection as HTTP/3.
///
/// If `real_client_addr` is provided (from PROXY protocol), it overrides
/// `connection.remote_address()` for client IP attribution.
pub async fn handle_connection(
&self,
connection: quinn::Connection,
_fallback_route: &RouteConfig,
port: u16,
real_client_addr: Option<SocketAddr>,
parent_cancel: &CancellationToken,
) -> anyhow::Result<()> {
let remote_addr = real_client_addr.unwrap_or_else(|| connection.remote_address());
debug!("HTTP/3 connection from {} on port {}", remote_addr, port);
let mut h3_conn: h3::server::Connection<h3_quinn::Connection, Bytes> =
h3::server::builder()
.send_grease(false)
.build(h3_quinn::Connection::new(connection))
.await
.map_err(|e| anyhow::anyhow!("H3 connection setup failed: {}", e))?;
loop {
let resolver = tokio::select! {
_ = parent_cancel.cancelled() => {
debug!("HTTP/3 connection from {} cancelled by parent", remote_addr);
break;
}
result = h3_conn.accept() => {
match result {
Ok(Some(resolver)) => resolver,
Ok(None) => {
debug!("HTTP/3 connection from {} closed", remote_addr);
break;
}
Err(e) => {
debug!("HTTP/3 accept error from {}: {}", remote_addr, e);
break;
}
}
}
};
let (request, stream) = match resolver.resolve_request().await {
Ok(pair) => pair,
Err(e) => {
debug!("HTTP/3 request resolve error: {}", e);
continue;
}
};
let http_proxy = Arc::clone(&self.http_proxy);
let request_cancel = parent_cancel.child_token();
tokio::spawn(async move {
if let Err(e) = handle_h3_request(
request, stream, port, remote_addr, &http_proxy, request_cancel,
).await {
debug!("HTTP/3 request error from {}: {}", remote_addr, e);
}
});
}
Ok(())
}
}
/// Handle a single HTTP/3 request by delegating to HttpProxyService.
///
/// 1. Read the H3 request body via an mpsc channel (streaming, not buffered)
/// 2. Build a `hyper::Request<BoxBody>` that HttpProxyService can handle
/// 3. Call `HttpProxyService::handle_request` — same route matching, connection
/// pool, ALPN protocol detection (H1/H2/H3) as the TCP/HTTP path
/// 4. Stream the response back over the H3 stream
async fn handle_h3_request(
request: hyper::Request<()>,
mut stream: h3::server::RequestStream<h3_quinn::BidiStream<Bytes>, Bytes>,
port: u16,
peer_addr: SocketAddr,
http_proxy: &HttpProxyService,
cancel: CancellationToken,
) -> anyhow::Result<()> {
// Stream request body from H3 client via an mpsc channel.
let (body_tx, body_rx) = tokio::sync::mpsc::channel::<Bytes>(32);
// Spawn the H3 body reader task with cancellation
let body_cancel = cancel.clone();
let body_reader = tokio::spawn(async move {
loop {
let chunk = tokio::select! {
_ = body_cancel.cancelled() => break,
result = stream.recv_data() => {
match result {
Ok(Some(chunk)) => chunk,
_ => break,
}
}
};
let mut chunk = chunk;
let data = chunk.copy_to_bytes(chunk.remaining());
if body_tx.send(data).await.is_err() {
break;
}
}
stream
});
// Build a hyper::Request<BoxBody> from the H3 request + streaming body.
// The URI already has scheme + authority + path set by the h3 crate.
let body = H3RequestBody { receiver: body_rx };
let (parts, _) = request.into_parts();
let boxed_body: BoxBody<Bytes, hyper::Error> = BoxBody::new(body);
let req = hyper::Request::from_parts(parts, boxed_body);
// Delegate to HttpProxyService — same backend path as TCP/HTTP:
// route matching, ALPN protocol detection, connection pool, H1/H2/H3 auto.
let conn_activity = ConnActivity::new_standalone();
let response = http_proxy.handle_request(req, peer_addr, port, cancel, conn_activity).await
.map_err(|e| anyhow::anyhow!("Backend request failed: {}", e))?;
// Await the body reader to get the H3 stream back
let mut stream = body_reader.await
.map_err(|e| anyhow::anyhow!("Body reader task failed: {}", e))?;
// Send response headers over H3 (skip hop-by-hop headers)
let (resp_parts, resp_body) = response.into_parts();
let mut h3_response = hyper::Response::builder().status(resp_parts.status);
for (name, value) in &resp_parts.headers {
let n = name.as_str();
if n == "transfer-encoding" || n == "connection" || n == "keep-alive" || n == "upgrade" {
continue;
}
h3_response = h3_response.header(name, value);
}
let h3_response = h3_response.body(())
.map_err(|e| anyhow::anyhow!("Failed to build H3 response: {}", e))?;
stream.send_response(h3_response).await
.map_err(|e| anyhow::anyhow!("Failed to send H3 response: {}", e))?;
// Stream response body back over H3
let mut resp_body = resp_body;
while let Some(frame) = resp_body.frame().await {
match frame {
Ok(frame) => {
if let Ok(data) = frame.into_data() {
stream.send_data(data).await
.map_err(|e| anyhow::anyhow!("Failed to send H3 data: {}", e))?;
}
}
Err(e) => {
warn!("Response body read error: {}", e);
break;
}
}
}
// Finish the H3 stream (send QUIC FIN)
stream.finish().await
.map_err(|e| anyhow::anyhow!("Failed to finish H3 stream: {}", e))?;
Ok(())
}
/// A streaming request body backed by an mpsc channel receiver.
///
/// Implements `http_body::Body` so hyper can poll chunks as they arrive
/// from the H3 client, avoiding buffering the entire request body in memory.
struct H3RequestBody {
receiver: tokio::sync::mpsc::Receiver<Bytes>,
}
impl http_body::Body for H3RequestBody {
type Data = Bytes;
type Error = hyper::Error;
fn poll_frame(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
match self.receiver.poll_recv(cx) {
Poll::Ready(Some(data)) => Poll::Ready(Some(Ok(Frame::data(data)))),
Poll::Ready(None) => Poll::Ready(None),
Poll::Pending => Poll::Pending,
}
}
}

View File

@@ -3,12 +3,19 @@
//! Hyper-based HTTP proxy service for RustProxy. //! Hyper-based HTTP proxy service for RustProxy.
//! Handles HTTP request parsing, route-based forwarding, and response filtering. //! Handles HTTP request parsing, route-based forwarding, and response filtering.
pub mod connection_pool;
pub mod counting_body;
pub mod protocol_cache;
pub mod proxy_service; pub mod proxy_service;
pub mod request_filter; pub mod request_filter;
pub mod response_filter; pub mod response_filter;
pub mod shutdown_on_drop;
pub mod template; pub mod template;
pub mod upstream_selector; pub mod upstream_selector;
pub mod h3_service;
pub use connection_pool::*;
pub use counting_body::*;
pub use proxy_service::*; pub use proxy_service::*;
pub use template::*; pub use template::*;
pub use upstream_selector::*; pub use upstream_selector::*;

View File

@@ -0,0 +1,631 @@
//! Bounded, sliding-TTL protocol detection cache with periodic re-probing and failure suppression.
//!
//! Caches the detected protocol (H1, H2, or H3) per backend endpoint and requested
//! domain (host:port + requested_host). This prevents cache oscillation when multiple
//! frontend domains share the same backend but differ in protocol support.
//!
//! ## Sliding TTL
//!
//! Each cache hit refreshes the entry's expiry timer (`last_accessed_at`). Entries
//! remain valid for up to 1 day of continuous use. Every 5 minutes, the next request
//! triggers an inline ALPN re-probe to verify the cached protocol is still correct.
//!
//! ## Upgrade signals
//!
//! - ALPN (TLS handshake) → detects H2 vs H1
//! - Alt-Svc (response header) → advertises H3
//!
//! ## Protocol transitions
//!
//! All protocol changes are logged at `info!()` level with the reason:
//! "Protocol transition: H1 → H2 because periodic ALPN re-probe"
//!
//! ## Failure suppression
//!
//! When a protocol fails, `record_failure()` prevents upgrade signals from
//! re-introducing it until an escalating cooldown expires (5s → 10s → ... → 300s).
//! Within-request escalation is allowed via `can_retry()` after a 5s minimum gap.
//!
//! ## Total failure eviction
//!
//! When all protocols (H3, H2, H1) fail for a backend, the cache entry is evicted
//! entirely via `evict()`, forcing a fresh probe on the next request.
//!
//! Cascading: when a lower protocol also fails, higher protocol cooldowns are
//! reduced to 5s remaining (not instant clear), preventing tight retry loops.
use std::sync::Arc;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use tracing::{debug, info};
/// Sliding TTL for cached protocol detection results.
/// Entries that haven't been accessed for this duration are evicted.
/// Each `get()` call refreshes the timer (sliding window).
const PROTOCOL_CACHE_TTL: Duration = Duration::from_secs(86400); // 1 day
/// Interval between inline ALPN re-probes for H1/H2 entries.
/// When a cached entry's `last_probed_at` exceeds this, the next request
/// triggers an ALPN re-probe to verify the backend still speaks the same protocol.
const PROTOCOL_REPROBE_INTERVAL: Duration = Duration::from_secs(300); // 5 minutes
/// Maximum number of entries in the protocol cache.
const PROTOCOL_CACHE_MAX_ENTRIES: usize = 4096;
/// Background cleanup interval.
const PROTOCOL_CACHE_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
/// Minimum cooldown between retry attempts of a failed protocol.
const PROTOCOL_FAILURE_COOLDOWN: Duration = Duration::from_secs(5);
/// Maximum cooldown (escalation ceiling).
const PROTOCOL_FAILURE_MAX_COOLDOWN: Duration = Duration::from_secs(300);
/// Consecutive failure count at which cooldown reaches maximum.
/// 5s × 2^5 = 160s, 5s × 2^6 = 320s → capped at 300s.
const PROTOCOL_FAILURE_ESCALATION_CAP: u32 = 6;
/// Detected backend protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DetectedProtocol {
H1,
H2,
H3,
}
impl std::fmt::Display for DetectedProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DetectedProtocol::H1 => write!(f, "H1"),
DetectedProtocol::H2 => write!(f, "H2"),
DetectedProtocol::H3 => write!(f, "H3"),
}
}
}
/// Result of a protocol cache lookup.
#[derive(Debug, Clone, Copy)]
pub struct CachedProtocol {
pub protocol: DetectedProtocol,
/// For H3: the port advertised by Alt-Svc (may differ from TCP port).
pub h3_port: Option<u16>,
/// True if the entry's `last_probed_at` exceeds `PROTOCOL_REPROBE_INTERVAL`.
/// Caller should perform an inline ALPN re-probe and call `update_probe_result()`.
/// Always `false` for H3 entries (H3 is discovered via Alt-Svc, not ALPN).
pub needs_reprobe: bool,
}
/// Key for the protocol cache: (host, port, requested_host).
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
pub struct ProtocolCacheKey {
pub host: String,
pub port: u16,
/// The incoming request's domain (Host header / :authority).
/// Distinguishes protocol detection when multiple domains share the same backend.
pub requested_host: Option<String>,
}
/// A cached protocol detection result with timestamps.
struct CachedEntry {
protocol: DetectedProtocol,
/// When this protocol was first detected (or last changed).
detected_at: Instant,
/// Last time any request used this entry (sliding-window TTL).
last_accessed_at: Instant,
/// Last time an ALPN re-probe was performed for this entry.
last_probed_at: Instant,
/// For H3: the port advertised by Alt-Svc (may differ from TCP port).
h3_port: Option<u16>,
}
/// Failure record for a single protocol level.
#[derive(Debug, Clone)]
struct FailureRecord {
/// When the failure was last recorded.
failed_at: Instant,
/// Current cooldown duration. Escalates on consecutive failures.
cooldown: Duration,
/// Number of consecutive failures (for escalation).
consecutive_failures: u32,
}
/// Per-key failure state. Tracks failures at each upgradeable protocol level.
/// H1 is never tracked (it's the protocol floor — nothing to fall back to).
#[derive(Debug, Clone, Default)]
struct FailureState {
h2: Option<FailureRecord>,
h3: Option<FailureRecord>,
}
impl FailureState {
fn is_empty(&self) -> bool {
self.h2.is_none() && self.h3.is_none()
}
fn all_expired(&self) -> bool {
let h2_expired = self.h2.as_ref()
.map(|r| r.failed_at.elapsed() >= r.cooldown)
.unwrap_or(true);
let h3_expired = self.h3.as_ref()
.map(|r| r.failed_at.elapsed() >= r.cooldown)
.unwrap_or(true);
h2_expired && h3_expired
}
fn get(&self, protocol: DetectedProtocol) -> Option<&FailureRecord> {
match protocol {
DetectedProtocol::H2 => self.h2.as_ref(),
DetectedProtocol::H3 => self.h3.as_ref(),
DetectedProtocol::H1 => None,
}
}
fn get_mut(&mut self, protocol: DetectedProtocol) -> &mut Option<FailureRecord> {
match protocol {
DetectedProtocol::H2 => &mut self.h2,
DetectedProtocol::H3 => &mut self.h3,
DetectedProtocol::H1 => unreachable!("H1 failures are never recorded"),
}
}
}
/// Snapshot of a single protocol cache entry, suitable for metrics/UI display.
#[derive(Debug, Clone)]
pub struct ProtocolCacheEntry {
pub host: String,
pub port: u16,
pub domain: Option<String>,
pub protocol: String,
pub h3_port: Option<u16>,
pub age_secs: u64,
pub last_accessed_secs: u64,
pub last_probed_secs: u64,
pub h2_suppressed: bool,
pub h3_suppressed: bool,
pub h2_cooldown_remaining_secs: Option<u64>,
pub h3_cooldown_remaining_secs: Option<u64>,
pub h2_consecutive_failures: Option<u32>,
pub h3_consecutive_failures: Option<u32>,
}
/// Exponential backoff: PROTOCOL_FAILURE_COOLDOWN × 2^(n-1), capped at MAX.
fn escalate_cooldown(consecutive: u32) -> Duration {
let base = PROTOCOL_FAILURE_COOLDOWN.as_secs();
let exp = consecutive.saturating_sub(1).min(63) as u64;
let secs = base.saturating_mul(1u64.checked_shl(exp as u32).unwrap_or(u64::MAX));
Duration::from_secs(secs.min(PROTOCOL_FAILURE_MAX_COOLDOWN.as_secs()))
}
/// Bounded, sliding-TTL protocol detection cache with failure suppression.
///
/// Memory safety guarantees:
/// - Hard cap at `PROTOCOL_CACHE_MAX_ENTRIES` — cannot grow unboundedly.
/// - Sliding TTL expiry — entries age out after 1 day without access.
/// - Background cleanup task — proactively removes expired entries every 60s.
/// - `clear()` — called on route updates to discard stale detections.
/// - `Drop` — aborts the background task to prevent dangling tokio tasks.
pub struct ProtocolCache {
cache: Arc<DashMap<ProtocolCacheKey, CachedEntry>>,
/// Generic protocol failure suppression map. Tracks per-protocol failure
/// records (H2, H3) for each cache key. Used to prevent upgrade signals
/// (ALPN, Alt-Svc) from re-introducing failed protocols.
failures: Arc<DashMap<ProtocolCacheKey, FailureState>>,
cleanup_handle: Option<tokio::task::JoinHandle<()>>,
}
impl ProtocolCache {
/// Create a new protocol cache and start the background cleanup task.
pub fn new() -> Self {
let cache: Arc<DashMap<ProtocolCacheKey, CachedEntry>> = Arc::new(DashMap::new());
let failures: Arc<DashMap<ProtocolCacheKey, FailureState>> = Arc::new(DashMap::new());
let cache_clone = Arc::clone(&cache);
let failures_clone = Arc::clone(&failures);
let cleanup_handle = tokio::spawn(async move {
Self::cleanup_loop(cache_clone, failures_clone).await;
});
Self {
cache,
failures,
cleanup_handle: Some(cleanup_handle),
}
}
/// Look up the cached protocol for a backend endpoint.
///
/// Returns `None` if not cached or expired (caller should probe via ALPN).
/// On hit, refreshes `last_accessed_at` (sliding TTL) and sets `needs_reprobe`
/// if the entry hasn't been probed in over 5 minutes (H1/H2 only).
pub fn get(&self, key: &ProtocolCacheKey) -> Option<CachedProtocol> {
let mut entry = self.cache.get_mut(key)?;
if entry.last_accessed_at.elapsed() < PROTOCOL_CACHE_TTL {
// Refresh sliding TTL
entry.last_accessed_at = Instant::now();
// H3 is the ceiling — can't ALPN-probe for H3 (discovered via Alt-Svc).
// Only H1/H2 entries trigger periodic re-probing.
let needs_reprobe = entry.protocol != DetectedProtocol::H3
&& entry.last_probed_at.elapsed() >= PROTOCOL_REPROBE_INTERVAL;
Some(CachedProtocol {
protocol: entry.protocol,
h3_port: entry.h3_port,
needs_reprobe,
})
} else {
// Expired — remove and return None to trigger re-probe
drop(entry); // release DashMap ref before remove
self.cache.remove(key);
None
}
}
/// Insert a detected protocol into the cache.
/// Returns `false` if suppressed due to active failure suppression.
///
/// **Key semantic**: only suppresses if the protocol being inserted matches
/// a suppressed protocol. H1 inserts are NEVER suppressed — downgrades
/// always succeed.
pub fn insert(&self, key: ProtocolCacheKey, protocol: DetectedProtocol, reason: &str) -> bool {
if self.is_suppressed(&key, protocol) {
debug!(
host = %key.host, port = %key.port, domain = ?key.requested_host,
protocol = ?protocol,
"Protocol cache insert suppressed — recent failure"
);
return false;
}
self.insert_internal(key, protocol, None, reason);
true
}
/// Insert an H3 detection result with the Alt-Svc advertised port.
/// Returns `false` if H3 is suppressed.
pub fn insert_h3(&self, key: ProtocolCacheKey, h3_port: u16, reason: &str) -> bool {
if self.is_suppressed(&key, DetectedProtocol::H3) {
debug!(
host = %key.host, port = %key.port, domain = ?key.requested_host,
"H3 upgrade suppressed — recent failure"
);
return false;
}
self.insert_internal(key, DetectedProtocol::H3, Some(h3_port), reason);
true
}
/// Update the cache after an inline ALPN re-probe completes.
///
/// Always updates `last_probed_at`. If the protocol changed, logs the transition
/// and updates the entry. Returns `Some(new_protocol)` if changed, `None` if unchanged.
pub fn update_probe_result(
&self,
key: &ProtocolCacheKey,
probed_protocol: DetectedProtocol,
reason: &str,
) -> Option<DetectedProtocol> {
if let Some(mut entry) = self.cache.get_mut(key) {
let old_protocol = entry.protocol;
entry.last_probed_at = Instant::now();
entry.last_accessed_at = Instant::now();
if old_protocol != probed_protocol {
info!(
host = %key.host, port = %key.port, domain = ?key.requested_host,
old = %old_protocol, new = %probed_protocol, reason = %reason,
"Protocol transition"
);
entry.protocol = probed_protocol;
entry.detected_at = Instant::now();
// Clear h3_port if downgrading from H3
if old_protocol == DetectedProtocol::H3 && probed_protocol != DetectedProtocol::H3 {
entry.h3_port = None;
}
return Some(probed_protocol);
}
debug!(
host = %key.host, port = %key.port, domain = ?key.requested_host,
protocol = %old_protocol, reason = %reason,
"Re-probe confirmed — no protocol change"
);
None
} else {
// Entry was evicted between the get() and the probe completing.
// Insert as a fresh entry.
self.insert_internal(key.clone(), probed_protocol, None, reason);
Some(probed_protocol)
}
}
/// Record a protocol failure. Future `insert()` calls for this protocol
/// will be suppressed until the escalating cooldown expires.
///
/// Cooldown escalation: 5s → 10s → 20s → 40s → 80s → 160s → 300s.
/// Consecutive counter resets if the previous failure is older than 2× its cooldown.
///
/// Cascading: when H2 fails, H3 cooldown is reduced to 5s remaining.
/// H1 failures are ignored (H1 is the protocol floor).
pub fn record_failure(&self, key: ProtocolCacheKey, protocol: DetectedProtocol) {
if protocol == DetectedProtocol::H1 {
return; // H1 is the floor — nothing to suppress
}
let mut entry = self.failures.entry(key.clone()).or_default();
let record = entry.get_mut(protocol);
let (consecutive, new_cooldown) = match record {
Some(existing) if existing.failed_at.elapsed() < existing.cooldown.saturating_mul(2) => {
// Still within the "recent" window — escalate
let c = existing.consecutive_failures.saturating_add(1)
.min(PROTOCOL_FAILURE_ESCALATION_CAP);
(c, escalate_cooldown(c))
}
_ => {
// First failure or old failure that expired long ago — reset
(1, PROTOCOL_FAILURE_COOLDOWN)
}
};
*record = Some(FailureRecord {
failed_at: Instant::now(),
cooldown: new_cooldown,
consecutive_failures: consecutive,
});
// Cascading: when H2 fails, reduce H3 cooldown to 5s remaining
if protocol == DetectedProtocol::H2 {
Self::reduce_cooldown_to(entry.h3.as_mut(), PROTOCOL_FAILURE_COOLDOWN);
}
info!(
host = %key.host, port = %key.port, domain = ?key.requested_host,
protocol = ?protocol,
consecutive = consecutive,
cooldown_secs = new_cooldown.as_secs(),
"Protocol failure recorded — suppressing for {:?}", new_cooldown
);
}
/// Check whether a protocol is currently suppressed for the given key.
/// Returns `true` if the protocol failed within its cooldown period.
/// H1 is never suppressed.
pub fn is_suppressed(&self, key: &ProtocolCacheKey, protocol: DetectedProtocol) -> bool {
if protocol == DetectedProtocol::H1 {
return false;
}
self.failures.get(key)
.and_then(|entry| entry.get(protocol).map(|r| r.failed_at.elapsed() < r.cooldown))
.unwrap_or(false)
}
/// Check whether a protocol can be retried (for within-request escalation).
/// Returns `true` if there's no failure record OR if ≥5s have passed since
/// the last attempt. More permissive than `is_suppressed`.
pub fn can_retry(&self, key: &ProtocolCacheKey, protocol: DetectedProtocol) -> bool {
if protocol == DetectedProtocol::H1 {
return true;
}
match self.failures.get(key) {
Some(entry) => match entry.get(protocol) {
Some(r) => r.failed_at.elapsed() >= PROTOCOL_FAILURE_COOLDOWN,
None => true, // no failure record
},
None => true,
}
}
/// Record a retry attempt WITHOUT escalating the cooldown.
/// Resets the `failed_at` timestamp to prevent rapid retries (5s gate).
/// Called before an escalation attempt. If the attempt fails,
/// `record_failure` should be called afterward with proper escalation.
pub fn record_retry_attempt(&self, key: &ProtocolCacheKey, protocol: DetectedProtocol) {
if protocol == DetectedProtocol::H1 {
return;
}
if let Some(mut entry) = self.failures.get_mut(key) {
if let Some(ref mut r) = entry.get_mut(protocol) {
r.failed_at = Instant::now();
}
}
}
/// Clear the failure record for a protocol (it recovered).
/// Called when an escalation retry succeeds.
pub fn clear_failure(&self, key: &ProtocolCacheKey, protocol: DetectedProtocol) {
if protocol == DetectedProtocol::H1 {
return;
}
if let Some(mut entry) = self.failures.get_mut(key) {
*entry.get_mut(protocol) = None;
if entry.is_empty() {
drop(entry);
self.failures.remove(key);
}
}
}
/// Evict a cache entry entirely. Called when all protocol probes (H3, H2, H1)
/// have failed for a backend.
pub fn evict(&self, key: &ProtocolCacheKey) {
self.cache.remove(key);
self.failures.remove(key);
info!(
host = %key.host, port = %key.port, domain = ?key.requested_host,
"Cache entry evicted — all protocols failed"
);
}
/// Clear all entries. Called on route updates to discard stale detections.
pub fn clear(&self) {
self.cache.clear();
self.failures.clear();
}
/// Snapshot all non-expired cache entries for metrics/UI display.
pub fn snapshot(&self) -> Vec<ProtocolCacheEntry> {
self.cache.iter()
.filter(|entry| entry.value().last_accessed_at.elapsed() < PROTOCOL_CACHE_TTL)
.map(|entry| {
let key = entry.key();
let val = entry.value();
let failure_info = self.failures.get(key);
let (h2_sup, h2_cd, h2_cons) = Self::suppression_info(
failure_info.as_deref().and_then(|f| f.h2.as_ref()),
);
let (h3_sup, h3_cd, h3_cons) = Self::suppression_info(
failure_info.as_deref().and_then(|f| f.h3.as_ref()),
);
ProtocolCacheEntry {
host: key.host.clone(),
port: key.port,
domain: key.requested_host.clone(),
protocol: match val.protocol {
DetectedProtocol::H1 => "h1".to_string(),
DetectedProtocol::H2 => "h2".to_string(),
DetectedProtocol::H3 => "h3".to_string(),
},
h3_port: val.h3_port,
age_secs: val.detected_at.elapsed().as_secs(),
last_accessed_secs: val.last_accessed_at.elapsed().as_secs(),
last_probed_secs: val.last_probed_at.elapsed().as_secs(),
h2_suppressed: h2_sup,
h3_suppressed: h3_sup,
h2_cooldown_remaining_secs: h2_cd,
h3_cooldown_remaining_secs: h3_cd,
h2_consecutive_failures: h2_cons,
h3_consecutive_failures: h3_cons,
}
})
.collect()
}
// --- Internal helpers ---
/// Insert a protocol detection result with an optional H3 port.
/// Logs protocol transitions when overwriting an existing entry.
/// No suppression check — callers must check before calling.
fn insert_internal(&self, key: ProtocolCacheKey, protocol: DetectedProtocol, h3_port: Option<u16>, reason: &str) {
// Check for existing entry to log protocol transitions
if let Some(existing) = self.cache.get(&key) {
if existing.protocol != protocol {
info!(
host = %key.host, port = %key.port, domain = ?key.requested_host,
old = %existing.protocol, new = %protocol, reason = %reason,
"Protocol transition"
);
}
drop(existing);
}
// Evict oldest entry if at capacity
if self.cache.len() >= PROTOCOL_CACHE_MAX_ENTRIES && !self.cache.contains_key(&key) {
let oldest = self.cache.iter()
.min_by_key(|entry| entry.value().last_accessed_at)
.map(|entry| entry.key().clone());
if let Some(oldest_key) = oldest {
self.cache.remove(&oldest_key);
}
}
let now = Instant::now();
self.cache.insert(key, CachedEntry {
protocol,
detected_at: now,
last_accessed_at: now,
last_probed_at: now,
h3_port,
});
}
/// Reduce a failure record's remaining cooldown to `target`, if it currently
/// has MORE than `target` remaining. Never increases cooldown.
fn reduce_cooldown_to(record: Option<&mut FailureRecord>, target: Duration) {
if let Some(r) = record {
let elapsed = r.failed_at.elapsed();
if elapsed < r.cooldown {
let remaining = r.cooldown - elapsed;
if remaining > target {
// Shrink cooldown so it expires in `target` from now
r.cooldown = elapsed + target;
}
}
}
}
/// Extract suppression info from a failure record for metrics.
fn suppression_info(record: Option<&FailureRecord>) -> (bool, Option<u64>, Option<u32>) {
match record {
Some(r) => {
let elapsed = r.failed_at.elapsed();
let suppressed = elapsed < r.cooldown;
let remaining = if suppressed {
Some((r.cooldown - elapsed).as_secs())
} else {
None
};
(suppressed, remaining, Some(r.consecutive_failures))
}
None => (false, None, None),
}
}
/// Background cleanup loop.
async fn cleanup_loop(
cache: Arc<DashMap<ProtocolCacheKey, CachedEntry>>,
failures: Arc<DashMap<ProtocolCacheKey, FailureState>>,
) {
let mut interval = tokio::time::interval(PROTOCOL_CACHE_CLEANUP_INTERVAL);
loop {
interval.tick().await;
// Clean expired cache entries (sliding TTL based on last_accessed_at)
let expired: Vec<ProtocolCacheKey> = cache.iter()
.filter(|entry| entry.value().last_accessed_at.elapsed() >= PROTOCOL_CACHE_TTL)
.map(|entry| entry.key().clone())
.collect();
if !expired.is_empty() {
debug!("Protocol cache cleanup: removing {} expired entries", expired.len());
for key in expired {
cache.remove(&key);
}
}
// Clean fully-expired failure entries
let expired_failures: Vec<ProtocolCacheKey> = failures.iter()
.filter(|entry| entry.value().all_expired())
.map(|entry| entry.key().clone())
.collect();
if !expired_failures.is_empty() {
debug!("Protocol cache cleanup: removing {} expired failure entries", expired_failures.len());
for key in expired_failures {
failures.remove(&key);
}
}
// Safety net: cap failures map at 2× max entries
if failures.len() > PROTOCOL_CACHE_MAX_ENTRIES * 2 {
let oldest: Vec<ProtocolCacheKey> = failures.iter()
.filter(|e| e.value().all_expired())
.map(|e| e.key().clone())
.take(failures.len() - PROTOCOL_CACHE_MAX_ENTRIES)
.collect();
for key in oldest {
failures.remove(&key);
}
}
}
}
}
impl Drop for ProtocolCache {
fn drop(&mut self) {
if let Some(handle) = self.cleanup_handle.take() {
handle.abort();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,6 @@ use std::sync::Arc;
use bytes::Bytes; use bytes::Bytes;
use http_body_util::Full; use http_body_util::Full;
use http_body_util::BodyExt; use http_body_util::BodyExt;
use hyper::body::Incoming;
use hyper::{Request, Response, StatusCode}; use hyper::{Request, Response, StatusCode};
use http_body_util::combinators::BoxBody; use http_body_util::combinators::BoxBody;
@@ -19,7 +18,7 @@ impl RequestFilter {
/// Apply security filters. Returns Some(response) if the request should be blocked. /// Apply security filters. Returns Some(response) if the request should be blocked.
pub fn apply( pub fn apply(
security: &RouteSecurity, security: &RouteSecurity,
req: &Request<Incoming>, req: &Request<impl hyper::body::Body>,
peer_addr: &SocketAddr, peer_addr: &SocketAddr,
) -> Option<Response<BoxBody<Bytes, hyper::Error>>> { ) -> Option<Response<BoxBody<Bytes, hyper::Error>>> {
Self::apply_with_rate_limiter(security, req, peer_addr, None) Self::apply_with_rate_limiter(security, req, peer_addr, None)
@@ -29,7 +28,7 @@ impl RequestFilter {
/// Returns Some(response) if the request should be blocked. /// Returns Some(response) if the request should be blocked.
pub fn apply_with_rate_limiter( pub fn apply_with_rate_limiter(
security: &RouteSecurity, security: &RouteSecurity,
req: &Request<Incoming>, req: &Request<impl hyper::body::Body>,
peer_addr: &SocketAddr, peer_addr: &SocketAddr,
rate_limiter: Option<&Arc<RateLimiter>>, rate_limiter: Option<&Arc<RateLimiter>>,
) -> Option<Response<BoxBody<Bytes, hyper::Error>>> { ) -> Option<Response<BoxBody<Bytes, hyper::Error>>> {
@@ -182,7 +181,7 @@ impl RequestFilter {
/// Determine the rate limit key based on configuration. /// Determine the rate limit key based on configuration.
fn rate_limit_key( fn rate_limit_key(
config: &rustproxy_config::RouteRateLimit, config: &rustproxy_config::RouteRateLimit,
req: &Request<Incoming>, req: &Request<impl hyper::body::Body>,
peer_addr: &SocketAddr, peer_addr: &SocketAddr,
) -> String { ) -> String {
use rustproxy_config::RateLimitKeyBy; use rustproxy_config::RateLimitKeyBy;
@@ -220,7 +219,7 @@ impl RequestFilter {
/// Handle CORS preflight (OPTIONS) requests. /// Handle CORS preflight (OPTIONS) requests.
/// Returns Some(response) if this is a CORS preflight that should be handled. /// Returns Some(response) if this is a CORS preflight that should be handled.
pub fn handle_cors_preflight( pub fn handle_cors_preflight(
req: &Request<Incoming>, req: &Request<impl hyper::body::Body>,
) -> Option<Response<BoxBody<Bytes, hyper::Error>>> { ) -> Option<Response<BoxBody<Bytes, hyper::Error>>> {
if req.method() != hyper::Method::OPTIONS { if req.method() != hyper::Method::OPTIONS {
return None; return None;

View File

@@ -10,7 +10,23 @@ pub struct ResponseFilter;
impl ResponseFilter { impl ResponseFilter {
/// Apply response headers from route config and CORS settings. /// Apply response headers from route config and CORS settings.
/// If a `RequestContext` is provided, template variables in header values will be expanded. /// If a `RequestContext` is provided, template variables in header values will be expanded.
/// Also injects Alt-Svc header for routes with HTTP/3 enabled.
pub fn apply_headers(route: &RouteConfig, headers: &mut HeaderMap, req_ctx: Option<&RequestContext>) { pub fn apply_headers(route: &RouteConfig, headers: &mut HeaderMap, req_ctx: Option<&RequestContext>) {
// Inject Alt-Svc for HTTP/3 advertisement if QUIC/HTTP3 is enabled on this route
if let Some(ref udp) = route.action.udp {
if let Some(ref quic) = udp.quic {
if quic.enable_http3.unwrap_or(false) {
let port = quic.alt_svc_port
.or_else(|| req_ctx.map(|c| c.port))
.unwrap_or(443);
let max_age = quic.alt_svc_max_age.unwrap_or(86400);
let alt_svc = format!("h3=\":{}\"; ma={}", port, max_age);
if let Ok(val) = HeaderValue::from_str(&alt_svc) {
headers.insert("alt-svc", val);
}
}
}
}
// Apply custom response headers from route config // Apply custom response headers from route config
if let Some(ref route_headers) = route.headers { if let Some(ref route_headers) = route.headers {
if let Some(ref response_headers) = route_headers.response { if let Some(ref response_headers) = route_headers.response {

View File

@@ -0,0 +1,102 @@
//! Wrapper that ensures TLS close_notify is sent when the stream is dropped.
//!
//! When hyper drops an HTTP connection (backend error, timeout, normal H2 close),
//! the underlying TLS stream is dropped WITHOUT `shutdown()`. tokio-rustls cannot
//! send `close_notify` in Drop (requires async). This wrapper tracks whether
//! `poll_shutdown` was called and, if not, spawns a background task to send it.
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
/// Wraps an AsyncRead+AsyncWrite stream and ensures `shutdown()` is called when
/// dropped, even if the caller (e.g. hyper) doesn't explicitly shut down.
///
/// This guarantees TLS `close_notify` is sent for TLS-wrapped streams, preventing
/// "GnuTLS recv error (-110): The TLS connection was non-properly terminated" errors.
pub struct ShutdownOnDrop<S: AsyncRead + AsyncWrite + Unpin + Send + 'static> {
inner: Option<S>,
shutdown_called: bool,
}
impl<S: AsyncRead + AsyncWrite + Unpin + Send + 'static> ShutdownOnDrop<S> {
/// Create a new wrapper around the given stream.
pub fn new(stream: S) -> Self {
Self {
inner: Some(stream),
shutdown_called: false,
}
}
}
impl<S: AsyncRead + AsyncWrite + Unpin + Send + 'static> AsyncRead for ShutdownOnDrop<S> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
Pin::new(self.get_mut().inner.as_mut().unwrap()).poll_read(cx, buf)
}
}
impl<S: AsyncRead + AsyncWrite + Unpin + Send + 'static> AsyncWrite for ShutdownOnDrop<S> {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
Pin::new(self.get_mut().inner.as_mut().unwrap()).poll_write(cx, buf)
}
fn poll_write_vectored(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[io::IoSlice<'_>],
) -> Poll<io::Result<usize>> {
Pin::new(self.get_mut().inner.as_mut().unwrap()).poll_write_vectored(cx, bufs)
}
fn is_write_vectored(&self) -> bool {
self.inner.as_ref().unwrap().is_write_vectored()
}
fn poll_flush(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<io::Result<()>> {
Pin::new(self.get_mut().inner.as_mut().unwrap()).poll_flush(cx)
}
fn poll_shutdown(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<io::Result<()>> {
let this = self.get_mut();
let result = Pin::new(this.inner.as_mut().unwrap()).poll_shutdown(cx);
if result.is_ready() {
this.shutdown_called = true;
}
result
}
}
impl<S: AsyncRead + AsyncWrite + Unpin + Send + 'static> Drop for ShutdownOnDrop<S> {
fn drop(&mut self) {
// If shutdown was already called (hyper closed properly), nothing to do.
// If not (hyper dropped without shutdown — e.g. H2 close, error, timeout),
// spawn a background task to send close_notify / TCP FIN.
if !self.shutdown_called {
if let Some(mut stream) = self.inner.take() {
tokio::spawn(async move {
let _ = tokio::time::timeout(
std::time::Duration::from_secs(2),
tokio::io::AsyncWriteExt::shutdown(&mut stream),
).await;
// stream is dropped here — all resources freed
});
}
}
}
}

View File

@@ -115,11 +115,27 @@ impl UpstreamSelector {
/// Record that a connection to the given host has ended. /// Record that a connection to the given host has ended.
pub fn connection_ended(&self, host: &str) { pub fn connection_ended(&self, host: &str) {
if let Some(counter) = self.active_connections.get(host) { if let Some(counter) = self.active_connections.get(host) {
let prev = counter.value().fetch_sub(1, Ordering::Relaxed); let prev = counter.value().load(Ordering::Relaxed);
// Guard against underflow (shouldn't happen, but be safe)
if prev == 0 { if prev == 0 {
counter.value().store(0, Ordering::Relaxed); // Already at zero — just clean up the entry
drop(counter);
self.active_connections.remove(host);
return;
} }
counter.value().fetch_sub(1, Ordering::Relaxed);
// Clean up zero-count entries to prevent memory growth
if prev <= 1 {
drop(counter);
self.active_connections.remove(host);
}
}
}
/// Clear stale round-robin counters on route update.
/// Resetting is harmless — counters just restart cycling from index 0.
pub fn reset_round_robin(&self) {
if let Ok(mut counters) = self.round_robin.lock() {
counters.clear();
} }
} }
@@ -168,6 +184,7 @@ mod tests {
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: None, priority: None,
} }
} }
@@ -204,6 +221,31 @@ mod tests {
assert_eq!(r4.host, "a"); assert_eq!(r4.host, "a");
} }
#[test]
fn test_connection_tracking_cleanup() {
let selector = UpstreamSelector::new();
selector.connection_started("backend:8080");
selector.connection_started("backend:8080");
assert_eq!(
selector.active_connections.get("backend:8080").unwrap().load(Ordering::Relaxed),
2
);
selector.connection_ended("backend:8080");
assert_eq!(
selector.active_connections.get("backend:8080").unwrap().load(Ordering::Relaxed),
1
);
// Last connection ends — entry should be removed entirely
selector.connection_ended("backend:8080");
assert!(selector.active_connections.get("backend:8080").is_none());
// Ending on a non-existent key should not panic
selector.connection_ended("nonexistent:9999");
}
#[test] #[test]
fn test_ip_hash_consistent() { fn test_ip_hash_consistent() {
let selector = UpstreamSelector::new(); let selector = UpstreamSelector::new();

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,10 @@
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Instant, SystemTime, UNIX_EPOCH}; use std::time::{Instant, SystemTime, UNIX_EPOCH};
/// A single throughput sample. /// A single throughput sample.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThroughputSample { pub struct ThroughputSample {
pub timestamp_ms: u64, pub timestamp_ms: u64,
pub bytes_in: u64, pub bytes_in: u64,
@@ -106,6 +108,27 @@ impl ThroughputTracker {
self.throughput(10) self.throughput(10)
} }
/// Return the last N samples in chronological order (oldest first).
pub fn history(&self, window_seconds: usize) -> Vec<ThroughputSample> {
let window = window_seconds.min(self.count);
if window == 0 {
return Vec::new();
}
let mut result = Vec::with_capacity(window);
for i in 0..window {
let idx = if self.write_index >= i + 1 {
self.write_index - i - 1
} else {
self.capacity - (i + 1 - self.write_index)
};
if idx < self.samples.len() {
result.push(self.samples[idx]);
}
}
result.reverse(); // Return oldest-first (chronological)
result
}
/// How long this tracker has been alive. /// How long this tracker has been alive.
pub fn uptime(&self) -> std::time::Duration { pub fn uptime(&self) -> std::time::Duration {
self.created_at.elapsed() self.created_at.elapsed()
@@ -170,4 +193,40 @@ mod tests {
std::thread::sleep(std::time::Duration::from_millis(10)); std::thread::sleep(std::time::Duration::from_millis(10));
assert!(tracker.uptime().as_millis() >= 10); assert!(tracker.uptime().as_millis() >= 10);
} }
#[test]
fn test_history_returns_chronological() {
let mut tracker = ThroughputTracker::new(60);
for i in 1..=5 {
tracker.record_bytes(i * 100, i * 200);
tracker.sample();
}
let history = tracker.history(5);
assert_eq!(history.len(), 5);
// First sample should have 100 bytes_in, last should have 500
assert_eq!(history[0].bytes_in, 100);
assert_eq!(history[4].bytes_in, 500);
}
#[test]
fn test_history_wraps_around() {
let mut tracker = ThroughputTracker::new(3); // Small capacity
for i in 1..=5 {
tracker.record_bytes(i * 100, i * 200);
tracker.sample();
}
// Only last 3 should be retained
let history = tracker.history(10); // Ask for more than available
assert_eq!(history.len(), 3);
assert_eq!(history[0].bytes_in, 300);
assert_eq!(history[1].bytes_in, 400);
assert_eq!(history[2].bytes_in, 500);
}
#[test]
fn test_history_empty() {
let tracker = ThroughputTracker::new(60);
let history = tracker.history(10);
assert!(history.is_empty());
}
} }

View File

@@ -1,17 +0,0 @@
[package]
name = "rustproxy-nftables"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "NFTables kernel-level forwarding for RustProxy"
[dependencies]
rustproxy-config = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
libc = { workspace = true }

View File

@@ -1,10 +0,0 @@
//! # rustproxy-nftables
//!
//! NFTables kernel-level forwarding for RustProxy.
//! Generates and manages nft CLI rules for DNAT/SNAT.
pub mod nft_manager;
pub mod rule_builder;
pub use nft_manager::*;
pub use rule_builder::*;

View File

@@ -1,238 +0,0 @@
use thiserror::Error;
use std::collections::HashMap;
use tracing::{debug, info, warn};
#[derive(Debug, Error)]
pub enum NftError {
#[error("nft command failed: {0}")]
CommandFailed(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Not running as root")]
NotRoot,
}
/// Manager for nftables rules.
///
/// Executes `nft` CLI commands to manage kernel-level packet forwarding.
/// Requires root privileges; operations are skipped gracefully if not root.
pub struct NftManager {
table_name: String,
/// Active rules indexed by route ID
active_rules: HashMap<String, Vec<String>>,
/// Whether the table has been initialized
table_initialized: bool,
}
impl NftManager {
pub fn new(table_name: Option<String>) -> Self {
Self {
table_name: table_name.unwrap_or_else(|| "rustproxy".to_string()),
active_rules: HashMap::new(),
table_initialized: false,
}
}
/// Check if we are running as root.
fn is_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
/// Execute a single nft command via the CLI.
async fn exec_nft(command: &str) -> Result<String, NftError> {
// The command starts with "nft ", strip it to get the args
let args = if command.starts_with("nft ") {
&command[4..]
} else {
command
};
let output = tokio::process::Command::new("nft")
.args(args.split_whitespace())
.output()
.await
.map_err(NftError::Io)?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(NftError::CommandFailed(format!(
"Command '{}' failed: {}",
command, stderr
)))
}
}
/// Ensure the nftables table and chains are set up.
async fn ensure_table(&mut self) -> Result<(), NftError> {
if self.table_initialized {
return Ok(());
}
let setup_commands = crate::rule_builder::build_table_setup(&self.table_name);
for cmd in &setup_commands {
Self::exec_nft(cmd).await?;
}
self.table_initialized = true;
info!("NFTables table '{}' initialized", self.table_name);
Ok(())
}
/// Apply rules for a route.
///
/// Executes the nft commands via the CLI. If not running as root,
/// the rules are stored locally but not applied to the kernel.
pub async fn apply_rules(&mut self, route_id: &str, rules: Vec<String>) -> Result<(), NftError> {
if !Self::is_root() {
warn!("Not running as root, nftables rules will not be applied to kernel");
self.active_rules.insert(route_id.to_string(), rules);
return Ok(());
}
self.ensure_table().await?;
for cmd in &rules {
Self::exec_nft(cmd).await?;
debug!("Applied nft rule: {}", cmd);
}
info!("Applied {} nftables rules for route '{}'", rules.len(), route_id);
self.active_rules.insert(route_id.to_string(), rules);
Ok(())
}
/// Remove rules for a route.
///
/// Currently removes the route from tracking. To fully remove specific
/// rules would require handle-based tracking; for now, cleanup() removes
/// the entire table.
pub async fn remove_rules(&mut self, route_id: &str) -> Result<(), NftError> {
if let Some(rules) = self.active_rules.remove(route_id) {
info!("Removed {} tracked nft rules for route '{}'", rules.len(), route_id);
}
Ok(())
}
/// Clean up all managed rules by deleting the entire nftables table.
pub async fn cleanup(&mut self) -> Result<(), NftError> {
if !Self::is_root() {
warn!("Not running as root, skipping nftables cleanup");
self.active_rules.clear();
self.table_initialized = false;
return Ok(());
}
if self.table_initialized {
let cleanup_commands = crate::rule_builder::build_table_cleanup(&self.table_name);
for cmd in &cleanup_commands {
match Self::exec_nft(cmd).await {
Ok(_) => debug!("Cleanup: {}", cmd),
Err(e) => warn!("Cleanup command failed (may be ok): {}", e),
}
}
info!("NFTables table '{}' cleaned up", self.table_name);
}
self.active_rules.clear();
self.table_initialized = false;
Ok(())
}
/// Get the table name.
pub fn table_name(&self) -> &str {
&self.table_name
}
/// Whether the table has been initialized in the kernel.
pub fn is_initialized(&self) -> bool {
self.table_initialized
}
/// Get the number of active route rule sets.
pub fn active_route_count(&self) -> usize {
self.active_rules.len()
}
/// Get the status of all active rules.
pub fn status(&self) -> HashMap<String, serde_json::Value> {
let mut status = HashMap::new();
for (route_id, rules) in &self.active_rules {
status.insert(
route_id.clone(),
serde_json::json!({
"ruleCount": rules.len(),
"rules": rules,
}),
);
}
status
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_default_table_name() {
let mgr = NftManager::new(None);
assert_eq!(mgr.table_name(), "rustproxy");
assert!(!mgr.is_initialized());
}
#[test]
fn test_new_custom_table_name() {
let mgr = NftManager::new(Some("custom".to_string()));
assert_eq!(mgr.table_name(), "custom");
}
#[tokio::test]
async fn test_apply_rules_non_root() {
let mut mgr = NftManager::new(None);
// When not root, rules are stored but not applied to kernel
let rules = vec!["nft add rule ip rustproxy prerouting tcp dport 443 dnat to 10.0.0.1:8443".to_string()];
mgr.apply_rules("route-1", rules).await.unwrap();
assert_eq!(mgr.active_route_count(), 1);
let status = mgr.status();
assert!(status.contains_key("route-1"));
assert_eq!(status["route-1"]["ruleCount"], 1);
}
#[tokio::test]
async fn test_remove_rules() {
let mut mgr = NftManager::new(None);
let rules = vec!["nft add rule test".to_string()];
mgr.apply_rules("route-1", rules).await.unwrap();
assert_eq!(mgr.active_route_count(), 1);
mgr.remove_rules("route-1").await.unwrap();
assert_eq!(mgr.active_route_count(), 0);
}
#[tokio::test]
async fn test_cleanup_non_root() {
let mut mgr = NftManager::new(None);
let rules = vec!["nft add rule test".to_string()];
mgr.apply_rules("route-1", rules).await.unwrap();
mgr.apply_rules("route-2", vec!["nft add rule test2".to_string()]).await.unwrap();
mgr.cleanup().await.unwrap();
assert_eq!(mgr.active_route_count(), 0);
assert!(!mgr.is_initialized());
}
#[tokio::test]
async fn test_status_multiple_routes() {
let mut mgr = NftManager::new(None);
mgr.apply_rules("web", vec!["rule1".to_string(), "rule2".to_string()]).await.unwrap();
mgr.apply_rules("api", vec!["rule3".to_string()]).await.unwrap();
let status = mgr.status();
assert_eq!(status.len(), 2);
assert_eq!(status["web"]["ruleCount"], 2);
assert_eq!(status["api"]["ruleCount"], 1);
}
}

View File

@@ -1,123 +0,0 @@
use rustproxy_config::{NfTablesOptions, NfTablesProtocol};
/// Build nftables DNAT rule for port forwarding.
pub fn build_dnat_rule(
table_name: &str,
chain_name: &str,
source_port: u16,
target_host: &str,
target_port: u16,
options: &NfTablesOptions,
) -> Vec<String> {
let protocol = match options.protocol.as_ref().unwrap_or(&NfTablesProtocol::Tcp) {
NfTablesProtocol::Tcp => "tcp",
NfTablesProtocol::Udp => "udp",
NfTablesProtocol::All => "tcp", // TODO: handle "all"
};
let mut rules = Vec::new();
// DNAT rule
rules.push(format!(
"nft add rule ip {} {} {} dport {} dnat to {}:{}",
table_name, chain_name, protocol, source_port, target_host, target_port,
));
// SNAT rule if preserving source IP is not enabled
if !options.preserve_source_ip.unwrap_or(false) {
rules.push(format!(
"nft add rule ip {} postrouting {} dport {} masquerade",
table_name, protocol, target_port,
));
}
// Rate limiting
if let Some(max_rate) = &options.max_rate {
rules.push(format!(
"nft add rule ip {} {} {} dport {} limit rate {} accept",
table_name, chain_name, protocol, source_port, max_rate,
));
}
rules
}
/// Build the initial table and chain setup commands.
pub fn build_table_setup(table_name: &str) -> Vec<String> {
vec![
format!("nft add table ip {}", table_name),
format!("nft add chain ip {} prerouting {{ type nat hook prerouting priority 0 \\; }}", table_name),
format!("nft add chain ip {} postrouting {{ type nat hook postrouting priority 100 \\; }}", table_name),
]
}
/// Build cleanup commands to remove the table.
pub fn build_table_cleanup(table_name: &str) -> Vec<String> {
vec![format!("nft delete table ip {}", table_name)]
}
#[cfg(test)]
mod tests {
use super::*;
fn make_options() -> NfTablesOptions {
NfTablesOptions {
preserve_source_ip: None,
protocol: None,
max_rate: None,
priority: None,
table_name: None,
use_ip_sets: None,
use_advanced_nat: None,
}
}
#[test]
fn test_basic_dnat_rule() {
let options = make_options();
let rules = build_dnat_rule("rustproxy", "prerouting", 443, "10.0.0.1", 8443, &options);
assert!(rules.len() >= 1);
assert!(rules[0].contains("dnat to 10.0.0.1:8443"));
assert!(rules[0].contains("dport 443"));
}
#[test]
fn test_preserve_source_ip() {
let mut options = make_options();
options.preserve_source_ip = Some(true);
let rules = build_dnat_rule("rustproxy", "prerouting", 443, "10.0.0.1", 8443, &options);
// When preserving source IP, no masquerade rule
assert!(rules.iter().all(|r| !r.contains("masquerade")));
}
#[test]
fn test_without_preserve_source_ip() {
let options = make_options();
let rules = build_dnat_rule("rustproxy", "prerouting", 443, "10.0.0.1", 8443, &options);
assert!(rules.iter().any(|r| r.contains("masquerade")));
}
#[test]
fn test_rate_limited_rule() {
let mut options = make_options();
options.max_rate = Some("100/second".to_string());
let rules = build_dnat_rule("rustproxy", "prerouting", 80, "10.0.0.1", 8080, &options);
assert!(rules.iter().any(|r| r.contains("limit rate 100/second")));
}
#[test]
fn test_table_setup_commands() {
let commands = build_table_setup("rustproxy");
assert_eq!(commands.len(), 3);
assert!(commands[0].contains("add table ip rustproxy"));
assert!(commands[1].contains("prerouting"));
assert!(commands[2].contains("postrouting"));
}
#[test]
fn test_table_cleanup() {
let commands = build_table_cleanup("rustproxy");
assert_eq!(commands.len(), 1);
assert!(commands[0].contains("delete table ip rustproxy"));
}
}

View File

@@ -23,3 +23,7 @@ rustls-pemfile = { workspace = true }
tokio-util = { workspace = true } tokio-util = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
socket2 = { workspace = true }
quinn = { workspace = true }
rcgen = { workspace = true }
base64 = { workspace = true }

View File

@@ -1,155 +0,0 @@
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
/// Per-connection tracking record with atomics for lock-free updates.
///
/// Each field uses atomics so that the forwarding tasks can update
/// bytes_received / bytes_sent / last_activity without holding any lock,
/// while the zombie scanner reads them concurrently.
pub struct ConnectionRecord {
/// Unique connection ID assigned by the ConnectionTracker.
pub id: u64,
/// Wall-clock instant when this connection was created.
pub created_at: Instant,
/// Milliseconds since `created_at` when the last activity occurred.
/// Updated atomically by the forwarding loops.
pub last_activity: AtomicU64,
/// Total bytes received from the client (inbound).
pub bytes_received: AtomicU64,
/// Total bytes sent to the client (outbound / from backend).
pub bytes_sent: AtomicU64,
/// True once the client side of the connection has closed.
pub client_closed: AtomicBool,
/// True once the backend side of the connection has closed.
pub backend_closed: AtomicBool,
/// Whether this connection uses TLS (affects zombie thresholds).
pub is_tls: AtomicBool,
/// Whether this connection has keep-alive semantics.
pub has_keep_alive: AtomicBool,
}
impl ConnectionRecord {
/// Create a new connection record with the given ID.
/// All counters start at zero, all flags start as false.
pub fn new(id: u64) -> Self {
Self {
id,
created_at: Instant::now(),
last_activity: AtomicU64::new(0),
bytes_received: AtomicU64::new(0),
bytes_sent: AtomicU64::new(0),
client_closed: AtomicBool::new(false),
backend_closed: AtomicBool::new(false),
is_tls: AtomicBool::new(false),
has_keep_alive: AtomicBool::new(false),
}
}
/// Update `last_activity` to reflect the current elapsed time.
pub fn touch(&self) {
let elapsed_ms = self.created_at.elapsed().as_millis() as u64;
self.last_activity.store(elapsed_ms, Ordering::Relaxed);
}
/// Record `n` bytes received from the client (inbound).
pub fn record_bytes_in(&self, n: u64) {
self.bytes_received.fetch_add(n, Ordering::Relaxed);
self.touch();
}
/// Record `n` bytes sent to the client (outbound / from backend).
pub fn record_bytes_out(&self, n: u64) {
self.bytes_sent.fetch_add(n, Ordering::Relaxed);
self.touch();
}
/// How long since the last activity on this connection.
pub fn idle_duration(&self) -> Duration {
let last_ms = self.last_activity.load(Ordering::Relaxed);
let age_ms = self.created_at.elapsed().as_millis() as u64;
Duration::from_millis(age_ms.saturating_sub(last_ms))
}
/// Total age of this connection (time since creation).
pub fn age(&self) -> Duration {
self.created_at.elapsed()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn test_new_record() {
let record = ConnectionRecord::new(42);
assert_eq!(record.id, 42);
assert_eq!(record.bytes_received.load(Ordering::Relaxed), 0);
assert_eq!(record.bytes_sent.load(Ordering::Relaxed), 0);
assert!(!record.client_closed.load(Ordering::Relaxed));
assert!(!record.backend_closed.load(Ordering::Relaxed));
assert!(!record.is_tls.load(Ordering::Relaxed));
assert!(!record.has_keep_alive.load(Ordering::Relaxed));
}
#[test]
fn test_record_bytes() {
let record = ConnectionRecord::new(1);
record.record_bytes_in(100);
record.record_bytes_in(200);
assert_eq!(record.bytes_received.load(Ordering::Relaxed), 300);
record.record_bytes_out(50);
record.record_bytes_out(75);
assert_eq!(record.bytes_sent.load(Ordering::Relaxed), 125);
}
#[test]
fn test_touch_updates_activity() {
let record = ConnectionRecord::new(1);
assert_eq!(record.last_activity.load(Ordering::Relaxed), 0);
// Sleep briefly so elapsed time is nonzero
thread::sleep(Duration::from_millis(10));
record.touch();
let activity = record.last_activity.load(Ordering::Relaxed);
assert!(activity >= 10, "last_activity should be at least 10ms, got {}", activity);
}
#[test]
fn test_idle_duration() {
let record = ConnectionRecord::new(1);
// Initially idle_duration ~ age since last_activity is 0
thread::sleep(Duration::from_millis(20));
let idle = record.idle_duration();
assert!(idle >= Duration::from_millis(20));
// After touch, idle should be near zero
record.touch();
let idle = record.idle_duration();
assert!(idle < Duration::from_millis(10));
}
#[test]
fn test_age() {
let record = ConnectionRecord::new(1);
thread::sleep(Duration::from_millis(20));
let age = record.age();
assert!(age >= Duration::from_millis(20));
}
#[test]
fn test_flags() {
let record = ConnectionRecord::new(1);
record.client_closed.store(true, Ordering::Relaxed);
record.is_tls.store(true, Ordering::Relaxed);
record.has_keep_alive.store(true, Ordering::Relaxed);
assert!(record.client_closed.load(Ordering::Relaxed));
assert!(!record.backend_closed.load(Ordering::Relaxed));
assert!(record.is_tls.load(Ordering::Relaxed));
assert!(record.has_keep_alive.load(Ordering::Relaxed));
}
}

View File

@@ -2,24 +2,9 @@ use dashmap::DashMap;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::net::IpAddr; use std::net::IpAddr;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use super::connection_record::ConnectionRecord;
/// Thresholds for zombie detection (non-TLS connections).
const HALF_ZOMBIE_TIMEOUT_PLAIN: Duration = Duration::from_secs(30);
/// Thresholds for zombie detection (TLS connections).
const HALF_ZOMBIE_TIMEOUT_TLS: Duration = Duration::from_secs(300);
/// Stuck connection timeout (non-TLS): received data but never sent any.
const STUCK_TIMEOUT_PLAIN: Duration = Duration::from_secs(60);
/// Stuck connection timeout (TLS): received data but never sent any.
const STUCK_TIMEOUT_TLS: Duration = Duration::from_secs(300);
/// Tracks active connections per IP and enforces per-IP limits and rate limiting. /// Tracks active connections per IP and enforces per-IP limits and rate limiting.
/// Also maintains per-connection records for zombie detection.
pub struct ConnectionTracker { pub struct ConnectionTracker {
/// Active connection counts per IP /// Active connection counts per IP
active: DashMap<IpAddr, AtomicU64>, active: DashMap<IpAddr, AtomicU64>,
@@ -29,10 +14,6 @@ pub struct ConnectionTracker {
max_per_ip: Option<u64>, max_per_ip: Option<u64>,
/// Maximum new connections per minute per IP (None = unlimited) /// Maximum new connections per minute per IP (None = unlimited)
rate_limit_per_minute: Option<u64>, rate_limit_per_minute: Option<u64>,
/// Per-connection tracking records for zombie detection
connections: DashMap<u64, Arc<ConnectionRecord>>,
/// Monotonically increasing connection ID counter
next_id: AtomicU64,
} }
impl ConnectionTracker { impl ConnectionTracker {
@@ -42,8 +23,6 @@ impl ConnectionTracker {
timestamps: DashMap::new(), timestamps: DashMap::new(),
max_per_ip, max_per_ip,
rate_limit_per_minute, rate_limit_per_minute,
connections: DashMap::new(),
next_id: AtomicU64::new(1),
} }
} }
@@ -95,10 +74,11 @@ impl ConnectionTracker {
pub fn connection_closed(&self, ip: &IpAddr) { pub fn connection_closed(&self, ip: &IpAddr) {
if let Some(counter) = self.active.get(ip) { if let Some(counter) = self.active.get(ip) {
let prev = counter.value().fetch_sub(1, Ordering::Relaxed); let prev = counter.value().fetch_sub(1, Ordering::Relaxed);
// Clean up zero entries // Clean up zero entries to prevent memory growth
if prev <= 1 { if prev <= 1 {
drop(counter); drop(counter);
self.active.remove(ip); self.active.remove(ip);
self.timestamps.remove(ip);
} }
} }
} }
@@ -111,115 +91,27 @@ impl ConnectionTracker {
.unwrap_or(0) .unwrap_or(0)
} }
/// Prune stale timestamp entries for IPs that have no active connections
/// and no recent timestamps. This cleans up entries left by rate-limited IPs
/// that never had connection_opened called.
pub fn cleanup_stale_timestamps(&self) {
if self.rate_limit_per_minute.is_none() {
return; // No rate limiting — timestamps map should be empty
}
let now = Instant::now();
let one_minute = Duration::from_secs(60);
self.timestamps.retain(|ip, timestamps| {
timestamps.retain(|t| now.duration_since(*t) < one_minute);
// Keep if there are active connections or recent timestamps
!timestamps.is_empty() || self.active.contains_key(ip)
});
}
/// Get the total number of tracked IPs. /// Get the total number of tracked IPs.
pub fn tracked_ips(&self) -> usize { pub fn tracked_ips(&self) -> usize {
self.active.len() self.active.len()
} }
/// Register a new connection and return its tracking record.
///
/// The returned `Arc<ConnectionRecord>` should be passed to the forwarding
/// loop so it can update bytes / activity atomics in real time.
pub fn register_connection(&self, is_tls: bool) -> Arc<ConnectionRecord> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let record = Arc::new(ConnectionRecord::new(id));
record.is_tls.store(is_tls, Ordering::Relaxed);
self.connections.insert(id, Arc::clone(&record));
record
}
/// Remove a connection record when the connection is fully closed.
pub fn unregister_connection(&self, id: u64) {
self.connections.remove(&id);
}
/// Scan all tracked connections and return IDs of zombie connections.
///
/// A connection is considered a zombie in any of these cases:
/// - **Full zombie**: both `client_closed` and `backend_closed` are true.
/// - **Half zombie**: one side closed for longer than the threshold
/// (5 min for TLS, 30s for non-TLS).
/// - **Stuck**: `bytes_received > 0` but `bytes_sent == 0` for longer
/// than the stuck threshold (5 min for TLS, 60s for non-TLS).
pub fn scan_zombies(&self) -> Vec<u64> {
let mut zombies = Vec::new();
for entry in self.connections.iter() {
let record = entry.value();
let id = *entry.key();
let is_tls = record.is_tls.load(Ordering::Relaxed);
let client_closed = record.client_closed.load(Ordering::Relaxed);
let backend_closed = record.backend_closed.load(Ordering::Relaxed);
let idle = record.idle_duration();
let bytes_in = record.bytes_received.load(Ordering::Relaxed);
let bytes_out = record.bytes_sent.load(Ordering::Relaxed);
// Full zombie: both sides closed
if client_closed && backend_closed {
zombies.push(id);
continue;
}
// Half zombie: one side closed for too long
let half_timeout = if is_tls {
HALF_ZOMBIE_TIMEOUT_TLS
} else {
HALF_ZOMBIE_TIMEOUT_PLAIN
};
if (client_closed || backend_closed) && idle >= half_timeout {
zombies.push(id);
continue;
}
// Stuck: received data but never sent anything for too long
let stuck_timeout = if is_tls {
STUCK_TIMEOUT_TLS
} else {
STUCK_TIMEOUT_PLAIN
};
if bytes_in > 0 && bytes_out == 0 && idle >= stuck_timeout {
zombies.push(id);
}
}
zombies
}
/// Start a background task that periodically scans for zombie connections.
///
/// The scanner runs every 10 seconds and logs any zombies it finds.
/// It stops when the provided `CancellationToken` is cancelled.
pub fn start_zombie_scanner(self: &Arc<Self>, cancel: CancellationToken) {
let tracker = Arc::clone(self);
tokio::spawn(async move {
let interval = Duration::from_secs(10);
loop {
tokio::select! {
_ = cancel.cancelled() => {
debug!("Zombie scanner shutting down");
break;
}
_ = tokio::time::sleep(interval) => {
let zombies = tracker.scan_zombies();
if !zombies.is_empty() {
warn!(
"Detected {} zombie connection(s): {:?}",
zombies.len(),
zombies
);
}
}
}
}
});
}
/// Get the total number of tracked connections (with records).
pub fn total_connections(&self) -> usize {
self.connections.len()
}
} }
#[cfg(test)] #[cfg(test)]
@@ -305,98 +197,51 @@ mod tests {
} }
#[test] #[test]
fn test_register_unregister_connection() { fn test_timestamps_cleaned_on_last_close() {
let tracker = ConnectionTracker::new(None, None); let tracker = ConnectionTracker::new(None, Some(100));
assert_eq!(tracker.total_connections(), 0); let ip: IpAddr = "10.0.0.1".parse().unwrap();
let record1 = tracker.register_connection(false); // try_accept populates the timestamps map (when rate limiting is enabled)
assert_eq!(tracker.total_connections(), 1); assert!(tracker.try_accept(&ip));
assert!(!record1.is_tls.load(Ordering::Relaxed)); tracker.connection_opened(&ip);
assert!(tracker.try_accept(&ip));
tracker.connection_opened(&ip);
let record2 = tracker.register_connection(true); // Timestamps should exist
assert_eq!(tracker.total_connections(), 2); assert!(tracker.timestamps.get(&ip).is_some());
assert!(record2.is_tls.load(Ordering::Relaxed));
// IDs should be unique // Close one connection — timestamps should still exist
assert_ne!(record1.id, record2.id); tracker.connection_closed(&ip);
assert!(tracker.timestamps.get(&ip).is_some());
tracker.unregister_connection(record1.id); // Close last connection — timestamps should be cleaned up
assert_eq!(tracker.total_connections(), 1); tracker.connection_closed(&ip);
assert!(tracker.timestamps.get(&ip).is_none());
tracker.unregister_connection(record2.id); assert!(tracker.active.get(&ip).is_none());
assert_eq!(tracker.total_connections(), 0);
} }
#[test] #[test]
fn test_full_zombie_detection() { fn test_cleanup_stale_timestamps() {
let tracker = ConnectionTracker::new(None, None); // Rate limit of 100/min so timestamps are tracked
let record = tracker.register_connection(false); let tracker = ConnectionTracker::new(None, Some(100));
let ip: IpAddr = "10.0.0.1".parse().unwrap();
// Not a zombie initially // try_accept adds a timestamp entry
assert!(tracker.scan_zombies().is_empty()); assert!(tracker.try_accept(&ip));
// Set both sides closed -> full zombie // Simulate: connection was rate-limited and never accepted,
record.client_closed.store(true, Ordering::Relaxed); // so no connection_opened / connection_closed pair
record.backend_closed.store(true, Ordering::Relaxed); assert!(tracker.timestamps.get(&ip).is_some());
assert!(tracker.active.get(&ip).is_none()); // never opened
let zombies = tracker.scan_zombies(); // Cleanup won't remove it yet because timestamp is recent
assert_eq!(zombies.len(), 1); tracker.cleanup_stale_timestamps();
assert_eq!(zombies[0], record.id); assert!(tracker.timestamps.get(&ip).is_some());
}
#[test] // After expiry (use 0-second window trick: create tracker with 0 rate)
fn test_half_zombie_not_triggered_immediately() { // Actually, we can't fast-forward time easily, so just verify the cleanup
let tracker = ConnectionTracker::new(None, None); // doesn't panic and handles the no-rate-limit case
let record = tracker.register_connection(false); let tracker2 = ConnectionTracker::new(None, None);
record.touch(); // mark activity now tracker2.cleanup_stale_timestamps(); // should be a no-op
// Only one side closed, but just now -> not a zombie yet
record.client_closed.store(true, Ordering::Relaxed);
assert!(tracker.scan_zombies().is_empty());
}
#[test]
fn test_stuck_connection_not_triggered_immediately() {
let tracker = ConnectionTracker::new(None, None);
let record = tracker.register_connection(false);
record.touch(); // mark activity now
// Has received data but sent nothing -> but just started, not stuck yet
record.bytes_received.store(1000, Ordering::Relaxed);
assert!(tracker.scan_zombies().is_empty());
}
#[test]
fn test_unregister_removes_from_zombie_scan() {
let tracker = ConnectionTracker::new(None, None);
let record = tracker.register_connection(false);
let id = record.id;
// Make it a full zombie
record.client_closed.store(true, Ordering::Relaxed);
record.backend_closed.store(true, Ordering::Relaxed);
assert_eq!(tracker.scan_zombies().len(), 1);
// Unregister should remove it
tracker.unregister_connection(id);
assert!(tracker.scan_zombies().is_empty());
}
#[test]
fn test_total_connections() {
let tracker = ConnectionTracker::new(None, None);
assert_eq!(tracker.total_connections(), 0);
let r1 = tracker.register_connection(false);
let r2 = tracker.register_connection(true);
let r3 = tracker.register_connection(false);
assert_eq!(tracker.total_connections(), 3);
tracker.unregister_connection(r2.id);
assert_eq!(tracker.total_connections(), 2);
tracker.unregister_connection(r1.id);
tracker.unregister_connection(r3.id);
assert_eq!(tracker.total_connections(), 0);
} }
} }

View File

@@ -5,13 +5,14 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use tracing::debug; use tracing::debug;
use super::connection_record::ConnectionRecord; use rustproxy_metrics::MetricsCollector;
/// Statistics for a forwarded connection. /// Context for forwarding metrics, replacing the growing tuple pattern.
#[derive(Debug, Default)] #[derive(Clone)]
pub struct ForwardStats { pub struct ForwardMetricsCtx {
pub bytes_in: AtomicU64, pub collector: Arc<MetricsCollector>,
pub bytes_out: AtomicU64, pub route_id: Option<String>,
pub source_ip: Option<String>,
} }
/// Perform bidirectional TCP forwarding between client and backend. /// Perform bidirectional TCP forwarding between client and backend.
@@ -68,6 +69,10 @@ pub async fn forward_bidirectional(
/// Perform bidirectional TCP forwarding with inactivity and max lifetime timeouts. /// Perform bidirectional TCP forwarding with inactivity and max lifetime timeouts.
/// ///
/// When `metrics` is provided, bytes are reported to the MetricsCollector
/// per-chunk (lock-free) as they flow through the copy loops, enabling
/// real-time throughput sampling for long-lived connections.
///
/// Returns (bytes_from_client, bytes_from_backend) when the connection closes or times out. /// Returns (bytes_from_client, bytes_from_backend) when the connection closes or times out.
pub async fn forward_bidirectional_with_timeouts( pub async fn forward_bidirectional_with_timeouts(
client: TcpStream, client: TcpStream,
@@ -76,10 +81,14 @@ pub async fn forward_bidirectional_with_timeouts(
inactivity_timeout: std::time::Duration, inactivity_timeout: std::time::Duration,
max_lifetime: std::time::Duration, max_lifetime: std::time::Duration,
cancel: CancellationToken, cancel: CancellationToken,
metrics: Option<ForwardMetricsCtx>,
) -> std::io::Result<(u64, u64)> { ) -> std::io::Result<(u64, u64)> {
// Send initial data (peeked bytes) to backend // Send initial data (peeked bytes) to backend
if let Some(data) = initial_data { if let Some(data) = initial_data {
backend.write_all(data).await?; backend.write_all(data).await?;
if let Some(ref ctx) = metrics {
ctx.collector.record_bytes(data.len() as u64, 0, ctx.route_id.as_deref(), ctx.source_ip.as_deref());
}
} }
let (mut client_read, mut client_write) = client.into_split(); let (mut client_read, mut client_write) = client.into_split();
@@ -88,49 +97,80 @@ pub async fn forward_bidirectional_with_timeouts(
let last_activity = Arc::new(AtomicU64::new(0)); let last_activity = Arc::new(AtomicU64::new(0));
let start = std::time::Instant::now(); let start = std::time::Instant::now();
// Per-connection cancellation token: the watchdog cancels this instead of
// aborting tasks, so the copy loops can shut down gracefully (TCP FIN instead
// of RST, TLS close_notify if the stream is TLS-wrapped).
let conn_cancel = CancellationToken::new();
let la1 = Arc::clone(&last_activity); let la1 = Arc::clone(&last_activity);
let initial_len = initial_data.map_or(0u64, |d| d.len() as u64); let initial_len = initial_data.map_or(0u64, |d| d.len() as u64);
let metrics_c2b = metrics.clone();
let cc1 = conn_cancel.clone();
let c2b = tokio::spawn(async move { let c2b = tokio::spawn(async move {
let mut buf = vec![0u8; 65536]; let mut buf = vec![0u8; 65536];
let mut total = initial_len; let mut total = initial_len;
loop { loop {
let n = match client_read.read(&mut buf).await { let n = tokio::select! {
Ok(0) | Err(_) => break, result = client_read.read(&mut buf) => match result {
Ok(n) => n, Ok(0) | Err(_) => break,
Ok(n) => n,
},
_ = cc1.cancelled() => break,
}; };
if backend_write.write_all(&buf[..n]).await.is_err() { if backend_write.write_all(&buf[..n]).await.is_err() {
break; break;
} }
total += n as u64; total += n as u64;
la1.store(start.elapsed().as_millis() as u64, Ordering::Relaxed); la1.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
if let Some(ref ctx) = metrics_c2b {
ctx.collector.record_bytes(n as u64, 0, ctx.route_id.as_deref(), ctx.source_ip.as_deref());
}
} }
let _ = backend_write.shutdown().await; // Graceful shutdown with timeout (sends TCP FIN / TLS close_notify)
let _ = tokio::time::timeout(
std::time::Duration::from_secs(2),
backend_write.shutdown(),
).await;
total total
}); });
let la2 = Arc::clone(&last_activity); let la2 = Arc::clone(&last_activity);
let metrics_b2c = metrics;
let cc2 = conn_cancel.clone();
let b2c = tokio::spawn(async move { let b2c = tokio::spawn(async move {
let mut buf = vec![0u8; 65536]; let mut buf = vec![0u8; 65536];
let mut total = 0u64; let mut total = 0u64;
loop { loop {
let n = match backend_read.read(&mut buf).await { let n = tokio::select! {
Ok(0) | Err(_) => break, result = backend_read.read(&mut buf) => match result {
Ok(n) => n, Ok(0) | Err(_) => break,
Ok(n) => n,
},
_ = cc2.cancelled() => break,
}; };
if client_write.write_all(&buf[..n]).await.is_err() { if client_write.write_all(&buf[..n]).await.is_err() {
break; break;
} }
total += n as u64; total += n as u64;
la2.store(start.elapsed().as_millis() as u64, Ordering::Relaxed); la2.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
if let Some(ref ctx) = metrics_b2c {
ctx.collector.record_bytes(0, n as u64, ctx.route_id.as_deref(), ctx.source_ip.as_deref());
}
} }
let _ = client_write.shutdown().await; // Graceful shutdown with timeout (sends TCP FIN / TLS close_notify)
let _ = tokio::time::timeout(
std::time::Duration::from_secs(2),
client_write.shutdown(),
).await;
total total
}); });
// Watchdog: inactivity, max lifetime, and cancellation // Watchdog: inactivity, max lifetime, and cancellation.
// First cancels the per-connection token for graceful shutdown (FIN/close_notify),
// then falls back to abort if the tasks are stuck (e.g. on a blocked write_all).
let la_watch = Arc::clone(&last_activity); let la_watch = Arc::clone(&last_activity);
let c2b_handle = c2b.abort_handle(); let c2b_abort = c2b.abort_handle();
let b2c_handle = b2c.abort_handle(); let b2c_abort = b2c.abort_handle();
let watchdog = tokio::spawn(async move { let watchdog = tokio::spawn(async move {
let check_interval = std::time::Duration::from_secs(5); let check_interval = std::time::Duration::from_secs(5);
let mut last_seen = 0u64; let mut last_seen = 0u64;
@@ -138,16 +178,12 @@ pub async fn forward_bidirectional_with_timeouts(
tokio::select! { tokio::select! {
_ = cancel.cancelled() => { _ = cancel.cancelled() => {
debug!("Connection cancelled by shutdown"); debug!("Connection cancelled by shutdown");
c2b_handle.abort();
b2c_handle.abort();
break; break;
} }
_ = tokio::time::sleep(check_interval) => { _ = tokio::time::sleep(check_interval) => {
// Check max lifetime // Check max lifetime
if start.elapsed() >= max_lifetime { if start.elapsed() >= max_lifetime {
debug!("Connection exceeded max lifetime, closing"); debug!("Connection exceeded max lifetime, closing");
c2b_handle.abort();
b2c_handle.abort();
break; break;
} }
@@ -157,158 +193,6 @@ pub async fn forward_bidirectional_with_timeouts(
let elapsed_since_activity = start.elapsed().as_millis() as u64 - current; let elapsed_since_activity = start.elapsed().as_millis() as u64 - current;
if elapsed_since_activity >= inactivity_timeout.as_millis() as u64 { if elapsed_since_activity >= inactivity_timeout.as_millis() as u64 {
debug!("Connection inactive for {}ms, closing", elapsed_since_activity); debug!("Connection inactive for {}ms, closing", elapsed_since_activity);
c2b_handle.abort();
b2c_handle.abort();
break;
}
}
last_seen = current;
}
}
}
});
let bytes_in = c2b.await.unwrap_or(0);
let bytes_out = b2c.await.unwrap_or(0);
watchdog.abort();
Ok((bytes_in, bytes_out))
}
/// Forward bidirectional with a callback for byte counting.
pub async fn forward_bidirectional_with_stats(
client: TcpStream,
backend: TcpStream,
initial_data: Option<&[u8]>,
stats: Arc<ForwardStats>,
) -> std::io::Result<()> {
let (bytes_in, bytes_out) = forward_bidirectional(client, backend, initial_data).await?;
stats.bytes_in.fetch_add(bytes_in, Ordering::Relaxed);
stats.bytes_out.fetch_add(bytes_out, Ordering::Relaxed);
Ok(())
}
/// Perform bidirectional TCP forwarding with inactivity / lifetime timeouts,
/// updating a `ConnectionRecord` with byte counts and activity timestamps
/// in real time for zombie detection.
///
/// When `record` is `None`, this behaves identically to
/// `forward_bidirectional_with_timeouts`.
///
/// The record's `client_closed` / `backend_closed` flags are set when the
/// respective copy loop terminates, giving the zombie scanner visibility
/// into half-open connections.
pub async fn forward_bidirectional_with_record(
client: TcpStream,
mut backend: TcpStream,
initial_data: Option<&[u8]>,
inactivity_timeout: std::time::Duration,
max_lifetime: std::time::Duration,
cancel: CancellationToken,
record: Option<Arc<ConnectionRecord>>,
) -> std::io::Result<(u64, u64)> {
// Send initial data (peeked bytes) to backend
if let Some(data) = initial_data {
backend.write_all(data).await?;
if let Some(ref r) = record {
r.record_bytes_in(data.len() as u64);
}
}
let (mut client_read, mut client_write) = client.into_split();
let (mut backend_read, mut backend_write) = backend.into_split();
let last_activity = Arc::new(AtomicU64::new(0));
let start = std::time::Instant::now();
let la1 = Arc::clone(&last_activity);
let initial_len = initial_data.map_or(0u64, |d| d.len() as u64);
let rec1 = record.clone();
let c2b = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
let mut total = initial_len;
loop {
let n = match client_read.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
if backend_write.write_all(&buf[..n]).await.is_err() {
break;
}
total += n as u64;
let now_ms = start.elapsed().as_millis() as u64;
la1.store(now_ms, Ordering::Relaxed);
if let Some(ref r) = rec1 {
r.record_bytes_in(n as u64);
}
}
let _ = backend_write.shutdown().await;
// Mark client side as closed
if let Some(ref r) = rec1 {
r.client_closed.store(true, Ordering::Relaxed);
}
total
});
let la2 = Arc::clone(&last_activity);
let rec2 = record.clone();
let b2c = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
let mut total = 0u64;
loop {
let n = match backend_read.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
if client_write.write_all(&buf[..n]).await.is_err() {
break;
}
total += n as u64;
let now_ms = start.elapsed().as_millis() as u64;
la2.store(now_ms, Ordering::Relaxed);
if let Some(ref r) = rec2 {
r.record_bytes_out(n as u64);
}
}
let _ = client_write.shutdown().await;
// Mark backend side as closed
if let Some(ref r) = rec2 {
r.backend_closed.store(true, Ordering::Relaxed);
}
total
});
// Watchdog: inactivity, max lifetime, and cancellation
let la_watch = Arc::clone(&last_activity);
let c2b_handle = c2b.abort_handle();
let b2c_handle = b2c.abort_handle();
let watchdog = tokio::spawn(async move {
let check_interval = std::time::Duration::from_secs(5);
let mut last_seen = 0u64;
loop {
tokio::select! {
_ = cancel.cancelled() => {
debug!("Connection cancelled by shutdown");
c2b_handle.abort();
b2c_handle.abort();
break;
}
_ = tokio::time::sleep(check_interval) => {
// Check max lifetime
if start.elapsed() >= max_lifetime {
debug!("Connection exceeded max lifetime, closing");
c2b_handle.abort();
b2c_handle.abort();
break;
}
// Check inactivity
let current = la_watch.load(Ordering::Relaxed);
if current == last_seen {
let elapsed_since_activity = start.elapsed().as_millis() as u64 - current;
if elapsed_since_activity >= inactivity_timeout.as_millis() as u64 {
debug!("Connection inactive for {}ms, closing", elapsed_since_activity);
c2b_handle.abort();
b2c_handle.abort();
break; break;
} }
} }
@@ -316,6 +200,13 @@ pub async fn forward_bidirectional_with_record(
} }
} }
} }
// Phase 1: Signal copy loops to exit gracefully (allows FIN/close_notify)
conn_cancel.cancel();
// Phase 2: Wait for graceful shutdown (2s shutdown timeout + 2s margin)
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
// Phase 3: Force-abort if still stuck (e.g. blocked on write_all)
c2b_abort.abort();
b2c_abort.abort();
}); });
let bytes_in = c2b.await.unwrap_or(0); let bytes_in = c2b.await.unwrap_or(0);

View File

@@ -1,22 +1,27 @@
//! # rustproxy-passthrough //! # rustproxy-passthrough
//! //!
//! Raw TCP/SNI passthrough engine for RustProxy. //! Raw TCP/SNI passthrough engine and UDP listener for RustProxy.
//! Handles TCP listening, TLS ClientHello SNI extraction, and bidirectional forwarding. //! Handles TCP listening, TLS ClientHello SNI extraction, bidirectional forwarding,
//! and UDP datagram session tracking with forwarding.
pub mod tcp_listener; pub mod tcp_listener;
pub mod sni_parser; pub mod sni_parser;
pub mod forwarder; pub mod forwarder;
pub mod proxy_protocol; pub mod proxy_protocol;
pub mod tls_handler; pub mod tls_handler;
pub mod connection_record;
pub mod connection_tracker; pub mod connection_tracker;
pub mod socket_relay; pub mod socket_opts;
pub mod udp_session;
pub mod udp_listener;
pub mod quic_handler;
pub use tcp_listener::*; pub use tcp_listener::*;
pub use sni_parser::*; pub use sni_parser::*;
pub use forwarder::*; pub use forwarder::*;
pub use proxy_protocol::*; pub use proxy_protocol::*;
pub use tls_handler::*; pub use tls_handler::*;
pub use connection_record::*;
pub use connection_tracker::*; pub use connection_tracker::*;
pub use socket_relay::*; pub use socket_opts::*;
pub use udp_session::*;
pub use udp_listener::*;
pub use quic_handler::*;

View File

@@ -1,4 +1,4 @@
use std::net::SocketAddr; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -9,9 +9,11 @@ pub enum ProxyProtocolError {
UnsupportedVersion, UnsupportedVersion,
#[error("Parse error: {0}")] #[error("Parse error: {0}")]
Parse(String), Parse(String),
#[error("Incomplete header: need {0} bytes, got {1}")]
Incomplete(usize, usize),
} }
/// Parsed PROXY protocol v1 header. /// Parsed PROXY protocol header (v1 or v2).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ProxyProtocolHeader { pub struct ProxyProtocolHeader {
pub source_addr: SocketAddr, pub source_addr: SocketAddr,
@@ -24,14 +26,29 @@ pub struct ProxyProtocolHeader {
pub enum ProxyProtocol { pub enum ProxyProtocol {
Tcp4, Tcp4,
Tcp6, Tcp6,
Udp4,
Udp6,
Unknown, Unknown,
} }
/// Transport type for PROXY v2 header generation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProxyV2Transport {
Stream, // TCP
Datagram, // UDP
}
/// PROXY protocol v2 signature (12 bytes).
const PROXY_V2_SIGNATURE: [u8; 12] = [
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A,
];
// ===== v1 (text format) =====
/// Parse a PROXY protocol v1 header from data. /// Parse a PROXY protocol v1 header from data.
/// ///
/// Format: `PROXY TCP4 <src_ip> <dst_ip> <src_port> <dst_port>\r\n` /// Format: `PROXY TCP4 <src_ip> <dst_ip> <src_port> <dst_port>\r\n`
pub fn parse_v1(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtocolError> { pub fn parse_v1(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtocolError> {
// Find the end of the header line
let line_end = data let line_end = data
.windows(2) .windows(2)
.position(|w| w == b"\r\n") .position(|w| w == b"\r\n")
@@ -56,10 +73,10 @@ pub fn parse_v1(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc
_ => return Err(ProxyProtocolError::UnsupportedVersion), _ => return Err(ProxyProtocolError::UnsupportedVersion),
}; };
let src_ip: std::net::IpAddr = parts[2] let src_ip: IpAddr = parts[2]
.parse() .parse()
.map_err(|_| ProxyProtocolError::Parse("Invalid source IP".to_string()))?; .map_err(|_| ProxyProtocolError::Parse("Invalid source IP".to_string()))?;
let dst_ip: std::net::IpAddr = parts[3] let dst_ip: IpAddr = parts[3]
.parse() .parse()
.map_err(|_| ProxyProtocolError::Parse("Invalid destination IP".to_string()))?; .map_err(|_| ProxyProtocolError::Parse("Invalid destination IP".to_string()))?;
let src_port: u16 = parts[4] let src_port: u16 = parts[4]
@@ -75,7 +92,6 @@ pub fn parse_v1(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc
protocol, protocol,
}; };
// Consumed bytes = line + \r\n
Ok((header, line_end + 2)) Ok((header, line_end + 2))
} }
@@ -97,10 +113,219 @@ pub fn is_proxy_protocol_v1(data: &[u8]) -> bool {
data.starts_with(b"PROXY ") data.starts_with(b"PROXY ")
} }
// ===== v2 (binary format) =====
/// Check if data starts with a PROXY protocol v2 header.
pub fn is_proxy_protocol_v2(data: &[u8]) -> bool {
data.len() >= 12 && data[..12] == PROXY_V2_SIGNATURE
}
/// Parse a PROXY protocol v2 binary header.
///
/// Binary format:
/// - [0..12] signature (12 bytes)
/// - [12] version (high nibble) + command (low nibble)
/// - [13] address family (high nibble) + transport (low nibble)
/// - [14..16] address block length (big-endian u16)
/// - [16..] address block (variable, depends on family)
pub fn parse_v2(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtocolError> {
if data.len() < 16 {
return Err(ProxyProtocolError::Incomplete(16, data.len()));
}
// Validate signature
if data[..12] != PROXY_V2_SIGNATURE {
return Err(ProxyProtocolError::InvalidHeader);
}
// Version (high nibble of byte 12) must be 0x2
let version = (data[12] >> 4) & 0x0F;
if version != 2 {
return Err(ProxyProtocolError::UnsupportedVersion);
}
// Command (low nibble of byte 12)
let command = data[12] & 0x0F;
// 0x0 = LOCAL, 0x1 = PROXY
if command > 1 {
return Err(ProxyProtocolError::Parse(format!("Unknown command: {}", command)));
}
// Address family (high nibble) + transport (low nibble) of byte 13
let family = (data[13] >> 4) & 0x0F;
let transport = data[13] & 0x0F;
// Address block length
let addr_len = u16::from_be_bytes([data[14], data[15]]) as usize;
let total_len = 16 + addr_len;
if data.len() < total_len {
return Err(ProxyProtocolError::Incomplete(total_len, data.len()));
}
// LOCAL command: no real addresses, return unspecified
if command == 0 {
return Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
dest_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
protocol: ProxyProtocol::Unknown,
},
total_len,
));
}
// PROXY command: parse addresses based on family + transport
let addr_block = &data[16..16 + addr_len];
match (family, transport) {
// AF_INET (0x1) + STREAM (0x1) = TCP4
(0x1, 0x1) => {
if addr_len < 12 {
return Err(ProxyProtocolError::Parse("IPv4 address block too short".to_string()));
}
let src_ip = Ipv4Addr::new(addr_block[0], addr_block[1], addr_block[2], addr_block[3]);
let dst_ip = Ipv4Addr::new(addr_block[4], addr_block[5], addr_block[6], addr_block[7]);
let src_port = u16::from_be_bytes([addr_block[8], addr_block[9]]);
let dst_port = u16::from_be_bytes([addr_block[10], addr_block[11]]);
Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V4(src_ip), src_port),
dest_addr: SocketAddr::new(IpAddr::V4(dst_ip), dst_port),
protocol: ProxyProtocol::Tcp4,
},
total_len,
))
}
// AF_INET (0x1) + DGRAM (0x2) = UDP4
(0x1, 0x2) => {
if addr_len < 12 {
return Err(ProxyProtocolError::Parse("IPv4 address block too short".to_string()));
}
let src_ip = Ipv4Addr::new(addr_block[0], addr_block[1], addr_block[2], addr_block[3]);
let dst_ip = Ipv4Addr::new(addr_block[4], addr_block[5], addr_block[6], addr_block[7]);
let src_port = u16::from_be_bytes([addr_block[8], addr_block[9]]);
let dst_port = u16::from_be_bytes([addr_block[10], addr_block[11]]);
Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V4(src_ip), src_port),
dest_addr: SocketAddr::new(IpAddr::V4(dst_ip), dst_port),
protocol: ProxyProtocol::Udp4,
},
total_len,
))
}
// AF_INET6 (0x2) + STREAM (0x1) = TCP6
(0x2, 0x1) => {
if addr_len < 36 {
return Err(ProxyProtocolError::Parse("IPv6 address block too short".to_string()));
}
let src_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[0..16]).unwrap());
let dst_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[16..32]).unwrap());
let src_port = u16::from_be_bytes([addr_block[32], addr_block[33]]);
let dst_port = u16::from_be_bytes([addr_block[34], addr_block[35]]);
Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V6(src_ip), src_port),
dest_addr: SocketAddr::new(IpAddr::V6(dst_ip), dst_port),
protocol: ProxyProtocol::Tcp6,
},
total_len,
))
}
// AF_INET6 (0x2) + DGRAM (0x2) = UDP6
(0x2, 0x2) => {
if addr_len < 36 {
return Err(ProxyProtocolError::Parse("IPv6 address block too short".to_string()));
}
let src_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[0..16]).unwrap());
let dst_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[16..32]).unwrap());
let src_port = u16::from_be_bytes([addr_block[32], addr_block[33]]);
let dst_port = u16::from_be_bytes([addr_block[34], addr_block[35]]);
Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V6(src_ip), src_port),
dest_addr: SocketAddr::new(IpAddr::V6(dst_ip), dst_port),
protocol: ProxyProtocol::Udp6,
},
total_len,
))
}
// AF_UNSPEC or unknown
(0x0, _) => Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
dest_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
protocol: ProxyProtocol::Unknown,
},
total_len,
)),
_ => Err(ProxyProtocolError::Parse(format!(
"Unsupported family/transport: 0x{:X}{:X}",
family, transport
))),
}
}
/// Generate a PROXY protocol v2 binary header.
pub fn generate_v2(
source: &SocketAddr,
dest: &SocketAddr,
transport: ProxyV2Transport,
) -> Vec<u8> {
let transport_nibble: u8 = match transport {
ProxyV2Transport::Stream => 0x1,
ProxyV2Transport::Datagram => 0x2,
};
match (source.ip(), dest.ip()) {
(IpAddr::V4(src_ip), IpAddr::V4(dst_ip)) => {
let mut buf = Vec::with_capacity(28);
buf.extend_from_slice(&PROXY_V2_SIGNATURE);
buf.push(0x21); // version 2, PROXY command
buf.push(0x10 | transport_nibble); // AF_INET + transport
buf.extend_from_slice(&12u16.to_be_bytes()); // addr block length
buf.extend_from_slice(&src_ip.octets());
buf.extend_from_slice(&dst_ip.octets());
buf.extend_from_slice(&source.port().to_be_bytes());
buf.extend_from_slice(&dest.port().to_be_bytes());
buf
}
(IpAddr::V6(src_ip), IpAddr::V6(dst_ip)) => {
let mut buf = Vec::with_capacity(52);
buf.extend_from_slice(&PROXY_V2_SIGNATURE);
buf.push(0x21); // version 2, PROXY command
buf.push(0x20 | transport_nibble); // AF_INET6 + transport
buf.extend_from_slice(&36u16.to_be_bytes()); // addr block length
buf.extend_from_slice(&src_ip.octets());
buf.extend_from_slice(&dst_ip.octets());
buf.extend_from_slice(&source.port().to_be_bytes());
buf.extend_from_slice(&dest.port().to_be_bytes());
buf
}
// Mixed IPv4/IPv6: map IPv4 to IPv6-mapped address
_ => {
let src_v6 = match source.ip() {
IpAddr::V4(v4) => v4.to_ipv6_mapped(),
IpAddr::V6(v6) => v6,
};
let dst_v6 = match dest.ip() {
IpAddr::V4(v4) => v4.to_ipv6_mapped(),
IpAddr::V6(v6) => v6,
};
let src6 = SocketAddr::new(IpAddr::V6(src_v6), source.port());
let dst6 = SocketAddr::new(IpAddr::V6(dst_v6), dest.port());
generate_v2(&src6, &dst6, transport)
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
// ===== v1 tests =====
#[test] #[test]
fn test_parse_v1_tcp4() { fn test_parse_v1_tcp4() {
let header = b"PROXY TCP4 192.168.1.100 10.0.0.1 12345 443\r\n"; let header = b"PROXY TCP4 192.168.1.100 10.0.0.1 12345 443\r\n";
@@ -126,4 +351,130 @@ mod tests {
assert!(is_proxy_protocol_v1(b"PROXY TCP4 ...")); assert!(is_proxy_protocol_v1(b"PROXY TCP4 ..."));
assert!(!is_proxy_protocol_v1(b"GET / HTTP/1.1")); assert!(!is_proxy_protocol_v1(b"GET / HTTP/1.1"));
} }
// ===== v2 tests =====
#[test]
fn test_is_proxy_protocol_v2() {
assert!(is_proxy_protocol_v2(&PROXY_V2_SIGNATURE));
assert!(!is_proxy_protocol_v2(b"PROXY TCP4 ..."));
assert!(!is_proxy_protocol_v2(b"short"));
}
#[test]
fn test_parse_v2_tcp4() {
let source: SocketAddr = "198.51.100.10:54321".parse().unwrap();
let dest: SocketAddr = "203.0.113.25:8443".parse().unwrap();
let header = generate_v2(&source, &dest, ProxyV2Transport::Stream);
assert_eq!(header.len(), 28);
let (parsed, consumed) = parse_v2(&header).unwrap();
assert_eq!(consumed, 28);
assert_eq!(parsed.protocol, ProxyProtocol::Tcp4);
assert_eq!(parsed.source_addr, source);
assert_eq!(parsed.dest_addr, dest);
}
#[test]
fn test_parse_v2_udp4() {
let source: SocketAddr = "10.0.0.1:12345".parse().unwrap();
let dest: SocketAddr = "10.0.0.2:53".parse().unwrap();
let header = generate_v2(&source, &dest, ProxyV2Transport::Datagram);
assert_eq!(header.len(), 28);
assert_eq!(header[13], 0x12); // AF_INET + DGRAM
let (parsed, consumed) = parse_v2(&header).unwrap();
assert_eq!(consumed, 28);
assert_eq!(parsed.protocol, ProxyProtocol::Udp4);
assert_eq!(parsed.source_addr, source);
assert_eq!(parsed.dest_addr, dest);
}
#[test]
fn test_parse_v2_tcp6() {
let source: SocketAddr = "[2001:db8::1]:54321".parse().unwrap();
let dest: SocketAddr = "[2001:db8::2]:443".parse().unwrap();
let header = generate_v2(&source, &dest, ProxyV2Transport::Stream);
assert_eq!(header.len(), 52);
assert_eq!(header[13], 0x21); // AF_INET6 + STREAM
let (parsed, consumed) = parse_v2(&header).unwrap();
assert_eq!(consumed, 52);
assert_eq!(parsed.protocol, ProxyProtocol::Tcp6);
assert_eq!(parsed.source_addr, source);
assert_eq!(parsed.dest_addr, dest);
}
#[test]
fn test_generate_v2_tcp4_byte_layout() {
let source: SocketAddr = "1.2.3.4:1000".parse().unwrap();
let dest: SocketAddr = "5.6.7.8:443".parse().unwrap();
let header = generate_v2(&source, &dest, ProxyV2Transport::Stream);
assert_eq!(&header[0..12], &PROXY_V2_SIGNATURE);
assert_eq!(header[12], 0x21); // v2, PROXY
assert_eq!(header[13], 0x11); // AF_INET, STREAM
assert_eq!(u16::from_be_bytes([header[14], header[15]]), 12); // addr len
assert_eq!(&header[16..20], &[1, 2, 3, 4]); // src ip
assert_eq!(&header[20..24], &[5, 6, 7, 8]); // dst ip
assert_eq!(u16::from_be_bytes([header[24], header[25]]), 1000); // src port
assert_eq!(u16::from_be_bytes([header[26], header[27]]), 443); // dst port
}
#[test]
fn test_generate_v2_udp4_byte_layout() {
let source: SocketAddr = "10.0.0.1:5000".parse().unwrap();
let dest: SocketAddr = "10.0.0.2:53".parse().unwrap();
let header = generate_v2(&source, &dest, ProxyV2Transport::Datagram);
assert_eq!(header[12], 0x21); // v2, PROXY
assert_eq!(header[13], 0x12); // AF_INET, DGRAM (UDP)
}
#[test]
fn test_parse_v2_local_command() {
// Build a LOCAL command header (no addresses)
let mut header = Vec::new();
header.extend_from_slice(&PROXY_V2_SIGNATURE);
header.push(0x20); // v2, LOCAL
header.push(0x00); // AF_UNSPEC
header.extend_from_slice(&0u16.to_be_bytes()); // 0-length address block
let (parsed, consumed) = parse_v2(&header).unwrap();
assert_eq!(consumed, 16);
assert_eq!(parsed.protocol, ProxyProtocol::Unknown);
assert_eq!(parsed.source_addr.port(), 0);
}
#[test]
fn test_parse_v2_incomplete() {
let data = &PROXY_V2_SIGNATURE[..8]; // only 8 bytes
assert!(parse_v2(data).is_err());
}
#[test]
fn test_parse_v2_wrong_version() {
let mut header = Vec::new();
header.extend_from_slice(&PROXY_V2_SIGNATURE);
header.push(0x11); // version 1, not 2
header.push(0x11);
header.extend_from_slice(&12u16.to_be_bytes());
header.extend_from_slice(&[0u8; 12]);
assert!(matches!(parse_v2(&header), Err(ProxyProtocolError::UnsupportedVersion)));
}
#[test]
fn test_v2_roundtrip_with_trailing_data() {
let source: SocketAddr = "192.168.1.1:8080".parse().unwrap();
let dest: SocketAddr = "10.0.0.1:443".parse().unwrap();
let mut data = generate_v2(&source, &dest, ProxyV2Transport::Stream);
data.extend_from_slice(b"GET / HTTP/1.1\r\n"); // trailing app data
let (parsed, consumed) = parse_v2(&data).unwrap();
assert_eq!(consumed, 28);
assert_eq!(parsed.source_addr, source);
assert_eq!(&data[consumed..], b"GET / HTTP/1.1\r\n");
}
} }

View File

@@ -0,0 +1,708 @@
//! QUIC connection handling.
//!
//! Manages QUIC endpoints (via quinn), accepts connections, and either:
//! - Forwards streams bidirectionally to TCP backends (QUIC termination)
//! - Dispatches to H3ProxyService for HTTP/3 handling (Phase 5)
//!
//! When `proxy_ips` is configured, a UDP relay layer intercepts PROXY protocol v2
//! headers before they reach quinn, extracting real client IPs for attribution.
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UdpSocket;
use tokio::task::JoinHandle;
use arc_swap::ArcSwap;
use dashmap::DashMap;
use quinn::{Endpoint, ServerConfig as QuinnServerConfig};
use rustls::ServerConfig as RustlsServerConfig;
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use rustproxy_config::{RouteConfig, TransportProtocol};
use rustproxy_metrics::MetricsCollector;
use rustproxy_routing::{MatchContext, RouteManager};
use rustproxy_http::h3_service::H3ProxyService;
use crate::connection_tracker::ConnectionTracker;
/// Create a QUIC server endpoint on the given port with the provided TLS config.
///
/// The TLS config must have ALPN protocols set (e.g., `h3` for HTTP/3).
pub fn create_quic_endpoint(
port: u16,
tls_config: Arc<RustlsServerConfig>,
) -> anyhow::Result<Endpoint> {
let quic_crypto = quinn::crypto::rustls::QuicServerConfig::try_from(tls_config)
.map_err(|e| anyhow::anyhow!("Failed to create QUIC crypto config: {}", e))?;
let server_config = QuinnServerConfig::with_crypto(Arc::new(quic_crypto));
let socket = std::net::UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], port)))?;
let endpoint = Endpoint::new(
quinn::EndpointConfig::default(),
Some(server_config),
socket,
quinn::default_runtime()
.ok_or_else(|| anyhow::anyhow!("No async runtime for quinn"))?,
)?;
info!("QUIC endpoint listening on port {}", port);
Ok(endpoint)
}
// ===== PROXY protocol relay for QUIC =====
/// Result of creating a QUIC endpoint with a PROXY protocol relay layer.
pub struct QuicProxyRelay {
/// The quinn endpoint (bound to 127.0.0.1:ephemeral).
pub endpoint: Endpoint,
/// The relay recv loop task handle.
pub relay_task: JoinHandle<()>,
/// Maps relay socket local addr → real client SocketAddr (from PROXY v2).
/// Consulted by `quic_accept_loop` to resolve real client IPs.
pub real_client_map: Arc<DashMap<SocketAddr, SocketAddr>>,
}
/// A single relay session for forwarding datagrams between an external source
/// and the internal quinn endpoint.
struct RelaySession {
socket: Arc<UdpSocket>,
last_activity: AtomicU64,
return_task: JoinHandle<()>,
cancel: CancellationToken,
}
impl Drop for RelaySession {
fn drop(&mut self) {
self.cancel.cancel();
self.return_task.abort();
}
}
/// Create a QUIC endpoint with a PROXY protocol v2 relay layer.
///
/// Instead of giving the external socket to quinn, we:
/// 1. Bind a raw UDP socket on 0.0.0.0:port (external)
/// 2. Bind quinn on 127.0.0.1:0 (internal, ephemeral)
/// 3. Run a relay loop that filters PROXY v2 headers and forwards datagrams
///
/// Only used when `proxy_ips` is non-empty.
pub fn create_quic_endpoint_with_proxy_relay(
port: u16,
tls_config: Arc<RustlsServerConfig>,
proxy_ips: Arc<Vec<IpAddr>>,
cancel: CancellationToken,
) -> anyhow::Result<QuicProxyRelay> {
// Bind external socket on the real port
let external_socket = std::net::UdpSocket::bind(SocketAddr::from(([0, 0, 0, 0], port)))?;
external_socket.set_nonblocking(true)?;
let external_socket = Arc::new(
UdpSocket::from_std(external_socket)
.map_err(|e| anyhow::anyhow!("Failed to wrap external socket: {}", e))?,
);
// Bind quinn on localhost ephemeral port
let internal_socket = std::net::UdpSocket::bind("127.0.0.1:0")?;
let quinn_internal_addr = internal_socket.local_addr()?;
let quic_crypto = quinn::crypto::rustls::QuicServerConfig::try_from(tls_config)
.map_err(|e| anyhow::anyhow!("Failed to create QUIC crypto config: {}", e))?;
let server_config = QuinnServerConfig::with_crypto(Arc::new(quic_crypto));
let endpoint = Endpoint::new(
quinn::EndpointConfig::default(),
Some(server_config),
internal_socket,
quinn::default_runtime()
.ok_or_else(|| anyhow::anyhow!("No async runtime for quinn"))?,
)?;
let real_client_map = Arc::new(DashMap::new());
let relay_task = tokio::spawn(quic_proxy_relay_loop(
external_socket,
quinn_internal_addr,
proxy_ips,
Arc::clone(&real_client_map),
cancel,
));
info!("QUIC endpoint with PROXY relay on port {} (quinn internal: {})", port, quinn_internal_addr);
Ok(QuicProxyRelay { endpoint, relay_task, real_client_map })
}
/// Main relay loop: reads datagrams from the external socket, filters PROXY v2
/// headers from trusted proxy IPs, and forwards everything else to quinn via
/// per-session relay sockets.
async fn quic_proxy_relay_loop(
external_socket: Arc<UdpSocket>,
quinn_internal_addr: SocketAddr,
proxy_ips: Arc<Vec<IpAddr>>,
real_client_map: Arc<DashMap<SocketAddr, SocketAddr>>,
cancel: CancellationToken,
) {
// Maps external source addr → real client addr (from PROXY v2 headers)
let proxy_addr_map: DashMap<SocketAddr, SocketAddr> = DashMap::new();
// Maps external source addr → relay session
let relay_sessions: DashMap<SocketAddr, Arc<RelaySession>> = DashMap::new();
let epoch = Instant::now();
let mut buf = vec![0u8; 65535];
// Inline cleanup: periodically scan relay_sessions for stale entries
let mut last_cleanup = Instant::now();
let cleanup_interval = std::time::Duration::from_secs(30);
let session_timeout_ms: u64 = 120_000;
loop {
let (len, src_addr) = tokio::select! {
_ = cancel.cancelled() => {
debug!("QUIC proxy relay loop cancelled");
break;
}
result = external_socket.recv_from(&mut buf) => {
match result {
Ok(r) => r,
Err(e) => {
warn!("QUIC proxy relay recv error: {}", e);
continue;
}
}
}
};
let datagram = &buf[..len];
// PROXY v2 handling: only on first datagram from a trusted proxy IP
// (before a relay session exists for this source)
if proxy_ips.contains(&src_addr.ip()) && relay_sessions.get(&src_addr).is_none() {
if crate::proxy_protocol::is_proxy_protocol_v2(datagram) {
match crate::proxy_protocol::parse_v2(datagram) {
Ok((header, _consumed)) => {
debug!("QUIC PROXY v2 from {}: real client {}", src_addr, header.source_addr);
proxy_addr_map.insert(src_addr, header.source_addr);
continue; // consume the PROXY v2 datagram
}
Err(e) => {
debug!("QUIC proxy relay: failed to parse PROXY v2 from {}: {}", src_addr, e);
}
}
}
}
// Determine real client address
let real_client = proxy_addr_map.get(&src_addr)
.map(|r| *r)
.unwrap_or(src_addr);
// Get or create relay session for this external source
let session = match relay_sessions.get(&src_addr) {
Some(s) => {
s.last_activity.store(epoch.elapsed().as_millis() as u64, Ordering::Relaxed);
Arc::clone(s.value())
}
None => {
// Create new relay socket connected to quinn's internal address
let relay_socket = match UdpSocket::bind("127.0.0.1:0").await {
Ok(s) => s,
Err(e) => {
warn!("QUIC relay: failed to bind relay socket: {}", e);
continue;
}
};
if let Err(e) = relay_socket.connect(quinn_internal_addr).await {
warn!("QUIC relay: failed to connect relay socket to {}: {}", quinn_internal_addr, e);
continue;
}
let relay_local_addr = match relay_socket.local_addr() {
Ok(a) => a,
Err(e) => {
warn!("QUIC relay: failed to get relay socket local addr: {}", e);
continue;
}
};
let relay_socket = Arc::new(relay_socket);
// Store the real client mapping for the QUIC accept loop
real_client_map.insert(relay_local_addr, real_client);
// Spawn return-path relay: quinn -> external socket -> original source
let session_cancel = cancel.child_token();
let return_task = tokio::spawn(relay_return_path(
Arc::clone(&relay_socket),
Arc::clone(&external_socket),
src_addr,
session_cancel.child_token(),
));
let session = Arc::new(RelaySession {
socket: relay_socket,
last_activity: AtomicU64::new(epoch.elapsed().as_millis() as u64),
return_task,
cancel: session_cancel,
});
relay_sessions.insert(src_addr, Arc::clone(&session));
debug!("QUIC relay: new session for {} (relay {}), real client {}",
src_addr, relay_local_addr, real_client);
session
}
};
// Forward datagram to quinn via the relay socket
if let Err(e) = session.socket.send(datagram).await {
debug!("QUIC relay: forward error to quinn for {}: {}", src_addr, e);
}
// Periodic cleanup of stale relay sessions
if last_cleanup.elapsed() >= cleanup_interval {
last_cleanup = Instant::now();
let now_ms = epoch.elapsed().as_millis() as u64;
let stale_keys: Vec<SocketAddr> = relay_sessions.iter()
.filter(|entry| {
let age = now_ms.saturating_sub(entry.value().last_activity.load(Ordering::Relaxed));
age > session_timeout_ms
})
.map(|entry| *entry.key())
.collect();
for key in stale_keys {
if let Some((_, session)) = relay_sessions.remove(&key) {
session.cancel.cancel();
session.return_task.abort();
// Clean up real_client_map entry
if let Ok(addr) = session.socket.local_addr() {
real_client_map.remove(&addr);
}
proxy_addr_map.remove(&key);
debug!("QUIC relay: cleaned up stale session for {}", key);
}
}
// Also clean orphaned proxy_addr_map entries (PROXY header received
// but no relay session was ever created, e.g. client never sent data)
let orphaned: Vec<SocketAddr> = proxy_addr_map.iter()
.filter(|entry| relay_sessions.get(entry.key()).is_none())
.map(|entry| *entry.key())
.collect();
for key in orphaned {
proxy_addr_map.remove(&key);
debug!("QUIC relay: cleaned up orphaned proxy_addr_map entry for {}", key);
}
}
}
// Shutdown: cancel all relay sessions
for entry in relay_sessions.iter() {
entry.value().cancel.cancel();
entry.value().return_task.abort();
}
}
/// Return-path relay: receives datagrams from quinn (via the relay socket)
/// and forwards them back to the external client through the external socket.
async fn relay_return_path(
relay_socket: Arc<UdpSocket>,
external_socket: Arc<UdpSocket>,
external_src_addr: SocketAddr,
cancel: CancellationToken,
) {
let mut buf = vec![0u8; 65535];
loop {
let len = tokio::select! {
_ = cancel.cancelled() => break,
result = relay_socket.recv(&mut buf) => {
match result {
Ok(len) => len,
Err(e) => {
debug!("QUIC relay return recv error for {}: {}", external_src_addr, e);
break;
}
}
}
};
if let Err(e) = external_socket.send_to(&buf[..len], external_src_addr).await {
debug!("QUIC relay return send error to {}: {}", external_src_addr, e);
break;
}
}
}
// ===== QUIC accept loop =====
/// Run the QUIC accept loop for a single endpoint.
///
/// Accepts incoming QUIC connections and spawns a task per connection.
/// When `real_client_map` is provided, it is consulted to resolve real client
/// IPs from PROXY protocol v2 headers (relay socket addr → real client addr).
pub async fn quic_accept_loop(
endpoint: Endpoint,
port: u16,
route_manager: Arc<ArcSwap<RouteManager>>,
metrics: Arc<MetricsCollector>,
conn_tracker: Arc<ConnectionTracker>,
cancel: CancellationToken,
h3_service: Option<Arc<H3ProxyService>>,
real_client_map: Option<Arc<DashMap<SocketAddr, SocketAddr>>>,
) {
loop {
let incoming = tokio::select! {
_ = cancel.cancelled() => {
debug!("QUIC accept loop on port {} cancelled", port);
break;
}
incoming = endpoint.accept() => {
match incoming {
Some(conn) => conn,
None => {
debug!("QUIC endpoint on port {} closed", port);
break;
}
}
}
};
let remote_addr = incoming.remote_address();
// Resolve real client IP from PROXY protocol map if available
let real_addr = real_client_map.as_ref()
.and_then(|map| map.get(&remote_addr).map(|r| *r))
.unwrap_or(remote_addr);
let ip = real_addr.ip();
// Per-IP rate limiting
if !conn_tracker.try_accept(&ip) {
debug!("QUIC connection rejected from {} (rate limit)", real_addr);
// Drop `incoming` to refuse the connection
continue;
}
// Route matching (port + client IP, no domain yet — QUIC Initial is encrypted)
let rm = route_manager.load();
let ip_str = ip.to_string();
let ctx = MatchContext {
port,
domain: None,
path: None,
client_ip: Some(&ip_str),
tls_version: None,
headers: None,
is_tls: true,
protocol: Some("quic"),
transport: Some(TransportProtocol::Udp),
};
let route = match rm.find_route(&ctx) {
Some(m) => m.route.clone(),
None => {
debug!("No QUIC route matched for port {} from {}", port, real_addr);
continue;
}
};
conn_tracker.connection_opened(&ip);
let route_id = route.name.clone().or(route.id.clone());
metrics.connection_opened(route_id.as_deref(), Some(&ip_str));
let metrics = Arc::clone(&metrics);
let conn_tracker = Arc::clone(&conn_tracker);
let cancel = cancel.child_token();
let h3_svc = h3_service.clone();
let real_client_addr = if real_addr != remote_addr { Some(real_addr) } else { None };
tokio::spawn(async move {
// RAII guard: ensures metrics/tracker cleanup even on panic
struct QuicConnGuard {
tracker: Arc<ConnectionTracker>,
metrics: Arc<MetricsCollector>,
ip: std::net::IpAddr,
ip_str: String,
route_id: Option<String>,
}
impl Drop for QuicConnGuard {
fn drop(&mut self) {
self.tracker.connection_closed(&self.ip);
self.metrics.connection_closed(self.route_id.as_deref(), Some(&self.ip_str));
}
}
let _guard = QuicConnGuard {
tracker: conn_tracker,
metrics: Arc::clone(&metrics),
ip,
ip_str,
route_id,
};
match handle_quic_connection(incoming, route, port, Arc::clone(&metrics), &cancel, h3_svc, real_client_addr).await {
Ok(()) => debug!("QUIC connection from {} completed", real_addr),
Err(e) => debug!("QUIC connection from {} error: {}", real_addr, e),
}
});
}
// Graceful shutdown: close endpoint and wait for in-flight connections
endpoint.close(quinn::VarInt::from_u32(0), b"server shutting down");
endpoint.wait_idle().await;
info!("QUIC endpoint on port {} shut down", port);
}
/// Handle a single accepted QUIC connection.
async fn handle_quic_connection(
incoming: quinn::Incoming,
route: RouteConfig,
port: u16,
metrics: Arc<MetricsCollector>,
cancel: &CancellationToken,
h3_service: Option<Arc<H3ProxyService>>,
real_client_addr: Option<SocketAddr>,
) -> anyhow::Result<()> {
let connection = incoming.await?;
let effective_addr = real_client_addr.unwrap_or_else(|| connection.remote_address());
debug!("QUIC connection established from {}", effective_addr);
// Check if this route has HTTP/3 enabled
let enable_http3 = route.action.udp.as_ref()
.and_then(|u| u.quic.as_ref())
.and_then(|q| q.enable_http3)
.unwrap_or(false);
if enable_http3 {
if let Some(ref h3_svc) = h3_service {
debug!("HTTP/3 enabled for route {:?}, dispatching to H3ProxyService", route.name);
h3_svc.handle_connection(connection, &route, port, real_client_addr, cancel).await
} else {
warn!("HTTP/3 enabled for route {:?} but H3ProxyService not initialized", route.name);
// Keep connection alive until cancelled
tokio::select! {
_ = cancel.cancelled() => {}
reason = connection.closed() => {
debug!("HTTP/3 connection closed (no service): {}", reason);
}
}
Ok(())
}
} else {
// Non-HTTP3 QUIC: bidirectional stream forwarding to TCP backend
handle_quic_stream_forwarding(connection, route, port, metrics, cancel, real_client_addr).await
}
}
/// Forward QUIC streams bidirectionally to a TCP backend.
///
/// For each accepted bidirectional QUIC stream, connects to the backend
/// via TCP and forwards data in both directions. Quinn's RecvStream/SendStream
/// implement AsyncRead/AsyncWrite, enabling reuse of existing forwarder patterns.
async fn handle_quic_stream_forwarding(
connection: quinn::Connection,
route: RouteConfig,
port: u16,
metrics: Arc<MetricsCollector>,
cancel: &CancellationToken,
real_client_addr: Option<SocketAddr>,
) -> anyhow::Result<()> {
let effective_addr = real_client_addr.unwrap_or_else(|| connection.remote_address());
let route_id = route.name.as_deref().or(route.id.as_deref());
let metrics_arc = metrics;
// Resolve backend target
let target = route.action.targets.as_ref()
.and_then(|t| t.first())
.ok_or_else(|| anyhow::anyhow!("No target for QUIC route"))?;
let backend_host = target.host.first();
let backend_port = target.port.resolve(port);
let backend_addr = format!("{}:{}", backend_host, backend_port);
loop {
let (send_stream, recv_stream) = tokio::select! {
_ = cancel.cancelled() => break,
result = connection.accept_bi() => {
match result {
Ok(streams) => streams,
Err(quinn::ConnectionError::ApplicationClosed(_)) => break,
Err(quinn::ConnectionError::LocallyClosed) => break,
Err(e) => {
debug!("QUIC stream accept error from {}: {}", effective_addr, e);
break;
}
}
}
};
let backend_addr = backend_addr.clone();
let ip_str = effective_addr.ip().to_string();
let stream_metrics = Arc::clone(&metrics_arc);
let stream_route_id = route_id.map(|s| s.to_string());
let stream_cancel = cancel.child_token();
// Spawn a task for each QUIC stream → TCP bidirectional forwarding
tokio::spawn(async move {
match forward_quic_stream_to_tcp(
send_stream,
recv_stream,
&backend_addr,
stream_cancel,
).await {
Ok((bytes_in, bytes_out)) => {
stream_metrics.record_bytes(
bytes_in, bytes_out,
stream_route_id.as_deref(),
Some(&ip_str),
);
debug!("QUIC stream forwarded: {}B in, {}B out", bytes_in, bytes_out);
}
Err(e) => {
debug!("QUIC stream forwarding error: {}", e);
}
}
});
}
Ok(())
}
/// Forward a single QUIC bidirectional stream to a TCP backend connection.
///
/// Includes inactivity timeout (60s), max lifetime (10min), and cancellation
/// to prevent leaked stream tasks when the parent connection closes.
async fn forward_quic_stream_to_tcp(
mut quic_send: quinn::SendStream,
mut quic_recv: quinn::RecvStream,
backend_addr: &str,
cancel: CancellationToken,
) -> anyhow::Result<(u64, u64)> {
let inactivity_timeout = std::time::Duration::from_secs(60);
let max_lifetime = std::time::Duration::from_secs(600);
// Connect to backend TCP
let tcp_stream = tokio::net::TcpStream::connect(backend_addr).await?;
let (mut tcp_read, mut tcp_write) = tcp_stream.into_split();
let last_activity = Arc::new(AtomicU64::new(0));
let start = std::time::Instant::now();
let conn_cancel = CancellationToken::new();
let la1 = Arc::clone(&last_activity);
let cc1 = conn_cancel.clone();
let c2b = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
let mut total = 0u64;
loop {
let n = tokio::select! {
result = quic_recv.read(&mut buf) => match result {
Ok(Some(0)) | Ok(None) | Err(_) => break,
Ok(Some(n)) => n,
},
_ = cc1.cancelled() => break,
};
if tcp_write.write_all(&buf[..n]).await.is_err() {
break;
}
total += n as u64;
la1.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
}
let _ = tokio::time::timeout(
std::time::Duration::from_secs(2),
tcp_write.shutdown(),
).await;
total
});
let la2 = Arc::clone(&last_activity);
let cc2 = conn_cancel.clone();
let b2c = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
let mut total = 0u64;
loop {
let n = tokio::select! {
result = tcp_read.read(&mut buf) => match result {
Ok(0) | Err(_) => break,
Ok(n) => n,
},
_ = cc2.cancelled() => break,
};
// quinn SendStream implements AsyncWrite
if quic_send.write_all(&buf[..n]).await.is_err() {
break;
}
total += n as u64;
la2.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
}
let _ = quic_send.finish();
total
});
// Watchdog: inactivity, max lifetime, and cancellation
let la_watch = Arc::clone(&last_activity);
let c2b_abort = c2b.abort_handle();
let b2c_abort = b2c.abort_handle();
let watchdog = tokio::spawn(async move {
let check_interval = std::time::Duration::from_secs(5);
let mut last_seen = 0u64;
loop {
tokio::select! {
_ = cancel.cancelled() => break,
_ = tokio::time::sleep(check_interval) => {
if start.elapsed() >= max_lifetime {
debug!("QUIC stream exceeded max lifetime, closing");
break;
}
let current = la_watch.load(Ordering::Relaxed);
if current == last_seen {
let elapsed = start.elapsed().as_millis() as u64 - current;
if elapsed >= inactivity_timeout.as_millis() as u64 {
debug!("QUIC stream inactive for {}ms, closing", elapsed);
break;
}
}
last_seen = current;
}
}
}
conn_cancel.cancel();
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
c2b_abort.abort();
b2c_abort.abort();
});
let bytes_in = c2b.await.unwrap_or(0);
let bytes_out = b2c.await.unwrap_or(0);
watchdog.abort();
Ok((bytes_in, bytes_out))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_quic_endpoint_requires_tls_config() {
// Install the ring crypto provider for tests
let _ = rustls::crypto::ring::default_provider().install_default();
// Generate a single self-signed cert and use its key pair
let self_signed = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
.unwrap();
let cert_der = self_signed.cert.der().clone();
let key_der = self_signed.key_pair.serialize_der();
let mut tls_config = RustlsServerConfig::builder()
.with_no_client_auth()
.with_single_cert(
vec![cert_der.into()],
rustls::pki_types::PrivateKeyDer::try_from(key_der).unwrap(),
)
.unwrap();
tls_config.alpn_protocols = vec![b"h3".to_vec()];
// Port 0 = OS assigns a free port
let result = create_quic_endpoint(0, Arc::new(tls_config));
assert!(result.is_ok(), "QUIC endpoint creation failed: {:?}", result.err());
}
}

View File

@@ -196,6 +196,7 @@ pub fn is_http(data: &[u8]) -> bool {
b"PATC", b"PATC",
b"OPTI", b"OPTI",
b"CONN", b"CONN",
b"PRI ", // HTTP/2 connection preface
]; ];
starts.iter().any(|s| data.starts_with(s)) starts.iter().any(|s| data.starts_with(s))
} }

View File

@@ -0,0 +1,19 @@
//! Socket-level options for TCP streams (keepalive, etc.).
//!
//! Uses `socket2::SockRef::from()` to borrow the raw fd without ownership transfer.
use std::io;
use std::time::Duration;
use tokio::net::TcpStream;
/// Apply TCP keepalive to a connected socket.
///
/// Enables SO_KEEPALIVE and sets the initial probe delay.
/// On Linux, also sets the interval between probes to the same value.
pub fn apply_keepalive(stream: &TcpStream, delay: Duration) -> io::Result<()> {
let sock_ref = socket2::SockRef::from(stream);
let ka = socket2::TcpKeepalive::new().with_time(delay);
#[cfg(target_os = "linux")]
let ka = ka.with_interval(delay);
sock_ref.set_tcp_keepalive(&ka)
}

View File

@@ -1,126 +1,4 @@
//! Socket handler relay for connecting client connections to a TypeScript handler //! Socket handler relay module.
//! via a Unix domain socket.
//! //!
//! Protocol: Send a JSON metadata line terminated by `\n`, then bidirectional relay. //! Note: The actual relay logic lives in `tcp_listener::relay_to_socket_handler()`
//! which has proper timeouts, cancellation, and metrics integration.
use tokio::net::UnixStream;
use tokio::io::{AsyncWriteExt, AsyncReadExt};
use tokio::net::TcpStream;
use serde::Serialize;
use tracing::debug;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RelayMetadata {
connection_id: u64,
remote_ip: String,
remote_port: u16,
local_port: u16,
sni: Option<String>,
route_name: String,
initial_data_base64: Option<String>,
}
/// Relay a client connection to a TypeScript handler via Unix domain socket.
///
/// Protocol: Send a JSON metadata line terminated by `\n`, then bidirectional relay.
pub async fn relay_to_handler(
client: TcpStream,
relay_socket_path: &str,
connection_id: u64,
remote_ip: String,
remote_port: u16,
local_port: u16,
sni: Option<String>,
route_name: String,
initial_data: Option<&[u8]>,
) -> std::io::Result<()> {
debug!(
"Relaying connection {} to handler socket {}",
connection_id, relay_socket_path
);
// Connect to TypeScript handler Unix socket
let mut handler = UnixStream::connect(relay_socket_path).await?;
// Build and send metadata header
let initial_data_base64 = initial_data.map(base64_encode);
let metadata = RelayMetadata {
connection_id,
remote_ip,
remote_port,
local_port,
sni,
route_name,
initial_data_base64,
};
let metadata_json = serde_json::to_string(&metadata)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
handler.write_all(metadata_json.as_bytes()).await?;
handler.write_all(b"\n").await?;
// Bidirectional relay between client and handler
let (mut client_read, mut client_write) = client.into_split();
let (mut handler_read, mut handler_write) = handler.into_split();
let c2h = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
let n = match client_read.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
if handler_write.write_all(&buf[..n]).await.is_err() {
break;
}
}
let _ = handler_write.shutdown().await;
});
let h2c = tokio::spawn(async move {
let mut buf = vec![0u8; 65536];
loop {
let n = match handler_read.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
if client_write.write_all(&buf[..n]).await.is_err() {
break;
}
}
let _ = client_write.shutdown().await;
});
let _ = tokio::join!(c2h, h2c);
debug!("Relay connection {} completed", connection_id);
Ok(())
}
/// Simple base64 encoding without external dependency.
fn base64_encode(data: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut result = String::new();
for chunk in data.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let n = (b0 << 16) | (b1 << 8) | b2;
result.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
result.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
if chunk.len() > 1 {
result.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
} else {
result.push('=');
}
if chunk.len() > 2 {
result.push(CHARS[(n & 0x3F) as usize] as char);
} else {
result.push('=');
}
}
result
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,121 @@
use std::collections::HashMap;
use std::io::BufReader; use std::io::BufReader;
use std::sync::Arc; use std::sync::{Arc, OnceLock};
use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::server::ResolvesServerCert;
use rustls::sign::CertifiedKey;
use rustls::ServerConfig; use rustls::ServerConfig;
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio_rustls::{TlsAcceptor, TlsConnector, server::TlsStream as ServerTlsStream}; use tokio_rustls::{TlsAcceptor, TlsConnector, server::TlsStream as ServerTlsStream};
use tracing::debug; use tracing::{debug, info};
use crate::tcp_listener::TlsCertConfig;
/// Ensure the default crypto provider is installed. /// Ensure the default crypto provider is installed.
fn ensure_crypto_provider() { fn ensure_crypto_provider() {
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
} }
/// SNI-based certificate resolver with pre-parsed CertifiedKeys.
/// Enables shared ServerConfig across connections — avoids per-connection PEM parsing
/// and enables TLS session resumption.
#[derive(Debug)]
pub struct CertResolver {
certs: HashMap<String, Arc<CertifiedKey>>,
fallback: Option<Arc<CertifiedKey>>,
}
impl CertResolver {
/// Build a resolver from PEM-encoded cert/key configs.
/// Parses all PEM data upfront so connections only do a cheap HashMap lookup.
pub fn new(configs: &HashMap<String, TlsCertConfig>) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
ensure_crypto_provider();
let provider = rustls::crypto::ring::default_provider();
let mut certs = HashMap::new();
let mut fallback = None;
for (domain, cfg) in configs {
let cert_chain = load_certs(&cfg.cert_pem)?;
let key = load_private_key(&cfg.key_pem)?;
let ck = Arc::new(CertifiedKey::from_der(cert_chain, key, &provider)
.map_err(|e| format!("CertifiedKey for {}: {}", domain, e))?);
if domain == "*" {
fallback = Some(Arc::clone(&ck));
}
certs.insert(domain.clone(), ck);
}
// If no explicit "*" fallback, use the first available cert
if fallback.is_none() {
fallback = certs.values().next().map(Arc::clone);
}
Ok(Self { certs, fallback })
}
}
impl ResolvesServerCert for CertResolver {
fn resolve(&self, client_hello: rustls::server::ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
let domain = match client_hello.server_name() {
Some(name) => name,
None => return self.fallback.clone(),
};
// Exact match
if let Some(ck) = self.certs.get(domain) {
return Some(Arc::clone(ck));
}
// Wildcard: sub.example.com → *.example.com
if let Some(dot) = domain.find('.') {
let wc = format!("*.{}", &domain[dot + 1..]);
if let Some(ck) = self.certs.get(&wc) {
return Some(Arc::clone(ck));
}
}
self.fallback.clone()
}
}
/// Build a shared TLS acceptor with SNI resolution, session cache, and session tickets.
/// The returned acceptor can be reused across all connections (cheap Arc clone).
pub fn build_shared_tls_acceptor(resolver: CertResolver) -> Result<TlsAcceptor, Box<dyn std::error::Error + Send + Sync>> {
ensure_crypto_provider();
let mut config = ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(Arc::new(resolver));
// ALPN: advertise h2 and http/1.1 for client-facing HTTP/2 support
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
// Shared session cache — enables session ID resumption across connections
config.session_storage = rustls::server::ServerSessionMemoryCache::new(4096);
// Session ticket resumption (12-hour lifetime, Chacha20Poly1305 encrypted)
config.ticketer = rustls::crypto::ring::Ticketer::new()
.map_err(|e| format!("Ticketer: {}", e))?;
info!("Built shared TLS config with session cache (4096), ticket support, and ALPN h2+http/1.1");
Ok(TlsAcceptor::from(Arc::new(config)))
}
/// Build a TLS acceptor from PEM-encoded cert and key data. /// Build a TLS acceptor from PEM-encoded cert and key data.
/// Advertises both h2 and http/1.1 via ALPN (for client-facing connections).
pub fn build_tls_acceptor(cert_pem: &str, key_pem: &str) -> Result<TlsAcceptor, Box<dyn std::error::Error + Send + Sync>> { pub fn build_tls_acceptor(cert_pem: &str, key_pem: &str) -> Result<TlsAcceptor, Box<dyn std::error::Error + Send + Sync>> {
build_tls_acceptor_with_config(cert_pem, key_pem, None) build_tls_acceptor_with_config(cert_pem, key_pem, None)
} }
/// Build a TLS acceptor for backend servers that only speak HTTP/1.1.
/// Does NOT advertise h2 in ALPN, preventing false h2 auto-detection.
pub fn build_tls_acceptor_h1_only(cert_pem: &str, key_pem: &str) -> Result<TlsAcceptor, Box<dyn std::error::Error + Send + Sync>> {
ensure_crypto_provider();
let certs = load_certs(cert_pem)?;
let key = load_private_key(key_pem)?;
let mut config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(TlsAcceptor::from(Arc::new(config)))
}
/// Build a TLS acceptor with optional RouteTls configuration for version/cipher tuning. /// Build a TLS acceptor with optional RouteTls configuration for version/cipher tuning.
pub fn build_tls_acceptor_with_config( pub fn build_tls_acceptor_with_config(
cert_pem: &str, cert_pem: &str,
@@ -40,6 +139,9 @@ pub fn build_tls_acceptor_with_config(
.with_single_cert(certs, key)? .with_single_cert(certs, key)?
}; };
// ALPN: advertise h2 and http/1.1 for client-facing HTTP/2 support
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
// Apply session timeout if configured // Apply session timeout if configured
if let Some(route_tls) = tls_config { if let Some(route_tls) = tls_config {
if let Some(timeout_secs) = route_tls.session_timeout { if let Some(timeout_secs) = route_tls.session_timeout {
@@ -97,21 +199,59 @@ pub async fn accept_tls(
Ok(tls_stream) Ok(tls_stream)
} }
/// Get or create a shared backend TLS `ClientConfig`.
///
/// Uses `OnceLock` to ensure only one config is created across the entire process.
/// The built-in rustls `Resumption` (session tickets + session IDs) is enabled
/// by default, so all outbound backend connections share the same session cache.
static SHARED_CLIENT_CONFIG: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
pub fn shared_backend_tls_config() -> Arc<rustls::ClientConfig> {
SHARED_CLIENT_CONFIG.get_or_init(|| {
ensure_crypto_provider();
let config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(InsecureVerifier))
.with_no_client_auth();
info!("Built shared backend TLS client config with session resumption");
Arc::new(config)
}).clone()
}
/// Get or create a shared backend TLS `ClientConfig` with ALPN `h2` + `http/1.1`.
///
/// Used for auto-detection mode: the backend server picks its preferred protocol
/// via ALPN, and the proxy reads the negotiated result to decide h1 vs h2 forwarding.
static SHARED_CLIENT_CONFIG_ALPN: OnceLock<Arc<rustls::ClientConfig>> = OnceLock::new();
pub fn shared_backend_tls_config_alpn() -> Arc<rustls::ClientConfig> {
SHARED_CLIENT_CONFIG_ALPN.get_or_init(|| {
ensure_crypto_provider();
let mut config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(InsecureVerifier))
.with_no_client_auth();
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
info!("Built shared backend TLS client config with ALPN h2+http/1.1 for auto-detection");
Arc::new(config)
}).clone()
}
/// Connect to a backend with TLS (for terminate-and-reencrypt mode). /// Connect to a backend with TLS (for terminate-and-reencrypt mode).
/// Uses the shared backend TLS config for session resumption.
pub async fn connect_tls( pub async fn connect_tls(
host: &str, host: &str,
port: u16, port: u16,
) -> Result<tokio_rustls::client::TlsStream<TcpStream>, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<tokio_rustls::client::TlsStream<TcpStream>, Box<dyn std::error::Error + Send + Sync>> {
ensure_crypto_provider(); let config = shared_backend_tls_config();
let config = rustls::ClientConfig::builder() let connector = TlsConnector::from(config);
.dangerous()
.with_custom_certificate_verifier(Arc::new(InsecureVerifier))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let stream = TcpStream::connect(format!("{}:{}", host, port)).await?; let stream = TcpStream::connect(format!("{}:{}", host, port)).await?;
stream.set_nodelay(true)?; stream.set_nodelay(true)?;
// Apply keepalive with 60s default (tls_handler doesn't have ConnectionConfig access)
if let Err(e) = crate::socket_opts::apply_keepalive(&stream, std::time::Duration::from_secs(60)) {
debug!("Failed to set keepalive on backend TLS socket: {}", e);
}
let server_name = rustls::pki_types::ServerName::try_from(host.to_string())?; let server_name = rustls::pki_types::ServerName::try_from(host.to_string())?;
let tls_stream = connector.connect(server_name, stream).await?; let tls_stream = connector.connect(server_name, stream).await?;

View File

@@ -0,0 +1,907 @@
//! UDP listener manager.
//!
//! Binds UDP sockets on configured ports, receives datagrams, matches routes,
//! tracks sessions (flows), and forwards datagrams to backend UDP sockets.
//!
//! Supports PROXY protocol v2 on both raw UDP and QUIC paths when `proxy_ips`
//! is configured. For QUIC, a relay layer intercepts datagrams before they
//! reach the quinn endpoint.
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::Ordering;
use std::sync::Arc;
use dashmap::DashMap;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use arc_swap::ArcSwap;
use tokio::net::UdpSocket;
use tokio::task::JoinHandle;
use tokio::sync::{Mutex, RwLock};
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use rustproxy_config::{RouteActionType, TransportProtocol};
use rustproxy_metrics::MetricsCollector;
use rustproxy_routing::{MatchContext, RouteManager};
use rustproxy_http::h3_service::H3ProxyService;
use crate::connection_tracker::ConnectionTracker;
use crate::udp_session::{SessionKey, UdpSession, UdpSessionConfig, UdpSessionTable};
/// Manages UDP listeners across all configured ports.
pub struct UdpListenerManager {
/// Port → (recv loop task handle, optional QUIC endpoint for TLS updates)
listeners: HashMap<u16, (JoinHandle<()>, Option<quinn::Endpoint>)>,
/// Hot-reloadable route table
route_manager: Arc<ArcSwap<RouteManager>>,
/// Shared metrics collector
metrics: Arc<MetricsCollector>,
/// Per-IP session/rate limiting (shared with TCP)
conn_tracker: Arc<ConnectionTracker>,
/// Shared session table across all ports
session_table: Arc<UdpSessionTable>,
/// Cancellation for graceful shutdown
cancel_token: CancellationToken,
/// Unix socket path for datagram handler relay
datagram_handler_relay: Arc<RwLock<Option<String>>>,
/// Persistent write half of the relay connection
relay_writer: Arc<Mutex<Option<tokio::net::unix::OwnedWriteHalf>>>,
/// Cancel token for the current relay reply reader task
relay_reader_cancel: Option<CancellationToken>,
/// H3 proxy service for HTTP/3 request handling
h3_service: Option<Arc<H3ProxyService>>,
/// Trusted proxy IPs that may send PROXY protocol v2 headers.
/// When non-empty, PROXY v2 detection is enabled on both raw UDP and QUIC paths.
proxy_ips: Arc<Vec<IpAddr>>,
}
impl Drop for UdpListenerManager {
fn drop(&mut self) {
self.cancel_token.cancel();
for (_, (handle, endpoint)) in self.listeners.drain() {
handle.abort();
if let Some(ep) = endpoint {
ep.close(quinn::VarInt::from_u32(0), b"shutdown");
}
}
}
}
impl UdpListenerManager {
pub fn new(
route_manager: Arc<RouteManager>,
metrics: Arc<MetricsCollector>,
conn_tracker: Arc<ConnectionTracker>,
cancel_token: CancellationToken,
) -> Self {
Self {
listeners: HashMap::new(),
route_manager: Arc::new(ArcSwap::from(route_manager)),
metrics,
conn_tracker,
session_table: Arc::new(UdpSessionTable::new()),
cancel_token,
datagram_handler_relay: Arc::new(RwLock::new(None)),
relay_writer: Arc::new(Mutex::new(None)),
relay_reader_cancel: None,
h3_service: None,
proxy_ips: Arc::new(Vec::new()),
}
}
/// Set the trusted proxy IPs for PROXY protocol v2 detection.
pub fn set_proxy_ips(&mut self, ips: Vec<IpAddr>) {
if !ips.is_empty() {
info!("UDP/QUIC PROXY protocol v2 enabled for {} trusted IPs", ips.len());
}
self.proxy_ips = Arc::new(ips);
}
/// Set the H3 proxy service for HTTP/3 request handling.
pub fn set_h3_service(&mut self, svc: Arc<H3ProxyService>) {
self.h3_service = Some(svc);
}
/// Update the route manager (for hot-reload).
pub fn update_routes(&self, route_manager: Arc<RouteManager>) {
self.route_manager.store(route_manager);
}
/// Start listening on a UDP port.
///
/// If any route on this port has QUIC config (`action.udp.quic`), a quinn
/// endpoint is created instead of a raw UDP socket.
pub async fn add_port(&mut self, port: u16) -> anyhow::Result<()> {
self.add_port_with_tls(port, None).await
}
/// Start listening on a UDP port with optional TLS config for QUIC.
pub async fn add_port_with_tls(
&mut self,
port: u16,
tls_config: Option<std::sync::Arc<rustls::ServerConfig>>,
) -> anyhow::Result<()> {
if self.listeners.contains_key(&port) {
debug!("UDP port {} already listening", port);
return Ok(());
}
// Check if any route on this port uses QUIC
let rm = self.route_manager.load();
let has_quic = rm.routes_for_port(port).iter().any(|r| {
r.action.udp.as_ref()
.and_then(|u| u.quic.as_ref())
.is_some()
});
if has_quic {
if let Some(tls) = tls_config {
if self.proxy_ips.is_empty() {
// Direct path: quinn owns the external socket (zero overhead)
let endpoint = crate::quic_handler::create_quic_endpoint(port, tls)?;
let endpoint_for_updates = endpoint.clone();
let handle = tokio::spawn(crate::quic_handler::quic_accept_loop(
endpoint,
port,
Arc::clone(&self.route_manager),
Arc::clone(&self.metrics),
Arc::clone(&self.conn_tracker),
self.cancel_token.child_token(),
self.h3_service.clone(),
None,
));
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
info!("QUIC endpoint started on port {}", port);
} else {
// Proxy relay path: we own external socket, quinn on localhost
let relay = crate::quic_handler::create_quic_endpoint_with_proxy_relay(
port,
tls,
Arc::clone(&self.proxy_ips),
self.cancel_token.child_token(),
)?;
let endpoint_for_updates = relay.endpoint.clone();
let handle = tokio::spawn(crate::quic_handler::quic_accept_loop(
relay.endpoint,
port,
Arc::clone(&self.route_manager),
Arc::clone(&self.metrics),
Arc::clone(&self.conn_tracker),
self.cancel_token.child_token(),
self.h3_service.clone(),
Some(relay.real_client_map),
));
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
info!("QUIC endpoint with PROXY relay started on port {}", port);
}
return Ok(());
} else {
warn!("QUIC routes on port {} but no TLS config provided, falling back to raw UDP", port);
}
}
// Raw UDP listener
let addr: SocketAddr = ([0, 0, 0, 0], port).into();
let socket = UdpSocket::bind(addr).await?;
let socket = Arc::new(socket);
info!("UDP listener bound on port {}", port);
let handle = tokio::spawn(Self::recv_loop(
socket,
port,
Arc::clone(&self.route_manager),
Arc::clone(&self.metrics),
Arc::clone(&self.conn_tracker),
Arc::clone(&self.session_table),
Arc::clone(&self.datagram_handler_relay),
Arc::clone(&self.relay_writer),
self.cancel_token.child_token(),
Arc::clone(&self.proxy_ips),
));
self.listeners.insert(port, (handle, None));
// Start the session cleanup task if this is the first port
if self.listeners.len() == 1 {
self.start_cleanup_task();
}
Ok(())
}
/// Stop listening on a UDP port.
pub fn remove_port(&mut self, port: u16) {
if let Some((handle, endpoint)) = self.listeners.remove(&port) {
handle.abort();
if let Some(ep) = endpoint {
ep.close(quinn::VarInt::from_u32(0), b"port removed");
}
info!("UDP listener removed from port {}", port);
}
}
/// Get all listening UDP ports.
pub fn listening_ports(&self) -> Vec<u16> {
let mut ports: Vec<u16> = self.listeners.keys().copied().collect();
ports.sort();
ports
}
/// Stop all listeners and clean up.
pub async fn stop(&mut self) {
self.cancel_token.cancel();
for (port, (handle, endpoint)) in self.listeners.drain() {
handle.abort();
if let Some(ep) = endpoint {
ep.close(quinn::VarInt::from_u32(0), b"shutdown");
}
debug!("UDP listener stopped on port {}", port);
}
info!("All UDP listeners stopped, {} sessions remaining",
self.session_table.session_count());
}
/// Update TLS config on all active QUIC endpoints (cert refresh).
/// Only affects new incoming connections — existing connections are undisturbed.
/// Uses quinn's Endpoint::set_server_config() for zero-downtime hot-swap.
pub fn update_quic_tls(&self, tls_config: Arc<rustls::ServerConfig>) {
for (port, (_handle, endpoint)) in &self.listeners {
if let Some(ep) = endpoint {
match quinn::crypto::rustls::QuicServerConfig::try_from(Arc::clone(&tls_config)) {
Ok(quic_crypto) => {
let server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_crypto));
ep.set_server_config(Some(server_config));
info!("Updated QUIC TLS config on port {}", port);
}
Err(e) => {
warn!("Failed to update QUIC TLS config on port {}: {}", port, e);
}
}
}
}
}
/// Upgrade raw UDP fallback listeners to QUIC endpoints.
///
/// At startup, if no TLS certs are available, QUIC routes fall back to raw UDP.
/// When certs become available later (via loadCertificate IPC or ACME), this method
/// stops the raw UDP listener, drains sessions, and creates a proper QUIC endpoint.
///
/// This is idempotent — ports that already have QUIC endpoints are skipped.
pub async fn upgrade_raw_to_quic(&mut self, tls_config: Arc<rustls::ServerConfig>) {
// Find ports that are raw UDP fallback (endpoint=None) but have QUIC routes
let rm = self.route_manager.load();
let upgrade_ports: Vec<u16> = self.listeners.iter()
.filter(|(_, (_, endpoint))| endpoint.is_none())
.filter(|(port, _)| {
rm.routes_for_port(**port).iter().any(|r| {
r.action.udp.as_ref()
.and_then(|u| u.quic.as_ref())
.is_some()
})
})
.map(|(port, _)| *port)
.collect();
for port in upgrade_ports {
info!("Upgrading raw UDP listener on port {} to QUIC endpoint", port);
// Stop the raw UDP listener task and drain sessions to release the socket
if let Some((handle, _)) = self.listeners.remove(&port) {
handle.abort();
}
let drained = self.session_table.drain_port(
port, &self.metrics, &self.conn_tracker,
);
if drained > 0 {
debug!("Drained {} UDP sessions on port {} for QUIC upgrade", drained, port);
}
// Brief yield to let aborted tasks drop their socket references
tokio::task::yield_now().await;
// Create QUIC endpoint on the now-free port
let create_result = if self.proxy_ips.is_empty() {
self.create_quic_direct(port, Arc::clone(&tls_config))
} else {
self.create_quic_with_relay(port, Arc::clone(&tls_config))
};
match create_result {
Ok(()) => {
info!("QUIC endpoint started on port {} (upgraded from raw UDP)", port);
}
Err(e) => {
// Port may still be held — retry once after a brief delay
warn!("QUIC endpoint creation failed on port {}, retrying: {}", port, e);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let retry_result = if self.proxy_ips.is_empty() {
self.create_quic_direct(port, Arc::clone(&tls_config))
} else {
self.create_quic_with_relay(port, Arc::clone(&tls_config))
};
match retry_result {
Ok(()) => {
info!("QUIC endpoint started on port {} (upgraded from raw UDP, retry)", port);
}
Err(e2) => {
error!("Failed to upgrade port {} to QUIC after retry: {}. \
Rebinding as raw UDP.", port, e2);
// Fallback: rebind as raw UDP so the port isn't dead
if let Ok(()) = self.rebind_raw_udp(port).await {
warn!("Port {} rebound as raw UDP (QUIC upgrade failed)", port);
}
}
}
}
}
}
}
/// Create a direct QUIC endpoint (quinn owns the socket).
fn create_quic_direct(&mut self, port: u16, tls_config: Arc<rustls::ServerConfig>) -> anyhow::Result<()> {
let endpoint = crate::quic_handler::create_quic_endpoint(port, tls_config)?;
let endpoint_for_updates = endpoint.clone();
let handle = tokio::spawn(crate::quic_handler::quic_accept_loop(
endpoint,
port,
Arc::clone(&self.route_manager),
Arc::clone(&self.metrics),
Arc::clone(&self.conn_tracker),
self.cancel_token.child_token(),
self.h3_service.clone(),
None,
));
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
Ok(())
}
/// Create a QUIC endpoint with PROXY protocol relay.
fn create_quic_with_relay(&mut self, port: u16, tls_config: Arc<rustls::ServerConfig>) -> anyhow::Result<()> {
let relay = crate::quic_handler::create_quic_endpoint_with_proxy_relay(
port,
tls_config,
Arc::clone(&self.proxy_ips),
self.cancel_token.child_token(),
)?;
let endpoint_for_updates = relay.endpoint.clone();
let handle = tokio::spawn(crate::quic_handler::quic_accept_loop(
relay.endpoint,
port,
Arc::clone(&self.route_manager),
Arc::clone(&self.metrics),
Arc::clone(&self.conn_tracker),
self.cancel_token.child_token(),
self.h3_service.clone(),
Some(relay.real_client_map),
));
self.listeners.insert(port, (handle, Some(endpoint_for_updates)));
Ok(())
}
/// Rebind a port as a raw UDP listener (fallback when QUIC upgrade fails).
async fn rebind_raw_udp(&mut self, port: u16) -> anyhow::Result<()> {
let addr: std::net::SocketAddr = ([0, 0, 0, 0], port).into();
let socket = UdpSocket::bind(addr).await?;
let socket = Arc::new(socket);
let handle = tokio::spawn(Self::recv_loop(
socket,
port,
Arc::clone(&self.route_manager),
Arc::clone(&self.metrics),
Arc::clone(&self.conn_tracker),
Arc::clone(&self.session_table),
Arc::clone(&self.datagram_handler_relay),
Arc::clone(&self.relay_writer),
self.cancel_token.child_token(),
Arc::clone(&self.proxy_ips),
));
self.listeners.insert(port, (handle, None));
Ok(())
}
/// Set the datagram handler relay socket path and establish connection.
pub async fn set_datagram_handler_relay(&mut self, path: String) {
// Cancel previous relay reader task if any
if let Some(old_cancel) = self.relay_reader_cancel.take() {
old_cancel.cancel();
}
// Store the path
{
let mut relay = self.datagram_handler_relay.write().await;
*relay = Some(path.clone());
}
// Connect to the Unix socket
match tokio::net::UnixStream::connect(&path).await {
Ok(stream) => {
let (read_half, write_half) = stream.into_split();
// Store write half for sending datagrams
{
let mut writer = self.relay_writer.lock().await;
*writer = Some(write_half);
}
// Spawn reply reader — reads length-prefixed JSON replies from TS
// and sends them back to clients via the listener sockets
let cancel = self.cancel_token.child_token();
self.relay_reader_cancel = Some(cancel.clone());
tokio::spawn(Self::relay_reply_reader(read_half, cancel));
info!("Datagram handler relay connected to {}", path);
}
Err(e) => {
error!("Failed to connect datagram handler relay to {}: {}", path, e);
}
}
}
/// Start periodic session cleanup task.
fn start_cleanup_task(&self) {
let session_table = Arc::clone(&self.session_table);
let metrics = Arc::clone(&self.metrics);
let conn_tracker = Arc::clone(&self.conn_tracker);
let cancel = self.cancel_token.child_token();
let route_manager = Arc::clone(&self.route_manager);
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
loop {
tokio::select! {
_ = cancel.cancelled() => break,
_ = interval.tick() => {
// Determine the timeout from routes (use the minimum configured timeout,
// or default 60s if none configured)
let rm = route_manager.load();
let timeout_ms = Self::get_min_session_timeout(&rm);
let removed = session_table.cleanup_idle(timeout_ms, &metrics, &conn_tracker);
if removed > 0 {
debug!("UDP session cleanup: removed {} idle sessions, {} remaining",
removed, session_table.session_count());
}
}
}
}
});
}
/// Get the minimum session timeout across all UDP routes.
fn get_min_session_timeout(_rm: &RouteManager) -> u64 {
// Default to 60 seconds; actual per-route timeouts checked during cleanup
60_000
}
/// Main receive loop for a UDP port.
///
/// When `proxy_ips` is non-empty, the first datagram from a trusted proxy IP
/// is checked for PROXY protocol v2. If found, the real client IP is extracted
/// and used for all subsequent session handling for that source address.
async fn recv_loop(
socket: Arc<UdpSocket>,
port: u16,
route_manager: Arc<ArcSwap<RouteManager>>,
metrics: Arc<MetricsCollector>,
conn_tracker: Arc<ConnectionTracker>,
session_table: Arc<UdpSessionTable>,
_datagram_handler_relay: Arc<RwLock<Option<String>>>,
relay_writer: Arc<Mutex<Option<tokio::net::unix::OwnedWriteHalf>>>,
cancel: CancellationToken,
proxy_ips: Arc<Vec<IpAddr>>,
) {
// Use a reasonably large buffer; actual max is per-route but we need a single buffer
let mut buf = vec![0u8; 65535];
// Maps proxy source addr → real client addr (from PROXY v2 headers).
// Only populated when proxy_ips is non-empty.
let proxy_addr_map: DashMap<SocketAddr, SocketAddr> = DashMap::new();
// Periodic cleanup for proxy_addr_map to prevent unbounded growth
let mut last_proxy_cleanup = tokio::time::Instant::now();
let proxy_cleanup_interval = std::time::Duration::from_secs(60);
loop {
// Periodic cleanup: remove proxy_addr_map entries with no active session
if !proxy_addr_map.is_empty() && last_proxy_cleanup.elapsed() >= proxy_cleanup_interval {
last_proxy_cleanup = tokio::time::Instant::now();
let stale: Vec<SocketAddr> = proxy_addr_map.iter()
.filter(|entry| {
let key: SessionKey = (*entry.key(), port);
session_table.get(&key).is_none()
})
.map(|entry| *entry.key())
.collect();
if !stale.is_empty() {
debug!("UDP proxy_addr_map cleanup: removing {} stale entries on port {}", stale.len(), port);
for addr in stale {
proxy_addr_map.remove(&addr);
}
}
}
let (len, client_addr) = tokio::select! {
_ = cancel.cancelled() => {
debug!("UDP recv loop on port {} cancelled", port);
break;
}
result = socket.recv_from(&mut buf) => {
match result {
Ok(r) => r,
Err(e) => {
warn!("UDP recv error on port {}: {}", port, e);
continue;
}
}
}
};
let datagram = &buf[..len];
// PROXY protocol v2 detection for datagrams from trusted proxy IPs
let effective_client_ip = if !proxy_ips.is_empty() && proxy_ips.contains(&client_addr.ip()) {
let session_key: SessionKey = (client_addr, port);
if session_table.get(&session_key).is_none() && !proxy_addr_map.contains_key(&client_addr) {
// No session and no prior PROXY header — check for PROXY v2
if crate::proxy_protocol::is_proxy_protocol_v2(datagram) {
match crate::proxy_protocol::parse_v2(datagram) {
Ok((header, _consumed)) => {
debug!("UDP PROXY v2 from {}: real client {}", client_addr, header.source_addr);
proxy_addr_map.insert(client_addr, header.source_addr);
continue; // discard the PROXY v2 datagram
}
Err(e) => {
debug!("UDP PROXY v2 parse error from {}: {}", client_addr, e);
client_addr.ip()
}
}
} else {
client_addr.ip()
}
} else {
// Use real client IP if we've previously seen a PROXY v2 header
proxy_addr_map.get(&client_addr)
.map(|r| r.ip())
.unwrap_or_else(|| client_addr.ip())
}
} else {
client_addr.ip()
};
// Route matching — use effective (real) client IP
let rm = route_manager.load();
let ip_str = effective_client_ip.to_string();
let ctx = MatchContext {
port,
domain: None,
path: None,
client_ip: Some(&ip_str),
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("udp"),
transport: Some(TransportProtocol::Udp),
};
let route_match = match rm.find_route(&ctx) {
Some(m) => m,
None => {
debug!("No UDP route matched for port {} from {}", port, client_addr);
continue;
}
};
let route = route_match.route;
let route_id = route.name.as_deref().or(route.id.as_deref());
// Socket handler routes → relay datagram to TS via persistent Unix socket
if route.action.action_type == RouteActionType::SocketHandler {
if let Err(e) = Self::relay_datagram_via_writer(
&relay_writer,
route_id.unwrap_or("unknown"),
&client_addr,
port,
datagram,
).await {
debug!("Failed to relay UDP datagram to TS: {}", e);
}
continue;
}
// Get UDP config from route
let udp_config = UdpSessionConfig::from_route_udp(route.action.udp.as_ref());
// Check datagram size
if len as u32 > udp_config.max_datagram_size {
debug!("UDP datagram too large ({} > {}) from {}, dropping",
len, udp_config.max_datagram_size, client_addr);
continue;
}
// Session lookup or create
// Session key uses the proxy's source addr for correct return-path routing
let session_key: SessionKey = (client_addr, port);
let session = match session_table.get(&session_key) {
Some(s) => s,
None => {
// New session — check per-IP limits using the real client IP
if !conn_tracker.try_accept(&effective_client_ip) {
debug!("UDP session rejected for {} (rate limit)", effective_client_ip);
continue;
}
if !session_table.can_create_session(
&effective_client_ip,
udp_config.max_sessions_per_ip,
) {
debug!("UDP session rejected for {} (per-IP session limit)", effective_client_ip);
continue;
}
// Resolve target
let target = match route_match.target.or_else(|| {
route.action.targets.as_ref().and_then(|t| t.first())
}) {
Some(t) => t,
None => {
warn!("No target for UDP route {:?}", route_id);
continue;
}
};
let backend_host = target.host.first();
let backend_port = target.port.resolve(port);
let backend_addr = format!("{}:{}", backend_host, backend_port);
// Create backend socket
let backend_socket = match UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => s,
Err(e) => {
error!("Failed to bind backend UDP socket: {}", e);
continue;
}
};
if let Err(e) = backend_socket.connect(&backend_addr).await {
error!("Failed to connect backend UDP socket to {}: {}", backend_addr, e);
continue;
}
let backend_socket = Arc::new(backend_socket);
debug!("New UDP session: {} -> {} (via port {}, real client {})",
client_addr, backend_addr, port, effective_client_ip);
// Spawn return-path relay task
let session_cancel = CancellationToken::new();
let return_task = tokio::spawn(Self::return_relay(
Arc::clone(&backend_socket),
Arc::clone(&socket),
client_addr,
Arc::clone(&session_table),
session_key,
Arc::clone(&metrics),
route_id.map(|s| s.to_string()),
session_cancel.child_token(),
));
let session = Arc::new(UdpSession {
backend_socket,
last_activity: std::sync::atomic::AtomicU64::new(session_table.elapsed_ms()),
created_at: std::time::Instant::now(),
route_id: route_id.map(|s| s.to_string()),
source_ip: effective_client_ip,
client_addr,
return_task,
cancel: session_cancel,
});
if !session_table.insert(session_key, Arc::clone(&session), udp_config.max_sessions_per_ip) {
warn!("Failed to insert UDP session (race condition)");
continue;
}
// Track in metrics using the real client IP
conn_tracker.connection_opened(&effective_client_ip);
metrics.connection_opened(route_id, Some(&ip_str));
metrics.udp_session_opened();
session
}
};
// Forward datagram to backend
match session.backend_socket.send(datagram).await {
Ok(_) => {
session.last_activity.store(session_table.elapsed_ms(), Ordering::Relaxed);
metrics.record_bytes(len as u64, 0, route_id, Some(&ip_str));
metrics.record_datagram_in();
}
Err(e) => {
debug!("Failed to send UDP datagram to backend: {}", e);
}
}
}
}
/// Return-path relay: backend → client.
async fn return_relay(
backend_socket: Arc<UdpSocket>,
listener_socket: Arc<UdpSocket>,
client_addr: SocketAddr,
session_table: Arc<UdpSessionTable>,
session_key: SessionKey,
metrics: Arc<MetricsCollector>,
route_id: Option<String>,
cancel: CancellationToken,
) {
let mut buf = vec![0u8; 65535];
let ip_str = client_addr.ip().to_string();
loop {
let len = tokio::select! {
_ = cancel.cancelled() => break,
result = backend_socket.recv(&mut buf) => {
match result {
Ok(len) => len,
Err(e) => {
debug!("UDP backend recv error for {}: {}", client_addr, e);
break;
}
}
}
};
// Send reply back to client
match listener_socket.send_to(&buf[..len], client_addr).await {
Ok(_) => {
// Update session activity
if let Some(session) = session_table.get(&session_key) {
session.last_activity.store(session_table.elapsed_ms(), Ordering::Relaxed);
}
metrics.record_bytes(0, len as u64, route_id.as_deref(), Some(&ip_str));
metrics.record_datagram_out();
}
Err(e) => {
debug!("Failed to send UDP reply to {}: {}", client_addr, e);
break;
}
}
}
}
/// Send a datagram to TS via the persistent relay writer.
async fn relay_datagram_via_writer(
writer: &Mutex<Option<tokio::net::unix::OwnedWriteHalf>>,
route_key: &str,
client_addr: &SocketAddr,
dest_port: u16,
datagram: &[u8],
) -> anyhow::Result<()> {
use base64::Engine;
let payload_b64 = base64::engine::general_purpose::STANDARD.encode(datagram);
let msg = serde_json::json!({
"type": "datagram",
"routeKey": route_key,
"sourceIp": client_addr.ip().to_string(),
"sourcePort": client_addr.port(),
"destPort": dest_port,
"payloadBase64": payload_b64,
});
let json = serde_json::to_vec(&msg)?;
let mut guard = writer.lock().await;
let stream = guard.as_mut()
.ok_or_else(|| anyhow::anyhow!("Datagram relay not connected"))?;
// Length-prefixed frame
let len_bytes = (json.len() as u32).to_be_bytes();
stream.write_all(&len_bytes).await?;
stream.write_all(&json).await?;
stream.flush().await?;
Ok(())
}
/// Background task reading reply frames from the TS datagram handler.
/// Parses replies and sends them back to the original client via UDP.
async fn relay_reply_reader(
mut reader: tokio::net::unix::OwnedReadHalf,
cancel: CancellationToken,
) {
use base64::Engine;
let mut len_buf = [0u8; 4];
loop {
// Read length prefix
let read_result = tokio::select! {
_ = cancel.cancelled() => break,
result = reader.read_exact(&mut len_buf) => result,
};
match read_result {
Ok(_) => {}
Err(e) => {
debug!("Datagram relay reader closed: {}", e);
break;
}
}
let frame_len = u32::from_be_bytes(len_buf) as usize;
if frame_len > 10 * 1024 * 1024 {
error!("Datagram relay frame too large: {} bytes", frame_len);
break;
}
let mut frame_buf = vec![0u8; frame_len];
match reader.read_exact(&mut frame_buf).await {
Ok(_) => {}
Err(e) => {
debug!("Datagram relay reader frame error: {}", e);
break;
}
}
// Parse the reply JSON
let reply: serde_json::Value = match serde_json::from_slice(&frame_buf) {
Ok(v) => v,
Err(e) => {
debug!("Datagram relay reply parse error: {}", e);
continue;
}
};
if reply.get("type").and_then(|v| v.as_str()) != Some("reply") {
continue;
}
let source_ip = reply.get("sourceIp").and_then(|v| v.as_str()).unwrap_or("");
let source_port = reply.get("sourcePort").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
let dest_port = reply.get("destPort").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
let payload_b64 = reply.get("payloadBase64").and_then(|v| v.as_str()).unwrap_or("");
let payload = match base64::engine::general_purpose::STANDARD.decode(payload_b64) {
Ok(p) => p,
Err(e) => {
debug!("Datagram relay reply base64 decode error: {}", e);
continue;
}
};
let client_addr: SocketAddr = match format!("{}:{}", source_ip, source_port).parse() {
Ok(a) => a,
Err(e) => {
debug!("Datagram relay reply address parse error: {}", e);
continue;
}
};
// Send the reply back to the client via a temporary UDP socket bound to the dest_port
// We need the listener socket for this port. For simplicity, use a fresh socket.
let reply_socket = match UdpSocket::bind(format!("0.0.0.0:{}", dest_port)).await {
Ok(s) => s,
Err(_) => {
// Port already bound by the listener — use unbound socket
match UdpSocket::bind("0.0.0.0:0").await {
Ok(s) => s,
Err(e) => {
debug!("Failed to create reply socket: {}", e);
continue;
}
}
}
};
if let Err(e) = reply_socket.send_to(&payload, client_addr).await {
debug!("Failed to send datagram reply to {}: {}", client_addr, e);
}
}
debug!("Datagram relay reply reader stopped");
}
}

View File

@@ -0,0 +1,354 @@
//! UDP session (flow) tracking.
//!
//! A UDP "session" is a flow identified by (client_addr, listening_port).
//! Each session maintains a backend socket bound to an ephemeral port and
//! connected to the backend target, plus a background task that relays
//! return datagrams from the backend back to the client.
use std::net::{IpAddr, SocketAddr};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
use dashmap::DashMap;
use tokio::net::UdpSocket;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::debug;
use rustproxy_metrics::MetricsCollector;
use crate::connection_tracker::ConnectionTracker;
/// A single UDP session (flow).
pub struct UdpSession {
/// Socket bound to ephemeral port, connected to backend
pub backend_socket: Arc<UdpSocket>,
/// Milliseconds since the session table's epoch
pub last_activity: AtomicU64,
/// When the session was created
pub created_at: Instant,
/// Route ID for metrics
pub route_id: Option<String>,
/// Source IP for metrics/tracking
pub source_ip: IpAddr,
/// Client address (for return path)
pub client_addr: SocketAddr,
/// Handle for the return-path relay task
pub return_task: JoinHandle<()>,
/// Per-session cancellation
pub cancel: CancellationToken,
}
impl Drop for UdpSession {
fn drop(&mut self) {
self.cancel.cancel();
self.return_task.abort();
}
}
/// Configuration for UDP session behavior.
#[derive(Debug, Clone)]
pub struct UdpSessionConfig {
/// Idle timeout in milliseconds. Default: 60000.
pub session_timeout_ms: u64,
/// Max concurrent sessions per source IP. Default: 1000.
pub max_sessions_per_ip: u32,
/// Max accepted datagram size in bytes. Default: 65535.
pub max_datagram_size: u32,
}
impl Default for UdpSessionConfig {
fn default() -> Self {
Self {
session_timeout_ms: 60_000,
max_sessions_per_ip: 1_000,
max_datagram_size: 65_535,
}
}
}
impl UdpSessionConfig {
/// Build from route's UDP config, falling back to defaults.
pub fn from_route_udp(udp: Option<&rustproxy_config::RouteUdp>) -> Self {
match udp {
Some(u) => Self {
session_timeout_ms: u.session_timeout.unwrap_or(60_000),
max_sessions_per_ip: u.max_sessions_per_ip.unwrap_or(1_000),
max_datagram_size: u.max_datagram_size.unwrap_or(65_535),
},
None => Self::default(),
}
}
}
/// Session key: (client address, listening port).
pub type SessionKey = (SocketAddr, u16);
/// Tracks all active UDP sessions across all ports.
pub struct UdpSessionTable {
/// Active sessions keyed by (client_addr, listen_port)
sessions: DashMap<SessionKey, Arc<UdpSession>>,
/// Per-IP session counts for enforcing limits
ip_session_counts: DashMap<IpAddr, u32>,
/// Time reference for last_activity
epoch: Instant,
}
impl UdpSessionTable {
pub fn new() -> Self {
Self {
sessions: DashMap::new(),
ip_session_counts: DashMap::new(),
epoch: Instant::now(),
}
}
/// Get elapsed milliseconds since epoch (for last_activity tracking).
pub fn elapsed_ms(&self) -> u64 {
self.epoch.elapsed().as_millis() as u64
}
/// Look up an existing session.
pub fn get(&self, key: &SessionKey) -> Option<Arc<UdpSession>> {
self.sessions.get(key).map(|entry| Arc::clone(entry.value()))
}
/// Check if we can create a new session for this IP (under the per-IP limit).
pub fn can_create_session(&self, ip: &IpAddr, max_per_ip: u32) -> bool {
let count = self.ip_session_counts
.get(ip)
.map(|c| *c.value())
.unwrap_or(0);
count < max_per_ip
}
/// Insert a new session. Returns false if per-IP limit exceeded.
pub fn insert(
&self,
key: SessionKey,
session: Arc<UdpSession>,
max_per_ip: u32,
) -> bool {
let ip = session.source_ip;
// Atomically check and increment per-IP count
let mut count_entry = self.ip_session_counts.entry(ip).or_insert(0);
if *count_entry.value() >= max_per_ip {
return false;
}
*count_entry.value_mut() += 1;
drop(count_entry);
self.sessions.insert(key, session);
true
}
/// Remove a session and decrement per-IP count.
pub fn remove(&self, key: &SessionKey) -> Option<Arc<UdpSession>> {
if let Some((_, session)) = self.sessions.remove(key) {
let ip = session.source_ip;
if let Some(mut count) = self.ip_session_counts.get_mut(&ip) {
*count.value_mut() = count.value().saturating_sub(1);
if *count.value() == 0 {
drop(count);
self.ip_session_counts.remove(&ip);
}
}
Some(session)
} else {
None
}
}
/// Clean up idle sessions past the given timeout.
/// Returns the number of sessions removed.
pub fn cleanup_idle(
&self,
timeout_ms: u64,
metrics: &MetricsCollector,
conn_tracker: &ConnectionTracker,
) -> usize {
let now_ms = self.elapsed_ms();
let mut removed = 0;
// Collect keys to remove (avoid holding DashMap refs during removal)
let stale_keys: Vec<SessionKey> = self.sessions.iter()
.filter(|entry| {
let last = entry.value().last_activity.load(Ordering::Relaxed);
now_ms.saturating_sub(last) >= timeout_ms
})
.map(|entry| *entry.key())
.collect();
for key in stale_keys {
if let Some(session) = self.remove(&key) {
debug!(
"UDP session expired: {} -> port {} (idle {}ms)",
session.client_addr, key.1,
now_ms.saturating_sub(session.last_activity.load(Ordering::Relaxed))
);
conn_tracker.connection_closed(&session.source_ip);
metrics.connection_closed(
session.route_id.as_deref(),
Some(&session.source_ip.to_string()),
);
metrics.udp_session_closed();
removed += 1;
}
}
removed
}
/// Drain all sessions on a given listening port, releasing socket references.
/// Used when upgrading a raw UDP listener to QUIC — the raw UDP socket's
/// Arc refcount must drop to zero so the port can be rebound.
pub fn drain_port(
&self,
port: u16,
metrics: &MetricsCollector,
conn_tracker: &ConnectionTracker,
) -> usize {
let keys: Vec<SessionKey> = self.sessions.iter()
.filter(|entry| entry.key().1 == port)
.map(|entry| *entry.key())
.collect();
let mut removed = 0;
for key in keys {
if let Some(session) = self.remove(&key) {
session.cancel.cancel();
conn_tracker.connection_closed(&session.source_ip);
metrics.connection_closed(
session.route_id.as_deref(),
Some(&session.source_ip.to_string()),
);
metrics.udp_session_closed();
removed += 1;
}
}
removed
}
/// Total number of active sessions.
pub fn session_count(&self) -> usize {
self.sessions.len()
}
/// Number of tracked IPs with active sessions.
pub fn tracked_ips(&self) -> usize {
self.ip_session_counts.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, SocketAddrV4};
fn make_addr(port: u16) -> SocketAddr {
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), port))
}
fn make_session(client_addr: SocketAddr, cancel: CancellationToken) -> Arc<UdpSession> {
// Create a dummy backend socket for testing
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let backend_socket = rt.block_on(async {
Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap())
});
let child_cancel = cancel.child_token();
let return_task = rt.spawn(async move {
child_cancel.cancelled().await;
});
Arc::new(UdpSession {
backend_socket,
last_activity: AtomicU64::new(0),
created_at: Instant::now(),
route_id: None,
source_ip: client_addr.ip(),
client_addr,
return_task,
cancel,
})
}
#[test]
fn test_session_table_insert_and_get() {
let table = UdpSessionTable::new();
let cancel = CancellationToken::new();
let addr = make_addr(12345);
let key: SessionKey = (addr, 53);
let session = make_session(addr, cancel);
assert!(table.insert(key, session, 1000));
assert!(table.get(&key).is_some());
assert_eq!(table.session_count(), 1);
}
#[test]
fn test_session_table_per_ip_limit() {
let table = UdpSessionTable::new();
let ip = Ipv4Addr::new(10, 0, 0, 1);
// Insert 2 sessions from same IP, limit is 2
for port in [12345u16, 12346] {
let addr = SocketAddr::V4(SocketAddrV4::new(ip, port));
let cancel = CancellationToken::new();
let session = make_session(addr, cancel);
assert!(table.insert((addr, 53), session, 2));
}
// Third should be rejected
let addr3 = SocketAddr::V4(SocketAddrV4::new(ip, 12347));
let cancel3 = CancellationToken::new();
let session3 = make_session(addr3, cancel3);
assert!(!table.insert((addr3, 53), session3, 2));
assert_eq!(table.session_count(), 2);
}
#[test]
fn test_session_table_remove() {
let table = UdpSessionTable::new();
let cancel = CancellationToken::new();
let addr = make_addr(12345);
let key: SessionKey = (addr, 53);
let session = make_session(addr, cancel);
table.insert(key, session, 1000);
assert_eq!(table.session_count(), 1);
assert_eq!(table.tracked_ips(), 1);
table.remove(&key);
assert_eq!(table.session_count(), 0);
assert_eq!(table.tracked_ips(), 0);
}
#[test]
fn test_session_config_defaults() {
let config = UdpSessionConfig::default();
assert_eq!(config.session_timeout_ms, 60_000);
assert_eq!(config.max_sessions_per_ip, 1_000);
assert_eq!(config.max_datagram_size, 65_535);
}
#[test]
fn test_session_config_from_route() {
let route_udp = rustproxy_config::RouteUdp {
session_timeout: Some(10_000),
max_sessions_per_ip: Some(500),
max_datagram_size: Some(1400),
quic: None,
};
let config = UdpSessionConfig::from_route_udp(Some(&route_udp));
assert_eq!(config.session_timeout_ms, 10_000);
assert_eq!(config.max_sessions_per_ip, 500);
assert_eq!(config.max_datagram_size, 1400);
}
}

View File

@@ -6,25 +6,28 @@
/// - `example.com` exact match /// - `example.com` exact match
/// - `**.example.com` matches any depth of subdomain /// - `**.example.com` matches any depth of subdomain
pub fn domain_matches(pattern: &str, domain: &str) -> bool { pub fn domain_matches(pattern: &str, domain: &str) -> bool {
let pattern = pattern.trim().to_lowercase(); let pattern = pattern.trim();
let domain = domain.trim().to_lowercase(); let domain = domain.trim();
if pattern == "*" { if pattern == "*" {
return true; return true;
} }
if pattern == domain { if pattern.eq_ignore_ascii_case(domain) {
return true; return true;
} }
// Wildcard patterns // Wildcard patterns
if pattern.starts_with("*.") { if pattern.starts_with("*.") || pattern.starts_with("*.") {
let suffix = &pattern[2..]; // e.g., "example.com" let suffix = &pattern[2..]; // e.g., "example.com"
// Match exact parent or any single-level subdomain // Match exact parent or any single-level subdomain
if domain == suffix { if domain.eq_ignore_ascii_case(suffix) {
return true; return true;
} }
if domain.ends_with(&format!(".{}", suffix)) { if domain.len() > suffix.len() + 1
&& domain.as_bytes()[domain.len() - suffix.len() - 1] == b'.'
&& domain[domain.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
{
// Check it's a single level subdomain for `*.` // Check it's a single level subdomain for `*.`
let prefix = &domain[..domain.len() - suffix.len() - 1]; let prefix = &domain[..domain.len() - suffix.len() - 1];
return !prefix.contains('.'); return !prefix.contains('.');
@@ -35,11 +38,22 @@ pub fn domain_matches(pattern: &str, domain: &str) -> bool {
if pattern.starts_with("**.") { if pattern.starts_with("**.") {
let suffix = &pattern[3..]; let suffix = &pattern[3..];
// Match exact parent or any depth of subdomain // Match exact parent or any depth of subdomain
return domain == suffix || domain.ends_with(&format!(".{}", suffix)); if domain.eq_ignore_ascii_case(suffix) {
return true;
}
if domain.len() > suffix.len() + 1
&& domain.as_bytes()[domain.len() - suffix.len() - 1] == b'.'
&& domain[domain.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
{
return true;
}
return false;
} }
// Use glob-match for more complex patterns // Use glob-match for more complex patterns (case-insensitive via lowercasing)
glob_match::glob_match(&pattern, &domain) let pattern_lower = pattern.to_lowercase();
let domain_lower = domain.to_lowercase();
glob_match::glob_match(&pattern_lower, &domain_lower)
} }
/// Check if a domain matches any of the given patterns. /// Check if a domain matches any of the given patterns.

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use rustproxy_config::{RouteConfig, RouteTarget, TlsMode}; use rustproxy_config::{RouteConfig, RouteTarget, TransportProtocol, TlsMode};
use crate::matchers; use crate::matchers;
/// Context for route matching (subset of connection info). /// Context for route matching (subset of connection info).
@@ -12,6 +12,10 @@ pub struct MatchContext<'a> {
pub tls_version: Option<&'a str>, pub tls_version: Option<&'a str>,
pub headers: Option<&'a HashMap<String, String>>, pub headers: Option<&'a HashMap<String, String>>,
pub is_tls: bool, pub is_tls: bool,
/// Detected protocol: "http", "tcp", "udp", "quic". None when unknown.
pub protocol: Option<&'a str>,
/// Transport protocol of the listener: None = TCP (backward compat), Some(Udp), Some(All).
pub transport: Option<TransportProtocol>,
} }
/// Result of a route match. /// Result of a route match.
@@ -58,6 +62,16 @@ impl RouteManager {
manager manager
} }
/// Check if any route on the given port uses header matching.
/// Used to skip expensive header HashMap construction when no route needs it.
pub fn any_route_has_headers(&self, port: u16) -> bool {
if let Some(indices) = self.port_index.get(&port) {
indices.iter().any(|&idx| self.routes[idx].route_match.headers.is_some())
} else {
false
}
}
/// Find the best matching route for the given context. /// Find the best matching route for the given context.
pub fn find_route<'a>(&'a self, ctx: &MatchContext<'_>) -> Option<RouteMatchResult<'a>> { pub fn find_route<'a>(&'a self, ctx: &MatchContext<'_>) -> Option<RouteMatchResult<'a>> {
// Get routes for this port // Get routes for this port
@@ -80,6 +94,22 @@ impl RouteManager {
fn matches_route(&self, route: &RouteConfig, ctx: &MatchContext<'_>) -> bool { fn matches_route(&self, route: &RouteConfig, ctx: &MatchContext<'_>) -> bool {
let rm = &route.route_match; let rm = &route.route_match;
// Transport filtering: ensure route transport matches context transport
let route_transport = rm.transport.as_ref();
let ctx_transport = ctx.transport.as_ref();
match (route_transport, ctx_transport) {
// Route requires UDP only — reject non-UDP contexts
(Some(TransportProtocol::Udp), None) |
(Some(TransportProtocol::Udp), Some(TransportProtocol::Tcp)) => return false,
// Route requires TCP only — reject UDP contexts
(Some(TransportProtocol::Tcp), Some(TransportProtocol::Udp)) => return false,
// Route has no transport (default = TCP) — reject UDP contexts
(None, Some(TransportProtocol::Udp)) => return false,
// All other combinations match: All matches everything, same transport matches,
// None + None/Tcp matches (backward compat)
_ => {}
}
// Domain matching // Domain matching
if let Some(ref domains) = rm.domains { if let Some(ref domains) = rm.domains {
if let Some(domain) = ctx.domain { if let Some(domain) = ctx.domain {
@@ -87,9 +117,23 @@ impl RouteManager {
if !matchers::domain_matches_any(&patterns, domain) { if !matchers::domain_matches_any(&patterns, domain) {
return false; return false;
} }
} else if ctx.is_tls {
// TLS connection without SNI cannot match a domain-restricted route.
// This prevents session-ticket resumption from misrouting when clients
// omit SNI (RFC 8446 recommends but doesn't mandate SNI on resumption).
// Wildcard-only routes (domains: ["*"]) still match since they accept all.
//
// Exception: QUIC (UDP transport) encrypts the TLS ClientHello, so SNI
// is unavailable at accept time. Domain verification happens per-request
// in H3ProxyService via the :authority header.
if ctx.transport != Some(TransportProtocol::Udp) {
let patterns = domains.to_vec();
let is_wildcard_only = patterns.iter().all(|d| *d == "*");
if !is_wildcard_only {
return false;
}
}
} }
// If no domain provided but route requires domain, it depends on context
// For TLS passthrough, we need SNI; for other cases we may still match
} }
// Path matching // Path matching
@@ -137,6 +181,17 @@ impl RouteManager {
} }
} }
// Protocol matching
if let Some(ref required_protocol) = rm.protocol {
if let Some(protocol) = ctx.protocol {
if required_protocol != protocol {
return false;
}
}
// If protocol not yet known (None), allow match — protocol will be
// validated after detection (post-TLS-termination peek)
}
true true
} }
@@ -272,11 +327,13 @@ mod tests {
id: None, id: None,
route_match: RouteMatch { route_match: RouteMatch {
ports: PortRange::Single(port), ports: PortRange::Single(port),
transport: None,
domains: domain.map(|d| DomainSpec::Single(d.to_string())), domains: domain.map(|d| DomainSpec::Single(d.to_string())),
path: None, path: None,
client_ip: None, client_ip: None,
tls_version: None, tls_version: None,
headers: None, headers: None,
protocol: None,
}, },
action: RouteAction { action: RouteAction {
action_type: RouteActionType::Forward, action_type: RouteActionType::Forward,
@@ -290,6 +347,7 @@ mod tests {
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: None, priority: None,
}]), }]),
tls: None, tls: None,
@@ -297,9 +355,8 @@ mod tests {
load_balancing: None, load_balancing: None,
advanced: None, advanced: None,
options: None, options: None,
forwarding_engine: None,
nftables: None,
send_proxy_protocol: None, send_proxy_protocol: None,
udp: None,
}, },
headers: None, headers: None,
security: None, security: None,
@@ -327,6 +384,8 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None,
transport: None,
}; };
let result = manager.find_route(&ctx); let result = manager.find_route(&ctx);
@@ -349,6 +408,8 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None,
transport: None,
}; };
let result = manager.find_route(&ctx).unwrap(); let result = manager.find_route(&ctx).unwrap();
@@ -372,6 +433,8 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None,
transport: None,
}; };
assert!(manager.find_route(&ctx).is_none()); assert!(manager.find_route(&ctx).is_none());
@@ -457,6 +520,122 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None,
transport: None,
};
assert!(manager.find_route(&ctx).is_some());
}
#[test]
fn test_tls_no_sni_rejects_domain_restricted_route() {
let routes = vec![make_route(443, Some("example.com"), 0)];
let manager = RouteManager::new(routes);
// TLS connection without SNI should NOT match a domain-restricted route
let ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: true,
protocol: None,
transport: None,
};
assert!(manager.find_route(&ctx).is_none());
}
#[test]
fn test_tls_no_sni_rejects_wildcard_subdomain_route() {
let routes = vec![make_route(443, Some("*.example.com"), 0)];
let manager = RouteManager::new(routes);
// TLS connection without SNI should NOT match *.example.com
let ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: true,
protocol: None,
transport: None,
};
assert!(manager.find_route(&ctx).is_none());
}
#[test]
fn test_tls_no_sni_matches_wildcard_only_route() {
let routes = vec![make_route(443, Some("*"), 0)];
let manager = RouteManager::new(routes);
// TLS connection without SNI SHOULD match a wildcard-only route
let ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: true,
protocol: None,
transport: None,
};
assert!(manager.find_route(&ctx).is_some());
}
#[test]
fn test_tls_no_sni_skips_domain_restricted_matches_fallback() {
// Two routes: first is domain-restricted, second is wildcard catch-all
let routes = vec![
make_route(443, Some("specific.com"), 10),
make_route(443, Some("*"), 0),
];
let manager = RouteManager::new(routes);
// TLS without SNI should skip specific.com and fall through to wildcard
let ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: true,
protocol: None,
transport: None,
};
let result = manager.find_route(&ctx);
assert!(result.is_some());
let matched_domains = result.unwrap().route.route_match.domains.as_ref()
.map(|d| d.to_vec()).unwrap();
assert!(matched_domains.contains(&"*"));
}
#[test]
fn test_non_tls_no_domain_still_matches_domain_restricted() {
// Non-TLS (plain HTTP) without domain should still match domain-restricted routes
// (the HTTP proxy layer handles Host-based routing)
let routes = vec![make_route(80, Some("example.com"), 0)];
let manager = RouteManager::new(routes);
let ctx = MatchContext {
port: 80,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: None,
transport: None,
}; };
assert!(manager.find_route(&ctx).is_some()); assert!(manager.find_route(&ctx).is_some());
@@ -475,6 +654,8 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None,
transport: None,
}; };
assert!(manager.find_route(&ctx).is_some()); assert!(manager.find_route(&ctx).is_some());
@@ -499,6 +680,7 @@ mod tests {
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: Some(10), priority: Some(10),
}, },
RouteTarget { RouteTarget {
@@ -511,6 +693,7 @@ mod tests {
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: None, priority: None,
}, },
]); ]);
@@ -525,6 +708,8 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None,
transport: None,
}; };
let result = manager.find_route(&ctx).unwrap(); let result = manager.find_route(&ctx).unwrap();
assert_eq!(result.target.unwrap().host.first(), "api-backend"); assert_eq!(result.target.unwrap().host.first(), "api-backend");
@@ -538,8 +723,330 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None,
transport: None,
}; };
let result = manager.find_route(&ctx).unwrap(); let result = manager.find_route(&ctx).unwrap();
assert_eq!(result.target.unwrap().host.first(), "default-backend"); assert_eq!(result.target.unwrap().host.first(), "default-backend");
} }
fn make_route_with_protocol(port: u16, domain: Option<&str>, protocol: Option<&str>) -> RouteConfig {
let mut route = make_route(port, domain, 0);
route.route_match.protocol = protocol.map(|s| s.to_string());
route
}
#[test]
fn test_protocol_http_matches_http() {
let routes = vec![make_route_with_protocol(80, None, Some("http"))];
let manager = RouteManager::new(routes);
let ctx = MatchContext {
port: 80,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("http"),
transport: None,
};
assert!(manager.find_route(&ctx).is_some());
}
#[test]
fn test_protocol_http_rejects_tcp() {
let routes = vec![make_route_with_protocol(80, None, Some("http"))];
let manager = RouteManager::new(routes);
let ctx = MatchContext {
port: 80,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("tcp"),
transport: None,
};
assert!(manager.find_route(&ctx).is_none());
}
#[test]
fn test_protocol_none_matches_any() {
// Route with no protocol restriction matches any protocol
let routes = vec![make_route_with_protocol(80, None, None)];
let manager = RouteManager::new(routes);
let ctx_http = MatchContext {
port: 80,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("http"),
transport: None,
};
assert!(manager.find_route(&ctx_http).is_some());
let ctx_tcp = MatchContext {
port: 80,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("tcp"),
transport: None,
};
assert!(manager.find_route(&ctx_tcp).is_some());
}
#[test]
fn test_protocol_http_matches_when_unknown() {
// Route with protocol: "http" should match when ctx.protocol is None
// (pre-TLS-termination, protocol not yet known)
let routes = vec![make_route_with_protocol(443, None, Some("http"))];
let manager = RouteManager::new(routes);
let ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: true,
protocol: None,
transport: None,
};
assert!(manager.find_route(&ctx).is_some());
}
// ===== Transport filtering tests =====
fn make_route_with_transport(port: u16, transport: Option<TransportProtocol>) -> RouteConfig {
let mut route = make_route(port, None, 0);
route.route_match.transport = transport;
route
}
#[test]
fn test_transport_udp_route_matches_udp_context() {
let routes = vec![make_route_with_transport(53, Some(TransportProtocol::Udp))];
let manager = RouteManager::new(routes);
let ctx = MatchContext {
port: 53,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("udp"),
transport: Some(TransportProtocol::Udp),
};
assert!(manager.find_route(&ctx).is_some());
}
#[test]
fn test_transport_udp_route_rejects_tcp_context() {
let routes = vec![make_route_with_transport(53, Some(TransportProtocol::Udp))];
let manager = RouteManager::new(routes);
// TCP context (transport: None = TCP)
let ctx = MatchContext {
port: 53,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: None,
transport: None,
};
assert!(manager.find_route(&ctx).is_none());
}
#[test]
fn test_transport_tcp_route_rejects_udp_context() {
let routes = vec![make_route_with_transport(80, Some(TransportProtocol::Tcp))];
let manager = RouteManager::new(routes);
let ctx = MatchContext {
port: 80,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("udp"),
transport: Some(TransportProtocol::Udp),
};
assert!(manager.find_route(&ctx).is_none());
}
#[test]
fn test_transport_all_matches_both() {
let routes = vec![make_route_with_transport(443, Some(TransportProtocol::All))];
let manager = RouteManager::new(routes);
// TCP context
let tcp_ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: None,
transport: None,
};
assert!(manager.find_route(&tcp_ctx).is_some());
// UDP context
let udp_ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("udp"),
transport: Some(TransportProtocol::Udp),
};
assert!(manager.find_route(&udp_ctx).is_some());
}
#[test]
fn test_transport_none_default_matches_tcp_only() {
// Route with no transport field = TCP only (backward compat)
let routes = vec![make_route_with_transport(80, None)];
let manager = RouteManager::new(routes);
// TCP context should match
let tcp_ctx = MatchContext {
port: 80,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: None,
transport: None,
};
assert!(manager.find_route(&tcp_ctx).is_some());
// UDP context should NOT match
let udp_ctx = MatchContext {
port: 80,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("udp"),
transport: Some(TransportProtocol::Udp),
};
assert!(manager.find_route(&udp_ctx).is_none());
}
#[test]
fn test_transport_mixed_routes_same_port() {
// TCP and UDP routes on the same port — each matches only its transport
let mut tcp_route = make_route_with_transport(443, Some(TransportProtocol::Tcp));
tcp_route.name = Some("tcp-route".to_string());
let mut udp_route = make_route_with_transport(443, Some(TransportProtocol::Udp));
udp_route.name = Some("udp-route".to_string());
let manager = RouteManager::new(vec![tcp_route, udp_route]);
let tcp_ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: None,
transport: None,
};
let result = manager.find_route(&tcp_ctx).unwrap();
assert_eq!(result.route.name.as_deref(), Some("tcp-route"));
let udp_ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: false,
protocol: Some("udp"),
transport: Some(TransportProtocol::Udp),
};
let result = manager.find_route(&udp_ctx).unwrap();
assert_eq!(result.route.name.as_deref(), Some("udp-route"));
}
#[test]
fn test_quic_tls_no_sni_matches_domain_restricted_route() {
// QUIC accept-level matching: is_tls=true, domain=None, transport=Udp.
// Should match because QUIC encrypts the ClientHello — SNI is unavailable
// at accept time but verified per-request in H3ProxyService.
let mut route = make_route(443, Some("example.com"), 0);
route.route_match.transport = Some(TransportProtocol::Udp);
let routes = vec![route];
let manager = RouteManager::new(routes);
let ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: true,
protocol: Some("quic"),
transport: Some(TransportProtocol::Udp),
};
assert!(manager.find_route(&ctx).is_some(),
"QUIC (UDP) with is_tls=true and domain=None should match domain-restricted routes");
}
#[test]
fn test_tcp_tls_no_sni_still_rejects_domain_restricted_route() {
// TCP TLS without SNI must still be rejected (no QUIC exemption).
let routes = vec![make_route(443, Some("example.com"), 0)];
let manager = RouteManager::new(routes);
let ctx = MatchContext {
port: 443,
domain: None,
path: None,
client_ip: None,
tls_version: None,
headers: None,
is_tls: true,
protocol: None,
transport: None, // TCP (default)
};
assert!(manager.find_route(&ctx).is_none(),
"TCP TLS without SNI should NOT match domain-restricted routes");
}
} }

View File

@@ -15,8 +15,6 @@ tracing = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }
rcgen = { workspace = true } rcgen = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = { workspace = true }

View File

@@ -1,16 +1,15 @@
//! ACME (Let's Encrypt) integration using instant-acme. //! ACME (Let's Encrypt) integration using instant-acme.
//! //!
//! This module handles HTTP-01 challenge creation and certificate provisioning. //! This module handles HTTP-01 challenge creation and certificate provisioning.
//! Supports persisting ACME account credentials to disk for reuse across restarts. //! Account credentials are ephemeral — the consumer owns all persistence.
use std::path::{Path, PathBuf};
use instant_acme::{ use instant_acme::{
Account, NewAccount, NewOrder, Identifier, ChallengeType, OrderStatus, Account, NewAccount, NewOrder, Identifier, ChallengeType, OrderStatus,
AccountCredentials, AccountCredentials,
}; };
use rcgen::{CertificateParams, KeyPair}; use rcgen::{CertificateParams, KeyPair};
use thiserror::Error; use thiserror::Error;
use tracing::{debug, info, warn}; use tracing::{debug, info};
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum AcmeError { pub enum AcmeError {
@@ -26,8 +25,6 @@ pub enum AcmeError {
NoHttp01Challenge, NoHttp01Challenge,
#[error("Timeout waiting for order: {0}")] #[error("Timeout waiting for order: {0}")]
Timeout(String), Timeout(String),
#[error("Account persistence error: {0}")]
Persistence(String),
} }
/// Pending HTTP-01 challenge that needs to be served. /// Pending HTTP-01 challenge that needs to be served.
@@ -41,8 +38,6 @@ pub struct PendingChallenge {
pub struct AcmeClient { pub struct AcmeClient {
use_production: bool, use_production: bool,
email: String, email: String,
/// Optional directory where account.json is persisted.
account_dir: Option<PathBuf>,
} }
impl AcmeClient { impl AcmeClient {
@@ -50,56 +45,15 @@ impl AcmeClient {
Self { Self {
use_production, use_production,
email, email,
account_dir: None,
} }
} }
/// Create a new client with account persistence at the given directory. /// Create a new ACME account (ephemeral — not persisted).
pub fn with_persistence(email: String, use_production: bool, account_dir: impl AsRef<Path>) -> Self {
Self {
use_production,
email,
account_dir: Some(account_dir.as_ref().to_path_buf()),
}
}
/// Get or create an ACME account, persisting credentials if account_dir is set.
async fn get_or_create_account(&self) -> Result<Account, AcmeError> { async fn get_or_create_account(&self) -> Result<Account, AcmeError> {
let directory_url = self.directory_url(); let directory_url = self.directory_url();
// Try to restore from persisted credentials
if let Some(ref dir) = self.account_dir {
let account_file = dir.join("account.json");
if account_file.exists() {
match std::fs::read_to_string(&account_file) {
Ok(json) => {
match serde_json::from_str::<AccountCredentials>(&json) {
Ok(credentials) => {
match Account::from_credentials(credentials).await {
Ok(account) => {
debug!("Restored ACME account from {}", account_file.display());
return Ok(account);
}
Err(e) => {
warn!("Failed to restore ACME account, creating new: {}", e);
}
}
}
Err(e) => {
warn!("Invalid account.json, creating new account: {}", e);
}
}
}
Err(e) => {
warn!("Could not read account.json: {}", e);
}
}
}
}
// Create a new account
let contact = format!("mailto:{}", self.email); let contact = format!("mailto:{}", self.email);
let (account, credentials) = Account::create( let (account, _credentials) = Account::create(
&NewAccount { &NewAccount {
contact: &[&contact], contact: &[&contact],
terms_of_service_agreed: true, terms_of_service_agreed: true,
@@ -113,27 +67,6 @@ impl AcmeClient {
debug!("ACME account created"); debug!("ACME account created");
// Persist credentials if we have a directory
if let Some(ref dir) = self.account_dir {
if let Err(e) = std::fs::create_dir_all(dir) {
warn!("Failed to create account directory {}: {}", dir.display(), e);
} else {
let account_file = dir.join("account.json");
match serde_json::to_string_pretty(&credentials) {
Ok(json) => {
if let Err(e) = std::fs::write(&account_file, &json) {
warn!("Failed to persist ACME account to {}: {}", account_file.display(), e);
} else {
info!("ACME account credentials persisted to {}", account_file.display());
}
}
Err(e) => {
warn!("Failed to serialize account credentials: {}", e);
}
}
}
}
Ok(account) Ok(account)
} }
@@ -158,7 +91,7 @@ impl AcmeClient {
{ {
info!("Starting ACME provisioning for {} via {}", domain, self.directory_url()); info!("Starting ACME provisioning for {} via {}", domain, self.directory_url());
// 1. Get or create ACME account (with persistence) // 1. Get or create ACME account
let account = self.get_or_create_account().await?; let account = self.get_or_create_account().await?;
// 2. Create order // 2. Create order
@@ -339,22 +272,4 @@ mod tests {
assert!(!client.directory_url().contains("staging")); assert!(!client.directory_url().contains("staging"));
assert!(client.is_production()); assert!(client.is_production());
} }
#[test]
fn test_with_persistence_sets_account_dir() {
let tmp = tempfile::tempdir().unwrap();
let client = AcmeClient::with_persistence(
"test@example.com".to_string(),
false,
tmp.path(),
);
assert!(client.account_dir.is_some());
assert_eq!(client.account_dir.unwrap(), tmp.path());
}
#[test]
fn test_without_persistence_no_account_dir() {
let client = AcmeClient::new("test@example.com".to_string(), false);
assert!(client.account_dir.is_none());
}
} }

View File

@@ -9,8 +9,6 @@ use crate::acme::AcmeClient;
pub enum CertManagerError { pub enum CertManagerError {
#[error("ACME provisioning failed for {domain}: {message}")] #[error("ACME provisioning failed for {domain}: {message}")]
AcmeFailure { domain: String, message: String }, AcmeFailure { domain: String, message: String },
#[error("Certificate store error: {0}")]
Store(#[from] crate::cert_store::CertStoreError),
#[error("No ACME email configured")] #[error("No ACME email configured")]
NoEmail, NoEmail,
} }
@@ -46,25 +44,19 @@ impl CertManager {
/// Create an ACME client using this manager's configuration. /// Create an ACME client using this manager's configuration.
/// Returns None if no ACME email is configured. /// Returns None if no ACME email is configured.
/// Account credentials are persisted in the cert store base directory.
pub fn acme_client(&self) -> Option<AcmeClient> { pub fn acme_client(&self) -> Option<AcmeClient> {
self.acme_email.as_ref().map(|email| { self.acme_email.as_ref().map(|email| {
AcmeClient::with_persistence( AcmeClient::new(email.clone(), self.use_production)
email.clone(),
self.use_production,
self.store.base_dir(),
)
}) })
} }
/// Load a static certificate into the store. /// Load a static certificate into the store (infallible — pure cache insert).
pub fn load_static( pub fn load_static(
&mut self, &mut self,
domain: String, domain: String,
bundle: CertBundle, bundle: CertBundle,
) -> Result<(), CertManagerError> { ) {
self.store.store(domain, bundle)?; self.store.store(domain, bundle);
Ok(())
} }
/// Check and return domains that need certificate renewal. /// Check and return domains that need certificate renewal.
@@ -153,19 +145,12 @@ impl CertManager {
}, },
}; };
self.store.store(domain.to_string(), bundle.clone())?; self.store.store(domain.to_string(), bundle.clone());
info!("Certificate renewed and stored for {}", domain); info!("Certificate renewed and stored for {}", domain);
Ok(bundle) Ok(bundle)
} }
/// Load all certificates from disk.
pub fn load_all(&mut self) -> Result<usize, CertManagerError> {
let loaded = self.store.load_all()?;
info!("Loaded {} certificates from store", loaded);
Ok(loaded)
}
/// Whether this manager has an ACME email configured. /// Whether this manager has an ACME email configured.
pub fn has_acme(&self) -> bool { pub fn has_acme(&self) -> bool {
self.acme_email.is_some() self.acme_email.is_some()

View File

@@ -1,21 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)] /// Certificate metadata stored alongside certs.
pub enum CertStoreError {
#[error("Certificate not found for domain: {0}")]
NotFound(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid certificate: {0}")]
Invalid(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
/// Certificate metadata stored alongside certs on disk.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CertMetadata { pub struct CertMetadata {
@@ -45,27 +31,18 @@ pub struct CertBundle {
pub metadata: CertMetadata, pub metadata: CertMetadata,
} }
/// Filesystem-backed certificate store. /// In-memory certificate store.
/// ///
/// File layout per domain: /// All persistence is owned by the consumer (TypeScript side).
/// ```text /// This struct is a thin HashMap wrapper used as a runtime cache.
/// {base_dir}/{domain}/
/// key.pem
/// cert.pem
/// ca.pem (optional)
/// metadata.json
/// ```
pub struct CertStore { pub struct CertStore {
base_dir: PathBuf,
/// In-memory cache of loaded certs
cache: HashMap<String, CertBundle>, cache: HashMap<String, CertBundle>,
} }
impl CertStore { impl CertStore {
/// Create a new cert store at the given directory. /// Create a new empty cert store.
pub fn new(base_dir: impl AsRef<Path>) -> Self { pub fn new() -> Self {
Self { Self {
base_dir: base_dir.as_ref().to_path_buf(),
cache: HashMap::new(), cache: HashMap::new(),
} }
} }
@@ -75,33 +52,9 @@ impl CertStore {
self.cache.get(domain) self.cache.get(domain)
} }
/// Store a certificate to both cache and filesystem. /// Store a certificate in the cache.
pub fn store(&mut self, domain: String, bundle: CertBundle) -> Result<(), CertStoreError> { pub fn store(&mut self, domain: String, bundle: CertBundle) {
// Sanitize domain for directory name (replace wildcards)
let dir_name = domain.replace('*', "_wildcard_");
let cert_dir = self.base_dir.join(&dir_name);
// Create directory
std::fs::create_dir_all(&cert_dir)?;
// Write key
std::fs::write(cert_dir.join("key.pem"), &bundle.key_pem)?;
// Write cert
std::fs::write(cert_dir.join("cert.pem"), &bundle.cert_pem)?;
// Write CA cert if present
if let Some(ref ca) = bundle.ca_pem {
std::fs::write(cert_dir.join("ca.pem"), ca)?;
}
// Write metadata
let metadata_json = serde_json::to_string_pretty(&bundle.metadata)?;
std::fs::write(cert_dir.join("metadata.json"), metadata_json)?;
// Update cache
self.cache.insert(domain, bundle); self.cache.insert(domain, bundle);
Ok(())
} }
/// Check if a certificate exists for a domain. /// Check if a certificate exists for a domain.
@@ -109,68 +62,6 @@ impl CertStore {
self.cache.contains_key(domain) self.cache.contains_key(domain)
} }
/// Load all certificates from the base directory.
pub fn load_all(&mut self) -> Result<usize, CertStoreError> {
if !self.base_dir.exists() {
return Ok(0);
}
let entries = std::fs::read_dir(&self.base_dir)?;
let mut loaded = 0;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let metadata_path = path.join("metadata.json");
let key_path = path.join("key.pem");
let cert_path = path.join("cert.pem");
// All three files must exist
if !metadata_path.exists() || !key_path.exists() || !cert_path.exists() {
continue;
}
// Load metadata
let metadata_str = std::fs::read_to_string(&metadata_path)?;
let metadata: CertMetadata = serde_json::from_str(&metadata_str)?;
// Load key and cert
let key_pem = std::fs::read_to_string(&key_path)?;
let cert_pem = std::fs::read_to_string(&cert_path)?;
// Load CA cert if present
let ca_path = path.join("ca.pem");
let ca_pem = if ca_path.exists() {
Some(std::fs::read_to_string(&ca_path)?)
} else {
None
};
let domain = metadata.domain.clone();
let bundle = CertBundle {
key_pem,
cert_pem,
ca_pem,
metadata,
};
self.cache.insert(domain, bundle);
loaded += 1;
}
Ok(loaded)
}
/// Get the base directory.
pub fn base_dir(&self) -> &Path {
&self.base_dir
}
/// Get the number of cached certificates. /// Get the number of cached certificates.
pub fn count(&self) -> usize { pub fn count(&self) -> usize {
self.cache.len() self.cache.len()
@@ -181,17 +72,15 @@ impl CertStore {
self.cache.iter() self.cache.iter()
} }
/// Remove a certificate from cache and filesystem. /// Remove a certificate from the cache.
pub fn remove(&mut self, domain: &str) -> Result<bool, CertStoreError> { pub fn remove(&mut self, domain: &str) -> bool {
let removed = self.cache.remove(domain).is_some(); self.cache.remove(domain).is_some()
if removed { }
let dir_name = domain.replace('*', "_wildcard_"); }
let cert_dir = self.base_dir.join(&dir_name);
if cert_dir.exists() { impl Default for CertStore {
std::fs::remove_dir_all(&cert_dir)?; fn default() -> Self {
} Self::new()
}
Ok(removed)
} }
} }
@@ -215,100 +104,71 @@ mod tests {
} }
#[test] #[test]
fn test_store_and_load_roundtrip() { fn test_store_and_get() {
let tmp = tempfile::tempdir().unwrap(); let mut store = CertStore::new();
let mut store = CertStore::new(tmp.path());
let bundle = make_test_bundle("example.com"); let bundle = make_test_bundle("example.com");
store.store("example.com".to_string(), bundle.clone()).unwrap(); store.store("example.com".to_string(), bundle.clone());
// Verify files exist let loaded = store.get("example.com").unwrap();
let cert_dir = tmp.path().join("example.com"); assert_eq!(loaded.key_pem, bundle.key_pem);
assert!(cert_dir.join("key.pem").exists()); assert_eq!(loaded.cert_pem, bundle.cert_pem);
assert!(cert_dir.join("cert.pem").exists()); assert_eq!(loaded.metadata.domain, "example.com");
assert!(cert_dir.join("metadata.json").exists()); assert_eq!(loaded.metadata.source, CertSource::Static);
assert!(!cert_dir.join("ca.pem").exists()); // No CA cert
// Load into a fresh store
let mut store2 = CertStore::new(tmp.path());
let loaded = store2.load_all().unwrap();
assert_eq!(loaded, 1);
let loaded_bundle = store2.get("example.com").unwrap();
assert_eq!(loaded_bundle.key_pem, bundle.key_pem);
assert_eq!(loaded_bundle.cert_pem, bundle.cert_pem);
assert_eq!(loaded_bundle.metadata.domain, "example.com");
assert_eq!(loaded_bundle.metadata.source, CertSource::Static);
} }
#[test] #[test]
fn test_store_with_ca_cert() { fn test_store_with_ca_cert() {
let tmp = tempfile::tempdir().unwrap(); let mut store = CertStore::new();
let mut store = CertStore::new(tmp.path());
let mut bundle = make_test_bundle("secure.com"); let mut bundle = make_test_bundle("secure.com");
bundle.ca_pem = Some("-----BEGIN CERTIFICATE-----\nca-cert\n-----END CERTIFICATE-----\n".to_string()); bundle.ca_pem = Some("-----BEGIN CERTIFICATE-----\nca-cert\n-----END CERTIFICATE-----\n".to_string());
store.store("secure.com".to_string(), bundle).unwrap(); store.store("secure.com".to_string(), bundle);
let cert_dir = tmp.path().join("secure.com"); let loaded = store.get("secure.com").unwrap();
assert!(cert_dir.join("ca.pem").exists());
let mut store2 = CertStore::new(tmp.path());
store2.load_all().unwrap();
let loaded = store2.get("secure.com").unwrap();
assert!(loaded.ca_pem.is_some()); assert!(loaded.ca_pem.is_some());
} }
#[test] #[test]
fn test_load_all_multiple_certs() { fn test_multiple_certs() {
let tmp = tempfile::tempdir().unwrap(); let mut store = CertStore::new();
let mut store = CertStore::new(tmp.path());
store.store("a.com".to_string(), make_test_bundle("a.com")).unwrap(); store.store("a.com".to_string(), make_test_bundle("a.com"));
store.store("b.com".to_string(), make_test_bundle("b.com")).unwrap(); store.store("b.com".to_string(), make_test_bundle("b.com"));
store.store("c.com".to_string(), make_test_bundle("c.com")).unwrap(); store.store("c.com".to_string(), make_test_bundle("c.com"));
let mut store2 = CertStore::new(tmp.path()); assert_eq!(store.count(), 3);
let loaded = store2.load_all().unwrap(); assert!(store.has("a.com"));
assert_eq!(loaded, 3); assert!(store.has("b.com"));
assert!(store2.has("a.com")); assert!(store.has("c.com"));
assert!(store2.has("b.com"));
assert!(store2.has("c.com"));
}
#[test]
fn test_load_all_missing_directory() {
let mut store = CertStore::new("/nonexistent/path/to/certs");
let loaded = store.load_all().unwrap();
assert_eq!(loaded, 0);
} }
#[test] #[test]
fn test_remove_cert() { fn test_remove_cert() {
let tmp = tempfile::tempdir().unwrap(); let mut store = CertStore::new();
let mut store = CertStore::new(tmp.path());
store.store("remove-me.com".to_string(), make_test_bundle("remove-me.com")).unwrap(); store.store("remove-me.com".to_string(), make_test_bundle("remove-me.com"));
assert!(store.has("remove-me.com")); assert!(store.has("remove-me.com"));
let removed = store.remove("remove-me.com").unwrap(); let removed = store.remove("remove-me.com");
assert!(removed); assert!(removed);
assert!(!store.has("remove-me.com")); assert!(!store.has("remove-me.com"));
assert!(!tmp.path().join("remove-me.com").exists());
} }
#[test] #[test]
fn test_wildcard_domain_storage() { fn test_remove_nonexistent() {
let tmp = tempfile::tempdir().unwrap(); let mut store = CertStore::new();
let mut store = CertStore::new(tmp.path()); assert!(!store.remove("nonexistent.com"));
}
store.store("*.example.com".to_string(), make_test_bundle("*.example.com")).unwrap(); #[test]
fn test_wildcard_domain() {
let mut store = CertStore::new();
// Directory should use sanitized name store.store("*.example.com".to_string(), make_test_bundle("*.example.com"));
assert!(tmp.path().join("_wildcard_.example.com").exists()); assert!(store.has("*.example.com"));
let mut store2 = CertStore::new(tmp.path()); let loaded = store.get("*.example.com").unwrap();
store2.load_all().unwrap(); assert_eq!(loaded.metadata.domain, "*.example.com");
assert!(store2.has("*.example.com"));
} }
} }

View File

@@ -20,7 +20,6 @@ rustproxy-routing = { workspace = true }
rustproxy-tls = { workspace = true } rustproxy-tls = { workspace = true }
rustproxy-passthrough = { workspace = true } rustproxy-passthrough = { workspace = true }
rustproxy-http = { workspace = true } rustproxy-http = { workspace = true }
rustproxy-nftables = { workspace = true }
rustproxy-metrics = { workspace = true } rustproxy-metrics = { workspace = true }
rustproxy-security = { workspace = true } rustproxy-security = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
@@ -32,6 +31,7 @@ arc-swap = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
rustls = { workspace = true } rustls = { workspace = true }
rustls-pemfile = { workspace = true }
tokio-rustls = { workspace = true } tokio-rustls = { workspace = true }
tokio-util = { workspace = true } tokio-util = { workspace = true }
dashmap = { workspace = true } dashmap = { workspace = true }
@@ -39,6 +39,13 @@ hyper = { workspace = true }
hyper-util = { workspace = true } hyper-util = { workspace = true }
http-body-util = { workspace = true } http-body-util = { workspace = true }
bytes = { workspace = true } bytes = { workspace = true }
mimalloc = { workspace = true }
[dev-dependencies] [dev-dependencies]
rcgen = { workspace = true } rcgen = { workspace = true }
quinn = { workspace = true }
h3 = { workspace = true }
h3-quinn = { workspace = true }
bytes = { workspace = true }
rustls = { workspace = true }
http = "1"

View File

@@ -7,14 +7,40 @@
//! //!
//! ```rust,no_run //! ```rust,no_run
//! use rustproxy::RustProxy; //! use rustproxy::RustProxy;
//! use rustproxy_config::{RustProxyOptions, create_https_passthrough_route}; //! use rustproxy_config::*;
//! //!
//! #[tokio::main] //! #[tokio::main]
//! async fn main() -> anyhow::Result<()> { //! async fn main() -> anyhow::Result<()> {
//! let options = RustProxyOptions { //! let options = RustProxyOptions {
//! routes: vec![ //! routes: vec![RouteConfig {
//! create_https_passthrough_route("example.com", "backend", 443), //! id: None,
//! ], //! route_match: RouteMatch {
//! ports: PortRange::Single(443),
//! domains: Some(DomainSpec::Single("example.com".to_string())),
//! path: None, client_ip: None, transport: None,
//! tls_version: None, headers: None, protocol: None,
//! },
//! action: RouteAction {
//! action_type: RouteActionType::Forward,
//! targets: Some(vec![RouteTarget {
//! target_match: None,
//! host: HostSpec::Single("backend".to_string()),
//! port: PortSpec::Fixed(443),
//! tls: None, websocket: None, load_balancing: None,
//! send_proxy_protocol: None, headers: None, advanced: None,
//! backend_transport: None, priority: None,
//! }]),
//! tls: Some(RouteTls {
//! mode: TlsMode::Passthrough,
//! certificate: None, acme: None, versions: None,
//! ciphers: None, honor_cipher_order: None, session_timeout: None,
//! }),
//! websocket: None, load_balancing: None, advanced: None,
//! options: None, send_proxy_protocol: None, udp: None,
//! },
//! headers: None, security: None, name: None, description: None,
//! priority: None, tags: None, enabled: None,
//! }],
//! ..Default::default() //! ..Default::default()
//! }; //! };
//! //!
@@ -27,7 +53,7 @@
pub mod challenge_server; pub mod challenge_server;
pub mod management; pub mod management;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
@@ -41,16 +67,15 @@ pub use rustproxy_routing;
pub use rustproxy_passthrough; pub use rustproxy_passthrough;
pub use rustproxy_tls; pub use rustproxy_tls;
pub use rustproxy_http; pub use rustproxy_http;
pub use rustproxy_nftables;
pub use rustproxy_metrics; pub use rustproxy_metrics;
pub use rustproxy_security; pub use rustproxy_security;
use rustproxy_config::{RouteConfig, RustProxyOptions, TlsMode, CertificateSpec, ForwardingEngine}; use rustproxy_config::{RouteConfig, RustProxyOptions, TlsMode, CertificateSpec};
use rustproxy_routing::RouteManager; use rustproxy_routing::RouteManager;
use rustproxy_passthrough::{TcpListenerManager, TlsCertConfig, ConnectionConfig}; use rustproxy_passthrough::{TcpListenerManager, UdpListenerManager, TlsCertConfig, ConnectionConfig};
use rustproxy_metrics::{MetricsCollector, Metrics, Statistics}; use rustproxy_metrics::{MetricsCollector, Metrics, Statistics};
use rustproxy_tls::{CertManager, CertStore, CertBundle, CertMetadata, CertSource}; use rustproxy_tls::{CertManager, CertStore, CertBundle, CertMetadata, CertSource};
use rustproxy_nftables::{NftManager, rule_builder}; use tokio_util::sync::CancellationToken;
/// Certificate status. /// Certificate status.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -67,15 +92,20 @@ pub struct RustProxy {
options: RustProxyOptions, options: RustProxyOptions,
route_table: ArcSwap<RouteManager>, route_table: ArcSwap<RouteManager>,
listener_manager: Option<TcpListenerManager>, listener_manager: Option<TcpListenerManager>,
udp_listener_manager: Option<UdpListenerManager>,
metrics: Arc<MetricsCollector>, metrics: Arc<MetricsCollector>,
cert_manager: Option<Arc<tokio::sync::Mutex<CertManager>>>, cert_manager: Option<Arc<tokio::sync::Mutex<CertManager>>>,
challenge_server: Option<challenge_server::ChallengeServer>, challenge_server: Option<challenge_server::ChallengeServer>,
renewal_handle: Option<tokio::task::JoinHandle<()>>, renewal_handle: Option<tokio::task::JoinHandle<()>>,
nft_manager: Option<NftManager>, sampling_handle: Option<tokio::task::JoinHandle<()>>,
started: bool, started: bool,
started_at: Option<Instant>, started_at: Option<Instant>,
/// Shared path to a Unix domain socket for relaying socket-handler connections back to TypeScript. /// Shared path to a Unix domain socket for relaying socket-handler connections back to TypeScript.
socket_handler_relay: Arc<std::sync::RwLock<Option<String>>>, socket_handler_relay: Arc<std::sync::RwLock<Option<String>>>,
/// Dynamically loaded certificates (via loadCertificate IPC), independent of CertManager.
loaded_certs: HashMap<String, TlsCertConfig>,
/// Cancellation token for cooperative shutdown of background tasks.
cancel_token: CancellationToken,
} }
impl RustProxy { impl RustProxy {
@@ -100,18 +130,25 @@ impl RustProxy {
let cert_manager = Self::build_cert_manager(&options) let cert_manager = Self::build_cert_manager(&options)
.map(|cm| Arc::new(tokio::sync::Mutex::new(cm))); .map(|cm| Arc::new(tokio::sync::Mutex::new(cm)));
let retention = options.metrics.as_ref()
.and_then(|m| m.retention_seconds)
.unwrap_or(3600) as usize;
Ok(Self { Ok(Self {
options, options,
route_table: ArcSwap::from(Arc::new(route_manager)), route_table: ArcSwap::from(Arc::new(route_manager)),
listener_manager: None, listener_manager: None,
metrics: Arc::new(MetricsCollector::new()), udp_listener_manager: None,
metrics: Arc::new(MetricsCollector::with_retention(retention)),
cert_manager, cert_manager,
challenge_server: None, challenge_server: None,
renewal_handle: None, renewal_handle: None,
nft_manager: None, sampling_handle: None,
started: false, started: false,
started_at: None, started_at: None,
socket_handler_relay: Arc::new(std::sync::RwLock::new(None)), socket_handler_relay: Arc::new(std::sync::RwLock::new(None)),
loaded_certs: HashMap::new(),
cancel_token: CancellationToken::new(),
}) })
} }
@@ -140,6 +177,7 @@ impl RustProxy {
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: None, priority: None,
} }
]); ]);
@@ -184,15 +222,12 @@ impl RustProxy {
return None; return None;
} }
let store_path = acme.certificate_store
.as_deref()
.unwrap_or("./certs");
let email = acme.email.clone() let email = acme.email.clone()
.or_else(|| acme.account_email.clone()); .or_else(|| acme.account_email.clone());
let use_production = acme.use_production.unwrap_or(false); let use_production = acme.use_production.unwrap_or(false);
let renew_before_days = acme.renew_threshold_days.unwrap_or(30); let renew_before_days = acme.renew_threshold_days.unwrap_or(30);
let store = CertStore::new(store_path); let store = CertStore::new();
Some(CertManager::new(store, email, use_production, renew_before_days)) Some(CertManager::new(store, email, use_production, renew_before_days))
} }
@@ -211,6 +246,13 @@ impl RustProxy {
extended_keep_alive_lifetime_ms: options.extended_keep_alive_lifetime, extended_keep_alive_lifetime_ms: options.extended_keep_alive_lifetime,
accept_proxy_protocol: options.accept_proxy_protocol.unwrap_or(false), accept_proxy_protocol: options.accept_proxy_protocol.unwrap_or(false),
send_proxy_protocol: options.send_proxy_protocol.unwrap_or(false), send_proxy_protocol: options.send_proxy_protocol.unwrap_or(false),
proxy_ips: options.proxy_ips.as_deref().unwrap_or(&[])
.iter()
.filter_map(|s| s.parse::<std::net::IpAddr>().ok())
.collect(),
keep_alive: options.keep_alive.unwrap_or(true),
keep_alive_initial_delay_ms: options.keep_alive_initial_delay.unwrap_or(60_000),
max_connections: options.max_connections.unwrap_or(100_000),
} }
} }
@@ -222,19 +264,6 @@ impl RustProxy {
info!("Starting RustProxy..."); info!("Starting RustProxy...");
// Load persisted certificates
if let Some(ref cm) = self.cert_manager {
let mut cm = cm.lock().await;
match cm.load_all() {
Ok(count) => {
if count > 0 {
info!("Loaded {} persisted certificates", count);
}
}
Err(e) => warn!("Failed to load persisted certificates: {}", e),
}
}
// Auto-provision certificates for routes with certificate: 'auto' // Auto-provision certificates for routes with certificate: 'auto'
self.auto_provision_certificates().await; self.auto_provision_certificates().await;
@@ -257,6 +286,8 @@ impl RustProxy {
conn_config.socket_timeout_ms, conn_config.socket_timeout_ms,
conn_config.max_connection_lifetime_ms, conn_config.max_connection_lifetime_ms,
); );
// Clone proxy_ips before conn_config is moved into the TCP listener
let udp_proxy_ips = conn_config.proxy_ips.clone();
listener.set_connection_config(conn_config); listener.set_connection_config(conn_config);
// Share the socket-handler relay path with the listener // Share the socket-handler relay path with the listener
@@ -278,22 +309,105 @@ impl RustProxy {
} }
} }
// Merge dynamically loaded certs (from loadCertificate IPC)
for (d, c) in &self.loaded_certs {
if !tls_configs.contains_key(d) {
tls_configs.insert(d.clone(), c.clone());
}
}
// Build QUIC TLS config before set_tls_configs consumes the map
let quic_tls_config = Self::build_quic_tls_config(&tls_configs);
if !tls_configs.is_empty() { if !tls_configs.is_empty() {
debug!("Loaded TLS certificates for {} domains", tls_configs.len()); debug!("Loaded TLS certificates for {} domains", tls_configs.len());
listener.set_tls_configs(tls_configs); listener.set_tls_configs(tls_configs);
} }
// Bind all ports // Determine which ports need TCP vs UDP based on route transport config
for port in &ports { let mut tcp_ports = std::collections::HashSet::new();
let mut udp_ports = std::collections::HashSet::new();
for route in &self.options.routes {
if !route.is_enabled() { continue; }
let transport = route.route_match.transport.as_ref();
let route_ports = route.route_match.ports.to_ports();
for port in route_ports {
match transport {
Some(rustproxy_config::TransportProtocol::Udp) => {
udp_ports.insert(port);
}
Some(rustproxy_config::TransportProtocol::All) => {
tcp_ports.insert(port);
udp_ports.insert(port);
}
Some(rustproxy_config::TransportProtocol::Tcp) | None => {
tcp_ports.insert(port);
}
}
}
}
// Bind TCP ports
for port in &tcp_ports {
listener.add_port(*port).await?; listener.add_port(*port).await?;
} }
self.listener_manager = Some(listener); self.listener_manager = Some(listener);
// Bind UDP ports (if any)
if !udp_ports.is_empty() {
let conn_tracker = self.listener_manager.as_ref().unwrap().conn_tracker().clone();
let mut udp_mgr = UdpListenerManager::new(
Arc::clone(&*self.route_table.load()),
Arc::clone(&self.metrics),
conn_tracker,
self.cancel_token.clone(),
);
udp_mgr.set_proxy_ips(udp_proxy_ips.clone());
// Share HttpProxyService with H3 — same route matching, connection
// pool, and ALPN protocol detection as the TCP/HTTP path.
let http_proxy = self.listener_manager.as_ref().unwrap().http_proxy().clone();
let h3_svc = rustproxy_http::h3_service::H3ProxyService::new(http_proxy);
udp_mgr.set_h3_service(Arc::new(h3_svc));
for port in &udp_ports {
udp_mgr.add_port_with_tls(*port, quic_tls_config.clone()).await?;
}
info!("UDP listeners started on {} ports: {:?}",
udp_ports.len(), udp_mgr.listening_ports());
self.udp_listener_manager = Some(udp_mgr);
}
self.started = true; self.started = true;
self.started_at = Some(Instant::now()); self.started_at = Some(Instant::now());
// Apply NFTables rules for routes using nftables forwarding engine // Start the throughput sampling task with cooperative cancellation
self.apply_nftables_rules(&self.options.routes.clone()).await; let metrics = Arc::clone(&self.metrics);
let conn_tracker = self.listener_manager.as_ref().unwrap().conn_tracker().clone();
let http_proxy = self.listener_manager.as_ref().unwrap().http_proxy().clone();
let interval_ms = self.options.metrics.as_ref()
.and_then(|m| m.sample_interval_ms)
.unwrap_or(1000);
let sampling_cancel = self.cancel_token.clone();
self.sampling_handle = Some(tokio::spawn(async move {
let mut interval = tokio::time::interval(
std::time::Duration::from_millis(interval_ms)
);
loop {
tokio::select! {
_ = sampling_cancel.cancelled() => break,
_ = interval.tick() => {
metrics.sample_all();
// Periodically clean up stale rate-limit timestamp entries
conn_tracker.cleanup_stale_timestamps();
// Clean up expired rate limiter entries to prevent unbounded
// growth from unique IPs after traffic stops
http_proxy.cleanup_all_rate_limiters();
}
}
}
}));
// Start renewal timer if ACME is enabled // Start renewal timer if ACME is enabled
self.start_renewal_timer(); self.start_renewal_timer();
@@ -396,9 +510,7 @@ impl RustProxy {
}; };
let mut cm = cm_arc.lock().await; let mut cm = cm_arc.lock().await;
if let Err(e) = cm.load_static(domain.clone(), bundle) { cm.load_static(domain.clone(), bundle);
error!("Failed to store certificate for {}: {}", domain, e);
}
info!("Certificate provisioned for {}", domain); info!("Certificate provisioned for {}", domain);
} }
@@ -437,51 +549,59 @@ impl RustProxy {
.unwrap_or(80); .unwrap_or(80);
let interval = std::time::Duration::from_secs(check_interval_hours as u64 * 3600); let interval = std::time::Duration::from_secs(check_interval_hours as u64 * 3600);
let renewal_cancel = self.cancel_token.clone();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
loop { loop {
tokio::time::sleep(interval).await; tokio::select! {
debug!("Certificate renewal check triggered (interval: {}h)", check_interval_hours); _ = renewal_cancel.cancelled() => {
debug!("Renewal timer shutting down");
break;
}
_ = tokio::time::sleep(interval) => {
debug!("Certificate renewal check triggered (interval: {}h)", check_interval_hours);
// Check which domains need renewal // Check which domains need renewal
let domains = { let domains = {
let cm = cm_arc.lock().await; let cm = cm_arc.lock().await;
cm.check_renewals() cm.check_renewals()
}; };
if domains.is_empty() { if domains.is_empty() {
debug!("No certificates need renewal"); debug!("No certificates need renewal");
continue; continue;
}
info!("Renewing {} certificate(s)", domains.len());
// Start challenge server for renewals
let mut cs = challenge_server::ChallengeServer::new();
if let Err(e) = cs.start(acme_port).await {
error!("Failed to start challenge server for renewal: {}", e);
continue;
}
for domain in &domains {
let cs_ref = &cs;
let mut cm = cm_arc.lock().await;
let result = cm.renew_domain(domain, |token, key_auth| {
cs_ref.set_challenge(token, key_auth);
async {}
}).await;
match result {
Ok(_bundle) => {
info!("Successfully renewed certificate for {}", domain);
} }
Err(e) => {
error!("Failed to renew certificate for {}: {}", domain, e); info!("Renewing {} certificate(s)", domains.len());
// Start challenge server for renewals
let mut cs = challenge_server::ChallengeServer::new();
if let Err(e) = cs.start(acme_port).await {
error!("Failed to start challenge server for renewal: {}", e);
continue;
} }
for domain in &domains {
let cs_ref = &cs;
let mut cm = cm_arc.lock().await;
let result = cm.renew_domain(domain, |token, key_auth| {
cs_ref.set_challenge(token, key_auth);
async {}
}).await;
match result {
Ok(_bundle) => {
info!("Successfully renewed certificate for {}", domain);
}
Err(e) => {
error!("Failed to renew certificate for {}: {}", domain, e);
}
}
}
cs.stop().await;
} }
} }
cs.stop().await;
} }
}); });
@@ -496,9 +616,17 @@ impl RustProxy {
info!("Stopping RustProxy..."); info!("Stopping RustProxy...");
// Stop renewal timer // Signal all background tasks to stop cooperatively
self.cancel_token.cancel();
// Await sampling task (cooperative shutdown)
if let Some(handle) = self.sampling_handle.take() {
let _ = handle.await;
}
// Await renewal timer (cooperative shutdown)
if let Some(handle) = self.renewal_handle.take() { if let Some(handle) = self.renewal_handle.take() {
handle.abort(); let _ = handle.await;
} }
// Stop challenge server if running // Stop challenge server if running
@@ -507,19 +635,20 @@ impl RustProxy {
} }
self.challenge_server = None; self.challenge_server = None;
// Clean up NFTables rules
if let Some(ref mut nft) = self.nft_manager {
if let Err(e) = nft.cleanup().await {
warn!("NFTables cleanup failed: {}", e);
}
}
self.nft_manager = None;
if let Some(ref mut listener) = self.listener_manager { if let Some(ref mut listener) = self.listener_manager {
listener.graceful_stop().await; listener.graceful_stop().await;
} }
self.listener_manager = None; self.listener_manager = None;
// Stop UDP listeners
if let Some(ref mut udp_mgr) = self.udp_listener_manager {
udp_mgr.stop().await;
}
self.udp_listener_manager = None;
self.started = false; self.started = false;
// Reset cancel token so proxy can be restarted
self.cancel_token = CancellationToken::new();
info!("RustProxy stopped"); info!("RustProxy stopped");
Ok(()) Ok(())
@@ -547,15 +676,48 @@ impl RustProxy {
vec![] vec![]
}; };
// Prune per-route metrics for route IDs that no longer exist
let active_route_ids: HashSet<String> = routes.iter()
.filter_map(|r| r.id.clone())
.collect();
self.metrics.retain_routes(&active_route_ids);
// Prune per-backend metrics for backends no longer in any route target.
// For PortSpec::Preserve routes, expand across all listening ports since
// the actual runtime port depends on the incoming connection.
let listening_ports = self.get_listening_ports();
let active_backends: HashSet<String> = routes.iter()
.filter_map(|r| r.action.targets.as_ref())
.flat_map(|targets| targets.iter())
.flat_map(|target| {
let hosts: Vec<String> = target.host.to_vec().into_iter().map(|s| s.to_string()).collect();
match &target.port {
rustproxy_config::PortSpec::Fixed(p) => {
hosts.into_iter().map(|h| format!("{}:{}", h, p)).collect::<Vec<_>>()
}
_ => {
// Preserve/special: expand across all listening ports
let lp = &listening_ports;
hosts.into_iter()
.flat_map(|h| lp.iter().map(move |p| format!("{}:{}", h, *p)))
.collect::<Vec<_>>()
}
}
})
.collect();
self.metrics.retain_backends(&active_backends);
// Atomically swap the route table // Atomically swap the route table
let new_manager = Arc::new(new_manager); let new_manager = Arc::new(new_manager);
self.route_table.store(Arc::clone(&new_manager)); self.route_table.store(Arc::clone(&new_manager));
// Update listener manager // Update listener manager.
// IMPORTANT: TLS configs must be swapped BEFORE the route manager so that
// new routes only become visible after their certs are loaded. The reverse
// order (routes first) creates a window where connections match new routes
// but get the old TLS acceptor, causing cert mismatches.
if let Some(ref mut listener) = self.listener_manager { if let Some(ref mut listener) = self.listener_manager {
listener.update_route_manager(Arc::clone(&new_manager)); // 1. Update TLS configs first (so new certs are available before new routes)
// Update TLS configs
let mut tls_configs = Self::extract_tls_configs(&routes); let mut tls_configs = Self::extract_tls_configs(&routes);
if let Some(ref cm_arc) = self.cert_manager { if let Some(ref cm_arc) = self.cert_manager {
let cm = cm_arc.lock().await; let cm = cm_arc.lock().await;
@@ -568,8 +730,21 @@ impl RustProxy {
} }
} }
} }
// Merge dynamically loaded certs (from loadCertificate IPC)
for (d, c) in &self.loaded_certs {
if !tls_configs.contains_key(d) {
tls_configs.insert(d.clone(), c.clone());
}
}
listener.set_tls_configs(tls_configs); listener.set_tls_configs(tls_configs);
// 2. Now swap the route manager (new routes become visible with certs already loaded)
listener.update_route_manager(Arc::clone(&new_manager));
// Cancel connections on routes that were removed or disabled
listener.invalidate_removed_routes(&active_route_ids);
// Prune HTTP proxy caches (rate limiters, regex cache, round-robin counters)
listener.prune_http_proxy_caches(&active_route_ids);
// Add new ports // Add new ports
for port in &new_ports { for port in &new_ports {
if !old_ports.contains(port) { if !old_ports.contains(port) {
@@ -585,8 +760,81 @@ impl RustProxy {
} }
} }
// Update NFTables rules: remove old, apply new // Reconcile UDP ports
self.update_nftables_rules(&routes).await; {
let mut new_udp_ports = HashSet::new();
for route in &routes {
if !route.is_enabled() { continue; }
let transport = route.route_match.transport.as_ref();
match transport {
Some(rustproxy_config::TransportProtocol::Udp) |
Some(rustproxy_config::TransportProtocol::All) => {
for port in route.route_match.ports.to_ports() {
new_udp_ports.insert(port);
}
}
_ => {}
}
}
let old_udp_ports: HashSet<u16> = self.udp_listener_manager
.as_ref()
.map(|u| u.listening_ports().into_iter().collect())
.unwrap_or_default();
if !new_udp_ports.is_empty() {
// Ensure UDP manager exists
if self.udp_listener_manager.is_none() {
if let Some(ref listener) = self.listener_manager {
let conn_tracker = listener.conn_tracker().clone();
let conn_config = Self::build_connection_config(&self.options);
let mut udp_mgr = UdpListenerManager::new(
Arc::clone(&new_manager),
Arc::clone(&self.metrics),
conn_tracker,
self.cancel_token.clone(),
);
udp_mgr.set_proxy_ips(conn_config.proxy_ips);
self.udp_listener_manager = Some(udp_mgr);
}
}
// Build TLS config for QUIC (needed for new ports and upgrading existing raw UDP)
let quic_tls = {
let tls_configs = self.current_tls_configs().await;
Self::build_quic_tls_config(&tls_configs)
};
if let Some(ref mut udp_mgr) = self.udp_listener_manager {
udp_mgr.update_routes(Arc::clone(&new_manager));
// Add new UDP ports (with TLS for QUIC)
for port in &new_udp_ports {
if !old_udp_ports.contains(port) {
udp_mgr.add_port_with_tls(*port, quic_tls.clone()).await?;
}
}
// Remove old UDP ports
for port in &old_udp_ports {
if !new_udp_ports.contains(port) {
udp_mgr.remove_port(*port);
}
}
// Upgrade existing raw UDP fallback listeners to QUIC if TLS is now available
if let Some(ref quic_config) = quic_tls {
udp_mgr.update_quic_tls(Arc::clone(quic_config));
udp_mgr.upgrade_raw_to_quic(Arc::clone(quic_config)).await;
}
}
} else if self.udp_listener_manager.is_some() {
// All UDP routes removed — shut down UDP manager
if let Some(ref mut udp_mgr) = self.udp_listener_manager {
udp_mgr.stop().await;
}
self.udp_listener_manager = None;
}
}
self.options.routes = routes; self.options.routes = routes;
Ok(()) Ok(())
@@ -631,12 +879,12 @@ impl RustProxy {
.map_err(|e| anyhow::anyhow!("ACME provisioning failed: {}", e))?; .map_err(|e| anyhow::anyhow!("ACME provisioning failed: {}", e))?;
// Hot-swap into TLS configs // Hot-swap into TLS configs
if let Some(ref mut listener) = self.listener_manager { let mut tls_configs = Self::extract_tls_configs(&self.options.routes);
let mut tls_configs = Self::extract_tls_configs(&self.options.routes); tls_configs.insert(domain.clone(), TlsCertConfig {
tls_configs.insert(domain.clone(), TlsCertConfig { cert_pem: bundle.cert_pem.clone(),
cert_pem: bundle.cert_pem.clone(), key_pem: bundle.key_pem.clone(),
key_pem: bundle.key_pem.clone(), });
}); {
let cm = cm_arc.lock().await; let cm = cm_arc.lock().await;
for (d, b) in cm.store().iter() { for (d, b) in cm.store().iter() {
if !tls_configs.contains_key(d) { if !tls_configs.contains_key(d) {
@@ -646,9 +894,22 @@ impl RustProxy {
}); });
} }
} }
}
let quic_tls = Self::build_quic_tls_config(&tls_configs);
if let Some(ref listener) = self.listener_manager {
listener.set_tls_configs(tls_configs); listener.set_tls_configs(tls_configs);
} }
// Update existing QUIC endpoints and upgrade raw UDP fallback listeners
if let Some(ref mut udp_mgr) = self.udp_listener_manager {
if let Some(ref quic_config) = quic_tls {
udp_mgr.update_quic_tls(Arc::clone(quic_config));
udp_mgr.upgrade_raw_to_quic(Arc::clone(quic_config)).await;
}
}
info!("Certificate provisioned and loaded for route '{}'", route_name); info!("Certificate provisioned and loaded for route '{}'", route_name);
Ok(()) Ok(())
} }
@@ -688,8 +949,31 @@ impl RustProxy {
} }
/// Get current metrics snapshot. /// Get current metrics snapshot.
/// Includes protocol cache entries from the HTTP proxy service.
pub fn get_metrics(&self) -> Metrics { pub fn get_metrics(&self) -> Metrics {
self.metrics.snapshot() let mut metrics = self.metrics.snapshot();
if let Some(ref lm) = self.listener_manager {
let entries = lm.http_proxy().protocol_cache_snapshot();
metrics.detected_protocols = entries.into_iter().map(|e| {
rustproxy_metrics::ProtocolCacheEntryMetric {
host: e.host,
port: e.port,
domain: e.domain,
protocol: e.protocol,
h3_port: e.h3_port,
age_secs: e.age_secs,
last_accessed_secs: e.last_accessed_secs,
last_probed_secs: e.last_probed_secs,
h2_suppressed: e.h2_suppressed,
h3_suppressed: e.h3_suppressed,
h2_cooldown_remaining_secs: e.h2_cooldown_remaining_secs,
h3_cooldown_remaining_secs: e.h3_cooldown_remaining_secs,
h2_consecutive_failures: e.h2_consecutive_failures,
h3_consecutive_failures: e.h3_consecutive_failures,
}
}).collect();
}
metrics
} }
/// Add a listening port at runtime. /// Add a listening port at runtime.
@@ -744,6 +1028,73 @@ impl RustProxy {
self.socket_handler_relay.read().unwrap().clone() self.socket_handler_relay.read().unwrap().clone()
} }
/// Build a rustls ServerConfig suitable for QUIC (TLS 1.3 only, h3 ALPN).
/// Uses the first available cert from tls_configs, or returns None if no certs available.
fn build_quic_tls_config(
tls_configs: &HashMap<String, TlsCertConfig>,
) -> Option<Arc<rustls::ServerConfig>> {
if tls_configs.is_empty() {
return None;
}
// Reuse CertResolver for SNI-based cert selection (same as TCP/TLS path).
// This ensures QUIC connections get the correct certificate for each domain
// instead of a single static cert.
let resolver = match rustproxy_passthrough::tls_handler::CertResolver::new(tls_configs) {
Ok(r) => r,
Err(e) => {
warn!("Failed to build QUIC cert resolver: {}", e);
return None;
}
};
let mut tls_config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(Arc::new(resolver));
// QUIC requires h3 ALPN
tls_config.alpn_protocols = vec![b"h3".to_vec()];
Some(Arc::new(tls_config))
}
/// Build the current full TLS config map from all sources (route configs, loaded certs, cert manager).
async fn current_tls_configs(&self) -> HashMap<String, TlsCertConfig> {
let mut configs = Self::extract_tls_configs(&self.options.routes);
// Merge dynamically loaded certs (from loadCertificate IPC)
for (d, c) in &self.loaded_certs {
if !configs.contains_key(d) {
configs.insert(d.clone(), c.clone());
}
}
// Merge certs from cert manager store
if let Some(ref cm_arc) = self.cert_manager {
let cm = cm_arc.lock().await;
for (d, b) in cm.store().iter() {
if !configs.contains_key(d) {
configs.insert(d.clone(), TlsCertConfig {
cert_pem: b.cert_pem.clone(),
key_pem: b.key_pem.clone(),
});
}
}
}
configs
}
/// Set the Unix domain socket path for relaying UDP datagrams to TypeScript datagramHandler callbacks.
pub async fn set_datagram_handler_relay_path(&mut self, path: Option<String>) {
info!("Datagram handler relay path set to: {:?}", path);
if let Some(ref mut udp_mgr) = self.udp_listener_manager {
if let Some(ref p) = path {
udp_mgr.set_datagram_handler_relay(p.clone()).await;
}
}
}
/// Load a certificate for a domain and hot-swap the TLS configuration. /// Load a certificate for a domain and hot-swap the TLS configuration.
pub async fn load_certificate( pub async fn load_certificate(
&mut self, &mut self,
@@ -775,133 +1126,37 @@ impl RustProxy {
}; };
let mut cm = cm_arc.lock().await; let mut cm = cm_arc.lock().await;
cm.load_static(domain.to_string(), bundle) cm.load_static(domain.to_string(), bundle);
.map_err(|e| anyhow::anyhow!("Failed to store certificate: {}", e))?;
} }
// Hot-swap TLS config on the listener // Persist in loaded_certs so future rebuild calls include this cert
if let Some(ref mut listener) = self.listener_manager { self.loaded_certs.insert(domain.to_string(), TlsCertConfig {
let mut tls_configs = Self::extract_tls_configs(&self.options.routes); cert_pem: cert_pem.clone(),
key_pem: key_pem.clone(),
});
// Add the new cert // Hot-swap TLS config on TCP and QUIC listeners
tls_configs.insert(domain.to_string(), TlsCertConfig { let tls_configs = self.current_tls_configs().await;
cert_pem: cert_pem.clone(),
key_pem: key_pem.clone(),
});
// Also include all existing certs from cert manager // Build QUIC TLS config before TCP consumes the map
if let Some(ref cm_arc) = self.cert_manager { let quic_tls = Self::build_quic_tls_config(&tls_configs);
let cm = cm_arc.lock().await;
for (d, b) in cm.store().iter() {
if !tls_configs.contains_key(d) {
tls_configs.insert(d.clone(), TlsCertConfig {
cert_pem: b.cert_pem.clone(),
key_pem: b.key_pem.clone(),
});
}
}
}
if let Some(ref listener) = self.listener_manager {
listener.set_tls_configs(tls_configs); listener.set_tls_configs(tls_configs);
} }
// Update existing QUIC endpoints and upgrade raw UDP fallback listeners
if let Some(ref mut udp_mgr) = self.udp_listener_manager {
if let Some(ref quic_config) = quic_tls {
udp_mgr.update_quic_tls(Arc::clone(quic_config));
udp_mgr.upgrade_raw_to_quic(Arc::clone(quic_config)).await;
}
}
info!("Certificate loaded and TLS config updated for {}", domain); info!("Certificate loaded and TLS config updated for {}", domain);
Ok(()) Ok(())
} }
/// Get NFTables status.
pub async fn get_nftables_status(&self) -> Result<HashMap<String, serde_json::Value>> {
match &self.nft_manager {
Some(nft) => Ok(nft.status()),
None => Ok(HashMap::new()),
}
}
/// Apply NFTables rules for routes using the nftables forwarding engine.
async fn apply_nftables_rules(&mut self, routes: &[RouteConfig]) {
let nft_routes: Vec<&RouteConfig> = routes.iter()
.filter(|r| r.action.forwarding_engine.as_ref() == Some(&ForwardingEngine::Nftables))
.collect();
if nft_routes.is_empty() {
return;
}
info!("Applying NFTables rules for {} routes", nft_routes.len());
let table_name = nft_routes.iter()
.find_map(|r| r.action.nftables.as_ref()?.table_name.clone())
.unwrap_or_else(|| "rustproxy".to_string());
let mut nft = NftManager::new(Some(table_name));
for route in &nft_routes {
let route_id = route.id.as_deref()
.or(route.name.as_deref())
.unwrap_or("unnamed");
let nft_options = match &route.action.nftables {
Some(opts) => opts.clone(),
None => rustproxy_config::NfTablesOptions {
preserve_source_ip: None,
protocol: None,
max_rate: None,
priority: None,
table_name: None,
use_ip_sets: None,
use_advanced_nat: None,
},
};
let targets = match &route.action.targets {
Some(targets) => targets,
None => {
warn!("NFTables route '{}' has no targets, skipping", route_id);
continue;
}
};
let source_ports = route.route_match.ports.to_ports();
for target in targets {
let target_host = target.host.first().to_string();
let target_port_spec = &target.port;
for &source_port in &source_ports {
let resolved_port = target_port_spec.resolve(source_port);
let rules = rule_builder::build_dnat_rule(
nft.table_name(),
"prerouting",
source_port,
&target_host,
resolved_port,
&nft_options,
);
let rule_id = format!("{}-{}-{}", route_id, source_port, resolved_port);
if let Err(e) = nft.apply_rules(&rule_id, rules).await {
error!("Failed to apply NFTables rules for route '{}': {}", route_id, e);
}
}
}
}
self.nft_manager = Some(nft);
}
/// Update NFTables rules when routes change.
async fn update_nftables_rules(&mut self, new_routes: &[RouteConfig]) {
// Clean up old rules
if let Some(ref mut nft) = self.nft_manager {
if let Err(e) = nft.cleanup().await {
warn!("NFTables cleanup during update failed: {}", e);
}
}
self.nft_manager = None;
// Apply new rules
self.apply_nftables_rules(new_routes).await;
}
/// Extract TLS configurations from route configs. /// Extract TLS configurations from route configs.
fn extract_tls_configs(routes: &[RouteConfig]) -> HashMap<String, TlsCertConfig> { fn extract_tls_configs(routes: &[RouteConfig]) -> HashMap<String, TlsCertConfig> {
let mut configs = HashMap::new(); let mut configs = HashMap::new();
@@ -934,3 +1189,21 @@ impl RustProxy {
configs configs
} }
} }
/// Safety net: abort background tasks if RustProxy is dropped without calling stop().
/// Normal shutdown should still use stop() for graceful behavior.
impl Drop for RustProxy {
fn drop(&mut self) {
self.cancel_token.cancel();
if let Some(handle) = self.sampling_handle.take() {
handle.abort();
}
if let Some(handle) = self.renewal_handle.take() {
handle.abort();
}
// Cancel the listener manager's token and abort accept loops
if let Some(ref mut listener) = self.listener_manager {
listener.stop_all();
}
}
}

View File

@@ -1,3 +1,6 @@
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
use clap::Parser; use clap::Parser;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use anyhow::Result; use anyhow::Result;
@@ -29,6 +32,11 @@ struct Cli {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Install the default CryptoProvider early, before any TLS or ACME code runs.
// This prevents panics from instant-acme/hyper-rustls calling ClientConfig::builder()
// before TLS listeners have started. Idempotent — later calls harmlessly return Err.
let _ = rustls::crypto::ring::default_provider().install_default();
let cli = Cli::parse(); let cli = Cli::parse();
// Initialize tracing - write to stderr so stdout is reserved for management IPC // Initialize tracing - write to stderr so stdout is reserved for management IPC

View File

@@ -147,8 +147,8 @@ async fn handle_request(
"renewCertificate" => handle_renew_certificate(&id, &request.params, proxy).await, "renewCertificate" => handle_renew_certificate(&id, &request.params, proxy).await,
"getCertificateStatus" => handle_get_certificate_status(&id, &request.params, proxy).await, "getCertificateStatus" => handle_get_certificate_status(&id, &request.params, proxy).await,
"getListeningPorts" => handle_get_listening_ports(&id, proxy), "getListeningPorts" => handle_get_listening_ports(&id, proxy),
"getNftablesStatus" => handle_get_nftables_status(&id, proxy).await,
"setSocketHandlerRelay" => handle_set_socket_handler_relay(&id, &request.params, proxy).await, "setSocketHandlerRelay" => handle_set_socket_handler_relay(&id, &request.params, proxy).await,
"setDatagramHandlerRelay" => handle_set_datagram_handler_relay(&id, &request.params, proxy).await,
"addListeningPort" => handle_add_listening_port(&id, &request.params, proxy).await, "addListeningPort" => handle_add_listening_port(&id, &request.params, proxy).await,
"removeListeningPort" => handle_remove_listening_port(&id, &request.params, proxy).await, "removeListeningPort" => handle_remove_listening_port(&id, &request.params, proxy).await,
"loadCertificate" => handle_load_certificate(&id, &request.params, proxy).await, "loadCertificate" => handle_load_certificate(&id, &request.params, proxy).await,
@@ -351,26 +351,6 @@ fn handle_get_listening_ports(
} }
} }
async fn handle_get_nftables_status(
id: &str,
proxy: &Option<RustProxy>,
) -> ManagementResponse {
match proxy.as_ref() {
Some(p) => {
match p.get_nftables_status().await {
Ok(status) => {
match serde_json::to_value(&status) {
Ok(v) => ManagementResponse::ok(id.to_string(), v),
Err(e) => ManagementResponse::err(id.to_string(), format!("Failed to serialize: {}", e)),
}
}
Err(e) => ManagementResponse::err(id.to_string(), format!("Failed to get status: {}", e)),
}
}
None => ManagementResponse::ok(id.to_string(), serde_json::json!({})),
}
}
async fn handle_set_socket_handler_relay( async fn handle_set_socket_handler_relay(
id: &str, id: &str,
params: &serde_json::Value, params: &serde_json::Value,
@@ -391,6 +371,26 @@ async fn handle_set_socket_handler_relay(
ManagementResponse::ok(id.to_string(), serde_json::json!({})) ManagementResponse::ok(id.to_string(), serde_json::json!({}))
} }
async fn handle_set_datagram_handler_relay(
id: &str,
params: &serde_json::Value,
proxy: &mut Option<RustProxy>,
) -> ManagementResponse {
let p = match proxy.as_mut() {
Some(p) => p,
None => return ManagementResponse::err(id.to_string(), "Proxy is not running".to_string()),
};
let socket_path = params.get("socketPath")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
info!("setDatagramHandlerRelay: socket_path={:?}", socket_path);
p.set_datagram_handler_relay_path(socket_path).await;
ManagementResponse::ok(id.to_string(), serde_json::json!({}))
}
async fn handle_add_listening_port( async fn handle_add_listening_port(
id: &str, id: &str,
params: &serde_json::Value, params: &serde_json::Value,

View File

@@ -185,6 +185,79 @@ pub async fn wait_for_port(port: u16, timeout_ms: u64) -> bool {
false false
} }
/// Start a TLS HTTP echo backend: accepts TLS, then responds with HTTP JSON
/// containing request details. Combines TLS acceptance with HTTP echo behavior.
pub async fn start_tls_http_backend(
port: u16,
backend_name: &str,
cert_pem: &str,
key_pem: &str,
) -> JoinHandle<()> {
use std::sync::Arc;
// Use h1-only acceptor: test backends speak raw HTTP/1.1 text,
// so they must NOT advertise h2 via ALPN (which would cause
// auto-detect to attempt h2 binary framing and fail).
let acceptor = rustproxy_passthrough::build_tls_acceptor_h1_only(cert_pem, key_pem)
.expect("Failed to build TLS acceptor");
let acceptor = Arc::new(acceptor);
let name = backend_name.to_string();
let listener = TcpListener::bind(format!("127.0.0.1:{}", port))
.await
.unwrap_or_else(|_| panic!("Failed to bind TLS HTTP backend on port {}", port));
tokio::spawn(async move {
loop {
let (stream, _) = match listener.accept().await {
Ok(conn) => conn,
Err(_) => break,
};
let acc = acceptor.clone();
let backend = name.clone();
tokio::spawn(async move {
let mut tls_stream = match acc.accept(stream).await {
Ok(s) => s,
Err(_) => return,
};
let mut buf = vec![0u8; 16384];
let n = match tls_stream.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
let req_str = String::from_utf8_lossy(&buf[..n]);
// Parse first line: METHOD PATH HTTP/x.x
let first_line = req_str.lines().next().unwrap_or("");
let parts: Vec<&str> = first_line.split_whitespace().collect();
let method = parts.first().copied().unwrap_or("UNKNOWN");
let path = parts.get(1).copied().unwrap_or("/");
// Extract Host header
let host = req_str
.lines()
.find(|l| l.to_lowercase().starts_with("host:"))
.map(|l| l[5..].trim())
.unwrap_or("unknown");
let body = format!(
r#"{{"method":"{}","path":"{}","host":"{}","backend":"{}"}}"#,
method, path, host, backend
);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body,
);
let _ = tls_stream.write_all(response.as_bytes()).await;
let _ = tls_stream.shutdown().await;
});
}
})
}
/// Helper to create a minimal route config for testing. /// Helper to create a minimal route config for testing.
pub fn make_test_route( pub fn make_test_route(
port: u16, port: u16,
@@ -196,11 +269,13 @@ pub fn make_test_route(
id: None, id: None,
route_match: rustproxy_config::RouteMatch { route_match: rustproxy_config::RouteMatch {
ports: rustproxy_config::PortRange::Single(port), ports: rustproxy_config::PortRange::Single(port),
transport: None,
domains: domain.map(|d| rustproxy_config::DomainSpec::Single(d.to_string())), domains: domain.map(|d| rustproxy_config::DomainSpec::Single(d.to_string())),
path: None, path: None,
client_ip: None, client_ip: None,
tls_version: None, tls_version: None,
headers: None, headers: None,
protocol: None,
}, },
action: rustproxy_config::RouteAction { action: rustproxy_config::RouteAction {
action_type: rustproxy_config::RouteActionType::Forward, action_type: rustproxy_config::RouteActionType::Forward,
@@ -214,6 +289,7 @@ pub fn make_test_route(
send_proxy_protocol: None, send_proxy_protocol: None,
headers: None, headers: None,
advanced: None, advanced: None,
backend_transport: None,
priority: None, priority: None,
}]), }]),
tls: None, tls: None,
@@ -221,9 +297,8 @@ pub fn make_test_route(
load_balancing: None, load_balancing: None,
advanced: None, advanced: None,
options: None, options: None,
forwarding_engine: None,
nftables: None,
send_proxy_protocol: None, send_proxy_protocol: None,
udp: None,
}, },
headers: None, headers: None,
security: None, security: None,
@@ -381,6 +456,86 @@ pub fn make_tls_terminate_route(
route route
} }
/// Start a TLS WebSocket echo backend: accepts TLS, performs WS handshake, then echoes data.
/// Combines TLS acceptance (like `start_tls_http_backend`) with WebSocket echo (like `start_ws_echo_backend`).
pub async fn start_tls_ws_echo_backend(
port: u16,
cert_pem: &str,
key_pem: &str,
) -> JoinHandle<()> {
use std::sync::Arc;
let acceptor = rustproxy_passthrough::build_tls_acceptor(cert_pem, key_pem)
.expect("Failed to build TLS acceptor");
let acceptor = Arc::new(acceptor);
let listener = TcpListener::bind(format!("127.0.0.1:{}", port))
.await
.unwrap_or_else(|_| panic!("Failed to bind TLS WS echo backend on port {}", port));
tokio::spawn(async move {
loop {
let (stream, _) = match listener.accept().await {
Ok(conn) => conn,
Err(_) => break,
};
let acc = acceptor.clone();
tokio::spawn(async move {
let mut tls_stream = match acc.accept(stream).await {
Ok(s) => s,
Err(_) => return,
};
// Read the HTTP upgrade request
let mut buf = vec![0u8; 4096];
let n = match tls_stream.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
let req_str = String::from_utf8_lossy(&buf[..n]);
// Extract Sec-WebSocket-Key for handshake
let ws_key = req_str
.lines()
.find(|l| l.to_lowercase().starts_with("sec-websocket-key:"))
.map(|l| l.split(':').nth(1).unwrap_or("").trim().to_string())
.unwrap_or_default();
// Send 101 Switching Protocols
let accept_response = format!(
"HTTP/1.1 101 Switching Protocols\r\n\
Upgrade: websocket\r\n\
Connection: Upgrade\r\n\
Sec-WebSocket-Accept: {}\r\n\
\r\n",
ws_key
);
if tls_stream
.write_all(accept_response.as_bytes())
.await
.is_err()
{
return;
}
// Echo all data back (raw TCP after upgrade)
let mut echo_buf = vec![0u8; 65536];
loop {
let n = match tls_stream.read(&mut echo_buf).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
if tls_stream.write_all(&echo_buf[..n]).await.is_err() {
break;
}
}
});
}
})
}
/// Helper to create a TLS passthrough route for testing. /// Helper to create a TLS passthrough route for testing.
pub fn make_tls_passthrough_route( pub fn make_tls_passthrough_route(
port: u16, port: u16,

View File

@@ -0,0 +1,195 @@
mod common;
use common::*;
use rustproxy::RustProxy;
use rustproxy_config::{RustProxyOptions, TransportProtocol, RouteUdp, RouteQuic};
use bytes::Buf;
use std::sync::Arc;
/// Build a route that listens on UDP with HTTP/3 enabled and TLS terminate.
fn make_h3_route(
port: u16,
target_host: &str,
target_port: u16,
cert_pem: &str,
key_pem: &str,
) -> rustproxy_config::RouteConfig {
let mut route = make_tls_terminate_route(port, "localhost", target_host, target_port, cert_pem, key_pem);
route.route_match.transport = Some(TransportProtocol::All);
// Keep domain="localhost" from make_tls_terminate_route — needed for TLS cert extraction
route.action.udp = Some(RouteUdp {
session_timeout: None,
max_sessions_per_ip: None,
max_datagram_size: None,
quic: Some(RouteQuic {
max_idle_timeout: Some(30000),
max_concurrent_bidi_streams: None,
max_concurrent_uni_streams: None,
enable_http3: Some(true),
alt_svc_port: None,
alt_svc_max_age: None,
initial_congestion_window: None,
}),
});
route
}
/// Build a quinn client endpoint with insecure TLS for testing.
fn make_h3_client_endpoint() -> quinn::Endpoint {
let mut tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(InsecureVerifier))
.with_no_client_auth();
tls_config.alpn_protocols = vec![b"h3".to_vec()];
let quic_client_config = quinn::crypto::rustls::QuicClientConfig::try_from(tls_config)
.expect("Failed to build QUIC client config");
let client_config = quinn::ClientConfig::new(Arc::new(quic_client_config));
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())
.expect("Failed to create QUIC client endpoint");
endpoint.set_default_client_config(client_config);
endpoint
}
/// Test that HTTP/3 response streams properly finish (FIN is received by client).
///
/// This is the critical regression test for the FIN bug: the proxy must send
/// a QUIC stream FIN after the response body so the client's `recv_data()`
/// returns `None` instead of hanging forever.
#[tokio::test]
async fn test_h3_response_stream_finishes() {
let backend_port = next_port();
let proxy_port = next_port();
let body_text = "Hello from HTTP/3 backend! This body has a known length for testing.";
// 1. Start plain HTTP backend with known body + content-length
let _backend = start_http_server(backend_port, 200, body_text).await;
// 2. Generate self-signed cert and configure H3 route
let (cert_pem, key_pem) = generate_self_signed_cert("localhost");
let route = make_h3_route(proxy_port, "127.0.0.1", backend_port, &cert_pem, &key_pem);
let options = RustProxyOptions {
routes: vec![route],
..Default::default()
};
// 3. Start proxy and wait for UDP bind
let mut proxy = RustProxy::new(options).unwrap();
proxy.start().await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// 4. Connect QUIC/H3 client
let endpoint = make_h3_client_endpoint();
let addr: std::net::SocketAddr = format!("127.0.0.1:{}", proxy_port).parse().unwrap();
let connection = endpoint
.connect(addr, "localhost")
.expect("Failed to initiate QUIC connection")
.await
.expect("QUIC handshake failed");
let (mut driver, mut send_request) = h3::client::new(
h3_quinn::Connection::new(connection),
)
.await
.expect("H3 connection setup failed");
// Drive the H3 connection in background
tokio::spawn(async move {
let _ = driver.wait_idle().await;
});
// 5. Send GET request
let req = http::Request::builder()
.method("GET")
.uri("https://localhost/")
.header("host", "localhost")
.body(())
.unwrap();
let mut stream = send_request.send_request(req).await
.expect("Failed to send H3 request");
stream.finish().await
.expect("Failed to finish sending H3 request body");
// 6. Read response headers
let resp = stream.recv_response().await
.expect("Failed to receive H3 response");
assert_eq!(resp.status(), http::StatusCode::OK,
"Expected 200 OK, got {}", resp.status());
// 7. Read body and verify stream ends (FIN received)
// This is the critical assertion: recv_data() must return None (stream ended)
// within the timeout, NOT hang forever waiting for a FIN that never arrives.
let result = with_timeout(async {
let mut total = 0usize;
while let Some(chunk) = stream.recv_data().await.expect("H3 data receive error") {
total += chunk.remaining();
}
// recv_data() returned None => stream ended (FIN received)
total
}, 10)
.await;
let bytes_received = result.expect(
"TIMEOUT: H3 stream never ended (FIN not received by client). \
The proxy sent all response data but failed to send the QUIC stream FIN."
);
assert_eq!(
bytes_received,
body_text.len(),
"Expected {} bytes, got {}",
body_text.len(),
bytes_received
);
// 8. Cleanup
endpoint.close(quinn::VarInt::from_u32(0), b"test done");
proxy.stop().await.unwrap();
}
/// Insecure TLS verifier that accepts any certificate (for tests only).
#[derive(Debug)]
struct InsecureVerifier;
impl rustls::client::danger::ServerCertVerifier for InsecureVerifier {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::RSA_PSS_SHA256,
]
}
}

View File

@@ -407,6 +407,305 @@ async fn test_websocket_through_proxy() {
proxy.stop().await.unwrap(); proxy.stop().await.unwrap();
} }
/// Test that terminate-and-reencrypt mode routes HTTP traffic through the
/// full HTTP proxy with per-request Host-based routing.
///
/// This verifies the new behavior: after TLS termination, HTTP data is detected
/// and routed through HttpProxyService (like nginx) instead of being blindly tunneled.
#[tokio::test]
async fn test_terminate_and_reencrypt_http_routing() {
let backend1_port = next_port();
let backend2_port = next_port();
let proxy_port = next_port();
let (cert1, key1) = generate_self_signed_cert("alpha.example.com");
let (cert2, key2) = generate_self_signed_cert("beta.example.com");
// Generate separate backend certs (backends are independent TLS servers)
let (backend_cert1, backend_key1) = generate_self_signed_cert("localhost");
let (backend_cert2, backend_key2) = generate_self_signed_cert("localhost");
// Start TLS HTTP echo backends (proxy re-encrypts to these)
let _b1 = start_tls_http_backend(backend1_port, "alpha", &backend_cert1, &backend_key1).await;
let _b2 = start_tls_http_backend(backend2_port, "beta", &backend_cert2, &backend_key2).await;
// Create terminate-and-reencrypt routes
let mut route1 = make_tls_terminate_route(
proxy_port, "alpha.example.com", "127.0.0.1", backend1_port, &cert1, &key1,
);
route1.action.tls.as_mut().unwrap().mode = rustproxy_config::TlsMode::TerminateAndReencrypt;
let mut route2 = make_tls_terminate_route(
proxy_port, "beta.example.com", "127.0.0.1", backend2_port, &cert2, &key2,
);
route2.action.tls.as_mut().unwrap().mode = rustproxy_config::TlsMode::TerminateAndReencrypt;
let options = RustProxyOptions {
routes: vec![route1, route2],
..Default::default()
};
let mut proxy = RustProxy::new(options).unwrap();
proxy.start().await.unwrap();
assert!(wait_for_port(proxy_port, 2000).await);
// Test alpha domain - HTTP request through TLS terminate-and-reencrypt
let alpha_result = with_timeout(async {
let _ = rustls::crypto::ring::default_provider().install_default();
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(std::sync::Arc::new(InsecureVerifier))
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(tls_config));
let stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", proxy_port))
.await
.unwrap();
let server_name = rustls::pki_types::ServerName::try_from("alpha.example.com".to_string()).unwrap();
let mut tls_stream = connector.connect(server_name, stream).await.unwrap();
let request = "GET /api/data HTTP/1.1\r\nHost: alpha.example.com\r\nConnection: close\r\n\r\n";
tls_stream.write_all(request.as_bytes()).await.unwrap();
let mut response = Vec::new();
tls_stream.read_to_end(&mut response).await.unwrap();
String::from_utf8_lossy(&response).to_string()
}, 10)
.await
.unwrap();
let alpha_body = extract_body(&alpha_result);
assert!(
alpha_body.contains(r#""backend":"alpha"#),
"Expected alpha backend, got: {}",
alpha_body
);
assert!(
alpha_body.contains(r#""method":"GET"#),
"Expected GET method, got: {}",
alpha_body
);
assert!(
alpha_body.contains(r#""path":"/api/data"#),
"Expected /api/data path, got: {}",
alpha_body
);
// Verify original Host header is preserved (not replaced with backend IP:port)
assert!(
alpha_body.contains(r#""host":"alpha.example.com"#),
"Expected original Host header alpha.example.com, got: {}",
alpha_body
);
// Test beta domain - different host goes to different backend
let beta_result = with_timeout(async {
let _ = rustls::crypto::ring::default_provider().install_default();
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(std::sync::Arc::new(InsecureVerifier))
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(tls_config));
let stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", proxy_port))
.await
.unwrap();
let server_name = rustls::pki_types::ServerName::try_from("beta.example.com".to_string()).unwrap();
let mut tls_stream = connector.connect(server_name, stream).await.unwrap();
let request = "GET /other HTTP/1.1\r\nHost: beta.example.com\r\nConnection: close\r\n\r\n";
tls_stream.write_all(request.as_bytes()).await.unwrap();
let mut response = Vec::new();
tls_stream.read_to_end(&mut response).await.unwrap();
String::from_utf8_lossy(&response).to_string()
}, 10)
.await
.unwrap();
let beta_body = extract_body(&beta_result);
assert!(
beta_body.contains(r#""backend":"beta"#),
"Expected beta backend, got: {}",
beta_body
);
assert!(
beta_body.contains(r#""path":"/other"#),
"Expected /other path, got: {}",
beta_body
);
// Verify original Host header is preserved for beta too
assert!(
beta_body.contains(r#""host":"beta.example.com"#),
"Expected original Host header beta.example.com, got: {}",
beta_body
);
proxy.stop().await.unwrap();
}
/// Test that WebSocket upgrade works through terminate-and-reencrypt mode.
///
/// Verifies the full chain: client→TLS→proxy terminates→re-encrypts→TLS→backend WebSocket.
/// The proxy's `handle_websocket_upgrade` checks `upstream.use_tls` and calls
/// `connect_tls_backend()` when true. This test covers that path.
#[tokio::test]
async fn test_terminate_and_reencrypt_websocket() {
let backend_port = next_port();
let proxy_port = next_port();
let domain = "ws.example.com";
// Frontend cert (client→proxy TLS)
let (frontend_cert, frontend_key) = generate_self_signed_cert(domain);
// Backend cert (proxy→backend TLS)
let (backend_cert, backend_key) = generate_self_signed_cert("localhost");
// Start TLS WebSocket echo backend
let _backend = start_tls_ws_echo_backend(backend_port, &backend_cert, &backend_key).await;
// Create terminate-and-reencrypt route
let mut route = make_tls_terminate_route(
proxy_port,
domain,
"127.0.0.1",
backend_port,
&frontend_cert,
&frontend_key,
);
route.action.tls.as_mut().unwrap().mode = rustproxy_config::TlsMode::TerminateAndReencrypt;
let options = RustProxyOptions {
routes: vec![route],
..Default::default()
};
let mut proxy = RustProxy::new(options).unwrap();
proxy.start().await.unwrap();
assert!(wait_for_port(proxy_port, 2000).await);
let result = with_timeout(
async {
let _ = rustls::crypto::ring::default_provider().install_default();
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(std::sync::Arc::new(InsecureVerifier))
.with_no_client_auth();
let connector =
tokio_rustls::TlsConnector::from(std::sync::Arc::new(tls_config));
let stream = tokio::net::TcpStream::connect(format!("127.0.0.1:{}", proxy_port))
.await
.unwrap();
let server_name =
rustls::pki_types::ServerName::try_from(domain.to_string()).unwrap();
let mut tls_stream = connector.connect(server_name, stream).await.unwrap();
// Send WebSocket upgrade request through TLS
let request = format!(
"GET /ws HTTP/1.1\r\n\
Host: {}\r\n\
Upgrade: websocket\r\n\
Connection: Upgrade\r\n\
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
Sec-WebSocket-Version: 13\r\n\
\r\n",
domain
);
tls_stream.write_all(request.as_bytes()).await.unwrap();
// Read the 101 response (byte-by-byte until \r\n\r\n)
let mut response_buf = Vec::with_capacity(4096);
let mut temp = [0u8; 1];
loop {
let n = tls_stream.read(&mut temp).await.unwrap();
if n == 0 {
break;
}
response_buf.push(temp[0]);
if response_buf.len() >= 4 {
let len = response_buf.len();
if response_buf[len - 4..] == *b"\r\n\r\n" {
break;
}
}
}
let response_str = String::from_utf8_lossy(&response_buf).to_string();
assert!(
response_str.contains("101"),
"Expected 101 Switching Protocols, got: {}",
response_str
);
assert!(
response_str.to_lowercase().contains("upgrade: websocket"),
"Expected Upgrade header, got: {}",
response_str
);
// After upgrade, send data and verify echo
let test_data = b"Hello TLS WebSocket!";
tls_stream.write_all(test_data).await.unwrap();
// Read echoed data
let mut echo_buf = vec![0u8; 256];
let n = tls_stream.read(&mut echo_buf).await.unwrap();
let echoed = &echo_buf[..n];
assert_eq!(echoed, test_data, "Expected echo of sent data");
"ok".to_string()
},
10,
)
.await
.unwrap();
assert_eq!(result, "ok");
proxy.stop().await.unwrap();
}
/// Test that the protocol field on route config is accepted and processed.
#[tokio::test]
async fn test_protocol_field_in_route_config() {
let backend_port = next_port();
let proxy_port = next_port();
let _backend = start_http_echo_backend(backend_port, "main").await;
// Create a route with protocol: "http" - should only match HTTP traffic
let mut route = make_test_route(proxy_port, None, "127.0.0.1", backend_port);
route.route_match.protocol = Some("http".to_string());
let options = RustProxyOptions {
routes: vec![route],
..Default::default()
};
let mut proxy = RustProxy::new(options).unwrap();
proxy.start().await.unwrap();
assert!(wait_for_port(proxy_port, 2000).await);
// HTTP request should match the route and get proxied
let result = with_timeout(async {
let response = send_http_request(proxy_port, "example.com", "GET", "/test").await;
extract_body(&response).to_string()
}, 10)
.await
.unwrap();
assert!(
result.contains(r#""backend":"main"#),
"Expected main backend, got: {}",
result
);
assert!(
result.contains(r#""path":"/test"#),
"Expected /test path, got: {}",
result
);
proxy.stop().await.unwrap();
}
/// InsecureVerifier for test TLS client connections. /// InsecureVerifier for test TLS client connections.
#[derive(Debug)] #[derive(Debug)]
struct InsecureVerifier; struct InsecureVerifier;

View File

@@ -1,200 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
delay,
retryWithBackoff,
withTimeout,
parallelLimit,
debounceAsync,
AsyncMutex,
CircuitBreaker
} from '../../../ts/core/utils/async-utils.js';
tap.test('delay should pause execution for specified milliseconds', async () => {
const startTime = Date.now();
await delay(100);
const elapsed = Date.now() - startTime;
// Allow some tolerance for timing
expect(elapsed).toBeGreaterThan(90);
expect(elapsed).toBeLessThan(150);
});
tap.test('retryWithBackoff should retry failed operations', async () => {
let attempts = 0;
const operation = async () => {
attempts++;
if (attempts < 3) {
throw new Error('Test error');
}
return 'success';
};
const result = await retryWithBackoff(operation, {
maxAttempts: 3,
initialDelay: 10
});
expect(result).toEqual('success');
expect(attempts).toEqual(3);
});
tap.test('retryWithBackoff should throw after max attempts', async () => {
let attempts = 0;
const operation = async () => {
attempts++;
throw new Error('Always fails');
};
let error: Error | null = null;
try {
await retryWithBackoff(operation, {
maxAttempts: 2,
initialDelay: 10
});
} catch (e: any) {
error = e;
}
expect(error).not.toBeNull();
expect(error?.message).toEqual('Always fails');
expect(attempts).toEqual(2);
});
tap.test('withTimeout should complete operations within timeout', async () => {
const operation = async () => {
await delay(50);
return 'completed';
};
const result = await withTimeout(operation, 100);
expect(result).toEqual('completed');
});
tap.test('withTimeout should throw on timeout', async () => {
const operation = async () => {
await delay(200);
return 'never happens';
};
let error: Error | null = null;
try {
await withTimeout(operation, 50);
} catch (e: any) {
error = e;
}
expect(error).not.toBeNull();
expect(error?.message).toContain('timed out');
});
tap.test('parallelLimit should respect concurrency limit', async () => {
let concurrent = 0;
let maxConcurrent = 0;
const items = [1, 2, 3, 4, 5, 6];
const operation = async (item: number) => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await delay(50);
concurrent--;
return item * 2;
};
const results = await parallelLimit(items, operation, 2);
expect(results).toEqual([2, 4, 6, 8, 10, 12]);
expect(maxConcurrent).toBeLessThan(3);
expect(maxConcurrent).toBeGreaterThan(0);
});
tap.test('debounceAsync should debounce function calls', async () => {
let callCount = 0;
const fn = async (value: string) => {
callCount++;
return value;
};
const debounced = debounceAsync(fn, 50);
// Make multiple calls quickly
debounced('a');
debounced('b');
debounced('c');
const result = await debounced('d');
// Wait a bit to ensure no more calls
await delay(100);
expect(result).toEqual('d');
expect(callCount).toEqual(1); // Only the last call should execute
});
tap.test('AsyncMutex should ensure exclusive access', async () => {
const mutex = new AsyncMutex();
const results: number[] = [];
const operation = async (value: number) => {
await mutex.runExclusive(async () => {
results.push(value);
await delay(10);
results.push(value * 10);
});
};
// Run operations concurrently
await Promise.all([
operation(1),
operation(2),
operation(3)
]);
// Results should show sequential execution
expect(results).toEqual([1, 10, 2, 20, 3, 30]);
});
tap.test('CircuitBreaker should open after failures', async () => {
const breaker = new CircuitBreaker({
failureThreshold: 2,
resetTimeout: 100
});
let attempt = 0;
const failingOperation = async () => {
attempt++;
throw new Error('Test failure');
};
// First two failures
for (let i = 0; i < 2; i++) {
try {
await breaker.execute(failingOperation);
} catch (e) {
// Expected
}
}
expect(breaker.isOpen()).toBeTrue();
// Next attempt should fail immediately
let error: Error | null = null;
try {
await breaker.execute(failingOperation);
} catch (e: any) {
error = e;
}
expect(error?.message).toEqual('Circuit breaker is open');
expect(attempt).toEqual(2); // Operation not called when circuit is open
// Wait for reset timeout
await delay(150);
// Circuit should be half-open now, allowing one attempt
const successOperation = async () => 'success';
const result = await breaker.execute(successOperation);
expect(result).toEqual('success');
expect(breaker.getState()).toEqual('closed');
});
tap.start();

View File

@@ -1,206 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { BinaryHeap } from '../../../ts/core/utils/binary-heap.js';
interface TestItem {
id: string;
priority: number;
value: string;
}
tap.test('should create empty heap', async () => {
const heap = new BinaryHeap<number>((a, b) => a - b);
expect(heap.size).toEqual(0);
expect(heap.isEmpty()).toBeTrue();
expect(heap.peek()).toBeUndefined();
});
tap.test('should insert and extract in correct order', async () => {
const heap = new BinaryHeap<number>((a, b) => a - b);
heap.insert(5);
heap.insert(3);
heap.insert(7);
heap.insert(1);
heap.insert(9);
heap.insert(4);
expect(heap.size).toEqual(6);
// Extract in ascending order
expect(heap.extract()).toEqual(1);
expect(heap.extract()).toEqual(3);
expect(heap.extract()).toEqual(4);
expect(heap.extract()).toEqual(5);
expect(heap.extract()).toEqual(7);
expect(heap.extract()).toEqual(9);
expect(heap.extract()).toBeUndefined();
});
tap.test('should work with custom objects and comparator', async () => {
const heap = new BinaryHeap<TestItem>(
(a, b) => a.priority - b.priority,
(item) => item.id
);
heap.insert({ id: 'a', priority: 5, value: 'five' });
heap.insert({ id: 'b', priority: 2, value: 'two' });
heap.insert({ id: 'c', priority: 8, value: 'eight' });
heap.insert({ id: 'd', priority: 1, value: 'one' });
const first = heap.extract();
expect(first?.priority).toEqual(1);
expect(first?.value).toEqual('one');
const second = heap.extract();
expect(second?.priority).toEqual(2);
expect(second?.value).toEqual('two');
});
tap.test('should support reverse order (max heap)', async () => {
const heap = new BinaryHeap<number>((a, b) => b - a);
heap.insert(5);
heap.insert(3);
heap.insert(7);
heap.insert(1);
heap.insert(9);
// Extract in descending order
expect(heap.extract()).toEqual(9);
expect(heap.extract()).toEqual(7);
expect(heap.extract()).toEqual(5);
});
tap.test('should extract by predicate', async () => {
const heap = new BinaryHeap<TestItem>((a, b) => a.priority - b.priority);
heap.insert({ id: 'a', priority: 5, value: 'five' });
heap.insert({ id: 'b', priority: 2, value: 'two' });
heap.insert({ id: 'c', priority: 8, value: 'eight' });
const extracted = heap.extractIf(item => item.id === 'b');
expect(extracted?.id).toEqual('b');
expect(heap.size).toEqual(2);
// Should not find it again
const notFound = heap.extractIf(item => item.id === 'b');
expect(notFound).toBeUndefined();
});
tap.test('should extract by key', async () => {
const heap = new BinaryHeap<TestItem>(
(a, b) => a.priority - b.priority,
(item) => item.id
);
heap.insert({ id: 'a', priority: 5, value: 'five' });
heap.insert({ id: 'b', priority: 2, value: 'two' });
heap.insert({ id: 'c', priority: 8, value: 'eight' });
expect(heap.hasKey('b')).toBeTrue();
const extracted = heap.extractByKey('b');
expect(extracted?.id).toEqual('b');
expect(heap.size).toEqual(2);
expect(heap.hasKey('b')).toBeFalse();
// Should not find it again
const notFound = heap.extractByKey('b');
expect(notFound).toBeUndefined();
});
tap.test('should throw when using key operations without extractKey', async () => {
const heap = new BinaryHeap<TestItem>((a, b) => a.priority - b.priority);
heap.insert({ id: 'a', priority: 5, value: 'five' });
let error: Error | null = null;
try {
heap.extractByKey('a');
} catch (e: any) {
error = e;
}
expect(error).not.toBeNull();
expect(error?.message).toContain('extractKey function must be provided');
});
tap.test('should handle duplicates correctly', async () => {
const heap = new BinaryHeap<number>((a, b) => a - b);
heap.insert(5);
heap.insert(5);
heap.insert(5);
heap.insert(3);
heap.insert(7);
expect(heap.size).toEqual(5);
expect(heap.extract()).toEqual(3);
expect(heap.extract()).toEqual(5);
expect(heap.extract()).toEqual(5);
expect(heap.extract()).toEqual(5);
expect(heap.extract()).toEqual(7);
});
tap.test('should convert to array without modifying heap', async () => {
const heap = new BinaryHeap<number>((a, b) => a - b);
heap.insert(5);
heap.insert(3);
heap.insert(7);
const array = heap.toArray();
expect(array).toContain(3);
expect(array).toContain(5);
expect(array).toContain(7);
expect(array.length).toEqual(3);
// Heap should still be intact
expect(heap.size).toEqual(3);
expect(heap.extract()).toEqual(3);
});
tap.test('should clear the heap', async () => {
const heap = new BinaryHeap<TestItem>(
(a, b) => a.priority - b.priority,
(item) => item.id
);
heap.insert({ id: 'a', priority: 5, value: 'five' });
heap.insert({ id: 'b', priority: 2, value: 'two' });
expect(heap.size).toEqual(2);
expect(heap.hasKey('a')).toBeTrue();
heap.clear();
expect(heap.size).toEqual(0);
expect(heap.isEmpty()).toBeTrue();
expect(heap.hasKey('a')).toBeFalse();
});
tap.test('should handle complex extraction patterns', async () => {
const heap = new BinaryHeap<number>((a, b) => a - b);
// Insert numbers 1-10 in random order
[8, 3, 5, 9, 1, 7, 4, 10, 2, 6].forEach(n => heap.insert(n));
// Extract some in order
expect(heap.extract()).toEqual(1);
expect(heap.extract()).toEqual(2);
// Insert more
heap.insert(0);
heap.insert(1.5);
// Continue extracting
expect(heap.extract()).toEqual(0);
expect(heap.extract()).toEqual(1.5);
expect(heap.extract()).toEqual(3);
// Verify remaining size (10 - 2 extracted + 2 inserted - 3 extracted = 7)
expect(heap.size).toEqual(7);
});
tap.start();

View File

@@ -1,185 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as path from 'path';
import { AsyncFileSystem } from '../../../ts/core/utils/fs-utils.js';
// Use a temporary directory for tests
const testDir = path.join(process.cwd(), '.nogit', 'test-fs-utils');
const testFile = path.join(testDir, 'test.txt');
const testJsonFile = path.join(testDir, 'test.json');
tap.test('should create and check directory existence', async () => {
// Ensure directory
await AsyncFileSystem.ensureDir(testDir);
// Check it exists
const exists = await AsyncFileSystem.exists(testDir);
expect(exists).toBeTrue();
// Check it's a directory
const isDir = await AsyncFileSystem.isDirectory(testDir);
expect(isDir).toBeTrue();
});
tap.test('should write and read text files', async () => {
const testContent = 'Hello, async filesystem!';
// Write file
await AsyncFileSystem.writeFile(testFile, testContent);
// Check file exists
const exists = await AsyncFileSystem.exists(testFile);
expect(exists).toBeTrue();
// Read file
const content = await AsyncFileSystem.readFile(testFile);
expect(content).toEqual(testContent);
// Check it's a file
const isFile = await AsyncFileSystem.isFile(testFile);
expect(isFile).toBeTrue();
});
tap.test('should write and read JSON files', async () => {
const testData = {
name: 'Test',
value: 42,
nested: {
array: [1, 2, 3]
}
};
// Write JSON
await AsyncFileSystem.writeJSON(testJsonFile, testData);
// Read JSON
const readData = await AsyncFileSystem.readJSON(testJsonFile);
expect(readData).toEqual(testData);
});
tap.test('should copy files', async () => {
const copyFile = path.join(testDir, 'copy.txt');
// Copy file
await AsyncFileSystem.copyFile(testFile, copyFile);
// Check copy exists
const exists = await AsyncFileSystem.exists(copyFile);
expect(exists).toBeTrue();
// Check content matches
const content = await AsyncFileSystem.readFile(copyFile);
const originalContent = await AsyncFileSystem.readFile(testFile);
expect(content).toEqual(originalContent);
});
tap.test('should move files', async () => {
const moveFile = path.join(testDir, 'moved.txt');
const copyFile = path.join(testDir, 'copy.txt');
// Move file
await AsyncFileSystem.moveFile(copyFile, moveFile);
// Check moved file exists
const movedExists = await AsyncFileSystem.exists(moveFile);
expect(movedExists).toBeTrue();
// Check original doesn't exist
const originalExists = await AsyncFileSystem.exists(copyFile);
expect(originalExists).toBeFalse();
});
tap.test('should list files in directory', async () => {
const files = await AsyncFileSystem.listFiles(testDir);
expect(files).toContain('test.txt');
expect(files).toContain('test.json');
expect(files).toContain('moved.txt');
});
tap.test('should list files with full paths', async () => {
const files = await AsyncFileSystem.listFilesFullPath(testDir);
const fileNames = files.map(f => path.basename(f));
expect(fileNames).toContain('test.txt');
expect(fileNames).toContain('test.json');
// All paths should be absolute
files.forEach(file => {
expect(path.isAbsolute(file)).toBeTrue();
});
});
tap.test('should get file stats', async () => {
const stats = await AsyncFileSystem.getStats(testFile);
expect(stats).not.toBeNull();
expect(stats?.isFile()).toBeTrue();
expect(stats?.size).toBeGreaterThan(0);
});
tap.test('should handle non-existent files gracefully', async () => {
const nonExistent = path.join(testDir, 'does-not-exist.txt');
// Check existence
const exists = await AsyncFileSystem.exists(nonExistent);
expect(exists).toBeFalse();
// Get stats should return null
const stats = await AsyncFileSystem.getStats(nonExistent);
expect(stats).toBeNull();
// Remove should not throw
await AsyncFileSystem.remove(nonExistent);
});
tap.test('should remove files', async () => {
// Remove a file
await AsyncFileSystem.remove(testFile);
// Check it's gone
const exists = await AsyncFileSystem.exists(testFile);
expect(exists).toBeFalse();
});
tap.test('should ensure file exists', async () => {
const ensureFile = path.join(testDir, 'ensure.txt');
// Ensure file
await AsyncFileSystem.ensureFile(ensureFile);
// Check it exists
const exists = await AsyncFileSystem.exists(ensureFile);
expect(exists).toBeTrue();
// Check it's empty
const content = await AsyncFileSystem.readFile(ensureFile);
expect(content).toEqual('');
});
tap.test('should recursively list files', async () => {
// Create subdirectory with file
const subDir = path.join(testDir, 'subdir');
const subFile = path.join(subDir, 'nested.txt');
await AsyncFileSystem.ensureDir(subDir);
await AsyncFileSystem.writeFile(subFile, 'nested content');
// List recursively
const files = await AsyncFileSystem.listFilesRecursive(testDir);
// Should include files from subdirectory
const fileNames = files.map(f => path.relative(testDir, f));
expect(fileNames).toContain('test.json');
expect(fileNames).toContain(path.join('subdir', 'nested.txt'));
});
tap.test('should clean up test directory', async () => {
// Remove entire test directory
await AsyncFileSystem.removeDir(testDir);
// Check it's gone
const exists = await AsyncFileSystem.exists(testDir);
expect(exists).toBeFalse();
});
tap.start();

View File

@@ -1,156 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { IpUtils } from '../../../ts/core/utils/ip-utils.js';
tap.test('ip-utils - normalizeIP', async () => {
// IPv4 normalization
const ipv4Variants = IpUtils.normalizeIP('127.0.0.1');
expect(ipv4Variants).toEqual(['127.0.0.1', '::ffff:127.0.0.1']);
// IPv6-mapped IPv4 normalization
const ipv6MappedVariants = IpUtils.normalizeIP('::ffff:127.0.0.1');
expect(ipv6MappedVariants).toEqual(['::ffff:127.0.0.1', '127.0.0.1']);
// IPv6 normalization
const ipv6Variants = IpUtils.normalizeIP('::1');
expect(ipv6Variants).toEqual(['::1']);
// Invalid/empty input handling
expect(IpUtils.normalizeIP('')).toEqual([]);
expect(IpUtils.normalizeIP(null as any)).toEqual([]);
expect(IpUtils.normalizeIP(undefined as any)).toEqual([]);
});
tap.test('ip-utils - isGlobIPMatch', async () => {
// Direct matches
expect(IpUtils.isGlobIPMatch('127.0.0.1', ['127.0.0.1'])).toEqual(true);
expect(IpUtils.isGlobIPMatch('::1', ['::1'])).toEqual(true);
// Wildcard matches
expect(IpUtils.isGlobIPMatch('127.0.0.1', ['127.0.0.*'])).toEqual(true);
expect(IpUtils.isGlobIPMatch('127.0.0.1', ['127.0.*.*'])).toEqual(true);
expect(IpUtils.isGlobIPMatch('127.0.0.1', ['127.*.*.*'])).toEqual(true);
// IPv4-mapped IPv6 handling
expect(IpUtils.isGlobIPMatch('::ffff:127.0.0.1', ['127.0.0.1'])).toEqual(true);
expect(IpUtils.isGlobIPMatch('127.0.0.1', ['::ffff:127.0.0.1'])).toEqual(true);
// Match multiple patterns
expect(IpUtils.isGlobIPMatch('127.0.0.1', ['10.0.0.1', '127.0.0.1', '192.168.1.1'])).toEqual(true);
// Non-matching patterns
expect(IpUtils.isGlobIPMatch('127.0.0.1', ['10.0.0.1'])).toEqual(false);
expect(IpUtils.isGlobIPMatch('127.0.0.1', ['128.0.0.1'])).toEqual(false);
expect(IpUtils.isGlobIPMatch('127.0.0.1', ['127.0.0.2'])).toEqual(false);
// Edge cases
expect(IpUtils.isGlobIPMatch('', ['127.0.0.1'])).toEqual(false);
expect(IpUtils.isGlobIPMatch('127.0.0.1', [])).toEqual(false);
expect(IpUtils.isGlobIPMatch('127.0.0.1', null as any)).toEqual(false);
expect(IpUtils.isGlobIPMatch(null as any, ['127.0.0.1'])).toEqual(false);
});
tap.test('ip-utils - isIPAuthorized', async () => {
// Basic tests to check the core functionality works
// No restrictions - all IPs allowed
expect(IpUtils.isIPAuthorized('127.0.0.1')).toEqual(true);
// Basic blocked IP test
const blockedIP = '8.8.8.8';
const blockedIPs = [blockedIP];
expect(IpUtils.isIPAuthorized(blockedIP, [], blockedIPs)).toEqual(false);
// Basic allowed IP test
const allowedIP = '10.0.0.1';
const allowedIPs = [allowedIP];
expect(IpUtils.isIPAuthorized(allowedIP, allowedIPs)).toEqual(true);
expect(IpUtils.isIPAuthorized('192.168.1.1', allowedIPs)).toEqual(false);
});
tap.test('ip-utils - isPrivateIP', async () => {
// Private IPv4 ranges
expect(IpUtils.isPrivateIP('10.0.0.1')).toEqual(true);
expect(IpUtils.isPrivateIP('172.16.0.1')).toEqual(true);
expect(IpUtils.isPrivateIP('172.31.255.255')).toEqual(true);
expect(IpUtils.isPrivateIP('192.168.0.1')).toEqual(true);
expect(IpUtils.isPrivateIP('127.0.0.1')).toEqual(true);
// Public IPv4 addresses
expect(IpUtils.isPrivateIP('8.8.8.8')).toEqual(false);
expect(IpUtils.isPrivateIP('203.0.113.1')).toEqual(false);
// IPv4-mapped IPv6 handling
expect(IpUtils.isPrivateIP('::ffff:10.0.0.1')).toEqual(true);
expect(IpUtils.isPrivateIP('::ffff:8.8.8.8')).toEqual(false);
// Private IPv6 addresses
expect(IpUtils.isPrivateIP('::1')).toEqual(true);
expect(IpUtils.isPrivateIP('fd00::')).toEqual(true);
expect(IpUtils.isPrivateIP('fe80::1')).toEqual(true);
// Public IPv6 addresses
expect(IpUtils.isPrivateIP('2001:db8::1')).toEqual(false);
// Edge cases
expect(IpUtils.isPrivateIP('')).toEqual(false);
expect(IpUtils.isPrivateIP(null as any)).toEqual(false);
expect(IpUtils.isPrivateIP(undefined as any)).toEqual(false);
});
tap.test('ip-utils - isPublicIP', async () => {
// Public IPv4 addresses
expect(IpUtils.isPublicIP('8.8.8.8')).toEqual(true);
expect(IpUtils.isPublicIP('203.0.113.1')).toEqual(true);
// Private IPv4 ranges
expect(IpUtils.isPublicIP('10.0.0.1')).toEqual(false);
expect(IpUtils.isPublicIP('172.16.0.1')).toEqual(false);
expect(IpUtils.isPublicIP('192.168.0.1')).toEqual(false);
expect(IpUtils.isPublicIP('127.0.0.1')).toEqual(false);
// Public IPv6 addresses
expect(IpUtils.isPublicIP('2001:db8::1')).toEqual(true);
// Private IPv6 addresses
expect(IpUtils.isPublicIP('::1')).toEqual(false);
expect(IpUtils.isPublicIP('fd00::')).toEqual(false);
expect(IpUtils.isPublicIP('fe80::1')).toEqual(false);
// Edge cases - the implementation treats these as non-private, which is technically correct but might not be what users expect
const emptyResult = IpUtils.isPublicIP('');
expect(emptyResult).toEqual(true);
const nullResult = IpUtils.isPublicIP(null as any);
expect(nullResult).toEqual(true);
const undefinedResult = IpUtils.isPublicIP(undefined as any);
expect(undefinedResult).toEqual(true);
});
tap.test('ip-utils - cidrToGlobPatterns', async () => {
// Class C network
const classC = IpUtils.cidrToGlobPatterns('192.168.1.0/24');
expect(classC).toEqual(['192.168.1.*']);
// Class B network
const classB = IpUtils.cidrToGlobPatterns('172.16.0.0/16');
expect(classB).toEqual(['172.16.*.*']);
// Class A network
const classA = IpUtils.cidrToGlobPatterns('10.0.0.0/8');
expect(classA).toEqual(['10.*.*.*']);
// Small subnet (/28 = 16 addresses)
const smallSubnet = IpUtils.cidrToGlobPatterns('192.168.1.0/28');
expect(smallSubnet.length).toEqual(16);
expect(smallSubnet).toContain('192.168.1.0');
expect(smallSubnet).toContain('192.168.1.15');
// Invalid inputs
expect(IpUtils.cidrToGlobPatterns('')).toEqual([]);
expect(IpUtils.cidrToGlobPatterns('192.168.1.0')).toEqual([]);
expect(IpUtils.cidrToGlobPatterns('192.168.1.0/')).toEqual([]);
expect(IpUtils.cidrToGlobPatterns('192.168.1.0/33')).toEqual([]);
expect(IpUtils.cidrToGlobPatterns('invalid/24')).toEqual([]);
});
export default tap.start();

View File

@@ -1,252 +0,0 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { LifecycleComponent } from '../../../ts/core/utils/lifecycle-component.js';
import { EventEmitter } from 'events';
// Test implementation of LifecycleComponent
class TestComponent extends LifecycleComponent {
public timerCallCount = 0;
public intervalCallCount = 0;
public cleanupCalled = false;
public testEmitter = new EventEmitter();
public listenerCallCount = 0;
constructor() {
super();
this.setupTimers();
this.setupListeners();
}
private setupTimers() {
// Set up a timeout
this.setTimeout(() => {
this.timerCallCount++;
}, 100);
// Set up an interval
this.setInterval(() => {
this.intervalCallCount++;
}, 50);
}
private setupListeners() {
this.addEventListener(this.testEmitter, 'test-event', () => {
this.listenerCallCount++;
});
}
protected async onCleanup(): Promise<void> {
this.cleanupCalled = true;
}
// Expose protected methods for testing
public testSetTimeout(handler: Function, timeout: number): NodeJS.Timeout {
return this.setTimeout(handler, timeout);
}
public testSetInterval(handler: Function, interval: number): NodeJS.Timeout {
return this.setInterval(handler, interval);
}
public testClearTimeout(timer: NodeJS.Timeout): void {
return this.clearTimeout(timer);
}
public testClearInterval(timer: NodeJS.Timeout): void {
return this.clearInterval(timer);
}
public testAddEventListener(target: any, event: string, handler: Function, options?: { once?: boolean }): void {
return this.addEventListener(target, event, handler, options);
}
public testIsShuttingDown(): boolean {
return this.isShuttingDownState();
}
}
tap.test('should manage timers properly', async () => {
const component = new TestComponent();
// Wait for timers to fire
await new Promise(resolve => setTimeout(resolve, 200));
expect(component.timerCallCount).toEqual(1);
expect(component.intervalCallCount).toBeGreaterThan(2);
await component.cleanup();
});
tap.test('should manage event listeners properly', async () => {
const component = new TestComponent();
// Emit events
component.testEmitter.emit('test-event');
component.testEmitter.emit('test-event');
expect(component.listenerCallCount).toEqual(2);
// Cleanup and verify listeners are removed
await component.cleanup();
component.testEmitter.emit('test-event');
expect(component.listenerCallCount).toEqual(2); // Should not increase
});
tap.test('should prevent timer execution after cleanup', async () => {
const component = new TestComponent();
let laterCallCount = 0;
component.testSetTimeout(() => {
laterCallCount++;
}, 100);
// Cleanup immediately
await component.cleanup();
// Wait for timer that would have fired
await new Promise(resolve => setTimeout(resolve, 150));
expect(laterCallCount).toEqual(0);
});
tap.test('should handle child components', async () => {
class ParentComponent extends LifecycleComponent {
public child: TestComponent;
constructor() {
super();
this.child = new TestComponent();
this.registerChildComponent(this.child);
}
}
const parent = new ParentComponent();
// Wait for child timers
await new Promise(resolve => setTimeout(resolve, 100));
expect(parent.child.timerCallCount).toEqual(1);
// Cleanup parent should cleanup child
await parent.cleanup();
expect(parent.child.cleanupCalled).toBeTrue();
expect(parent.child.testIsShuttingDown()).toBeTrue();
});
tap.test('should handle multiple cleanup calls gracefully', async () => {
const component = new TestComponent();
// Call cleanup multiple times
const promises = [
component.cleanup(),
component.cleanup(),
component.cleanup()
];
await Promise.all(promises);
// Should only clean up once
expect(component.cleanupCalled).toBeTrue();
});
tap.test('should clear specific timers', async () => {
const component = new TestComponent();
let callCount = 0;
const timer = component.testSetTimeout(() => {
callCount++;
}, 100);
// Clear the timer
component.testClearTimeout(timer);
// Wait and verify it didn't fire
await new Promise(resolve => setTimeout(resolve, 150));
expect(callCount).toEqual(0);
await component.cleanup();
});
tap.test('should clear specific intervals', async () => {
const component = new TestComponent();
let callCount = 0;
const interval = component.testSetInterval(() => {
callCount++;
}, 50);
// Let it run a bit
await new Promise(resolve => setTimeout(resolve, 120));
const countBeforeClear = callCount;
expect(countBeforeClear).toBeGreaterThan(1);
// Clear the interval
component.testClearInterval(interval);
// Wait and verify it stopped
await new Promise(resolve => setTimeout(resolve, 100));
expect(callCount).toEqual(countBeforeClear);
await component.cleanup();
});
tap.test('should handle once event listeners', async () => {
const component = new TestComponent();
const emitter = new EventEmitter();
let callCount = 0;
const handler = () => {
callCount++;
};
component.testAddEventListener(emitter, 'once-event', handler, { once: true });
// Check listener count before emit
const beforeCount = emitter.listenerCount('once-event');
expect(beforeCount).toEqual(1);
// Emit once - the listener should fire and auto-remove
emitter.emit('once-event');
expect(callCount).toEqual(1);
// Check listener was auto-removed
const afterCount = emitter.listenerCount('once-event');
expect(afterCount).toEqual(0);
// Emit again - should not increase count
emitter.emit('once-event');
expect(callCount).toEqual(1);
await component.cleanup();
});
tap.test('should not create timers when shutting down', async () => {
const component = new TestComponent();
// Start cleanup
const cleanupPromise = component.cleanup();
// Try to create timers during shutdown
let timerFired = false;
let intervalFired = false;
component.testSetTimeout(() => {
timerFired = true;
}, 10);
component.testSetInterval(() => {
intervalFired = true;
}, 10);
await cleanupPromise;
await new Promise(resolve => setTimeout(resolve, 50));
expect(timerFired).toBeFalse();
expect(intervalFired).toBeFalse();
});
export default tap.start();

View File

@@ -1,158 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SharedSecurityManager } from '../../../ts/core/utils/shared-security-manager.js';
import type { IRouteConfig, IRouteContext } from '../../../ts/proxies/smart-proxy/models/route-types.js';
// Test security manager
tap.test('Shared Security Manager', async () => {
let securityManager: SharedSecurityManager;
// Set up a new security manager for each test
securityManager = new SharedSecurityManager({
maxConnectionsPerIP: 5,
connectionRateLimitPerMinute: 10
});
tap.test('should validate IPs correctly', async () => {
// Should allow IPs under connection limit
expect(securityManager.validateIP('192.168.1.1').allowed).toBeTrue();
// Track multiple connections
for (let i = 0; i < 4; i++) {
securityManager.trackConnectionByIP('192.168.1.1', `conn_${i}`);
}
// Should still allow IPs under connection limit
expect(securityManager.validateIP('192.168.1.1').allowed).toBeTrue();
// Add one more to reach the limit
securityManager.trackConnectionByIP('192.168.1.1', 'conn_4');
// Should now block IPs over connection limit
expect(securityManager.validateIP('192.168.1.1').allowed).toBeFalse();
// Remove a connection
securityManager.removeConnectionByIP('192.168.1.1', 'conn_0');
// Should allow again after connection is removed
expect(securityManager.validateIP('192.168.1.1').allowed).toBeTrue();
});
tap.test('should authorize IPs based on allow/block lists', async () => {
// Test with allow list only
expect(securityManager.isIPAuthorized('192.168.1.1', ['192.168.1.*'])).toBeTrue();
expect(securityManager.isIPAuthorized('192.168.2.1', ['192.168.1.*'])).toBeFalse();
// Test with block list
expect(securityManager.isIPAuthorized('192.168.1.5', ['*'], ['192.168.1.5'])).toBeFalse();
expect(securityManager.isIPAuthorized('192.168.1.1', ['*'], ['192.168.1.5'])).toBeTrue();
// Test with both allow and block lists
expect(securityManager.isIPAuthorized('192.168.1.1', ['192.168.1.*'], ['192.168.1.5'])).toBeTrue();
expect(securityManager.isIPAuthorized('192.168.1.5', ['192.168.1.*'], ['192.168.1.5'])).toBeFalse();
});
tap.test('should validate route access', async () => {
const route: IRouteConfig = {
match: {
ports: [8080]
},
action: {
type: 'forward',
targets: [{ host: 'target.com', port: 443 }]
},
security: {
ipAllowList: ['10.0.0.*', '192.168.1.*'],
ipBlockList: ['192.168.1.100'],
maxConnections: 3
}
};
const allowedContext: IRouteContext = {
clientIp: '192.168.1.1',
port: 8080,
serverIp: '127.0.0.1',
isTls: false,
timestamp: Date.now(),
connectionId: 'test_conn_1'
};
const blockedByIPContext: IRouteContext = {
...allowedContext,
clientIp: '192.168.1.100'
};
const blockedByRangeContext: IRouteContext = {
...allowedContext,
clientIp: '172.16.0.1'
};
const blockedByMaxConnectionsContext: IRouteContext = {
...allowedContext,
connectionId: 'test_conn_4'
};
expect(securityManager.isAllowed(route, allowedContext)).toBeTrue();
expect(securityManager.isAllowed(route, blockedByIPContext)).toBeFalse();
expect(securityManager.isAllowed(route, blockedByRangeContext)).toBeFalse();
// Test max connections for route - assuming implementation has been updated
if ((securityManager as any).trackConnectionByRoute) {
(securityManager as any).trackConnectionByRoute(route, 'conn_1');
(securityManager as any).trackConnectionByRoute(route, 'conn_2');
(securityManager as any).trackConnectionByRoute(route, 'conn_3');
// Should now block due to max connections
expect(securityManager.isAllowed(route, blockedByMaxConnectionsContext)).toBeFalse();
}
});
tap.test('should clean up expired entries', async () => {
const route: IRouteConfig = {
match: {
ports: [8080]
},
action: {
type: 'forward',
targets: [{ host: 'target.com', port: 443 }]
},
security: {
rateLimit: {
enabled: true,
maxRequests: 5,
window: 60 // 60 seconds
}
}
};
const context: IRouteContext = {
clientIp: '192.168.1.1',
port: 8080,
serverIp: '127.0.0.1',
isTls: false,
timestamp: Date.now(),
connectionId: 'test_conn_1'
};
// Test rate limiting if method exists
if ((securityManager as any).checkRateLimit) {
// Add 5 attempts (max allowed)
for (let i = 0; i < 5; i++) {
expect((securityManager as any).checkRateLimit(route, context)).toBeTrue();
}
// Should now be blocked
expect((securityManager as any).checkRateLimit(route, context)).toBeFalse();
// Force cleanup (normally runs periodically)
if ((securityManager as any).cleanup) {
(securityManager as any).cleanup();
}
// Should still be blocked since entries are not expired yet
expect((securityManager as any).checkRateLimit(route, context)).toBeFalse();
}
});
});
// Export test runner
export default tap.start();

View File

@@ -1,302 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { ValidationUtils } from '../../../ts/core/utils/validation-utils.js';
import type { IDomainOptions, IAcmeOptions } from '../../../ts/core/models/common-types.js';
tap.test('validation-utils - isValidPort', async () => {
// Valid port values
expect(ValidationUtils.isValidPort(1)).toEqual(true);
expect(ValidationUtils.isValidPort(80)).toEqual(true);
expect(ValidationUtils.isValidPort(443)).toEqual(true);
expect(ValidationUtils.isValidPort(8080)).toEqual(true);
expect(ValidationUtils.isValidPort(65535)).toEqual(true);
// Invalid port values
expect(ValidationUtils.isValidPort(0)).toEqual(false);
expect(ValidationUtils.isValidPort(-1)).toEqual(false);
expect(ValidationUtils.isValidPort(65536)).toEqual(false);
expect(ValidationUtils.isValidPort(80.5)).toEqual(false);
expect(ValidationUtils.isValidPort(NaN)).toEqual(false);
expect(ValidationUtils.isValidPort(null as any)).toEqual(false);
expect(ValidationUtils.isValidPort(undefined as any)).toEqual(false);
});
tap.test('validation-utils - isValidDomainName', async () => {
// Valid domain names
expect(ValidationUtils.isValidDomainName('example.com')).toEqual(true);
expect(ValidationUtils.isValidDomainName('sub.example.com')).toEqual(true);
expect(ValidationUtils.isValidDomainName('*.example.com')).toEqual(true);
expect(ValidationUtils.isValidDomainName('a-hyphenated-domain.example.com')).toEqual(true);
expect(ValidationUtils.isValidDomainName('example123.com')).toEqual(true);
// Invalid domain names
expect(ValidationUtils.isValidDomainName('')).toEqual(false);
expect(ValidationUtils.isValidDomainName(null as any)).toEqual(false);
expect(ValidationUtils.isValidDomainName(undefined as any)).toEqual(false);
expect(ValidationUtils.isValidDomainName('-invalid.com')).toEqual(false);
expect(ValidationUtils.isValidDomainName('invalid-.com')).toEqual(false);
expect(ValidationUtils.isValidDomainName('inv@lid.com')).toEqual(false);
expect(ValidationUtils.isValidDomainName('example')).toEqual(false);
expect(ValidationUtils.isValidDomainName('example.')).toEqual(false);
});
tap.test('validation-utils - isValidEmail', async () => {
// Valid email addresses
expect(ValidationUtils.isValidEmail('user@example.com')).toEqual(true);
expect(ValidationUtils.isValidEmail('admin@sub.example.com')).toEqual(true);
expect(ValidationUtils.isValidEmail('first.last@example.com')).toEqual(true);
expect(ValidationUtils.isValidEmail('user+tag@example.com')).toEqual(true);
// Invalid email addresses
expect(ValidationUtils.isValidEmail('')).toEqual(false);
expect(ValidationUtils.isValidEmail(null as any)).toEqual(false);
expect(ValidationUtils.isValidEmail(undefined as any)).toEqual(false);
expect(ValidationUtils.isValidEmail('user')).toEqual(false);
expect(ValidationUtils.isValidEmail('user@')).toEqual(false);
expect(ValidationUtils.isValidEmail('@example.com')).toEqual(false);
expect(ValidationUtils.isValidEmail('user example.com')).toEqual(false);
});
tap.test('validation-utils - isValidCertificate', async () => {
// Valid certificate format
const validCert = `-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUJlq+zz9CO2E91rlD4vhx0CX1Z/kwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMzAxMDEwMDAwMDBaFw0yNDAx
MDEwMDAwMDBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQC0aQeHIV9vQpZ4UVwW/xhx9zl01UbppLXdoqe3NP9x
KfXTCB1YbtJ4GgKIlQqHGLGsLI5ZOE7KxmJeGEwK7ueP4f3WkUlM5C5yTbZ5hSUo
R+OFnszFRJJiBXJlw57YAW9+zqKQHYxwve64O64dlgw6pekDYJhXtrUUZ78Lz0GX
veJvCrci1M4Xk6/7/p1Ii9PNmbPKqHafdmkFLf6TXiWPuRDhPuHW7cXyE8xD5ahr
NsDuwJyRUk+GS4/oJg0TqLSiD0IPxDH50V5MSfUIB82i+lc1t+OAGwLhjUDuQmJi
Pv1+9Zvv+HA5PXBCsGXnSADrOOUO6t9q5R9PXbSvAgMBAAGjUzBRMB0GA1UdDgQW
BBQEtdtBhH/z1XyIf+y+5O9ErDGCVjAfBgNVHSMEGDAWgBQEtdtBhH/z1XyIf+y+
5O9ErDGCVjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBmJyQ0
r0pBJkYJJVDJ6i3WMoEEFTD8MEUkWxASHRnuMzm7XlZ8WS1HvbEWF0+WfJPCYHnk
tGbvUFGaZ4qUxZ4Ip2mvKXoeYTJCZRxxhHeSVWnZZu0KS3X7xVAFwQYQNhdLOqP8
XOHyLhHV/1/kcFd3GvKKjXxE79jUUZ/RXHZ/IY50KvxGzWc/5ZOFYrPEW1/rNlRo
7ixXo1hNnBQsG1YoFAxTBGegdTFJeTYHYjZZ5XlRvY2aBq6QveRbJGJLcPm1UQMd
HQYxacbWSVAQf3ltYwSH+y3a97C5OsJJiQXpRRJlQKL3txklzcpg3E5swhr63bM2
jUoNXr5G5Q5h3GD5
-----END CERTIFICATE-----`;
expect(ValidationUtils.isValidCertificate(validCert)).toEqual(true);
// Invalid certificate format
expect(ValidationUtils.isValidCertificate('')).toEqual(false);
expect(ValidationUtils.isValidCertificate(null as any)).toEqual(false);
expect(ValidationUtils.isValidCertificate(undefined as any)).toEqual(false);
expect(ValidationUtils.isValidCertificate('invalid certificate')).toEqual(false);
expect(ValidationUtils.isValidCertificate('-----BEGIN CERTIFICATE-----')).toEqual(false);
});
tap.test('validation-utils - isValidPrivateKey', async () => {
// Valid private key format
const validKey = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0aQeHIV9vQpZ4
UVwW/xhx9zl01UbppLXdoqe3NP9xKfXTCB1YbtJ4GgKIlQqHGLGsLI5ZOE7KxmJe
GEwK7ueP4f3WkUlM5C5yTbZ5hSUoR+OFnszFRJJiBXJlw57YAW9+zqKQHYxwve64
O64dlgw6pekDYJhXtrUUZ78Lz0GXveJvCrci1M4Xk6/7/p1Ii9PNmbPKqHafdmkF
Lf6TXiWPuRDhPuHW7cXyE8xD5ahrNsDuwJyRUk+GS4/oJg0TqLSiD0IPxDH50V5M
SfUIB82i+lc1t+OAGwLhjUDuQmJiPv1+9Zvv+HA5PXBCsGXnSADrOOUO6t9q5R9P
XbSvAgMBAAECggEADw8Xx9iEv3FvS8hYIRn2ZWM8ObRgbHkFN92NJ/5RvUwgyV03
gG8GwVN+7IsVLnIQRyIYEGGJ0ZLZFIq7//Jy0jYUgEGLmXxknuZQn1cQEqqYVyBr
G9JrfKkXaDEoP/bZBMvZ0KEO2C9Vq6mY8M0h0GxDT2y6UQnQYjH3+H6Rvhbhh+Ld
n8lCJqWoW1t9GOUZ4xLsZ5jEDibcMJJzLBWYRxgHWyECK31/VtEQDKFiUcymrJ3I
/zoDEDGbp1gdJHvlCxfSLJ2za7ErtRKRXYFRhZ9QkNSXl1pVFMqRQkedXIcA1/Cs
VpUxiIE2JA3hSrv2csjmXoGJKDLVCvZ3CFxKL3u/AQKBgQDf6MxHXN3IDuJNrJP7
0gyRbO5d6vcvP/8qiYjtEt2xB2MNt5jDz9Bxl6aKEdNW2+UE0rvXXT6KAMZv9LiF
hxr5qiJmmSB8OeGfr0W4FCixGN4BkRNwfT1gUqZgQOrfMOLHNXOksc1CJwHJfROV
h6AH+gjtF2BCXnVEHcqtRklk4QKBgQDOOYnLJn1CwgFAyRUYK8LQYKnrLp2cGn7N
YH0SLf+VnCu7RCeNr3dm9FoHBCynjkx+qv9kGvCaJuZqEJ7+7IimNUZfDjwXTOJ+
pzs8kEPN5EQOcbkmYCTQyOA0YeBuEXcv5xIZRZUYQvKg1xXOe/JhAQ4siVIMhgQL
2XR3QwzRDwKBgB7rjZs2VYnuVExGr74lUUAGoZ71WCgt9Du9aYGJfNUriDtTEWAd
VT5sKgVqpRwkY/zXujdxGr+K8DZu4vSdHBLcDLQsEBvRZIILTzjwXBRPGMnVe95v
Q90+vytbmHshlkbMaVRNQxCjdbf7LbQbLecgRt+5BKxHVwL4u3BZNIqhAoGAas4f
PoPOdFfKAMKZL7FLGMhEXLyFsg1JcGRfmByxTNgOJKXpYv5Hl7JLYOvfaiUOUYKI
5Dnh5yLdFOaOjnB3iP0KEiSVEwZK0/Vna5JkzFTqImK9QD3SQCtQLXHJLD52EPFR
9gRa8N5k68+mIzGDEzPBoC1AajbXFGPxNOwaQQ0CgYEAq0dPYK0TTv3Yez27LzVy
RbHkwpE+df4+KhpHbCzUKzfQYo4WTahlR6IzhpOyVQKIptkjuTDyQzkmt0tXEGw3
/M3yHa1FcY9IzPrHXHJoOeU1r9ay0GOQUi4FxKkYYWxUCtjOi5xlUxI0ABD8vGGR
QbKMrQXRgLd/84nDnY2cYzA=
-----END PRIVATE KEY-----`;
expect(ValidationUtils.isValidPrivateKey(validKey)).toEqual(true);
// Invalid private key format
expect(ValidationUtils.isValidPrivateKey('')).toEqual(false);
expect(ValidationUtils.isValidPrivateKey(null as any)).toEqual(false);
expect(ValidationUtils.isValidPrivateKey(undefined as any)).toEqual(false);
expect(ValidationUtils.isValidPrivateKey('invalid key')).toEqual(false);
expect(ValidationUtils.isValidPrivateKey('-----BEGIN PRIVATE KEY-----')).toEqual(false);
});
tap.test('validation-utils - validateDomainOptions', async () => {
// Valid domain options
const validDomainOptions: IDomainOptions = {
domainName: 'example.com',
sslRedirect: true,
acmeMaintenance: true
};
expect(ValidationUtils.validateDomainOptions(validDomainOptions).isValid).toEqual(true);
// Valid domain options with forward
const validDomainOptionsWithForward: IDomainOptions = {
domainName: 'example.com',
sslRedirect: true,
acmeMaintenance: true,
forward: {
ip: '127.0.0.1',
port: 8080
}
};
expect(ValidationUtils.validateDomainOptions(validDomainOptionsWithForward).isValid).toEqual(true);
// Invalid domain options - no domain name
const invalidDomainOptions1: IDomainOptions = {
domainName: '',
sslRedirect: true,
acmeMaintenance: true
};
expect(ValidationUtils.validateDomainOptions(invalidDomainOptions1).isValid).toEqual(false);
// Invalid domain options - invalid domain name
const invalidDomainOptions2: IDomainOptions = {
domainName: 'inv@lid.com',
sslRedirect: true,
acmeMaintenance: true
};
expect(ValidationUtils.validateDomainOptions(invalidDomainOptions2).isValid).toEqual(false);
// Invalid domain options - forward missing ip
const invalidDomainOptions3: IDomainOptions = {
domainName: 'example.com',
sslRedirect: true,
acmeMaintenance: true,
forward: {
ip: '',
port: 8080
}
};
expect(ValidationUtils.validateDomainOptions(invalidDomainOptions3).isValid).toEqual(false);
// Invalid domain options - forward missing port
const invalidDomainOptions4: IDomainOptions = {
domainName: 'example.com',
sslRedirect: true,
acmeMaintenance: true,
forward: {
ip: '127.0.0.1',
port: null as any
}
};
expect(ValidationUtils.validateDomainOptions(invalidDomainOptions4).isValid).toEqual(false);
// Invalid domain options - invalid forward port
const invalidDomainOptions5: IDomainOptions = {
domainName: 'example.com',
sslRedirect: true,
acmeMaintenance: true,
forward: {
ip: '127.0.0.1',
port: 99999
}
};
expect(ValidationUtils.validateDomainOptions(invalidDomainOptions5).isValid).toEqual(false);
});
tap.test('validation-utils - validateAcmeOptions', async () => {
// Valid ACME options
const validAcmeOptions: IAcmeOptions = {
enabled: true,
accountEmail: 'admin@example.com',
port: 80,
httpsRedirectPort: 443,
useProduction: false,
renewThresholdDays: 30,
renewCheckIntervalHours: 24,
certificateStore: './certs'
};
expect(ValidationUtils.validateAcmeOptions(validAcmeOptions).isValid).toEqual(true);
// ACME disabled - should be valid regardless of other options
const disabledAcmeOptions: IAcmeOptions = {
enabled: false
};
// Don't need to verify other fields when ACME is disabled
const disabledResult = ValidationUtils.validateAcmeOptions(disabledAcmeOptions);
expect(disabledResult.isValid).toEqual(true);
// Invalid ACME options - missing email
const invalidAcmeOptions1: IAcmeOptions = {
enabled: true,
accountEmail: '',
port: 80
};
expect(ValidationUtils.validateAcmeOptions(invalidAcmeOptions1).isValid).toEqual(false);
// Invalid ACME options - invalid email
const invalidAcmeOptions2: IAcmeOptions = {
enabled: true,
accountEmail: 'invalid-email',
port: 80
};
expect(ValidationUtils.validateAcmeOptions(invalidAcmeOptions2).isValid).toEqual(false);
// Invalid ACME options - invalid port
const invalidAcmeOptions3: IAcmeOptions = {
enabled: true,
accountEmail: 'admin@example.com',
port: 99999
};
expect(ValidationUtils.validateAcmeOptions(invalidAcmeOptions3).isValid).toEqual(false);
// Invalid ACME options - invalid HTTPS redirect port
const invalidAcmeOptions4: IAcmeOptions = {
enabled: true,
accountEmail: 'admin@example.com',
port: 80,
httpsRedirectPort: -1
};
expect(ValidationUtils.validateAcmeOptions(invalidAcmeOptions4).isValid).toEqual(false);
// Invalid ACME options - invalid renew threshold days
const invalidAcmeOptions5: IAcmeOptions = {
enabled: true,
accountEmail: 'admin@example.com',
port: 80,
renewThresholdDays: 0
};
// The implementation allows renewThresholdDays of 0, even though the docstring suggests otherwise
const validationResult5 = ValidationUtils.validateAcmeOptions(invalidAcmeOptions5);
expect(validationResult5.isValid).toEqual(true);
// Invalid ACME options - invalid renew check interval hours
const invalidAcmeOptions6: IAcmeOptions = {
enabled: true,
accountEmail: 'admin@example.com',
port: 80,
renewCheckIntervalHours: 0
};
// The implementation should validate this, but let's check the actual result
const checkIntervalResult = ValidationUtils.validateAcmeOptions(invalidAcmeOptions6);
// Adjust test to match actual implementation behavior
expect(checkIntervalResult.isValid !== false ? true : false).toEqual(true);
});
export default tap.start();

View File

@@ -0,0 +1,70 @@
import * as net from 'net';
/**
* Finds `count` free ports by binding to port 0 and reading the OS-assigned port.
* All servers are opened simultaneously to guarantee uniqueness.
* Returns an array of guaranteed-free ports.
*/
export async function findFreePorts(count: number): Promise<number[]> {
const servers: net.Server[] = [];
const ports: number[] = [];
// Open all servers simultaneously on port 0
await Promise.all(
Array.from({ length: count }, () =>
new Promise<void>((resolve, reject) => {
const server = net.createServer();
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as net.AddressInfo;
ports.push(addr.port);
servers.push(server);
resolve();
});
server.on('error', reject);
})
)
);
// Close all servers
await Promise.all(
servers.map(
(server) => new Promise<void>((resolve) => server.close(() => resolve()))
)
);
return ports;
}
/**
* Verifies that all given ports are free (not listening).
* Useful as a cleanup assertion at the end of tests.
* Throws if any port is still in use.
*/
export async function assertPortsFree(ports: number[]): Promise<void> {
const results = await Promise.all(
ports.map(
(port) =>
new Promise<{ port: number; free: boolean }>((resolve) => {
const client = net.connect({ port, host: '127.0.0.1' });
client.on('connect', () => {
client.destroy();
resolve({ port, free: false });
});
client.on('error', () => {
resolve({ port, free: true });
});
client.setTimeout(1000, () => {
client.destroy();
resolve({ port, free: true });
});
})
)
);
const occupied = results.filter((r) => !r.free);
if (occupied.length > 0) {
throw new Error(
`Ports still in use after cleanup: ${occupied.map((r) => r.port).join(', ')}`
);
}
}

View File

@@ -1,9 +1,12 @@
import { tap, expect } from '@git.zone/tstest/tapbundle'; import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SmartProxy, SocketHandlers } from '../ts/index.js'; import { SmartProxy, SocketHandlers } from '../ts/index.js';
import * as net from 'net'; import * as net from 'net';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
// Test that HTTP-01 challenges are properly processed when the initial data arrives // Test that HTTP-01 challenges are properly processed when the initial data arrives
tap.test('should correctly handle HTTP-01 challenge requests with initial data chunk', async (tapTest) => { tap.test('should correctly handle HTTP-01 challenge requests with initial data chunk', async (tapTest) => {
const [PORT] = await findFreePorts(1);
// Prepare test data // Prepare test data
const challengeToken = 'test-acme-http01-challenge-token'; const challengeToken = 'test-acme-http01-challenge-token';
const challengeResponse = 'mock-response-for-challenge'; const challengeResponse = 'mock-response-for-challenge';
@@ -37,7 +40,7 @@ tap.test('should correctly handle HTTP-01 challenge requests with initial data c
routes: [{ routes: [{
name: 'acme-challenge-route', name: 'acme-challenge-route',
match: { match: {
ports: 8080, ports: PORT,
path: '/.well-known/acme-challenge/*' path: '/.well-known/acme-challenge/*'
}, },
action: { action: {
@@ -60,7 +63,7 @@ tap.test('should correctly handle HTTP-01 challenge requests with initial data c
// Connect to the proxy and send the HTTP-01 challenge request // Connect to the proxy and send the HTTP-01 challenge request
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
testClient.connect(8080, 'localhost', () => { testClient.connect(PORT, 'localhost', () => {
// Send HTTP request for the challenge token // Send HTTP request for the challenge token
testClient.write( testClient.write(
`GET ${challengePath} HTTP/1.1\r\n` + `GET ${challengePath} HTTP/1.1\r\n` +
@@ -86,10 +89,13 @@ tap.test('should correctly handle HTTP-01 challenge requests with initial data c
// Cleanup // Cleanup
testClient.destroy(); testClient.destroy();
await proxy.stop(); await proxy.stop();
await assertPortsFree([PORT]);
}); });
// Test that non-existent challenge tokens return 404 // Test that non-existent challenge tokens return 404
tap.test('should return 404 for non-existent challenge tokens', async (tapTest) => { tap.test('should return 404 for non-existent challenge tokens', async (tapTest) => {
const [PORT] = await findFreePorts(1);
// Create a socket handler that behaves like a real ACME handler // Create a socket handler that behaves like a real ACME handler
const acmeHandler = SocketHandlers.httpServer((req, res) => { const acmeHandler = SocketHandlers.httpServer((req, res) => {
if (req.url?.startsWith('/.well-known/acme-challenge/')) { if (req.url?.startsWith('/.well-known/acme-challenge/')) {
@@ -113,7 +119,7 @@ tap.test('should return 404 for non-existent challenge tokens', async (tapTest)
routes: [{ routes: [{
name: 'acme-challenge-route', name: 'acme-challenge-route',
match: { match: {
ports: 8081, ports: PORT,
path: '/.well-known/acme-challenge/*' path: '/.well-known/acme-challenge/*'
}, },
action: { action: {
@@ -135,7 +141,7 @@ tap.test('should return 404 for non-existent challenge tokens', async (tapTest)
// Connect and send a request for a non-existent token // Connect and send a request for a non-existent token
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
testClient.connect(8081, 'localhost', () => { testClient.connect(PORT, 'localhost', () => {
testClient.write( testClient.write(
'GET /.well-known/acme-challenge/invalid-token HTTP/1.1\r\n' + 'GET /.well-known/acme-challenge/invalid-token HTTP/1.1\r\n' +
'Host: test.example.com\r\n' + 'Host: test.example.com\r\n' +
@@ -157,6 +163,7 @@ tap.test('should return 404 for non-existent challenge tokens', async (tapTest)
// Cleanup // Cleanup
testClient.destroy(); testClient.destroy();
await proxy.stop(); await proxy.stop();
await assertPortsFree([PORT]);
}); });
export default tap.start(); export default tap.start();

120
test/test.bun.ts Normal file
View File

@@ -0,0 +1,120 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
mergeRouteConfigs,
cloneRoute,
routeMatchesPath,
} from '../ts/proxies/smart-proxy/utils/route-utils.js';
import {
validateRoutes,
validateRouteConfig,
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
tap.test('route creation - HTTPS terminate route has correct structure', async () => {
const route: IRouteConfig = {
match: { ports: 443, domains: 'secure.example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 8443 }], tls: { mode: 'terminate', certificate: 'auto' } }
};
expect(route).toHaveProperty('match');
expect(route).toHaveProperty('action');
expect(route.action.type).toEqual('forward');
expect(route.action.tls).toBeDefined();
expect(route.action.tls!.mode).toEqual('terminate');
expect(route.match.domains).toEqual('secure.example.com');
});
tap.test('route validation - validateRoutes on a set of routes', async () => {
const routes: IRouteConfig[] = [
{ match: { ports: 80, domains: 'a.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] } },
{ match: { ports: 80, domains: 'b.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 4000 }] } },
];
const result = validateRoutes(routes);
expect(result.valid).toBeTrue();
expect(result.errors).toHaveLength(0);
});
tap.test('route validation - validateRoutes catches invalid route in set', async () => {
const routes: any[] = [
{ match: { ports: 80, domains: 'valid.com' }, action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] } },
{ match: { ports: 80 } }, // missing action
];
const result = validateRoutes(routes);
expect(result.valid).toBeFalse();
expect(result.errors.length).toBeGreaterThan(0);
});
tap.test('path matching - routeMatchesPath with exact path', async () => {
const route: IRouteConfig = {
match: { ports: 80, domains: 'example.com', path: '/api' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
};
expect(routeMatchesPath(route, '/api')).toBeTrue();
expect(routeMatchesPath(route, '/other')).toBeFalse();
});
tap.test('path matching - route without path matches everything', async () => {
const route: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
};
expect(routeMatchesPath(route, '/anything')).toBeTrue();
expect(routeMatchesPath(route, '/')).toBeTrue();
});
tap.test('route merging - mergeRouteConfigs combines routes', async () => {
const base: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
priority: 10,
name: 'base-route'
};
const merged = mergeRouteConfigs(base, {
priority: 50,
name: 'merged-route',
});
expect(merged.priority).toEqual(50);
expect(merged.name).toEqual('merged-route');
expect(merged.match.domains).toEqual('example.com');
expect(merged.action.targets![0].host).toEqual('127.0.0.1');
});
tap.test('route merging - mergeRouteConfigs does not mutate original', async () => {
const base: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
name: 'original'
};
const merged = mergeRouteConfigs(base, { name: 'changed' });
expect(base.name).toEqual('original');
expect(merged.name).toEqual('changed');
});
tap.test('route cloning - cloneRoute produces independent copy', async () => {
const original: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
priority: 42,
name: 'original-route'
};
const cloned = cloneRoute(original);
expect(cloned.match.domains).toEqual('example.com');
expect(cloned.priority).toEqual(42);
expect(cloned.name).toEqual('original-route');
expect(cloned.action.targets![0].host).toEqual('127.0.0.1');
expect(cloned.action.targets![0].port).toEqual(3000);
cloned.name = 'cloned-route';
cloned.priority = 99;
expect(original.name).toEqual('original-route');
expect(original.priority).toEqual(42);
});
export default tap.start();

View File

@@ -5,6 +5,7 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { SmartProxy } from '../ts/proxies/smart-proxy/smart-proxy.js'; import { SmartProxy } from '../ts/proxies/smart-proxy/smart-proxy.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js'; import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
// Setup test infrastructure // Setup test infrastructure
const testCertPath = path.join(process.cwd(), 'test', 'helpers', 'test-cert.pem'); const testCertPath = path.join(process.cwd(), 'test', 'helpers', 'test-cert.pem');
@@ -13,8 +14,14 @@ const testKeyPath = path.join(process.cwd(), 'test', 'helpers', 'test-key.pem');
let testServer: net.Server; let testServer: net.Server;
let tlsTestServer: tls.Server; let tlsTestServer: tls.Server;
let smartProxy: SmartProxy; let smartProxy: SmartProxy;
let PROXY_TCP_PORT: number;
let PROXY_TLS_PORT: number;
let TCP_SERVER_PORT: number;
let TLS_SERVER_PORT: number;
tap.test('setup test servers', async () => { tap.test('setup test servers', async () => {
[PROXY_TCP_PORT, PROXY_TLS_PORT, TCP_SERVER_PORT, TLS_SERVER_PORT] = await findFreePorts(4);
// Create TCP test server // Create TCP test server
testServer = net.createServer((socket) => { testServer = net.createServer((socket) => {
socket.write('Connected to TCP test server\n'); socket.write('Connected to TCP test server\n');
@@ -24,8 +31,8 @@ tap.test('setup test servers', async () => {
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
testServer.listen(7001, '127.0.0.1', () => { testServer.listen(TCP_SERVER_PORT, '127.0.0.1', () => {
console.log('TCP test server listening on port 7001'); console.log(`TCP test server listening on port ${TCP_SERVER_PORT}`);
resolve(); resolve();
}); });
}); });
@@ -45,8 +52,8 @@ tap.test('setup test servers', async () => {
); );
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
tlsTestServer.listen(7002, '127.0.0.1', () => { tlsTestServer.listen(TLS_SERVER_PORT, '127.0.0.1', () => {
console.log('TLS test server listening on port 7002'); console.log(`TLS test server listening on port ${TLS_SERVER_PORT}`);
resolve(); resolve();
}); });
}); });
@@ -60,13 +67,13 @@ tap.test('should forward TCP connections correctly', async () => {
{ {
name: 'tcp-forward', name: 'tcp-forward',
match: { match: {
ports: 8080, ports: PROXY_TCP_PORT,
}, },
action: { action: {
type: 'forward', type: 'forward',
targets: [{ targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 7001, port: TCP_SERVER_PORT,
}], }],
}, },
}, },
@@ -77,7 +84,7 @@ tap.test('should forward TCP connections correctly', async () => {
// Test TCP forwarding // Test TCP forwarding
const client = await new Promise<net.Socket>((resolve, reject) => { const client = await new Promise<net.Socket>((resolve, reject) => {
const socket = net.connect(8080, '127.0.0.1', () => { const socket = net.connect(PROXY_TCP_PORT, '127.0.0.1', () => {
console.log('Connected to proxy'); console.log('Connected to proxy');
resolve(socket); resolve(socket);
}); });
@@ -106,7 +113,7 @@ tap.test('should handle TLS passthrough correctly', async () => {
{ {
name: 'tls-passthrough', name: 'tls-passthrough',
match: { match: {
ports: 8443, ports: PROXY_TLS_PORT,
domains: 'test.example.com', domains: 'test.example.com',
}, },
action: { action: {
@@ -116,7 +123,7 @@ tap.test('should handle TLS passthrough correctly', async () => {
}, },
targets: [{ targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 7002, port: TLS_SERVER_PORT,
}], }],
}, },
}, },
@@ -129,7 +136,7 @@ tap.test('should handle TLS passthrough correctly', async () => {
const client = await new Promise<tls.TLSSocket>((resolve, reject) => { const client = await new Promise<tls.TLSSocket>((resolve, reject) => {
const socket = tls.connect( const socket = tls.connect(
{ {
port: 8443, port: PROXY_TLS_PORT,
host: '127.0.0.1', host: '127.0.0.1',
servername: 'test.example.com', servername: 'test.example.com',
rejectUnauthorized: false, rejectUnauthorized: false,
@@ -164,7 +171,7 @@ tap.test('should handle SNI-based forwarding', async () => {
{ {
name: 'domain-a', name: 'domain-a',
match: { match: {
ports: 8443, ports: PROXY_TLS_PORT,
domains: 'a.example.com', domains: 'a.example.com',
}, },
action: { action: {
@@ -174,14 +181,14 @@ tap.test('should handle SNI-based forwarding', async () => {
}, },
targets: [{ targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 7002, port: TLS_SERVER_PORT,
}], }],
}, },
}, },
{ {
name: 'domain-b', name: 'domain-b',
match: { match: {
ports: 8443, ports: PROXY_TLS_PORT,
domains: 'b.example.com', domains: 'b.example.com',
}, },
action: { action: {
@@ -191,7 +198,7 @@ tap.test('should handle SNI-based forwarding', async () => {
}, },
targets: [{ targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 7002, port: TLS_SERVER_PORT,
}], }],
}, },
}, },
@@ -204,7 +211,7 @@ tap.test('should handle SNI-based forwarding', async () => {
const clientA = await new Promise<tls.TLSSocket>((resolve, reject) => { const clientA = await new Promise<tls.TLSSocket>((resolve, reject) => {
const socket = tls.connect( const socket = tls.connect(
{ {
port: 8443, port: PROXY_TLS_PORT,
host: '127.0.0.1', host: '127.0.0.1',
servername: 'a.example.com', servername: 'a.example.com',
rejectUnauthorized: false, rejectUnauthorized: false,
@@ -231,7 +238,7 @@ tap.test('should handle SNI-based forwarding', async () => {
const clientB = await new Promise<tls.TLSSocket>((resolve, reject) => { const clientB = await new Promise<tls.TLSSocket>((resolve, reject) => {
const socket = tls.connect( const socket = tls.connect(
{ {
port: 8443, port: PROXY_TLS_PORT,
host: '127.0.0.1', host: '127.0.0.1',
servername: 'b.example.com', servername: 'b.example.com',
rejectUnauthorized: false, rejectUnauthorized: false,
@@ -261,6 +268,7 @@ tap.test('should handle SNI-based forwarding', async () => {
tap.test('cleanup', async () => { tap.test('cleanup', async () => {
testServer.close(); testServer.close();
tlsTestServer.close(); tlsTestServer.close();
await assertPortsFree([PROXY_TCP_PORT, PROXY_TLS_PORT, TCP_SERVER_PORT, TLS_SERVER_PORT]);
}); });
export default tap.start(); export default tap.start();

View File

@@ -0,0 +1,125 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as dgram from 'dgram';
import { SmartProxy } from '../ts/index.js';
import type { TDatagramHandler, IDatagramInfo } from '../ts/index.js';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
let smartProxy: SmartProxy;
let PROXY_PORT: number;
// Helper: send a single UDP datagram and wait for a response
function sendDatagram(port: number, msg: string, timeoutMs = 5000): Promise<string> {
return new Promise((resolve, reject) => {
const client = dgram.createSocket('udp4');
const timeout = setTimeout(() => {
client.close();
reject(new Error(`UDP response timeout after ${timeoutMs}ms`));
}, timeoutMs);
client.send(Buffer.from(msg), port, '127.0.0.1');
client.on('message', (data) => {
clearTimeout(timeout);
client.close();
resolve(data.toString());
});
client.on('error', (err) => {
clearTimeout(timeout);
client.close();
reject(err);
});
});
}
tap.test('setup: start SmartProxy with datagramHandler', async () => {
[PROXY_PORT] = await findFreePorts(1);
const handler: TDatagramHandler = (datagram, info, reply) => {
reply(Buffer.from(`Handled: ${datagram.toString()}`));
};
smartProxy = new SmartProxy({
routes: [
{
name: 'dgram-handler-test',
match: {
ports: PROXY_PORT,
transport: 'udp' as const,
},
action: {
type: 'socket-handler',
datagramHandler: handler,
},
},
],
defaults: {
security: {
ipAllowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
},
},
});
await smartProxy.start();
});
tap.test('datagram handler: receives and replies to datagram', async () => {
const response = await sendDatagram(PROXY_PORT, 'Hello Handler');
expect(response).toEqual('Handled: Hello Handler');
});
tap.test('datagram handler: async handler works', async () => {
// Stop and restart with async handler
await smartProxy.stop();
[PROXY_PORT] = await findFreePorts(1);
const asyncHandler: TDatagramHandler = async (datagram, info, reply) => {
// Simulate async work
await new Promise<void>((resolve) => setTimeout(resolve, 10));
reply(Buffer.from(`Async: ${datagram.toString()}`));
};
smartProxy = new SmartProxy({
routes: [
{
name: 'dgram-async-handler',
match: {
ports: PROXY_PORT,
transport: 'udp' as const,
},
action: {
type: 'socket-handler',
datagramHandler: asyncHandler,
},
},
],
defaults: {
security: {
ipAllowList: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
},
},
});
await smartProxy.start();
const response = await sendDatagram(PROXY_PORT, 'Test Async');
expect(response).toEqual('Async: Test Async');
});
tap.test('datagram handler: multiple rapid datagrams', async () => {
const promises: Promise<string>[] = [];
for (let i = 0; i < 5; i++) {
promises.push(sendDatagram(PROXY_PORT, `msg-${i}`));
}
const responses = await Promise.all(promises);
for (let i = 0; i < 5; i++) {
expect(responses).toContain(`Async: msg-${i}`);
}
});
tap.test('cleanup: stop SmartProxy', async () => {
await smartProxy.stop();
await assertPortsFree([PROXY_PORT]);
});
export default tap.start();

115
test/test.deno.ts Normal file
View File

@@ -0,0 +1,115 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
findMatchingRoutes,
findBestMatchingRoute,
routeMatchesDomain,
routeMatchesPort,
routeMatchesPath,
} from '../ts/proxies/smart-proxy/utils/route-utils.js';
import {
validateRouteConfig,
isValidDomain,
isValidPort,
} from '../ts/proxies/smart-proxy/utils/route-validator.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
tap.test('route validation - validateRouteConfig accepts valid route', async () => {
const route: IRouteConfig = {
match: { ports: 80, domains: 'valid.example.com' },
action: { type: 'forward', targets: [{ host: '10.0.0.1', port: 8080 }] }
};
const result = validateRouteConfig(route);
expect(result.valid).toBeTrue();
expect(result.errors).toHaveLength(0);
});
tap.test('route validation - validateRouteConfig rejects missing action', async () => {
const badRoute = { match: { ports: 80 } } as any;
const result = validateRouteConfig(badRoute);
expect(result.valid).toBeFalse();
expect(result.errors.length).toBeGreaterThan(0);
});
tap.test('route validation - isValidDomain checks correctly', async () => {
expect(isValidDomain('example.com')).toBeTrue();
expect(isValidDomain('*.example.com')).toBeTrue();
expect(isValidDomain('')).toBeFalse();
});
tap.test('route validation - isValidPort checks correctly', async () => {
expect(isValidPort(80)).toBeTrue();
expect(isValidPort(443)).toBeTrue();
expect(isValidPort(0)).toBeFalse();
expect(isValidPort(70000)).toBeFalse();
expect(isValidPort(-1)).toBeFalse();
});
tap.test('domain matching - exact domain', async () => {
const route: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
};
expect(routeMatchesDomain(route, 'example.com')).toBeTrue();
expect(routeMatchesDomain(route, 'other.com')).toBeFalse();
});
tap.test('domain matching - wildcard domain', async () => {
const route: IRouteConfig = {
match: { ports: 80, domains: '*.example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
};
expect(routeMatchesDomain(route, 'sub.example.com')).toBeTrue();
expect(routeMatchesDomain(route, 'example.com')).toBeFalse();
});
tap.test('port matching - single port', async () => {
const route: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
};
expect(routeMatchesPort(route, 80)).toBeTrue();
expect(routeMatchesPort(route, 443)).toBeFalse();
});
tap.test('route finding - findBestMatchingRoute selects by priority', async () => {
const lowPriority: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
priority: 10
};
const highPriority: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 4000 }] },
priority: 100
};
const routes: IRouteConfig[] = [lowPriority, highPriority];
const best = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
expect(best).toBeDefined();
expect(best!.priority).toEqual(100);
expect(best!.action.targets![0].port).toEqual(4000);
});
tap.test('route finding - findMatchingRoutes returns all matches', async () => {
const route1: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] }
};
const route2: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 4000 }] }
};
const route3: IRouteConfig = {
match: { ports: 80, domains: 'other.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 5000 }] }
};
const matches = findMatchingRoutes([route1, route2, route3], { domain: 'example.com', port: 80 });
expect(matches).toHaveLength(2);
});
export default tap.start();

View File

@@ -1,146 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartproxy from '../ts/index.js';
tap.test('Protocol Detection - TLS Detection', async () => {
// Test TLS handshake detection
const tlsHandshake = Buffer.from([
0x16, // Handshake record type
0x03, 0x01, // TLS 1.0
0x00, 0x05, // Length: 5 bytes
0x01, // ClientHello
0x00, 0x00, 0x01, 0x00 // Handshake length and data
]);
const detector = new smartproxy.detection.TlsDetector();
expect(detector.canHandle(tlsHandshake)).toEqual(true);
const result = detector.detect(tlsHandshake);
expect(result).toBeDefined();
expect(result?.protocol).toEqual('tls');
expect(result?.connectionInfo.tlsVersion).toEqual('TLSv1.0');
});
tap.test('Protocol Detection - HTTP Detection', async () => {
// Test HTTP request detection
const httpRequest = Buffer.from(
'GET /test HTTP/1.1\r\n' +
'Host: example.com\r\n' +
'User-Agent: TestClient/1.0\r\n' +
'\r\n'
);
const detector = new smartproxy.detection.HttpDetector();
expect(detector.canHandle(httpRequest)).toEqual(true);
const result = detector.detect(httpRequest);
expect(result).toBeDefined();
expect(result?.protocol).toEqual('http');
expect(result?.connectionInfo.method).toEqual('GET');
expect(result?.connectionInfo.path).toEqual('/test');
expect(result?.connectionInfo.domain).toEqual('example.com');
});
tap.test('Protocol Detection - Main Detector TLS', async () => {
const tlsHandshake = Buffer.from([
0x16, // Handshake record type
0x03, 0x03, // TLS 1.2
0x00, 0x05, // Length: 5 bytes
0x01, // ClientHello
0x00, 0x00, 0x01, 0x00 // Handshake length and data
]);
const result = await smartproxy.detection.ProtocolDetector.detect(tlsHandshake);
expect(result.protocol).toEqual('tls');
expect(result.connectionInfo.tlsVersion).toEqual('TLSv1.2');
});
tap.test('Protocol Detection - Main Detector HTTP', async () => {
const httpRequest = Buffer.from(
'POST /api/test HTTP/1.1\r\n' +
'Host: api.example.com\r\n' +
'Content-Type: application/json\r\n' +
'Content-Length: 2\r\n' +
'\r\n' +
'{}'
);
const result = await smartproxy.detection.ProtocolDetector.detect(httpRequest);
expect(result.protocol).toEqual('http');
expect(result.connectionInfo.method).toEqual('POST');
expect(result.connectionInfo.path).toEqual('/api/test');
expect(result.connectionInfo.domain).toEqual('api.example.com');
});
tap.test('Protocol Detection - Unknown Protocol', async () => {
const unknownData = Buffer.from('UNKNOWN PROTOCOL DATA\r\n');
const result = await smartproxy.detection.ProtocolDetector.detect(unknownData);
expect(result.protocol).toEqual('unknown');
expect(result.isComplete).toEqual(true);
});
tap.test('Protocol Detection - Fragmented HTTP', async () => {
// Create connection context
const context = smartproxy.detection.ProtocolDetector.createConnectionContext({
sourceIp: '127.0.0.1',
sourcePort: 12345,
destIp: '127.0.0.1',
destPort: 80,
socketId: 'test-connection-1'
});
// First fragment
const fragment1 = Buffer.from('GET /test HT');
let result = await smartproxy.detection.ProtocolDetector.detectWithContext(
fragment1,
context
);
expect(result.protocol).toEqual('http');
expect(result.isComplete).toEqual(false);
// Second fragment
const fragment2 = Buffer.from('TP/1.1\r\nHost: example.com\r\n\r\n');
result = await smartproxy.detection.ProtocolDetector.detectWithContext(
fragment2,
context
);
expect(result.protocol).toEqual('http');
expect(result.isComplete).toEqual(true);
expect(result.connectionInfo.method).toEqual('GET');
expect(result.connectionInfo.path).toEqual('/test');
expect(result.connectionInfo.domain).toEqual('example.com');
// Clean up fragments
smartproxy.detection.ProtocolDetector.cleanupConnection(context);
});
tap.test('Protocol Detection - HTTP Methods', async () => {
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
for (const method of methods) {
const request = Buffer.from(
`${method} /test HTTP/1.1\r\n` +
'Host: example.com\r\n' +
'\r\n'
);
const detector = new smartproxy.detection.HttpDetector();
const result = detector.detect(request);
expect(result?.connectionInfo.method).toEqual(method);
}
});
tap.test('Protocol Detection - Invalid Data', async () => {
// Binary data that's not a valid protocol
const binaryData = Buffer.from([0xFF, 0xFE, 0xFD, 0xFC, 0xFB]);
const result = await smartproxy.detection.ProtocolDetector.detect(binaryData);
expect(result.protocol).toEqual('unknown');
});
tap.test('cleanup detection', async () => {
// Clean up the protocol detector instance
smartproxy.detection.ProtocolDetector.destroy();
});
export default tap.start();

View File

@@ -1,9 +1,12 @@
import { expect, tap } from '@git.zone/tstest/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net'; import * as net from 'net';
import { SmartProxy } from '../ts/proxies/smart-proxy/smart-proxy.js'; import { SmartProxy } from '../ts/proxies/smart-proxy/smart-proxy.js';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
// Test to verify port forwarding works correctly // Test to verify port forwarding works correctly
tap.test('forward connections should not be immediately closed', async (t) => { tap.test('forward connections should not be immediately closed', async (t) => {
const [PROXY_PORT, SERVER_PORT] = await findFreePorts(2);
// Create a backend server that accepts connections // Create a backend server that accepts connections
const testServer = net.createServer((socket) => { const testServer = net.createServer((socket) => {
console.log('Client connected to test server'); console.log('Client connected to test server');
@@ -21,8 +24,8 @@ tap.test('forward connections should not be immediately closed', async (t) => {
// Listen on a non-privileged port // Listen on a non-privileged port
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
testServer.listen(9090, '127.0.0.1', () => { testServer.listen(SERVER_PORT, '127.0.0.1', () => {
console.log('Test server listening on port 9090'); console.log(`Test server listening on port ${SERVER_PORT}`);
resolve(); resolve();
}); });
}); });
@@ -34,13 +37,13 @@ tap.test('forward connections should not be immediately closed', async (t) => {
{ {
name: 'forward-test', name: 'forward-test',
match: { match: {
ports: 8080, ports: PROXY_PORT,
}, },
action: { action: {
type: 'forward', type: 'forward',
targets: [{ targets: [{
host: '127.0.0.1', host: '127.0.0.1',
port: 9090, port: SERVER_PORT,
}], }],
}, },
}, },
@@ -51,7 +54,7 @@ tap.test('forward connections should not be immediately closed', async (t) => {
// Create a client connection through the proxy // Create a client connection through the proxy
const client = net.createConnection({ const client = net.createConnection({
port: 8080, port: PROXY_PORT,
host: '127.0.0.1', host: '127.0.0.1',
}); });
@@ -105,6 +108,7 @@ tap.test('forward connections should not be immediately closed', async (t) => {
client.end(); client.end();
await smartProxy.stop(); await smartProxy.stop();
testServer.close(); testServer.close();
await assertPortsFree([PROXY_PORT, SERVER_PORT]);
}); });
export default tap.start(); export default tap.start();

View File

@@ -2,146 +2,101 @@ import * as path from 'path';
import { tap, expect } from '@git.zone/tstest/tapbundle'; import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js'; import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
import { import { SocketHandlers } from '../ts/proxies/smart-proxy/utils/socket-handlers.js';
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute,
createApiRoute,
createWebSocketRoute
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js'; import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
// Test to demonstrate various route configurations using the new helpers
tap.test('Route-based configuration examples', async (tools) => { tap.test('Route-based configuration examples', async (tools) => {
// Example 1: HTTP-only configuration const httpOnlyRoute: IRouteConfig = {
const httpOnlyRoute = createHttpRoute( match: { ports: 80, domains: 'http.example.com' },
'http.example.com', action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
{ name: 'Basic HTTP Route'
host: 'localhost', };
port: 3000
},
{
name: 'Basic HTTP Route'
}
);
console.log('HTTP-only route created successfully:', httpOnlyRoute.name); console.log('HTTP-only route created successfully:', httpOnlyRoute.name);
expect(httpOnlyRoute.action.type).toEqual('forward'); expect(httpOnlyRoute.action.type).toEqual('forward');
expect(httpOnlyRoute.match.domains).toEqual('http.example.com'); expect(httpOnlyRoute.match.domains).toEqual('http.example.com');
// Example 2: HTTPS Passthrough (SNI) configuration const httpsPassthroughRoute: IRouteConfig = {
const httpsPassthroughRoute = createHttpsPassthroughRoute( match: { ports: 443, domains: 'pass.example.com' },
'pass.example.com', action: { type: 'forward', targets: [{ host: '10.0.0.1', port: 443 }, { host: '10.0.0.2', port: 443 }], tls: { mode: 'passthrough' } },
{ name: 'HTTPS Passthrough Route'
host: ['10.0.0.1', '10.0.0.2'], // Round-robin target IPs };
port: 443
},
{
name: 'HTTPS Passthrough Route'
}
);
expect(httpsPassthroughRoute).toBeTruthy(); expect(httpsPassthroughRoute).toBeTruthy();
expect(httpsPassthroughRoute.action.tls?.mode).toEqual('passthrough'); expect(httpsPassthroughRoute.action.tls?.mode).toEqual('passthrough');
expect(Array.isArray(httpsPassthroughRoute.action.targets)).toBeTrue(); expect(Array.isArray(httpsPassthroughRoute.action.targets)).toBeTrue();
// Example 3: HTTPS Termination to HTTP Backend const terminateToHttpRoute: IRouteConfig = {
const terminateToHttpRoute = createHttpsTerminateRoute( match: { ports: 443, domains: 'secure.example.com' },
'secure.example.com', action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }], tls: { mode: 'terminate', certificate: 'auto' } },
{ name: 'HTTPS Termination to HTTP Backend'
host: 'localhost', };
port: 8080
},
{
certificate: 'auto',
name: 'HTTPS Termination to HTTP Backend'
}
);
// Create the HTTP to HTTPS redirect for this domain const httpToHttpsRedirect: IRouteConfig = {
const httpToHttpsRedirect = createHttpToHttpsRedirect( match: { ports: 80, domains: 'secure.example.com' },
'secure.example.com', action: { type: 'socket-handler', socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301) },
443, name: 'HTTP to HTTPS Redirect for secure.example.com'
{ };
name: 'HTTP to HTTPS Redirect for secure.example.com'
}
);
expect(terminateToHttpRoute).toBeTruthy(); expect(terminateToHttpRoute).toBeTruthy();
expect(terminateToHttpRoute.action.tls?.mode).toEqual('terminate'); expect(terminateToHttpRoute.action.tls?.mode).toEqual('terminate');
expect(httpToHttpsRedirect.action.type).toEqual('socket-handler');
// Example 4: Load Balancer with HTTPS const loadBalancerRoute: IRouteConfig = {
const loadBalancerRoute = createLoadBalancerRoute( match: { ports: 443, domains: 'proxy.example.com' },
'proxy.example.com', action: {
['internal-api-1.local', 'internal-api-2.local'], type: 'forward',
8443, targets: [
{ { host: 'internal-api-1.local', port: 8443 },
tls: { { host: 'internal-api-2.local', port: 8443 }
mode: 'terminate-and-reencrypt', ],
certificate: 'auto' tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' }
}, },
name: 'Load Balanced HTTPS Route' name: 'Load Balanced HTTPS Route'
} };
);
expect(loadBalancerRoute).toBeTruthy(); expect(loadBalancerRoute).toBeTruthy();
expect(loadBalancerRoute.action.tls?.mode).toEqual('terminate-and-reencrypt'); expect(loadBalancerRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
expect(Array.isArray(loadBalancerRoute.action.targets)).toBeTrue(); expect(Array.isArray(loadBalancerRoute.action.targets)).toBeTrue();
// Example 5: API Route const apiRoute: IRouteConfig = {
const apiRoute = createApiRoute( match: { ports: 443, domains: 'api.example.com', path: '/api' },
'api.example.com', action: {
'/api', type: 'forward',
{ host: 'localhost', port: 8081 }, targets: [{ host: 'localhost', port: 8081 }],
{ tls: { mode: 'terminate', certificate: 'auto' }
name: 'API Route', },
useTls: true, name: 'API Route'
addCorsHeaders: true };
}
);
expect(apiRoute.action.type).toEqual('forward'); expect(apiRoute.action.type).toEqual('forward');
expect(apiRoute.match.path).toBeTruthy(); expect(apiRoute.match.path).toBeTruthy();
// Example 6: Complete HTTPS Server with HTTP Redirect const httpsRoute: IRouteConfig = {
const httpsServerRoutes = createCompleteHttpsServer( match: { ports: 443, domains: 'complete.example.com' },
'complete.example.com', action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }], tls: { mode: 'terminate', certificate: 'auto' } },
{ name: 'Complete HTTPS Server'
host: 'localhost', };
port: 8080
const httpsRedirectRoute: IRouteConfig = {
match: { ports: 80, domains: 'complete.example.com' },
action: { type: 'socket-handler', socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301) },
name: 'Complete HTTPS Server - Redirect'
};
const webSocketRoute: IRouteConfig = {
match: { ports: 443, domains: 'ws.example.com', path: '/ws' },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8082 }],
tls: { mode: 'terminate', certificate: 'auto' },
websocket: { enabled: true }
}, },
{ name: 'WebSocket Route'
certificate: 'auto', };
name: 'Complete HTTPS Server'
}
);
expect(Array.isArray(httpsServerRoutes)).toBeTrue();
expect(httpsServerRoutes.length).toEqual(2); // HTTPS route and HTTP redirect
expect(httpsServerRoutes[0].action.tls?.mode).toEqual('terminate');
expect(httpsServerRoutes[1].action.type).toEqual('socket-handler');
// Example 7: Static File Server - removed (use nginx/apache behind proxy)
// Example 8: WebSocket Route
const webSocketRoute = createWebSocketRoute(
'ws.example.com',
'/ws',
{ host: 'localhost', port: 8082 },
{
useTls: true,
name: 'WebSocket Route'
}
);
expect(webSocketRoute.action.type).toEqual('forward'); expect(webSocketRoute.action.type).toEqual('forward');
expect(webSocketRoute.action.websocket?.enabled).toBeTrue(); expect(webSocketRoute.action.websocket?.enabled).toBeTrue();
// Create a SmartProxy instance with all routes
const allRoutes: IRouteConfig[] = [ const allRoutes: IRouteConfig[] = [
httpOnlyRoute, httpOnlyRoute,
httpsPassthroughRoute, httpsPassthroughRoute,
@@ -149,19 +104,17 @@ tap.test('Route-based configuration examples', async (tools) => {
httpToHttpsRedirect, httpToHttpsRedirect,
loadBalancerRoute, loadBalancerRoute,
apiRoute, apiRoute,
...httpsServerRoutes, httpsRoute,
httpsRedirectRoute,
webSocketRoute webSocketRoute
]; ];
// We're not actually starting the SmartProxy in this test,
// just verifying that the configuration is valid
const smartProxy = new SmartProxy({ const smartProxy = new SmartProxy({
routes: allRoutes routes: allRoutes
}); });
// Just verify that all routes are configured correctly
console.log(`Created ${allRoutes.length} example routes`); console.log(`Created ${allRoutes.length} example routes`);
expect(allRoutes.length).toEqual(9); // One less without static file route expect(allRoutes.length).toEqual(9);
}); });
export default tap.start(); export default tap.start();

View File

@@ -1,27 +1,8 @@
import { tap, expect } from '@git.zone/tstest/tapbundle'; import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js'; import * as plugins from '../ts/plugins.js';
// Import route-based helpers import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
import {
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
// Create helper functions for backward compatibility
const helpers = {
httpOnly: (domains: string | string[], target: any) => createHttpRoute(domains, target),
tlsTerminateToHttp: (domains: string | string[], target: any) =>
createHttpsTerminateRoute(domains, target),
tlsTerminateToHttps: (domains: string | string[], target: any) =>
createHttpsTerminateRoute(domains, target, { reencrypt: true }),
httpsPassthrough: (domains: string | string[], target: any) =>
createHttpsPassthroughRoute(domains, target)
};
// Route-based utility functions for testing
function findRouteForDomain(routes: any[], domain: string): any { function findRouteForDomain(routes: any[], domain: string): any {
return routes.find(route => { return routes.find(route => {
const domains = Array.isArray(route.match.domains) const domains = Array.isArray(route.match.domains)
@@ -31,55 +12,44 @@ function findRouteForDomain(routes: any[], domain: string): any {
}); });
} }
// Replace the old test with route-based tests
tap.test('Route Helpers - Create HTTP routes', async () => { tap.test('Route Helpers - Create HTTP routes', async () => {
const route = helpers.httpOnly('example.com', { host: 'localhost', port: 3000 }); const route: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] }
};
expect(route.action.type).toEqual('forward'); expect(route.action.type).toEqual('forward');
expect(route.match.domains).toEqual('example.com'); expect(route.match.domains).toEqual('example.com');
expect(route.action.targets?.[0]).toEqual({ host: 'localhost', port: 3000 }); expect(route.action.targets?.[0]).toEqual({ host: 'localhost', port: 3000 });
}); });
tap.test('Route Helpers - Create HTTPS terminate to HTTP routes', async () => { tap.test('Route Helpers - Create HTTPS terminate to HTTP routes', async () => {
const route = helpers.tlsTerminateToHttp('secure.example.com', { host: 'localhost', port: 3000 }); const route: IRouteConfig = {
match: { ports: 443, domains: 'secure.example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }], tls: { mode: 'terminate', certificate: 'auto' } }
};
expect(route.action.type).toEqual('forward'); expect(route.action.type).toEqual('forward');
expect(route.match.domains).toEqual('secure.example.com'); expect(route.match.domains).toEqual('secure.example.com');
expect(route.action.tls?.mode).toEqual('terminate'); expect(route.action.tls?.mode).toEqual('terminate');
}); });
tap.test('Route Helpers - Create HTTPS passthrough routes', async () => { tap.test('Route Helpers - Create HTTPS passthrough routes', async () => {
const route = helpers.httpsPassthrough('passthrough.example.com', { host: 'backend', port: 443 }); const route: IRouteConfig = {
match: { ports: 443, domains: 'passthrough.example.com' },
action: { type: 'forward', targets: [{ host: 'backend', port: 443 }], tls: { mode: 'passthrough' } }
};
expect(route.action.type).toEqual('forward'); expect(route.action.type).toEqual('forward');
expect(route.match.domains).toEqual('passthrough.example.com'); expect(route.match.domains).toEqual('passthrough.example.com');
expect(route.action.tls?.mode).toEqual('passthrough'); expect(route.action.tls?.mode).toEqual('passthrough');
}); });
tap.test('Route Helpers - Create HTTPS to HTTPS routes', async () => { tap.test('Route Helpers - Create HTTPS to HTTPS routes', async () => {
const route = helpers.tlsTerminateToHttps('reencrypt.example.com', { host: 'backend', port: 443 }); const route: IRouteConfig = {
match: { ports: 443, domains: 'reencrypt.example.com' },
action: { type: 'forward', targets: [{ host: 'backend', port: 443 }], tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' } }
};
expect(route.action.type).toEqual('forward'); expect(route.action.type).toEqual('forward');
expect(route.match.domains).toEqual('reencrypt.example.com'); expect(route.match.domains).toEqual('reencrypt.example.com');
expect(route.action.tls?.mode).toEqual('terminate-and-reencrypt'); expect(route.action.tls?.mode).toEqual('terminate-and-reencrypt');
}); });
tap.test('Route Helpers - Create complete HTTPS server with redirect', async () => { export default tap.start();
const routes = createCompleteHttpsServer(
'full.example.com',
{ host: 'localhost', port: 3000 },
{ certificate: 'auto' }
);
expect(routes.length).toEqual(2);
// Check HTTP to HTTPS redirect - find route by port
const redirectRoute = routes.find(r => r.match.ports === 80);
expect(redirectRoute.action.type).toEqual('socket-handler');
expect(redirectRoute.action.socketHandler).toBeDefined();
expect(redirectRoute.match.ports).toEqual(80);
// Check HTTPS route
const httpsRoute = routes.find(r => r.action.type === 'forward');
expect(httpsRoute.match.ports).toEqual(443);
expect(httpsRoute.action.tls?.mode).toEqual('terminate');
});
// Export test runner
export default tap.start();

View File

@@ -1,10 +1,13 @@
import { tap, expect } from '@git.zone/tstest/tapbundle'; import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/index.js'; import { SmartProxy } from '../ts/index.js';
import * as http from 'http'; import * as http from 'http';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
tap.test('should forward HTTP connections on port 8080', async (tapTest) => { tap.test('should forward HTTP connections on port 8080', async (tapTest) => {
const [PROXY_PORT, TARGET_PORT] = await findFreePorts(2);
// Create a mock HTTP server to act as our target // Create a mock HTTP server to act as our target
const targetPort = 8181; const targetPort = TARGET_PORT;
let receivedRequest = false; let receivedRequest = false;
let receivedPath = ''; let receivedPath = '';
@@ -36,7 +39,7 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => {
routes: [{ routes: [{
name: 'test-route', name: 'test-route',
match: { match: {
ports: 8080 ports: PROXY_PORT
// Remove domain restriction for HTTP connections // Remove domain restriction for HTTP connections
// Domain matching happens after HTTP headers are received // Domain matching happens after HTTP headers are received
}, },
@@ -46,16 +49,16 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => {
} }
}] }]
}); });
await proxy.start(); await proxy.start();
// Give the proxy a moment to fully initialize // Give the proxy a moment to fully initialize
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => setTimeout(resolve, 500));
// Make an HTTP request to port 8080 // Make an HTTP request to port 8080
const options = { const options = {
hostname: 'localhost', hostname: 'localhost',
port: 8080, port: PROXY_PORT,
path: '/.well-known/acme-challenge/test-token', path: '/.well-known/acme-challenge/test-token',
method: 'GET', method: 'GET',
headers: { headers: {
@@ -97,14 +100,17 @@ tap.test('should forward HTTP connections on port 8080', async (tapTest) => {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
targetServer.close(() => resolve()); targetServer.close(() => resolve());
}); });
// Wait a bit to ensure port is fully released // Wait a bit to ensure port is fully released
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => setTimeout(resolve, 500));
await assertPortsFree([PROXY_PORT, TARGET_PORT]);
}); });
tap.test('should handle basic HTTP request forwarding', async (tapTest) => { tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
const [PROXY_PORT, TARGET_PORT] = await findFreePorts(2);
// Create a simple target server // Create a simple target server
const targetPort = 8182; const targetPort = TARGET_PORT;
let receivedRequest = false; let receivedRequest = false;
const targetServer = http.createServer((req, res) => { const targetServer = http.createServer((req, res) => {
@@ -126,7 +132,7 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
routes: [{ routes: [{
name: 'simple-forward', name: 'simple-forward',
match: { match: {
ports: 8081 ports: PROXY_PORT
// Remove domain restriction for HTTP connections // Remove domain restriction for HTTP connections
}, },
action: { action: {
@@ -142,7 +148,7 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
// Make request // Make request
const options = { const options = {
hostname: 'localhost', hostname: 'localhost',
port: 8081, port: PROXY_PORT,
path: '/test', path: '/test',
method: 'GET', method: 'GET',
headers: { headers: {
@@ -184,9 +190,10 @@ tap.test('should handle basic HTTP request forwarding', async (tapTest) => {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
targetServer.close(() => resolve()); targetServer.close(() => resolve());
}); });
// Wait a bit to ensure port is fully released // Wait a bit to ensure port is fully released
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => setTimeout(resolve, 500));
await assertPortsFree([PROXY_PORT, TARGET_PORT]);
}); });
export default tap.start(); export default tap.start();

View File

@@ -1,128 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartproxy from '../ts/index.js';
import { RouteValidator } from '../ts/proxies/smart-proxy/utils/route-validator.js';
import { IpUtils } from '../ts/core/utils/ip-utils.js';
tap.test('IP Validation - Shorthand patterns', async () => {
// Test shorthand patterns are now accepted
const testPatterns = [
{ pattern: '192.168.*', shouldPass: true },
{ pattern: '192.168.*.*', shouldPass: true },
{ pattern: '10.*', shouldPass: true },
{ pattern: '10.*.*.*', shouldPass: true },
{ pattern: '172.16.*', shouldPass: true },
{ pattern: '10.0.0.0/8', shouldPass: true },
{ pattern: '192.168.0.0/16', shouldPass: true },
{ pattern: '192.168.1.100', shouldPass: true },
{ pattern: '*', shouldPass: true },
{ pattern: '192.168.1.1-192.168.1.100', shouldPass: true },
];
for (const { pattern, shouldPass } of testPatterns) {
const route = {
name: 'test',
match: { ports: 80 },
action: { type: 'forward' as const, targets: [{ host: 'localhost', port: 8080 }] },
security: { ipAllowList: [pattern] }
};
const result = RouteValidator.validateRoute(route);
if (shouldPass) {
expect(result.valid).toEqual(true);
console.log(`✅ Pattern '${pattern}' correctly accepted`);
} else {
expect(result.valid).toEqual(false);
console.log(`✅ Pattern '${pattern}' correctly rejected`);
}
}
});
tap.test('IP Matching - Runtime shorthand pattern matching', async () => {
// Test runtime matching with shorthand patterns
const testCases = [
{ ip: '192.168.1.100', patterns: ['192.168.*'], expected: true },
{ ip: '192.168.1.100', patterns: ['192.168.1.*'], expected: true },
{ ip: '192.168.1.100', patterns: ['192.168.2.*'], expected: false },
{ ip: '10.0.0.1', patterns: ['10.*'], expected: true },
{ ip: '10.1.2.3', patterns: ['10.*'], expected: true },
{ ip: '172.16.0.1', patterns: ['10.*'], expected: false },
{ ip: '192.168.1.1', patterns: ['192.168.*.*'], expected: true },
];
for (const { ip, patterns, expected } of testCases) {
const result = IpUtils.isGlobIPMatch(ip, patterns);
expect(result).toEqual(expected);
console.log(`✅ IP ${ip} with pattern ${patterns[0]} = ${result} (expected ${expected})`);
}
});
tap.test('IP Matching - CIDR notation', async () => {
// Test CIDR notation matching
const cidrTests = [
{ ip: '10.0.0.1', cidr: '10.0.0.0/8', expected: true },
{ ip: '10.255.255.255', cidr: '10.0.0.0/8', expected: true },
{ ip: '11.0.0.1', cidr: '10.0.0.0/8', expected: false },
{ ip: '192.168.1.1', cidr: '192.168.0.0/16', expected: true },
{ ip: '192.168.255.255', cidr: '192.168.0.0/16', expected: true },
{ ip: '192.169.0.1', cidr: '192.168.0.0/16', expected: false },
{ ip: '192.168.1.100', cidr: '192.168.1.0/24', expected: true },
{ ip: '192.168.2.100', cidr: '192.168.1.0/24', expected: false },
];
for (const { ip, cidr, expected } of cidrTests) {
const result = IpUtils.isGlobIPMatch(ip, [cidr]);
expect(result).toEqual(expected);
console.log(`✅ IP ${ip} in CIDR ${cidr} = ${result} (expected ${expected})`);
}
});
tap.test('IP Matching - Range notation', async () => {
// Test range notation matching
const rangeTests = [
{ ip: '192.168.1.1', range: '192.168.1.1-192.168.1.100', expected: true },
{ ip: '192.168.1.50', range: '192.168.1.1-192.168.1.100', expected: true },
{ ip: '192.168.1.100', range: '192.168.1.1-192.168.1.100', expected: true },
{ ip: '192.168.1.101', range: '192.168.1.1-192.168.1.100', expected: false },
{ ip: '192.168.2.50', range: '192.168.1.1-192.168.1.100', expected: false },
];
for (const { ip, range, expected } of rangeTests) {
const result = IpUtils.isGlobIPMatch(ip, [range]);
expect(result).toEqual(expected);
console.log(`✅ IP ${ip} in range ${range} = ${result} (expected ${expected})`);
}
});
tap.test('IP Matching - Mixed patterns', async () => {
// Test with mixed pattern types
const allowList = [
'10.0.0.0/8', // CIDR
'192.168.*', // Shorthand glob
'172.16.1.*', // Specific subnet glob
'8.8.8.8', // Single IP
'1.1.1.1-1.1.1.10' // Range
];
const tests = [
{ ip: '10.1.2.3', expected: true }, // Matches CIDR
{ ip: '192.168.100.1', expected: true }, // Matches shorthand glob
{ ip: '172.16.1.5', expected: true }, // Matches specific glob
{ ip: '8.8.8.8', expected: true }, // Matches single IP
{ ip: '1.1.1.5', expected: true }, // Matches range
{ ip: '9.9.9.9', expected: false }, // Doesn't match any
];
for (const { ip, expected } of tests) {
const result = IpUtils.isGlobIPMatch(ip, allowList);
expect(result).toEqual(expected);
console.log(`✅ IP ${ip} in mixed patterns = ${result} (expected ${expected})`);
}
});
export default tap.start();

View File

@@ -1,112 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { LogDeduplicator } from '../ts/core/utils/log-deduplicator.js';
let deduplicator: LogDeduplicator;
tap.test('Setup log deduplicator', async () => {
deduplicator = new LogDeduplicator(1000); // 1 second flush interval for testing
});
tap.test('Connection rejection deduplication', async (tools) => {
// Simulate multiple connection rejections
for (let i = 0; i < 10; i++) {
deduplicator.log(
'connection-rejected',
'warn',
'Connection rejected',
{ reason: 'global-limit', component: 'test' },
'global-limit'
);
}
for (let i = 0; i < 5; i++) {
deduplicator.log(
'connection-rejected',
'warn',
'Connection rejected',
{ reason: 'route-limit', component: 'test' },
'route-limit'
);
}
// Force flush
deduplicator.flush('connection-rejected');
// The logs should have been aggregated
// (Can't easily test the actual log output, but we can verify the mechanism works)
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.test('IP rejection deduplication', async (tools) => {
// Simulate rejections from multiple IPs
const ips = ['192.168.1.100', '192.168.1.101', '192.168.1.100', '10.0.0.1'];
const reasons = ['per-ip-limit', 'rate-limit', 'per-ip-limit', 'global-limit'];
for (let i = 0; i < ips.length; i++) {
deduplicator.log(
'ip-rejected',
'warn',
`Connection rejected from ${ips[i]}`,
{ remoteIP: ips[i], reason: reasons[i] },
ips[i]
);
}
// Add more rejections from the same IP
for (let i = 0; i < 20; i++) {
deduplicator.log(
'ip-rejected',
'warn',
'Connection rejected from 192.168.1.100',
{ remoteIP: '192.168.1.100', reason: 'rate-limit' },
'192.168.1.100'
);
}
// Force flush
deduplicator.flush('ip-rejected');
// Verify the deduplicator exists and works
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.test('Connection cleanup deduplication', async (tools) => {
// Simulate various cleanup events
const reasons = ['normal', 'timeout', 'error', 'normal', 'zombie'];
for (const reason of reasons) {
for (let i = 0; i < 5; i++) {
deduplicator.log(
'connection-cleanup',
'info',
`Connection cleanup: ${reason}`,
{ connectionId: `conn-${i}`, reason },
reason
);
}
}
// Wait for automatic flush
await tools.delayFor(1500);
// Verify deduplicator is working
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.test('Automatic periodic flush', async (tools) => {
// Add some events
deduplicator.log('test-event', 'info', 'Test message', {}, 'test');
// Wait for automatic flush (should happen within 2x flush interval = 2 seconds)
await tools.delayFor(2500);
// Events should have been flushed automatically
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
tap.test('Cleanup deduplicator', async () => {
deduplicator.cleanup();
expect(deduplicator).toBeInstanceOf(LogDeduplicator);
});
export default tap.start();

View File

@@ -2,46 +2,55 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as net from 'net'; import * as net from 'net';
import * as tls from 'tls'; import * as tls from 'tls';
import { SmartProxy } from '../ts/index.js'; import { SmartProxy } from '../ts/index.js';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
let testProxy: SmartProxy; let testProxy: SmartProxy;
let targetServer: net.Server; let targetServer: net.Server;
let ECHO_PORT: number;
let PROXY_PORT: number;
// Create a simple echo server as target // Create a simple echo server as target
tap.test('setup test environment', async () => { tap.test('setup test environment', async () => {
[ECHO_PORT, PROXY_PORT] = await findFreePorts(2);
// Create target server that echoes data back // Create target server that echoes data back
targetServer = net.createServer((socket) => { targetServer = net.createServer((socket) => {
console.log('Target server: client connected'); console.log('Target server: client connected');
// Echo data back // Echo data back
socket.on('data', (data) => { socket.on('data', (data) => {
console.log(`Target server received: ${data.toString().trim()}`); console.log(`Target server received: ${data.toString().trim()}`);
socket.write(data); socket.write(data);
}); });
socket.on('close', () => { socket.on('close', () => {
console.log('Target server: client disconnected'); console.log('Target server: client disconnected');
}); });
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve, reject) => {
targetServer.listen(9876, () => { targetServer.on('error', (err) => {
console.log('Target server listening on port 9876'); console.error(`Echo server error: ${err.message}`);
reject(err);
});
targetServer.listen(ECHO_PORT, () => {
console.log(`Target server listening on port ${ECHO_PORT}`);
resolve(); resolve();
}); });
}); });
// Create proxy with simple TCP forwarding (no TLS) // Create proxy with simple TCP forwarding (no TLS)
testProxy = new SmartProxy({ testProxy = new SmartProxy({
routes: [{ routes: [{
name: 'tcp-forward-test', name: 'tcp-forward-test',
match: { match: {
ports: 8888 // Plain TCP port ports: PROXY_PORT // Plain TCP port
}, },
action: { action: {
type: 'forward', type: 'forward',
targets: [{ targets: [{
host: 'localhost', host: 'localhost',
port: 9876 port: ECHO_PORT
}] }]
// No TLS configuration - just plain TCP forwarding // No TLS configuration - just plain TCP forwarding
} }
@@ -49,7 +58,7 @@ tap.test('setup test environment', async () => {
defaults: { defaults: {
target: { target: {
host: 'localhost', host: 'localhost',
port: 9876 port: ECHO_PORT
} }
}, },
enableDetailedLogging: true, enableDetailedLogging: true,
@@ -59,72 +68,72 @@ tap.test('setup test environment', async () => {
keepAlive: true, keepAlive: true,
keepAliveInitialDelay: 1000 keepAliveInitialDelay: 1000
}); });
await testProxy.start(); await testProxy.start();
}); });
tap.test('should keep WebSocket-like connection open for extended period', async (tools) => { tap.test('should keep WebSocket-like connection open for extended period', async (tools) => {
tools.timeout(60000); // 60 second test timeout tools.timeout(15000); // 15 second test timeout
const client = new net.Socket(); const client = new net.Socket();
let messagesReceived = 0; let messagesReceived = 0;
let connectionClosed = false; let connectionClosed = false;
// Connect to proxy // Connect to proxy
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
client.connect(8888, 'localhost', () => { client.connect(PROXY_PORT, 'localhost', () => {
console.log('Client connected to proxy'); console.log('Client connected to proxy');
resolve(); resolve();
}); });
client.on('error', reject); client.on('error', reject);
}); });
// Set up data handler // Set up data handler
client.on('data', (data) => { client.on('data', (data) => {
console.log(`Client received: ${data.toString().trim()}`); console.log(`Client received: ${data.toString().trim()}`);
messagesReceived++; messagesReceived++;
}); });
client.on('close', () => { client.on('close', () => {
console.log('Client connection closed'); console.log('Client connection closed');
connectionClosed = true; connectionClosed = true;
}); });
// Send initial handshake-like data // Send initial handshake-like data
client.write('HELLO\n'); client.write('HELLO\n');
// Wait for response // Wait for response
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
expect(messagesReceived).toEqual(1); expect(messagesReceived).toEqual(1);
// Simulate WebSocket-like keep-alive pattern // Simulate WebSocket-like keep-alive pattern
// Send periodic messages over 60 seconds // Send periodic messages over 5 seconds
const startTime = Date.now(); const startTime = Date.now();
const pingInterval = setInterval(() => { const pingInterval = setInterval(() => {
if (!connectionClosed && Date.now() - startTime < 60000) { if (!connectionClosed && Date.now() - startTime < 5000) {
console.log('Sending ping...'); console.log('Sending ping...');
client.write('PING\n'); client.write('PING\n');
} else { } else {
clearInterval(pingInterval); clearInterval(pingInterval);
} }
}, 10000); // Every 10 seconds }, 1000); // Every 1 second
// Wait for 55 seconds (must complete within 60s runner timeout) // Wait for 5 seconds — sufficient to verify the connection stays open
await new Promise(resolve => setTimeout(resolve, 55000)); await new Promise(resolve => setTimeout(resolve, 5000));
// Clean up interval // Clean up interval
clearInterval(pingInterval); clearInterval(pingInterval);
// Connection should still be open // Connection should still be open
expect(connectionClosed).toEqual(false); expect(connectionClosed).toEqual(false);
// Should have received responses (1 hello + 6 pings) // Should have received responses (1 hello + ~5 pings)
expect(messagesReceived).toBeGreaterThan(5); expect(messagesReceived).toBeGreaterThan(3);
// Close connection gracefully // Close connection gracefully
client.end(); client.end();
// Wait for close // Wait for close
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
expect(connectionClosed).toEqual(true); expect(connectionClosed).toEqual(true);
@@ -134,13 +143,15 @@ tap.test('should keep WebSocket-like connection open for extended period', async
tap.test('cleanup', async () => { tap.test('cleanup', async () => {
await testProxy.stop(); await testProxy.stop();
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
targetServer.close(() => { targetServer.close(() => {
console.log('Target server closed'); console.log('Target server closed');
resolve(); resolve();
}); });
}); });
await assertPortsFree([ECHO_PORT, PROXY_PORT]);
}); });
export default tap.start(); export default tap.start();

View File

@@ -2,21 +2,27 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js'; import * as plugins from '../ts/plugins.js';
import { SmartProxy } from '../ts/index.js'; import { SmartProxy } from '../ts/index.js';
import * as net from 'net'; import * as net from 'net';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
let smartProxyInstance: SmartProxy; let smartProxyInstance: SmartProxy;
let echoServer: net.Server; let echoServer: net.Server;
const echoServerPort = 9876; let echoServerPort: number;
const proxyPort = 8080; let proxyPort: number;
// Create an echo server for testing // Create an echo server for testing
tap.test('should create echo server for testing', async () => { tap.test('should create echo server for testing', async () => {
[echoServerPort, proxyPort] = await findFreePorts(2);
echoServer = net.createServer((socket) => { echoServer = net.createServer((socket) => {
socket.on('data', (data) => { socket.on('data', (data) => {
socket.write(data); // Echo back the data socket.write(data); // Echo back the data
}); });
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve, reject) => {
echoServer.on('error', (err) => {
console.error(`Echo server error: ${err.message}`);
reject(err);
});
echoServer.listen(echoServerPort, () => { echoServer.listen(echoServerPort, () => {
console.log(`Echo server listening on port ${echoServerPort}`); console.log(`Echo server listening on port ${echoServerPort}`);
resolve(); resolve();
@@ -263,6 +269,8 @@ tap.test('should clean up resources', async () => {
resolve(); resolve();
}); });
}); });
await assertPortsFree([echoServerPort, proxyPort]);
}); });
export default tap.start(); export default tap.start();

View File

@@ -1,15 +1,14 @@
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js'; import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
import { createNfTablesRoute, createNfTablesTerminateRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import { expect, tap } from '@git.zone/tstest/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as child_process from 'child_process'; import * as child_process from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
const exec = promisify(child_process.exec); const exec = promisify(child_process.exec);
// Check if we have root privileges to run NFTables tests
async function checkRootPrivileges(): Promise<boolean> { async function checkRootPrivileges(): Promise<boolean> {
try { try {
// Check if we're running as root
const { stdout } = await exec('id -u'); const { stdout } = await exec('id -u');
return stdout.trim() === '0'; return stdout.trim() === '0';
} catch (err) { } catch (err) {
@@ -17,7 +16,6 @@ async function checkRootPrivileges(): Promise<boolean> {
} }
} }
// Check if tests should run
const isRoot = await checkRootPrivileges(); const isRoot = await checkRootPrivileges();
if (!isRoot) { if (!isRoot) {
@@ -29,68 +27,70 @@ if (!isRoot) {
console.log(''); console.log('');
} }
// Define the test with proper skip condition
const testFn = isRoot ? tap.test : tap.skip.test; const testFn = isRoot ? tap.test : tap.skip.test;
testFn('NFTables integration tests', async () => { testFn('NFTables integration tests', async () => {
console.log('Running NFTables tests with root privileges'); console.log('Running NFTables tests with root privileges');
// Create test routes const routes: IRouteConfig[] = [
const routes = [ {
createNfTablesRoute('tcp-forward', { match: { ports: 9080 },
host: 'localhost', action: {
port: 8080 type: 'forward',
}, { forwardingEngine: 'nftables',
ports: 9080, targets: [{ host: 'localhost', port: 8080 }],
protocol: 'tcp' nftables: { protocol: 'tcp' }
}), },
name: 'tcp-forward'
createNfTablesRoute('udp-forward', { },
host: 'localhost',
port: 5353 {
}, { match: { ports: 5354 },
ports: 5354, action: {
protocol: 'udp' type: 'forward',
}), forwardingEngine: 'nftables',
targets: [{ host: 'localhost', port: 5353 }],
createNfTablesRoute('port-range', { nftables: { protocol: 'udp' }
host: 'localhost', },
port: 8080 name: 'udp-forward'
}, { },
ports: [{ from: 9000, to: 9100 }],
protocol: 'tcp' {
}) match: { ports: [{ from: 9000, to: 9100 }] },
action: {
type: 'forward',
forwardingEngine: 'nftables',
targets: [{ host: 'localhost', port: 8080 }],
nftables: { protocol: 'tcp' }
},
name: 'port-range'
}
]; ];
const smartProxy = new SmartProxy({ const smartProxy = new SmartProxy({
enableDetailedLogging: true, enableDetailedLogging: true,
routes routes
}); });
// Start the proxy
await smartProxy.start(); await smartProxy.start();
console.log('SmartProxy started with NFTables routes'); console.log('SmartProxy started with NFTables routes');
// Get NFTables status
const status = await smartProxy.getNfTablesStatus(); const status = await smartProxy.getNfTablesStatus();
console.log('NFTables status:', JSON.stringify(status, null, 2)); console.log('NFTables status:', JSON.stringify(status, null, 2));
// Verify all routes are provisioned
expect(Object.keys(status).length).toEqual(routes.length); expect(Object.keys(status).length).toEqual(routes.length);
for (const routeStatus of Object.values(status)) { for (const routeStatus of Object.values(status)) {
expect(routeStatus.active).toBeTrue(); expect(routeStatus.active).toBeTrue();
expect(routeStatus.ruleCount.total).toBeGreaterThan(0); expect(routeStatus.ruleCount.total).toBeGreaterThan(0);
} }
// Stop the proxy
await smartProxy.stop(); await smartProxy.stop();
console.log('SmartProxy stopped'); console.log('SmartProxy stopped');
// Verify all rules are cleaned up
const finalStatus = await smartProxy.getNfTablesStatus(); const finalStatus = await smartProxy.getNfTablesStatus();
expect(Object.keys(finalStatus).length).toEqual(0); expect(Object.keys(finalStatus).length).toEqual(0);
}); });
export default tap.start(); export default tap.start();

View File

@@ -1,5 +1,4 @@
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js'; import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
import { createNfTablesRoute, createNfTablesTerminateRoute } from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import { expect, tap } from '@git.zone/tstest/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net'; import * as net from 'net';
import * as http from 'http'; import * as http from 'http';
@@ -10,13 +9,13 @@ import { fileURLToPath } from 'url';
import * as child_process from 'child_process'; import * as child_process from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
const exec = promisify(child_process.exec); const exec = promisify(child_process.exec);
// Get __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
// Check if we have root privileges
async function checkRootPrivileges(): Promise<boolean> { async function checkRootPrivileges(): Promise<boolean> {
try { try {
const { stdout } = await exec('id -u'); const { stdout } = await exec('id -u');
@@ -26,7 +25,6 @@ async function checkRootPrivileges(): Promise<boolean> {
} }
} }
// Check if tests should run
const runTests = await checkRootPrivileges(); const runTests = await checkRootPrivileges();
if (!runTests) { if (!runTests) {
@@ -36,10 +34,8 @@ if (!runTests) {
console.log('Skipping NFTables integration tests'); console.log('Skipping NFTables integration tests');
console.log('========================================'); console.log('========================================');
console.log(''); console.log('');
// Skip tests when not running as root - tests are marked with tap.skip.test
} }
// Test server and client utilities
let testTcpServer: net.Server; let testTcpServer: net.Server;
let testHttpServer: http.Server; let testHttpServer: http.Server;
let testHttpsServer: https.Server; let testHttpsServer: https.Server;
@@ -53,10 +49,8 @@ const PROXY_HTTP_PORT = 5001;
const PROXY_HTTPS_PORT = 5002; const PROXY_HTTPS_PORT = 5002;
const TEST_DATA = 'Hello through NFTables!'; const TEST_DATA = 'Hello through NFTables!';
// Helper to create test certificates
async function createTestCertificates() { async function createTestCertificates() {
try { try {
// Import the certificate helper
const certsModule = await import('./helpers/certificates.js'); const certsModule = await import('./helpers/certificates.js');
const certificates = certsModule.loadTestCertificates(); const certificates = certsModule.loadTestCertificates();
return { return {
@@ -65,7 +59,6 @@ async function createTestCertificates() {
}; };
} catch (err) { } catch (err) {
console.error('Failed to load test certificates:', err); console.error('Failed to load test certificates:', err);
// Use dummy certificates for testing
return { return {
cert: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8'), cert: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8'),
key: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8') key: fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8')
@@ -75,111 +68,112 @@ async function createTestCertificates() {
tap.skip.test('setup NFTables integration test environment', async () => { tap.skip.test('setup NFTables integration test environment', async () => {
console.log('Running NFTables integration tests with root privileges'); console.log('Running NFTables integration tests with root privileges');
// Create a basic TCP test server
testTcpServer = net.createServer((socket) => { testTcpServer = net.createServer((socket) => {
socket.on('data', (data) => { socket.on('data', (data) => {
socket.write(`Server says: ${data.toString()}`); socket.write(`Server says: ${data.toString()}`);
}); });
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
testTcpServer.listen(TEST_TCP_PORT, () => { testTcpServer.listen(TEST_TCP_PORT, () => {
console.log(`TCP test server listening on port ${TEST_TCP_PORT}`); console.log(`TCP test server listening on port ${TEST_TCP_PORT}`);
resolve(); resolve();
}); });
}); });
// Create an HTTP test server
testHttpServer = http.createServer((req, res) => { testHttpServer = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' }); res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`HTTP Server says: ${TEST_DATA}`); res.end(`HTTP Server says: ${TEST_DATA}`);
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
testHttpServer.listen(TEST_HTTP_PORT, () => { testHttpServer.listen(TEST_HTTP_PORT, () => {
console.log(`HTTP test server listening on port ${TEST_HTTP_PORT}`); console.log(`HTTP test server listening on port ${TEST_HTTP_PORT}`);
resolve(); resolve();
}); });
}); });
// Create an HTTPS test server
const certs = await createTestCertificates(); const certs = await createTestCertificates();
testHttpsServer = https.createServer({ key: certs.key, cert: certs.cert }, (req, res) => { testHttpsServer = https.createServer({ key: certs.key, cert: certs.cert }, (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' }); res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`HTTPS Server says: ${TEST_DATA}`); res.end(`HTTPS Server says: ${TEST_DATA}`);
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
testHttpsServer.listen(TEST_HTTPS_PORT, () => { testHttpsServer.listen(TEST_HTTPS_PORT, () => {
console.log(`HTTPS test server listening on port ${TEST_HTTPS_PORT}`); console.log(`HTTPS test server listening on port ${TEST_HTTPS_PORT}`);
resolve(); resolve();
}); });
}); });
// Create SmartProxy with various NFTables routes
smartProxy = new SmartProxy({ smartProxy = new SmartProxy({
enableDetailedLogging: true, enableDetailedLogging: true,
routes: [ routes: [
// TCP forwarding route {
createNfTablesRoute('tcp-nftables', { match: { ports: PROXY_TCP_PORT },
host: 'localhost', action: {
port: TEST_TCP_PORT type: 'forward',
}, { forwardingEngine: 'nftables',
ports: PROXY_TCP_PORT, targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
protocol: 'tcp' nftables: { protocol: 'tcp' }
}), },
name: 'tcp-nftables'
// HTTP forwarding route },
createNfTablesRoute('http-nftables', {
host: 'localhost', {
port: TEST_HTTP_PORT match: { ports: PROXY_HTTP_PORT },
}, { action: {
ports: PROXY_HTTP_PORT, type: 'forward',
protocol: 'tcp' forwardingEngine: 'nftables',
}), targets: [{ host: 'localhost', port: TEST_HTTP_PORT }],
nftables: { protocol: 'tcp' }
// HTTPS termination route },
createNfTablesTerminateRoute('https-nftables.example.com', { name: 'http-nftables'
host: 'localhost', },
port: TEST_HTTPS_PORT
}, { {
ports: PROXY_HTTPS_PORT, match: { ports: PROXY_HTTPS_PORT, domains: 'https-nftables.example.com' },
protocol: 'tcp', action: {
certificate: certs type: 'forward',
}), forwardingEngine: 'nftables',
targets: [{ host: 'localhost', port: TEST_HTTPS_PORT }],
// Route with IP allow list tls: { mode: 'terminate', certificate: 'auto' },
createNfTablesRoute('secure-tcp', { nftables: { protocol: 'tcp' }
host: 'localhost', },
port: TEST_TCP_PORT name: 'https-nftables'
}, { },
ports: 5003,
protocol: 'tcp', {
ipAllowList: ['127.0.0.1', '::1'] match: { ports: 5003 },
}), action: {
type: 'forward',
// Route with QoS settings forwardingEngine: 'nftables',
createNfTablesRoute('qos-tcp', { targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
host: 'localhost', nftables: { protocol: 'tcp', ipAllowList: ['127.0.0.1', '::1'] }
port: TEST_TCP_PORT },
}, { name: 'secure-tcp'
ports: 5004, },
protocol: 'tcp',
maxRate: '10mbps', {
priority: 1 match: { ports: 5004 },
}) action: {
type: 'forward',
forwardingEngine: 'nftables',
targets: [{ host: 'localhost', port: TEST_TCP_PORT }],
nftables: { protocol: 'tcp', maxRate: '10mbps', priority: 1 }
},
name: 'qos-tcp'
}
] ]
}); });
console.log('SmartProxy created, now starting...'); console.log('SmartProxy created, now starting...');
// Start the proxy
try { try {
await smartProxy.start(); await smartProxy.start();
console.log('SmartProxy started successfully'); console.log('SmartProxy started successfully');
// Verify proxy is listening on expected ports
const listeningPorts = smartProxy.getListeningPorts(); const listeningPorts = smartProxy.getListeningPorts();
console.log(`SmartProxy is listening on ports: ${listeningPorts.join(', ')}`); console.log(`SmartProxy is listening on ports: ${listeningPorts.join(', ')}`);
} catch (err) { } catch (err) {
@@ -190,8 +184,7 @@ tap.skip.test('setup NFTables integration test environment', async () => {
tap.skip.test('should forward TCP connections through NFTables', async () => { tap.skip.test('should forward TCP connections through NFTables', async () => {
console.log(`Attempting to connect to proxy TCP port ${PROXY_TCP_PORT}...`); console.log(`Attempting to connect to proxy TCP port ${PROXY_TCP_PORT}...`);
// First verify our test server is running
try { try {
const testClient = new net.Socket(); const testClient = new net.Socket();
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -205,40 +198,39 @@ tap.skip.test('should forward TCP connections through NFTables', async () => {
} catch (err) { } catch (err) {
console.error(`Test server on port ${TEST_TCP_PORT} is not accessible: ${err}`); console.error(`Test server on port ${TEST_TCP_PORT} is not accessible: ${err}`);
} }
// Connect to the proxy port
const client = new net.Socket(); const client = new net.Socket();
const response = await new Promise<string>((resolve, reject) => { const response = await new Promise<string>((resolve, reject) => {
let responseData = ''; let responseData = '';
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
client.destroy(); client.destroy();
reject(new Error(`Connection timeout after 5 seconds to proxy port ${PROXY_TCP_PORT}`)); reject(new Error(`Connection timeout after 5 seconds to proxy port ${PROXY_TCP_PORT}`));
}, 5000); }, 5000);
client.connect(PROXY_TCP_PORT, 'localhost', () => { client.connect(PROXY_TCP_PORT, 'localhost', () => {
console.log(`Connected to proxy port ${PROXY_TCP_PORT}, sending data...`); console.log(`Connected to proxy port ${PROXY_TCP_PORT}, sending data...`);
client.write(TEST_DATA); client.write(TEST_DATA);
}); });
client.on('data', (data) => { client.on('data', (data) => {
console.log(`Received data from proxy: ${data.toString()}`); console.log(`Received data from proxy: ${data.toString()}`);
responseData += data.toString(); responseData += data.toString();
client.end(); client.end();
}); });
client.on('end', () => { client.on('end', () => {
clearTimeout(timeout); clearTimeout(timeout);
resolve(responseData); resolve(responseData);
}); });
client.on('error', (err) => { client.on('error', (err) => {
clearTimeout(timeout); clearTimeout(timeout);
console.error(`Connection error on proxy port ${PROXY_TCP_PORT}: ${err.message}`); console.error(`Connection error on proxy port ${PROXY_TCP_PORT}: ${err.message}`);
reject(err); reject(err);
}); });
}); });
expect(response).toEqual(`Server says: ${TEST_DATA}`); expect(response).toEqual(`Server says: ${TEST_DATA}`);
}); });
@@ -254,21 +246,20 @@ tap.skip.test('should forward HTTP connections through NFTables', async () => {
}); });
}).on('error', reject); }).on('error', reject);
}); });
expect(response).toEqual(`HTTP Server says: ${TEST_DATA}`); expect(response).toEqual(`HTTP Server says: ${TEST_DATA}`);
}); });
tap.skip.test('should handle HTTPS termination with NFTables', async () => { tap.skip.test('should handle HTTPS termination with NFTables', async () => {
// Skip this test if running without proper certificates
const response = await new Promise<string>((resolve, reject) => { const response = await new Promise<string>((resolve, reject) => {
const options = { const options = {
hostname: 'localhost', hostname: 'localhost',
port: PROXY_HTTPS_PORT, port: PROXY_HTTPS_PORT,
path: '/', path: '/',
method: 'GET', method: 'GET',
rejectUnauthorized: false // For self-signed cert rejectUnauthorized: false
}; };
https.get(options, (res) => { https.get(options, (res) => {
let data = ''; let data = '';
res.on('data', (chunk) => { res.on('data', (chunk) => {
@@ -279,43 +270,40 @@ tap.skip.test('should handle HTTPS termination with NFTables', async () => {
}); });
}).on('error', reject); }).on('error', reject);
}); });
expect(response).toEqual(`HTTPS Server says: ${TEST_DATA}`); expect(response).toEqual(`HTTPS Server says: ${TEST_DATA}`);
}); });
tap.skip.test('should respect IP allow lists in NFTables', async () => { tap.skip.test('should respect IP allow lists in NFTables', async () => {
// This test should pass since we're connecting from localhost
const client = new net.Socket(); const client = new net.Socket();
const connected = await new Promise<boolean>((resolve) => { const connected = await new Promise<boolean>((resolve) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
client.destroy(); client.destroy();
resolve(false); resolve(false);
}, 2000); }, 2000);
client.connect(5003, 'localhost', () => { client.connect(5003, 'localhost', () => {
clearTimeout(timeout); clearTimeout(timeout);
client.end(); client.end();
resolve(true); resolve(true);
}); });
client.on('error', () => { client.on('error', () => {
clearTimeout(timeout); clearTimeout(timeout);
resolve(false); resolve(false);
}); });
}); });
expect(connected).toBeTrue(); expect(connected).toBeTrue();
}); });
tap.skip.test('should get NFTables status', async () => { tap.skip.test('should get NFTables status', async () => {
const status = await smartProxy.getNfTablesStatus(); const status = await smartProxy.getNfTablesStatus();
// Check that we have status for our routes
const statusKeys = Object.keys(status); const statusKeys = Object.keys(status);
expect(statusKeys.length).toBeGreaterThan(0); expect(statusKeys.length).toBeGreaterThan(0);
// Check status structure for one of the routes
const firstStatus = status[statusKeys[0]]; const firstStatus = status[statusKeys[0]];
expect(firstStatus).toHaveProperty('active'); expect(firstStatus).toHaveProperty('active');
expect(firstStatus).toHaveProperty('ruleCount'); expect(firstStatus).toHaveProperty('ruleCount');
@@ -324,21 +312,20 @@ tap.skip.test('should get NFTables status', async () => {
}); });
tap.skip.test('cleanup NFTables integration test environment', async () => { tap.skip.test('cleanup NFTables integration test environment', async () => {
// Stop the proxy and test servers
await smartProxy.stop(); await smartProxy.stop();
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
testTcpServer.close(() => { testTcpServer.close(() => {
resolve(); resolve();
}); });
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
testHttpServer.close(() => { testHttpServer.close(() => {
resolve(); resolve();
}); });
}); });
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
testHttpsServer.close(() => { testHttpsServer.close(() => {
resolve(); resolve();
@@ -346,4 +333,4 @@ tap.skip.test('cleanup NFTables integration test environment', async () => {
}); });
}); });
export default tap.start(); export default tap.start();

View File

@@ -0,0 +1,477 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/index.js';
import * as http from 'http';
import * as https from 'https';
import * as http2 from 'http2';
import * as net from 'net';
import * as tls from 'tls';
import * as fs from 'fs';
import * as path from 'path';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
// ---------------------------------------------------------------------------
// Port assignments (dynamically allocated to avoid conflicts)
// ---------------------------------------------------------------------------
let HTTP_ECHO_PORT: number;
let PROXY_HTTP_PORT: number;
let PROXY_HTTPS_PORT: number;
let TCP_ECHO_PORT: number;
let PROXY_TCP_PORT: number;
// ---------------------------------------------------------------------------
// Shared state
// ---------------------------------------------------------------------------
let httpEchoServer: http.Server;
let tcpEchoServer: net.Server;
let proxy: SmartProxy;
const certPem = fs.readFileSync(path.join(import.meta.dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8');
const keyPem = fs.readFileSync(path.join(import.meta.dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8');
// ---------------------------------------------------------------------------
// Helper: make an HTTP request and return { status, body }
// ---------------------------------------------------------------------------
function httpRequest(
options: http.RequestOptions,
body?: string,
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk: string) => (data += chunk));
res.on('end', () => resolve({ status: res.statusCode!, body: data }));
});
req.on('error', reject);
req.setTimeout(5000, () => {
req.destroy(new Error('timeout'));
});
if (body) req.end(body);
else req.end();
});
}
// Same but for HTTPS
function httpsRequest(
options: https.RequestOptions,
body?: string,
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk: string) => (data += chunk));
res.on('end', () => resolve({ status: res.statusCode!, body: data }));
});
req.on('error', reject);
req.setTimeout(5000, () => {
req.destroy(new Error('timeout'));
});
if (body) req.end(body);
else req.end();
});
}
// Helper: wait for metrics to settle on a condition
async function waitForMetrics(
metrics: ReturnType<SmartProxy['getMetrics']>,
condition: () => boolean,
maxWaitMs = 3000,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
// Force a fresh poll
await (proxy as any).metricsAdapter.poll();
if (condition()) return;
await new Promise((r) => setTimeout(r, 100));
}
}
// ===========================================================================
// 1. Setup backend servers
// ===========================================================================
tap.test('setup - backend servers', async () => {
[HTTP_ECHO_PORT, PROXY_HTTP_PORT, PROXY_HTTPS_PORT, TCP_ECHO_PORT, PROXY_TCP_PORT] = await findFreePorts(5);
// HTTP echo server: POST → echo:<body>, GET → ok
httpEchoServer = http.createServer((req, res) => {
if (req.method === 'POST') {
let body = '';
req.on('data', (chunk: string) => (body += chunk));
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`echo:${body}`);
});
} else {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('ok');
}
});
await new Promise<void>((resolve, reject) => {
httpEchoServer.on('error', reject);
httpEchoServer.listen(HTTP_ECHO_PORT, () => {
console.log(`HTTP echo server on port ${HTTP_ECHO_PORT}`);
resolve();
});
});
// TCP echo server
tcpEchoServer = net.createServer((socket) => {
socket.on('data', (data) => socket.write(data));
});
await new Promise<void>((resolve, reject) => {
tcpEchoServer.on('error', reject);
tcpEchoServer.listen(TCP_ECHO_PORT, () => {
console.log(`TCP echo server on port ${TCP_ECHO_PORT}`);
resolve();
});
});
});
// ===========================================================================
// 2. Setup SmartProxy
// ===========================================================================
tap.test('setup - SmartProxy with 3 routes', async () => {
proxy = new SmartProxy({
routes: [
// Plain HTTP forward: 47601 → 47600
{
name: 'http-forward',
match: { ports: PROXY_HTTP_PORT },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: HTTP_ECHO_PORT }],
},
},
// TLS-terminate HTTPS: 47602 → 47600
{
name: 'https-terminate',
match: { ports: PROXY_HTTPS_PORT, domains: 'localhost' },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: HTTP_ECHO_PORT }],
tls: {
mode: 'terminate',
certificate: {
key: keyPem,
cert: certPem,
},
},
},
},
// Plain TCP forward: 47604 → 47603
{
name: 'tcp-forward',
match: { ports: PROXY_TCP_PORT },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: TCP_ECHO_PORT }],
},
},
],
metrics: {
enabled: true,
sampleIntervalMs: 100,
},
enableDetailedLogging: false,
});
await proxy.start();
// Give the proxy a moment to fully bind
await new Promise((r) => setTimeout(r, 500));
});
// ===========================================================================
// 3. HTTP/1.1 connection pooling: sequential requests reuse connections
// ===========================================================================
tap.test('HTTP/1.1 connection pooling: sequential requests reuse connections', async (tools) => {
tools.timeout(30000);
const metrics = proxy.getMetrics();
const REQUEST_COUNT = 20;
// Use a non-keepalive agent so each request closes the client→proxy socket
// (Rust's backend connection pool still reuses proxy→backend connections)
const agent = new http.Agent({ keepAlive: false });
for (let i = 0; i < REQUEST_COUNT; i++) {
const result = await httpRequest(
{
hostname: 'localhost',
port: PROXY_HTTP_PORT,
path: '/echo',
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
agent,
},
`msg-${i}`,
);
expect(result.status).toEqual(200);
expect(result.body).toEqual(`echo:msg-${i}`);
}
agent.destroy();
// Wait for all connections to settle and metrics to update
await waitForMetrics(metrics, () => metrics.connections.active() === 0, 5000);
expect(metrics.connections.active()).toEqual(0);
// Bytes should have been transferred
await waitForMetrics(metrics, () => metrics.totals.bytesIn() > 0);
expect(metrics.totals.bytesIn()).toBeGreaterThan(0);
expect(metrics.totals.bytesOut()).toBeGreaterThan(0);
console.log(`HTTP pooling test: ${REQUEST_COUNT} requests completed. bytesIn=${metrics.totals.bytesIn()}, bytesOut=${metrics.totals.bytesOut()}`);
});
// ===========================================================================
// 4. HTTPS with TLS termination: multiple requests through TLS
// ===========================================================================
tap.test('HTTPS with TLS termination: multiple requests through TLS', async (tools) => {
tools.timeout(30000);
const REQUEST_COUNT = 10;
const agent = new https.Agent({ keepAlive: false, rejectUnauthorized: false });
for (let i = 0; i < REQUEST_COUNT; i++) {
const result = await httpsRequest(
{
hostname: 'localhost',
port: PROXY_HTTPS_PORT,
path: '/echo',
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
rejectUnauthorized: false,
servername: 'localhost',
agent,
},
`tls-${i}`,
);
expect(result.status).toEqual(200);
expect(result.body).toEqual(`echo:tls-${i}`);
}
agent.destroy();
console.log(`HTTPS termination test: ${REQUEST_COUNT} requests completed successfully`);
});
// ===========================================================================
// 5. TLS ALPN negotiation verification
// ===========================================================================
tap.test('HTTP/2 end-to-end: ALPN h2 with multiplexed requests', async (tools) => {
tools.timeout(15000);
// Connect an HTTP/2 session over TLS
const session = http2.connect(`https://localhost:${PROXY_HTTPS_PORT}`, {
rejectUnauthorized: false,
});
await new Promise<void>((resolve, reject) => {
session.on('connect', () => resolve());
session.on('error', reject);
setTimeout(() => reject(new Error('h2 connect timeout')), 5000);
});
// Verify ALPN negotiated h2
const alpnProtocol = (session.socket as tls.TLSSocket).alpnProtocol;
console.log(`TLS ALPN negotiated protocol: ${alpnProtocol}`);
expect(alpnProtocol).toEqual('h2');
// Send 5 multiplexed POST requests on the same h2 session
const REQUEST_COUNT = 5;
const promises: Promise<{ status: number; body: string }>[] = [];
for (let i = 0; i < REQUEST_COUNT; i++) {
promises.push(
new Promise<{ status: number; body: string }>((resolve, reject) => {
const reqStream = session.request({
':method': 'POST',
':path': '/echo',
'content-type': 'text/plain',
});
let data = '';
let status = 0;
reqStream.on('response', (headers) => {
status = headers[':status'] as number;
});
reqStream.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
reqStream.on('end', () => resolve({ status, body: data }));
reqStream.on('error', reject);
reqStream.end(`h2-msg-${i}`);
}),
);
}
const results = await Promise.all(promises);
for (let i = 0; i < REQUEST_COUNT; i++) {
expect(results[i].status).toEqual(200);
expect(results[i].body).toEqual(`echo:h2-msg-${i}`);
}
await new Promise<void>((resolve) => session.close(() => resolve()));
console.log(`HTTP/2 end-to-end: ${REQUEST_COUNT} multiplexed requests completed successfully`);
});
// ===========================================================================
// 6. Connection stability: no leaked connections after repeated open/close
// ===========================================================================
tap.test('connection stability: no leaked connections after repeated open/close', async (tools) => {
tools.timeout(60000);
const metrics = proxy.getMetrics();
const BATCH_SIZE = 50;
// Ensure we start clean
await waitForMetrics(metrics, () => metrics.connections.active() === 0);
// Record total connections before
await (proxy as any).metricsAdapter.poll();
const totalBefore = metrics.connections.total();
// --- Batch 1: 50 sequential TCP connections ---
for (let i = 0; i < BATCH_SIZE; i++) {
await new Promise<void>((resolve, reject) => {
const client = new net.Socket();
client.connect(PROXY_TCP_PORT, 'localhost', () => {
const msg = `batch1-${i}`;
client.write(msg);
client.once('data', (data) => {
expect(data.toString()).toEqual(msg);
client.end();
});
});
client.on('close', () => resolve());
client.on('error', reject);
client.setTimeout(5000, () => {
client.destroy(new Error('timeout'));
});
});
}
// Wait for all connections to drain
await waitForMetrics(metrics, () => metrics.connections.active() === 0, 5000);
expect(metrics.connections.active()).toEqual(0);
console.log(`Batch 1 done: active=${metrics.connections.active()}, total=${metrics.connections.total()}`);
// --- Batch 2: another 50 ---
for (let i = 0; i < BATCH_SIZE; i++) {
await new Promise<void>((resolve, reject) => {
const client = new net.Socket();
client.connect(PROXY_TCP_PORT, 'localhost', () => {
const msg = `batch2-${i}`;
client.write(msg);
client.once('data', (data) => {
expect(data.toString()).toEqual(msg);
client.end();
});
});
client.on('close', () => resolve());
client.on('error', reject);
client.setTimeout(5000, () => {
client.destroy(new Error('timeout'));
});
});
}
// Wait for all connections to drain again
await waitForMetrics(metrics, () => metrics.connections.active() === 0, 5000);
expect(metrics.connections.active()).toEqual(0);
// Total should reflect ~100 new connections
await (proxy as any).metricsAdapter.poll();
const totalAfter = metrics.connections.total();
const newConnections = totalAfter - totalBefore;
console.log(`Batch 2 done: active=${metrics.connections.active()}, total=${totalAfter}, new=${newConnections}`);
expect(newConnections).toBeGreaterThanOrEqual(BATCH_SIZE * 2);
});
// ===========================================================================
// 7. Concurrent connections: burst and drain
// ===========================================================================
tap.test('concurrent connections: burst and drain', async (tools) => {
tools.timeout(30000);
const metrics = proxy.getMetrics();
const CONCURRENT = 20;
// Ensure we start clean
await waitForMetrics(metrics, () => metrics.connections.active() === 0, 5000);
// Open 20 TCP connections simultaneously
const clients: net.Socket[] = [];
const connectPromises: Promise<void>[] = [];
for (let i = 0; i < CONCURRENT; i++) {
const client = new net.Socket();
clients.push(client);
connectPromises.push(
new Promise<void>((resolve, reject) => {
client.connect(PROXY_TCP_PORT, 'localhost', () => resolve());
client.on('error', reject);
client.setTimeout(5000, () => {
client.destroy(new Error('timeout'));
});
}),
);
}
await Promise.all(connectPromises);
// Send data on all connections and wait for echo
const echoPromises = clients.map((client, i) => {
return new Promise<void>((resolve, reject) => {
const msg = `concurrent-${i}`;
client.once('data', (data) => {
expect(data.toString()).toEqual(msg);
resolve();
});
client.write(msg);
client.on('error', reject);
});
});
await Promise.all(echoPromises);
// Poll metrics — active connections should be CONCURRENT
await waitForMetrics(metrics, () => metrics.connections.active() >= CONCURRENT, 3000);
const activeWhileOpen = metrics.connections.active();
console.log(`Burst: active connections while open = ${activeWhileOpen}`);
expect(activeWhileOpen).toBeGreaterThanOrEqual(CONCURRENT);
// Close all connections
for (const client of clients) {
client.end();
}
// Wait for drain
await waitForMetrics(metrics, () => metrics.connections.active() === 0, 5000);
expect(metrics.connections.active()).toEqual(0);
console.log('Drain: all connections closed, active=0');
});
// ===========================================================================
// 8. Cleanup
// ===========================================================================
tap.test('cleanup', async () => {
await proxy.stop();
await new Promise<void>((resolve) => {
httpEchoServer.close(() => {
console.log('HTTP echo server closed');
resolve();
});
});
await new Promise<void>((resolve) => {
tcpEchoServer.close(() => {
console.log('TCP echo server closed');
resolve();
});
});
await assertPortsFree([HTTP_ECHO_PORT, PROXY_HTTP_PORT, PROXY_HTTPS_PORT, TCP_ECHO_PORT, PROXY_TCP_PORT]);
});
export default tap.start();

View File

@@ -1,23 +1,33 @@
import { expect, tap } from '@git.zone/tstest/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net'; import * as net from 'net';
import { SmartProxy } from '../ts/proxies/smart-proxy/smart-proxy.js'; import { SmartProxy } from '../ts/proxies/smart-proxy/smart-proxy.js';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
let echoServer: net.Server; let echoServer: net.Server;
let proxy: SmartProxy; let proxy: SmartProxy;
let ECHO_PORT: number;
let PROXY_PORT_1: number;
let PROXY_PORT_2: number;
tap.test('port forwarding should not immediately close connections', async (tools) => { tap.test('port forwarding should not immediately close connections', async (tools) => {
// Set a timeout for this test // Set a timeout for this test
tools.timeout(10000); // 10 seconds tools.timeout(10000); // 10 seconds
[ECHO_PORT, PROXY_PORT_1, PROXY_PORT_2] = await findFreePorts(3);
// Create an echo server // Create an echo server
echoServer = await new Promise<net.Server>((resolve) => { echoServer = await new Promise<net.Server>((resolve, reject) => {
const server = net.createServer((socket) => { const server = net.createServer((socket) => {
socket.on('data', (data) => { socket.on('data', (data) => {
socket.write(`ECHO: ${data}`); socket.write(`ECHO: ${data}`);
}); });
}); });
server.listen(8888, () => { server.on('error', (err) => {
console.log('Echo server listening on port 8888'); console.error(`Echo server error: ${err.message}`);
reject(err);
});
server.listen(ECHO_PORT, () => {
console.log(`Echo server listening on port ${ECHO_PORT}`);
resolve(server); resolve(server);
}); });
}); });
@@ -26,10 +36,10 @@ tap.test('port forwarding should not immediately close connections', async (tool
proxy = new SmartProxy({ proxy = new SmartProxy({
routes: [{ routes: [{
name: 'test-forward', name: 'test-forward',
match: { ports: 9999 }, match: { ports: PROXY_PORT_1 },
action: { action: {
type: 'forward', type: 'forward',
targets: [{ host: 'localhost', port: 8888 }] targets: [{ host: 'localhost', port: ECHO_PORT }]
} }
}] }]
}); });
@@ -37,21 +47,24 @@ tap.test('port forwarding should not immediately close connections', async (tool
await proxy.start(); await proxy.start();
// Test connection through proxy // Test connection through proxy
const client = net.createConnection(9999, 'localhost'); const client = net.createConnection(PROXY_PORT_1, 'localhost');
const result = await new Promise<string>((resolve, reject) => { const result = await new Promise<string>((resolve, reject) => {
client.on('data', (data) => { client.on('data', (data) => {
const response = data.toString(); const response = data.toString();
client.end(); // Close the connection after receiving data client.end(); // Close the connection after receiving data
resolve(response); resolve(response);
}); });
client.on('error', reject); client.on('error', reject);
client.write('Hello'); client.write('Hello');
}); });
expect(result).toEqual('ECHO: Hello'); expect(result).toEqual('ECHO: Hello');
// Stop proxy from test 1 before test 2 reassigns the variable
await proxy.stop();
}); });
tap.test('TLS passthrough should work correctly', async () => { tap.test('TLS passthrough should work correctly', async () => {
@@ -59,7 +72,7 @@ tap.test('TLS passthrough should work correctly', async () => {
proxy = new SmartProxy({ proxy = new SmartProxy({
routes: [{ routes: [{
name: 'tls-test', name: 'tls-test',
match: { ports: 8443, domains: 'test.example.com' }, match: { ports: PROXY_PORT_2, domains: 'test.example.com' },
action: { action: {
type: 'forward', type: 'forward',
tls: { mode: 'passthrough' }, tls: { mode: 'passthrough' },
@@ -85,16 +98,7 @@ tap.test('cleanup', async () => {
}); });
}); });
} }
if (proxy) { await assertPortsFree([ECHO_PORT, PROXY_PORT_1, PROXY_PORT_2]);
await proxy.stop();
console.log('Proxy stopped');
}
}); });
export default tap.start().then(() => { export default tap.start();
// Force exit after tests complete
setTimeout(() => {
console.log('Forcing process exit');
process.exit(0);
}, 1000);
});

View File

@@ -1,29 +1,20 @@
import { expect, tap } from '@git.zone/tstest/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net'; import * as net from 'net';
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js'; import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
import {
createPortMappingRoute,
createOffsetPortMappingRoute,
createDynamicRoute,
createSmartLoadBalancer,
createPortOffset
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import type { IRouteConfig, IRouteContext } from '../ts/proxies/smart-proxy/models/route-types.js'; import type { IRouteConfig, IRouteContext } from '../ts/proxies/smart-proxy/models/route-types.js';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
// Test server and client utilities
let testServers: Array<{ server: net.Server; port: number }> = []; let testServers: Array<{ server: net.Server; port: number }> = [];
let smartProxy: SmartProxy; let smartProxy: SmartProxy;
const TEST_PORT_START = 4000; let TEST_PORTS: number[];
const PROXY_PORT_START = 5000; let PROXY_PORTS: number[];
const TEST_DATA = 'Hello through dynamic port mapper!'; const TEST_DATA = 'Hello through dynamic port mapper!';
// Cleanup function to close all servers and proxies
function cleanup() { function cleanup() {
console.log('Starting cleanup...'); console.log('Starting cleanup...');
const promises = []; const promises = [];
// Close test servers
for (const { server, port } of testServers) { for (const { server, port } of testServers) {
promises.push(new Promise<void>(resolve => { promises.push(new Promise<void>(resolve => {
console.log(`Closing test server on port ${port}`); console.log(`Closing test server on port ${port}`);
@@ -33,31 +24,28 @@ function cleanup() {
}); });
})); }));
} }
// Stop SmartProxy
if (smartProxy) { if (smartProxy) {
console.log('Stopping SmartProxy...'); console.log('Stopping SmartProxy...');
promises.push(smartProxy.stop().then(() => { promises.push(smartProxy.stop().then(() => {
console.log('SmartProxy stopped'); console.log('SmartProxy stopped');
})); }));
} }
return Promise.all(promises); return Promise.all(promises);
} }
// Helper: Creates a test TCP server that listens on a given port
function createTestServer(port: number): Promise<net.Server> { function createTestServer(port: number): Promise<net.Server> {
return new Promise((resolve) => { return new Promise((resolve) => {
const server = net.createServer((socket) => { const server = net.createServer((socket) => {
socket.on('data', (data) => { socket.on('data', (data) => {
// Echo the received data back with a server identifier
socket.write(`Server ${port} says: ${data.toString()}`); socket.write(`Server ${port} says: ${data.toString()}`);
}); });
socket.on('error', (error) => { socket.on('error', (error) => {
console.error(`[Test Server] Socket error on port ${port}:`, error); console.error(`[Test Server] Socket error on port ${port}:`, error);
}); });
}); });
server.listen(port, () => { server.listen(port, () => {
console.log(`[Test Server] Listening on port ${port}`); console.log(`[Test Server] Listening on port ${port}`);
testServers.push({ server, port }); testServers.push({ server, port });
@@ -66,32 +54,31 @@ function createTestServer(port: number): Promise<net.Server> {
}); });
} }
// Helper: Creates a test client connection with timeout
function createTestClient(port: number, data: string): Promise<string> { function createTestClient(port: number, data: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const client = new net.Socket(); const client = new net.Socket();
let response = ''; let response = '';
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
client.destroy(); client.destroy();
reject(new Error(`Client connection timeout to port ${port}`)); reject(new Error(`Client connection timeout to port ${port}`));
}, 5000); }, 5000);
client.connect(port, 'localhost', () => { client.connect(port, 'localhost', () => {
console.log(`[Test Client] Connected to server on port ${port}`); console.log(`[Test Client] Connected to server on port ${port}`);
client.write(data); client.write(data);
}); });
client.on('data', (chunk) => { client.on('data', (chunk) => {
response += chunk.toString(); response += chunk.toString();
client.end(); client.end();
}); });
client.on('end', () => { client.on('end', () => {
clearTimeout(timeout); clearTimeout(timeout);
resolve(response); resolve(response);
}); });
client.on('error', (error) => { client.on('error', (error) => {
clearTimeout(timeout); clearTimeout(timeout);
reject(error); reject(error);
@@ -99,118 +86,111 @@ function createTestClient(port: number, data: string): Promise<string> {
}); });
} }
// Set up test environment
tap.test('setup port mapping test environment', async () => { tap.test('setup port mapping test environment', async () => {
// Create multiple test servers on different ports const allPorts = await findFreePorts(9);
TEST_PORTS = allPorts.slice(0, 3);
PROXY_PORTS = allPorts.slice(3, 9);
await Promise.all([ await Promise.all([
createTestServer(TEST_PORT_START), // Server on port 4000 createTestServer(TEST_PORTS[0]),
createTestServer(TEST_PORT_START + 1), // Server on port 4001 createTestServer(TEST_PORTS[1]),
createTestServer(TEST_PORT_START + 2), // Server on port 4002 createTestServer(TEST_PORTS[2]),
]); ]);
// Create a SmartProxy with dynamic port mapping routes const portOffset = TEST_PORTS[1] - PROXY_PORTS[1];
smartProxy = new SmartProxy({ smartProxy = new SmartProxy({
routes: [ routes: [
// Simple function that returns the same port (identity mapping) {
createPortMappingRoute({ match: { ports: PROXY_PORTS[0] },
sourcePortRange: PROXY_PORT_START, action: {
targetHost: 'localhost', type: 'forward',
portMapper: (context) => TEST_PORT_START, targets: [{
name: 'Identity Port Mapping' host: 'localhost',
}), port: (context: IRouteContext) => TEST_PORTS[0]
}]
// Offset port mapping from 5001 to 4001 (offset -1000)
createOffsetPortMappingRoute({
ports: PROXY_PORT_START + 1,
targetHost: 'localhost',
offset: -1000,
name: 'Offset Port Mapping (-1000)'
}),
// Dynamic route with conditional port mapping
createDynamicRoute({
ports: [PROXY_PORT_START + 2, PROXY_PORT_START + 3],
targetHost: (context) => {
// Dynamic host selection based on port
return context.port === PROXY_PORT_START + 2 ? 'localhost' : '127.0.0.1';
}, },
portMapper: (context) => { name: 'Identity Port Mapping'
// Port mapping logic based on incoming port },
if (context.port === PROXY_PORT_START + 2) {
return TEST_PORT_START; {
} else { match: { ports: PROXY_PORTS[1] },
return TEST_PORT_START + 2; action: {
} type: 'forward',
targets: [{
host: 'localhost',
port: (context: IRouteContext) => context.port + portOffset
}]
},
name: `Offset Port Mapping (${portOffset})`
},
{
match: { ports: [PROXY_PORTS[2], PROXY_PORTS[3]] },
action: {
type: 'forward',
targets: [{
host: (context: IRouteContext) => {
return context.port === PROXY_PORTS[2] ? 'localhost' : '127.0.0.1';
},
port: (context: IRouteContext) => {
if (context.port === PROXY_PORTS[2]) {
return TEST_PORTS[0];
} else {
return TEST_PORTS[2];
}
}
}]
}, },
name: 'Dynamic Host and Port Mapping' name: 'Dynamic Host and Port Mapping'
}), },
// Smart load balancer for domain-based routing {
createSmartLoadBalancer({ match: { ports: PROXY_PORTS[4] },
ports: PROXY_PORT_START + 4, action: {
domainTargets: { type: 'forward',
'test1.example.com': 'localhost', targets: [{
'test2.example.com': '127.0.0.1' host: (context: IRouteContext) => {
if (context.domain === 'test1.example.com') return 'localhost';
if (context.domain === 'test2.example.com') return '127.0.0.1';
return 'localhost';
},
port: (context: IRouteContext) => {
if (context.domain === 'test1.example.com') {
return TEST_PORTS[0];
} else {
return TEST_PORTS[1];
}
}
}]
}, },
portMapper: (context) => {
// Use different backend ports based on domain
if (context.domain === 'test1.example.com') {
return TEST_PORT_START;
} else {
return TEST_PORT_START + 1;
}
},
defaultTarget: 'localhost',
name: 'Smart Domain Load Balancer' name: 'Smart Domain Load Balancer'
}) }
] ]
}); });
// Start the SmartProxy
await smartProxy.start(); await smartProxy.start();
}); });
// Test 1: Simple identity port mapping (5000 -> 4000)
tap.test('should map port using identity function', async () => { tap.test('should map port using identity function', async () => {
const response = await createTestClient(PROXY_PORT_START, TEST_DATA); const response = await createTestClient(PROXY_PORTS[0], TEST_DATA);
expect(response).toEqual(`Server ${TEST_PORT_START} says: ${TEST_DATA}`); expect(response).toEqual(`Server ${TEST_PORTS[0]} says: ${TEST_DATA}`);
}); });
// Test 2: Offset port mapping (5001 -> 4001)
tap.test('should map port using offset function', async () => { tap.test('should map port using offset function', async () => {
const response = await createTestClient(PROXY_PORT_START + 1, TEST_DATA); const response = await createTestClient(PROXY_PORTS[1], TEST_DATA);
expect(response).toEqual(`Server ${TEST_PORT_START + 1} says: ${TEST_DATA}`); expect(response).toEqual(`Server ${TEST_PORTS[1]} says: ${TEST_DATA}`);
}); });
// Test 3: Dynamic port and host mapping (conditional logic)
tap.test('should map port using dynamic logic', async () => { tap.test('should map port using dynamic logic', async () => {
const response = await createTestClient(PROXY_PORT_START + 2, TEST_DATA); const response = await createTestClient(PROXY_PORTS[2], TEST_DATA);
expect(response).toEqual(`Server ${TEST_PORT_START} says: ${TEST_DATA}`); expect(response).toEqual(`Server ${TEST_PORTS[0]} says: ${TEST_DATA}`);
}); });
// Test 4: Test reuse of createPortOffset helper
tap.test('should use createPortOffset helper for port mapping', async () => {
// Test the createPortOffset helper
const offsetFn = createPortOffset(-1000);
const context = {
port: PROXY_PORT_START + 1,
clientIp: '127.0.0.1',
serverIp: '127.0.0.1',
isTls: false,
timestamp: Date.now(),
connectionId: 'test-connection'
} as IRouteContext;
const mappedPort = offsetFn(context);
expect(mappedPort).toEqual(TEST_PORT_START + 1);
});
// Test 5: Test error handling for invalid port mapping functions
tap.test('should handle errors in port mapping functions', async () => { tap.test('should handle errors in port mapping functions', async () => {
// Create a route with a function that throws an error
const errorRoute: IRouteConfig = { const errorRoute: IRouteConfig = {
match: { match: {
ports: PROXY_PORT_START + 5 ports: PROXY_PORTS[5]
}, },
action: { action: {
type: 'forward', type: 'forward',
@@ -223,37 +203,32 @@ tap.test('should handle errors in port mapping functions', async () => {
}, },
name: 'Error Route' name: 'Error Route'
}; };
// Add the route to SmartProxy
await smartProxy.updateRoutes([...smartProxy.settings.routes, errorRoute]); await smartProxy.updateRoutes([...smartProxy.settings.routes, errorRoute]);
// The connection should fail or timeout
try { try {
await createTestClient(PROXY_PORT_START + 5, TEST_DATA); await createTestClient(PROXY_PORTS[5], TEST_DATA);
// Connection should not succeed
expect(false).toBeTrue(); expect(false).toBeTrue();
} catch (error) { } catch (error) {
// Connection failed as expected
expect(true).toBeTrue(); expect(true).toBeTrue();
} }
}); });
// Cleanup
tap.test('cleanup port mapping test environment', async () => { tap.test('cleanup port mapping test environment', async () => {
// Add timeout to prevent hanging if SmartProxy shutdown has issues
const cleanupPromise = cleanup(); const cleanupPromise = cleanup();
const timeoutPromise = new Promise((_, reject) => const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Cleanup timeout after 5 seconds')), 5000) setTimeout(() => reject(new Error('Cleanup timeout after 5 seconds')), 5000)
); );
try { try {
await Promise.race([cleanupPromise, timeoutPromise]); await Promise.race([cleanupPromise, timeoutPromise]);
} catch (error) { } catch (error) {
console.error('Cleanup error:', error); console.error('Cleanup error:', error);
// Force cleanup even if there's an error
testServers = []; testServers = [];
smartProxy = null as any; smartProxy = null as any;
} }
await assertPortsFree([...TEST_PORTS, ...PROXY_PORTS]);
}); });
export default tap.start(); export default tap.start();

View File

@@ -1,133 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartproxy from '../ts/index.js';
import { ProxyProtocolParser } from '../ts/core/utils/proxy-protocol.js';
tap.test('PROXY protocol v1 parser - valid headers', async () => {
// Test TCP4 format
const tcp4Header = Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n', 'ascii');
const tcp4Result = ProxyProtocolParser.parse(tcp4Header);
expect(tcp4Result.proxyInfo).property('protocol').toEqual('TCP4');
expect(tcp4Result.proxyInfo).property('sourceIP').toEqual('192.168.1.1');
expect(tcp4Result.proxyInfo).property('sourcePort').toEqual(56324);
expect(tcp4Result.proxyInfo).property('destinationIP').toEqual('10.0.0.1');
expect(tcp4Result.proxyInfo).property('destinationPort').toEqual(443);
expect(tcp4Result.remainingData.length).toEqual(0);
// Test TCP6 format
const tcp6Header = Buffer.from('PROXY TCP6 2001:db8::1 2001:db8::2 56324 443\r\n', 'ascii');
const tcp6Result = ProxyProtocolParser.parse(tcp6Header);
expect(tcp6Result.proxyInfo).property('protocol').toEqual('TCP6');
expect(tcp6Result.proxyInfo).property('sourceIP').toEqual('2001:db8::1');
expect(tcp6Result.proxyInfo).property('sourcePort').toEqual(56324);
expect(tcp6Result.proxyInfo).property('destinationIP').toEqual('2001:db8::2');
expect(tcp6Result.proxyInfo).property('destinationPort').toEqual(443);
// Test UNKNOWN protocol
const unknownHeader = Buffer.from('PROXY UNKNOWN\r\n', 'ascii');
const unknownResult = ProxyProtocolParser.parse(unknownHeader);
expect(unknownResult.proxyInfo).property('protocol').toEqual('UNKNOWN');
expect(unknownResult.proxyInfo).property('sourceIP').toEqual('');
expect(unknownResult.proxyInfo).property('sourcePort').toEqual(0);
});
tap.test('PROXY protocol v1 parser - with remaining data', async () => {
const headerWithData = Buffer.concat([
Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n', 'ascii'),
Buffer.from('GET / HTTP/1.1\r\n', 'ascii')
]);
const result = ProxyProtocolParser.parse(headerWithData);
expect(result.proxyInfo).property('protocol').toEqual('TCP4');
expect(result.proxyInfo).property('sourceIP').toEqual('192.168.1.1');
expect(result.remainingData.toString()).toEqual('GET / HTTP/1.1\r\n');
});
tap.test('PROXY protocol v1 parser - invalid headers', async () => {
// Not a PROXY protocol header
const notProxy = Buffer.from('GET / HTTP/1.1\r\n', 'ascii');
const notProxyResult = ProxyProtocolParser.parse(notProxy);
expect(notProxyResult.proxyInfo).toBeNull();
expect(notProxyResult.remainingData).toEqual(notProxy);
// Invalid protocol
expect(() => {
ProxyProtocolParser.parse(Buffer.from('PROXY INVALID 1.1.1.1 2.2.2.2 80 443\r\n', 'ascii'));
}).toThrow();
// Wrong number of fields
expect(() => {
ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324\r\n', 'ascii'));
}).toThrow();
// Invalid port
expect(() => {
ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 99999 443\r\n', 'ascii'));
}).toThrow();
// Invalid IP for protocol
expect(() => {
ProxyProtocolParser.parse(Buffer.from('PROXY TCP4 2001:db8::1 10.0.0.1 56324 443\r\n', 'ascii'));
}).toThrow();
});
tap.test('PROXY protocol v1 parser - incomplete headers', async () => {
// Header without terminator
const incomplete = Buffer.from('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443', 'ascii');
const result = ProxyProtocolParser.parse(incomplete);
expect(result.proxyInfo).toBeNull();
expect(result.remainingData).toEqual(incomplete);
// Header exceeding max length - create a buffer that actually starts with PROXY
const longHeader = Buffer.from('PROXY TCP4 ' + '1'.repeat(100), 'ascii');
expect(() => {
ProxyProtocolParser.parse(longHeader);
}).toThrow();
});
tap.test('PROXY protocol v1 generator', async () => {
// Generate TCP4 header
const tcp4Info = {
protocol: 'TCP4' as const,
sourceIP: '192.168.1.1',
sourcePort: 56324,
destinationIP: '10.0.0.1',
destinationPort: 443
};
const tcp4Header = ProxyProtocolParser.generate(tcp4Info);
expect(tcp4Header.toString('ascii')).toEqual('PROXY TCP4 192.168.1.1 10.0.0.1 56324 443\r\n');
// Generate TCP6 header
const tcp6Info = {
protocol: 'TCP6' as const,
sourceIP: '2001:db8::1',
sourcePort: 56324,
destinationIP: '2001:db8::2',
destinationPort: 443
};
const tcp6Header = ProxyProtocolParser.generate(tcp6Info);
expect(tcp6Header.toString('ascii')).toEqual('PROXY TCP6 2001:db8::1 2001:db8::2 56324 443\r\n');
// Generate UNKNOWN header
const unknownInfo = {
protocol: 'UNKNOWN' as const,
sourceIP: '',
sourcePort: 0,
destinationIP: '',
destinationPort: 0
};
const unknownHeader = ProxyProtocolParser.generate(unknownInfo);
expect(unknownHeader.toString('ascii')).toEqual('PROXY UNKNOWN\r\n');
});
// Skipping integration tests for now - focus on unit tests
// Integration tests would require more complex setup and teardown
export default tap.start();

View File

@@ -6,7 +6,7 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
// Import from core modules // Import from core modules
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js'; import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
// Import route utilities and helpers // Import route utilities
import { import {
findMatchingRoutes, findMatchingRoutes,
findBestMatchingRoute, findBestMatchingRoute,
@@ -28,16 +28,7 @@ import {
assertValidRoute assertValidRoute
} from '../ts/proxies/smart-proxy/utils/route-validator.js'; } from '../ts/proxies/smart-proxy/utils/route-validator.js';
import { import { SocketHandlers } from '../ts/proxies/smart-proxy/utils/socket-handlers.js';
createHttpRoute,
createHttpsTerminateRoute,
createHttpsPassthroughRoute,
createHttpToHttpsRedirect,
createCompleteHttpsServer,
createLoadBalancerRoute,
createApiRoute,
createWebSocketRoute
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
// Import test helpers // Import test helpers
import { loadTestCertificates } from './helpers/certificates.js'; import { loadTestCertificates } from './helpers/certificates.js';
@@ -47,12 +38,12 @@ import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.
// --------------------------------- Route Creation Tests --------------------------------- // --------------------------------- Route Creation Tests ---------------------------------
tap.test('Routes: Should create basic HTTP route', async () => { tap.test('Routes: Should create basic HTTP route', async () => {
// Create a simple HTTP route const httpRoute: IRouteConfig = {
const httpRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }, { match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'Basic HTTP Route' name: 'Basic HTTP Route'
}); };
// Validate the route configuration
expect(httpRoute.match.ports).toEqual(80); expect(httpRoute.match.ports).toEqual(80);
expect(httpRoute.match.domains).toEqual('example.com'); expect(httpRoute.match.domains).toEqual('example.com');
expect(httpRoute.action.type).toEqual('forward'); expect(httpRoute.action.type).toEqual('forward');
@@ -62,14 +53,17 @@ tap.test('Routes: Should create basic HTTP route', async () => {
}); });
tap.test('Routes: Should create HTTPS route with TLS termination', async () => { tap.test('Routes: Should create HTTPS route with TLS termination', async () => {
// Create an HTTPS route with TLS termination const httpsRoute: IRouteConfig = {
const httpsRoute = createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 8080 }, { match: { ports: 443, domains: 'secure.example.com' },
certificate: 'auto', action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
name: 'HTTPS Route' name: 'HTTPS Route'
}); };
// Validate the route configuration expect(httpsRoute.match.ports).toEqual(443);
expect(httpsRoute.match.ports).toEqual(443); // Default HTTPS port
expect(httpsRoute.match.domains).toEqual('secure.example.com'); expect(httpsRoute.match.domains).toEqual('secure.example.com');
expect(httpsRoute.action.type).toEqual('forward'); expect(httpsRoute.action.type).toEqual('forward');
expect(httpsRoute.action.tls?.mode).toEqual('terminate'); expect(httpsRoute.action.tls?.mode).toEqual('terminate');
@@ -80,10 +74,15 @@ tap.test('Routes: Should create HTTPS route with TLS termination', async () => {
}); });
tap.test('Routes: Should create HTTP to HTTPS redirect', async () => { tap.test('Routes: Should create HTTP to HTTPS redirect', async () => {
// Create an HTTP to HTTPS redirect const redirectRoute: IRouteConfig = {
const redirectRoute = createHttpToHttpsRedirect('example.com', 443); match: { ports: 80, domains: 'example.com' },
action: {
type: 'socket-handler',
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
},
name: 'HTTP to HTTPS Redirect for example.com'
};
// Validate the route configuration
expect(redirectRoute.match.ports).toEqual(80); expect(redirectRoute.match.ports).toEqual(80);
expect(redirectRoute.match.domains).toEqual('example.com'); expect(redirectRoute.match.domains).toEqual('example.com');
expect(redirectRoute.action.type).toEqual('socket-handler'); expect(redirectRoute.action.type).toEqual('socket-handler');
@@ -91,22 +90,34 @@ tap.test('Routes: Should create HTTP to HTTPS redirect', async () => {
}); });
tap.test('Routes: Should create complete HTTPS server with redirects', async () => { tap.test('Routes: Should create complete HTTPS server with redirects', async () => {
// Create a complete HTTPS server setup const routes: IRouteConfig[] = [
const routes = createCompleteHttpsServer('example.com', { host: 'localhost', port: 8080 }, { {
certificate: 'auto' match: { ports: 443, domains: 'example.com' },
}); action: {
type: 'forward',
targets: [{ host: 'localhost', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
name: 'HTTPS Terminate Route for example.com'
},
{
match: { ports: 80, domains: 'example.com' },
action: {
type: 'socket-handler',
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
},
name: 'HTTP to HTTPS Redirect for example.com'
}
];
// Validate that we got two routes (HTTPS route and HTTP redirect)
expect(routes.length).toEqual(2); expect(routes.length).toEqual(2);
// Validate HTTPS route
const httpsRoute = routes[0]; const httpsRoute = routes[0];
expect(httpsRoute.match.ports).toEqual(443); expect(httpsRoute.match.ports).toEqual(443);
expect(httpsRoute.match.domains).toEqual('example.com'); expect(httpsRoute.match.domains).toEqual('example.com');
expect(httpsRoute.action.type).toEqual('forward'); expect(httpsRoute.action.type).toEqual('forward');
expect(httpsRoute.action.tls?.mode).toEqual('terminate'); expect(httpsRoute.action.tls?.mode).toEqual('terminate');
// Validate HTTP redirect route
const redirectRoute = routes[1]; const redirectRoute = routes[1];
expect(redirectRoute.match.ports).toEqual(80); expect(redirectRoute.match.ports).toEqual(80);
expect(redirectRoute.action.type).toEqual('socket-handler'); expect(redirectRoute.action.type).toEqual('socket-handler');
@@ -114,21 +125,17 @@ tap.test('Routes: Should create complete HTTPS server with redirects', async ()
}); });
tap.test('Routes: Should create load balancer route', async () => { tap.test('Routes: Should create load balancer route', async () => {
// Create a load balancer route const lbRoute: IRouteConfig = {
const lbRoute = createLoadBalancerRoute( match: { ports: 443, domains: 'app.example.com' },
'app.example.com', action: {
['10.0.0.1', '10.0.0.2', '10.0.0.3'], type: 'forward',
8080, targets: [{ host: ['10.0.0.1', '10.0.0.2', '10.0.0.3'], port: 8080 }],
{ tls: { mode: 'terminate', certificate: 'auto' },
tls: { loadBalancing: { algorithm: 'round-robin' }
mode: 'terminate', },
certificate: 'auto' name: 'Load Balanced Route'
}, };
name: 'Load Balanced Route'
}
);
// Validate the route configuration
expect(lbRoute.match.domains).toEqual('app.example.com'); expect(lbRoute.match.domains).toEqual('app.example.com');
expect(lbRoute.action.type).toEqual('forward'); expect(lbRoute.action.type).toEqual('forward');
expect(Array.isArray(lbRoute.action.targets?.[0]?.host)).toBeTrue(); expect(Array.isArray(lbRoute.action.targets?.[0]?.host)).toBeTrue();
@@ -139,23 +146,32 @@ tap.test('Routes: Should create load balancer route', async () => {
}); });
tap.test('Routes: Should create API route with CORS', async () => { tap.test('Routes: Should create API route with CORS', async () => {
// Create an API route with CORS headers const apiRoute: IRouteConfig = {
const apiRoute = createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3000 }, { match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
useTls: true, action: {
certificate: 'auto', type: 'forward',
addCorsHeaders: true, targets: [{ host: 'localhost', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
headers: {
response: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400'
}
},
priority: 100,
name: 'API Route' name: 'API Route'
}); };
// Validate the route configuration
expect(apiRoute.match.domains).toEqual('api.example.com'); expect(apiRoute.match.domains).toEqual('api.example.com');
expect(apiRoute.match.path).toEqual('/v1/*'); expect(apiRoute.match.path).toEqual('/v1/*');
expect(apiRoute.action.type).toEqual('forward'); expect(apiRoute.action.type).toEqual('forward');
expect(apiRoute.action.tls?.mode).toEqual('terminate'); expect(apiRoute.action.tls?.mode).toEqual('terminate');
expect(apiRoute.action.targets?.[0]?.host).toEqual('localhost'); expect(apiRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(apiRoute.action.targets?.[0]?.port).toEqual(3000); expect(apiRoute.action.targets?.[0]?.port).toEqual(3000);
// Check CORS headers
expect(apiRoute.headers).toBeDefined(); expect(apiRoute.headers).toBeDefined();
if (apiRoute.headers?.response) { if (apiRoute.headers?.response) {
expect(apiRoute.headers.response['Access-Control-Allow-Origin']).toEqual('*'); expect(apiRoute.headers.response['Access-Control-Allow-Origin']).toEqual('*');
@@ -164,23 +180,25 @@ tap.test('Routes: Should create API route with CORS', async () => {
}); });
tap.test('Routes: Should create WebSocket route', async () => { tap.test('Routes: Should create WebSocket route', async () => {
// Create a WebSocket route const wsRoute: IRouteConfig = {
const wsRoute = createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 5000 }, { match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
useTls: true, action: {
certificate: 'auto', type: 'forward',
pingInterval: 15000, targets: [{ host: 'localhost', port: 5000 }],
tls: { mode: 'terminate', certificate: 'auto' },
websocket: { enabled: true, pingInterval: 15000 }
},
priority: 100,
name: 'WebSocket Route' name: 'WebSocket Route'
}); };
// Validate the route configuration
expect(wsRoute.match.domains).toEqual('ws.example.com'); expect(wsRoute.match.domains).toEqual('ws.example.com');
expect(wsRoute.match.path).toEqual('/socket'); expect(wsRoute.match.path).toEqual('/socket');
expect(wsRoute.action.type).toEqual('forward'); expect(wsRoute.action.type).toEqual('forward');
expect(wsRoute.action.tls?.mode).toEqual('terminate'); expect(wsRoute.action.tls?.mode).toEqual('terminate');
expect(wsRoute.action.targets?.[0]?.host).toEqual('localhost'); expect(wsRoute.action.targets?.[0]?.host).toEqual('localhost');
expect(wsRoute.action.targets?.[0]?.port).toEqual(5000); expect(wsRoute.action.targets?.[0]?.port).toEqual(5000);
// Check WebSocket configuration
expect(wsRoute.action.websocket).toBeDefined(); expect(wsRoute.action.websocket).toBeDefined();
if (wsRoute.action.websocket) { if (wsRoute.action.websocket) {
expect(wsRoute.action.websocket.enabled).toBeTrue(); expect(wsRoute.action.websocket.enabled).toBeTrue();
@@ -191,22 +209,27 @@ tap.test('Routes: Should create WebSocket route', async () => {
// Static file serving has been removed - should be handled by external servers // Static file serving has been removed - should be handled by external servers
tap.test('SmartProxy: Should create instance with route-based config', async () => { tap.test('SmartProxy: Should create instance with route-based config', async () => {
// Create TLS certificates for testing
const certs = loadTestCertificates(); const certs = loadTestCertificates();
// Create a SmartProxy instance with route-based configuration
const proxy = new SmartProxy({ const proxy = new SmartProxy({
routes: [ routes: [
createHttpRoute('example.com', { host: 'localhost', port: 3000 }, { {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route' name: 'HTTP Route'
}), },
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 8443 }, { {
certificate: { match: { ports: 443, domains: 'secure.example.com' },
key: certs.privateKey, action: {
cert: certs.publicKey type: 'forward',
targets: [{ host: 'localhost', port: 8443 }],
tls: {
mode: 'terminate',
certificate: { key: certs.privateKey, cert: certs.publicKey }
}
}, },
name: 'HTTPS Route' name: 'HTTPS Route'
}) }
], ],
defaults: { defaults: {
target: { target: {
@@ -218,13 +241,11 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
maxConnections: 100 maxConnections: 100
} }
}, },
// Additional settings
initialDataTimeout: 10000, initialDataTimeout: 10000,
inactivityTimeout: 300000, inactivityTimeout: 300000,
enableDetailedLogging: true enableDetailedLogging: true
}); });
// Simply verify the instance was created successfully
expect(typeof proxy).toEqual('object'); expect(typeof proxy).toEqual('object');
expect(typeof proxy.start).toEqual('function'); expect(typeof proxy.start).toEqual('function');
expect(typeof proxy.stop).toEqual('function'); expect(typeof proxy.stop).toEqual('function');
@@ -233,94 +254,109 @@ tap.test('SmartProxy: Should create instance with route-based config', async ()
// --------------------------------- Edge Case Tests --------------------------------- // --------------------------------- Edge Case Tests ---------------------------------
tap.test('Edge Case - Empty Routes Array', async () => { tap.test('Edge Case - Empty Routes Array', async () => {
// Attempting to find routes in an empty array
const emptyRoutes: IRouteConfig[] = []; const emptyRoutes: IRouteConfig[] = [];
const matches = findMatchingRoutes(emptyRoutes, { domain: 'example.com', port: 80 }); const matches = findMatchingRoutes(emptyRoutes, { domain: 'example.com', port: 80 });
expect(matches).toBeInstanceOf(Array); expect(matches).toBeInstanceOf(Array);
expect(matches.length).toEqual(0); expect(matches.length).toEqual(0);
const bestMatch = findBestMatchingRoute(emptyRoutes, { domain: 'example.com', port: 80 }); const bestMatch = findBestMatchingRoute(emptyRoutes, { domain: 'example.com', port: 80 });
expect(bestMatch).toBeUndefined(); expect(bestMatch).toBeUndefined();
}); });
tap.test('Edge Case - Multiple Matching Routes with Same Priority', async () => { tap.test('Edge Case - Multiple Matching Routes with Same Priority', async () => {
// Create multiple routes with identical priority but different targets const route1: IRouteConfig = {
const route1 = createHttpRoute('example.com', { host: 'server1', port: 3000 }); match: { ports: 80, domains: 'example.com' },
const route2 = createHttpRoute('example.com', { host: 'server2', port: 3000 }); action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
const route3 = createHttpRoute('example.com', { host: 'server3', port: 3000 }); name: 'HTTP Route for example.com'
};
// Set all to the same priority const route2: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'server2', port: 3000 }] },
name: 'HTTP Route for example.com'
};
const route3: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'server3', port: 3000 }] },
name: 'HTTP Route for example.com'
};
route1.priority = 100; route1.priority = 100;
route2.priority = 100; route2.priority = 100;
route3.priority = 100; route3.priority = 100;
const routes = [route1, route2, route3]; const routes = [route1, route2, route3];
// Find matching routes
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 }); const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
// Should find all three routes
expect(matches.length).toEqual(3); expect(matches.length).toEqual(3);
// First match could be any of the routes since they have the same priority
// But the implementation should be consistent (likely keep the original order)
const bestMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 }); const bestMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
expect(bestMatch).not.toBeUndefined(); expect(bestMatch).not.toBeUndefined();
}); });
tap.test('Edge Case - Wildcard Domains and Path Matching', async () => { tap.test('Edge Case - Wildcard Domains and Path Matching', async () => {
// Create routes with wildcard domains and path patterns const wildcardApiRoute: IRouteConfig = {
const wildcardApiRoute = createApiRoute('*.example.com', '/api', { host: 'api-server', port: 3000 }, { match: { ports: 443, domains: '*.example.com', path: '/api/*' },
useTls: true, action: {
certificate: 'auto' type: 'forward',
}); targets: [{ host: 'api-server', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' }
const exactApiRoute = createApiRoute('api.example.com', '/api', { host: 'specific-api-server', port: 3001 }, { },
useTls: true, priority: 100,
certificate: 'auto', name: 'API Route for *.example.com'
priority: 200 // Higher priority };
});
const exactApiRoute: IRouteConfig = {
match: { ports: 443, domains: 'api.example.com', path: '/api/*' },
action: {
type: 'forward',
targets: [{ host: 'specific-api-server', port: 3001 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
priority: 200,
name: 'API Route for api.example.com'
};
const routes = [wildcardApiRoute, exactApiRoute]; const routes = [wildcardApiRoute, exactApiRoute];
// Test with a specific subdomain that matches both routes
const matches = findMatchingRoutes(routes, { domain: 'api.example.com', path: '/api/users', port: 443 }); const matches = findMatchingRoutes(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
// Should match both routes
expect(matches.length).toEqual(2); expect(matches.length).toEqual(2);
// The exact domain match should have higher priority
const bestMatch = findBestMatchingRoute(routes, { domain: 'api.example.com', path: '/api/users', port: 443 }); const bestMatch = findBestMatchingRoute(routes, { domain: 'api.example.com', path: '/api/users', port: 443 });
expect(bestMatch).not.toBeUndefined(); expect(bestMatch).not.toBeUndefined();
if (bestMatch) { if (bestMatch) {
expect(bestMatch.action.targets[0].port).toEqual(3001); // Should match the exact domain route expect(bestMatch.action.targets[0].port).toEqual(3001);
} }
// Test with a different subdomain - should only match the wildcard route
const otherMatches = findMatchingRoutes(routes, { domain: 'other.example.com', path: '/api/products', port: 443 }); const otherMatches = findMatchingRoutes(routes, { domain: 'other.example.com', path: '/api/products', port: 443 });
expect(otherMatches.length).toEqual(1); expect(otherMatches.length).toEqual(1);
expect(otherMatches[0].action.targets[0].port).toEqual(3000); // Should match the wildcard domain route expect(otherMatches[0].action.targets[0].port).toEqual(3000);
}); });
tap.test('Edge Case - Disabled Routes', async () => { tap.test('Edge Case - Disabled Routes', async () => {
// Create enabled and disabled routes const enabledRoute: IRouteConfig = {
const enabledRoute = createHttpRoute('example.com', { host: 'server1', port: 3000 }); match: { ports: 80, domains: 'example.com' },
const disabledRoute = createHttpRoute('example.com', { host: 'server2', port: 3001 }); action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
name: 'HTTP Route for example.com'
};
const disabledRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'server2', port: 3001 }] },
name: 'HTTP Route for example.com'
};
disabledRoute.enabled = false; disabledRoute.enabled = false;
const routes = [enabledRoute, disabledRoute]; const routes = [enabledRoute, disabledRoute];
// Find matching routes
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 }); const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
// Should only find the enabled route
expect(matches.length).toEqual(1); expect(matches.length).toEqual(1);
expect(matches[0].action.targets[0].port).toEqual(3000); expect(matches[0].action.targets[0].port).toEqual(3000);
}); });
tap.test('Edge Case - Complex Path and Headers Matching', async () => { tap.test('Edge Case - Complex Path and Headers Matching', async () => {
// Create route with complex path and headers matching
const complexRoute: IRouteConfig = { const complexRoute: IRouteConfig = {
match: { match: {
domains: 'api.example.com', domains: 'api.example.com',
@@ -344,22 +380,20 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
}, },
name: 'Complex API Route' name: 'Complex API Route'
}; };
// Test with matching criteria
const matchingPath = routeMatchesPath(complexRoute, '/api/v2/users'); const matchingPath = routeMatchesPath(complexRoute, '/api/v2/users');
expect(matchingPath).toBeTrue(); expect(matchingPath).toBeTrue();
const matchingHeaders = routeMatchesHeaders(complexRoute, { const matchingHeaders = routeMatchesHeaders(complexRoute, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': 'valid-key', 'X-API-Key': 'valid-key',
'Accept': 'application/json' 'Accept': 'application/json'
}); });
expect(matchingHeaders).toBeTrue(); expect(matchingHeaders).toBeTrue();
// Test with non-matching criteria
const nonMatchingPath = routeMatchesPath(complexRoute, '/api/v1/users'); const nonMatchingPath = routeMatchesPath(complexRoute, '/api/v1/users');
expect(nonMatchingPath).toBeFalse(); expect(nonMatchingPath).toBeFalse();
const nonMatchingHeaders = routeMatchesHeaders(complexRoute, { const nonMatchingHeaders = routeMatchesHeaders(complexRoute, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-API-Key': 'invalid-key' 'X-API-Key': 'invalid-key'
@@ -368,7 +402,6 @@ tap.test('Edge Case - Complex Path and Headers Matching', async () => {
}); });
tap.test('Edge Case - Port Range Matching', async () => { tap.test('Edge Case - Port Range Matching', async () => {
// Create route with port range matching
const portRangeRoute: IRouteConfig = { const portRangeRoute: IRouteConfig = {
match: { match: {
domains: 'example.com', domains: 'example.com',
@@ -383,17 +416,14 @@ tap.test('Edge Case - Port Range Matching', async () => {
}, },
name: 'Port Range Route' name: 'Port Range Route'
}; };
// Test with ports in the range expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue();
expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue(); // Lower bound expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue();
expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue(); // Middle expect(routeMatchesPort(portRangeRoute, 9000)).toBeTrue();
expect(routeMatchesPort(portRangeRoute, 9000)).toBeTrue(); // Upper bound
expect(routeMatchesPort(portRangeRoute, 7999)).toBeFalse();
// Test with ports outside the range expect(routeMatchesPort(portRangeRoute, 9001)).toBeFalse();
expect(routeMatchesPort(portRangeRoute, 7999)).toBeFalse(); // Just below
expect(routeMatchesPort(portRangeRoute, 9001)).toBeFalse(); // Just above
// Test with multiple port ranges
const multiRangeRoute: IRouteConfig = { const multiRangeRoute: IRouteConfig = {
match: { match: {
domains: 'example.com', domains: 'example.com',
@@ -411,7 +441,7 @@ tap.test('Edge Case - Port Range Matching', async () => {
}, },
name: 'Multi Range Route' name: 'Multi Range Route'
}; };
expect(routeMatchesPort(multiRangeRoute, 85)).toBeTrue(); expect(routeMatchesPort(multiRangeRoute, 85)).toBeTrue();
expect(routeMatchesPort(multiRangeRoute, 8500)).toBeTrue(); expect(routeMatchesPort(multiRangeRoute, 8500)).toBeTrue();
expect(routeMatchesPort(multiRangeRoute, 100)).toBeFalse(); expect(routeMatchesPort(multiRangeRoute, 100)).toBeFalse();
@@ -420,55 +450,56 @@ tap.test('Edge Case - Port Range Matching', async () => {
// --------------------------------- Wildcard Domain Tests --------------------------------- // --------------------------------- Wildcard Domain Tests ---------------------------------
tap.test('Wildcard Domain Handling', async () => { tap.test('Wildcard Domain Handling', async () => {
// Create routes with different wildcard patterns const simpleDomainRoute: IRouteConfig = {
const simpleDomainRoute = createHttpRoute('example.com', { host: 'server1', port: 3000 }); match: { ports: 80, domains: 'example.com' },
const wildcardSubdomainRoute = createHttpRoute('*.example.com', { host: 'server2', port: 3001 }); action: { type: 'forward', targets: [{ host: 'server1', port: 3000 }] },
const specificSubdomainRoute = createHttpRoute('api.example.com', { host: 'server3', port: 3002 }); name: 'HTTP Route for example.com'
};
const wildcardSubdomainRoute: IRouteConfig = {
match: { ports: 80, domains: '*.example.com' },
action: { type: 'forward', targets: [{ host: 'server2', port: 3001 }] },
name: 'HTTP Route for *.example.com'
};
const specificSubdomainRoute: IRouteConfig = {
match: { ports: 80, domains: 'api.example.com' },
action: { type: 'forward', targets: [{ host: 'server3', port: 3002 }] },
name: 'HTTP Route for api.example.com'
};
// Set explicit priorities to ensure deterministic matching specificSubdomainRoute.priority = 200;
specificSubdomainRoute.priority = 200; // Highest priority for specific domain wildcardSubdomainRoute.priority = 100;
wildcardSubdomainRoute.priority = 100; // Medium priority for wildcard simpleDomainRoute.priority = 50;
simpleDomainRoute.priority = 50; // Lowest priority for generic domain
const routes = [simpleDomainRoute, wildcardSubdomainRoute, specificSubdomainRoute]; const routes = [simpleDomainRoute, wildcardSubdomainRoute, specificSubdomainRoute];
// Test exact domain match
expect(routeMatchesDomain(simpleDomainRoute, 'example.com')).toBeTrue(); expect(routeMatchesDomain(simpleDomainRoute, 'example.com')).toBeTrue();
expect(routeMatchesDomain(simpleDomainRoute, 'sub.example.com')).toBeFalse(); expect(routeMatchesDomain(simpleDomainRoute, 'sub.example.com')).toBeFalse();
// Test wildcard subdomain match
expect(routeMatchesDomain(wildcardSubdomainRoute, 'any.example.com')).toBeTrue(); expect(routeMatchesDomain(wildcardSubdomainRoute, 'any.example.com')).toBeTrue();
expect(routeMatchesDomain(wildcardSubdomainRoute, 'nested.sub.example.com')).toBeTrue(); expect(routeMatchesDomain(wildcardSubdomainRoute, 'nested.sub.example.com')).toBeTrue();
expect(routeMatchesDomain(wildcardSubdomainRoute, 'example.com')).toBeFalse(); expect(routeMatchesDomain(wildcardSubdomainRoute, 'example.com')).toBeFalse();
// Test specific subdomain match
expect(routeMatchesDomain(specificSubdomainRoute, 'api.example.com')).toBeTrue(); expect(routeMatchesDomain(specificSubdomainRoute, 'api.example.com')).toBeTrue();
expect(routeMatchesDomain(specificSubdomainRoute, 'other.example.com')).toBeFalse(); expect(routeMatchesDomain(specificSubdomainRoute, 'other.example.com')).toBeFalse();
expect(routeMatchesDomain(specificSubdomainRoute, 'sub.api.example.com')).toBeFalse(); expect(routeMatchesDomain(specificSubdomainRoute, 'sub.api.example.com')).toBeFalse();
// Test finding best match when multiple domains match
const specificSubdomainRequest = { domain: 'api.example.com', port: 80 }; const specificSubdomainRequest = { domain: 'api.example.com', port: 80 };
const bestSpecificMatch = findBestMatchingRoute(routes, specificSubdomainRequest); const bestSpecificMatch = findBestMatchingRoute(routes, specificSubdomainRequest);
expect(bestSpecificMatch).not.toBeUndefined(); expect(bestSpecificMatch).not.toBeUndefined();
if (bestSpecificMatch) { if (bestSpecificMatch) {
// Find which route was matched
const matchedPort = bestSpecificMatch.action.targets[0].port; const matchedPort = bestSpecificMatch.action.targets[0].port;
console.log(`Matched route with port: ${matchedPort}`); console.log(`Matched route with port: ${matchedPort}`);
// Verify it's the specific subdomain route (with highest priority)
expect(bestSpecificMatch.priority).toEqual(200); expect(bestSpecificMatch.priority).toEqual(200);
} }
// Test with a subdomain that matches wildcard but not specific
const otherSubdomainRequest = { domain: 'other.example.com', port: 80 }; const otherSubdomainRequest = { domain: 'other.example.com', port: 80 };
const bestWildcardMatch = findBestMatchingRoute(routes, otherSubdomainRequest); const bestWildcardMatch = findBestMatchingRoute(routes, otherSubdomainRequest);
expect(bestWildcardMatch).not.toBeUndefined(); expect(bestWildcardMatch).not.toBeUndefined();
if (bestWildcardMatch) { if (bestWildcardMatch) {
// Find which route was matched
const matchedPort = bestWildcardMatch.action.targets[0].port; const matchedPort = bestWildcardMatch.action.targets[0].port;
console.log(`Matched route with port: ${matchedPort}`); console.log(`Matched route with port: ${matchedPort}`);
// Verify it's the wildcard subdomain route (with medium priority)
expect(bestWildcardMatch.priority).toEqual(100); expect(bestWildcardMatch.priority).toEqual(100);
} }
}); });
@@ -476,56 +507,83 @@ tap.test('Wildcard Domain Handling', async () => {
// --------------------------------- Integration Tests --------------------------------- // --------------------------------- Integration Tests ---------------------------------
tap.test('Route Integration - Combining Multiple Route Types', async () => { tap.test('Route Integration - Combining Multiple Route Types', async () => {
// Create a comprehensive set of routes for a full application
const routes: IRouteConfig[] = [ const routes: IRouteConfig[] = [
// Main website with HTTPS and HTTP redirect {
...createCompleteHttpsServer('example.com', { host: 'web-server', port: 8080 }, { match: { ports: 443, domains: 'example.com' },
certificate: 'auto' action: {
}), type: 'forward',
targets: [{ host: 'web-server', port: 8080 }],
// API endpoints tls: { mode: 'terminate', certificate: 'auto' }
createApiRoute('api.example.com', '/v1', { host: 'api-server', port: 3000 }, { },
useTls: true, name: 'HTTPS Terminate Route for example.com'
certificate: 'auto', },
addCorsHeaders: true {
}), match: { ports: 80, domains: 'example.com' },
action: {
// WebSocket for real-time updates type: 'socket-handler',
createWebSocketRoute('ws.example.com', '/live', { host: 'websocket-server', port: 5000 }, { socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
useTls: true, },
certificate: 'auto' name: 'HTTP to HTTPS Redirect for example.com'
}), },
{
match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
// Legacy system with passthrough action: {
createHttpsPassthroughRoute('legacy.example.com', { host: 'legacy-server', port: 443 }) type: 'forward',
targets: [{ host: 'api-server', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
headers: {
response: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400'
}
},
priority: 100,
name: 'API Route for api.example.com'
},
{
match: { ports: 443, domains: 'ws.example.com', path: '/live' },
action: {
type: 'forward',
targets: [{ host: 'websocket-server', port: 5000 }],
tls: { mode: 'terminate', certificate: 'auto' },
websocket: { enabled: true }
},
priority: 100,
name: 'WebSocket Route for ws.example.com'
},
{
match: { ports: 443, domains: 'legacy.example.com' },
action: {
type: 'forward',
targets: [{ host: 'legacy-server', port: 443 }],
tls: { mode: 'passthrough' }
},
name: 'HTTPS Passthrough Route for legacy.example.com'
}
]; ];
// Validate all routes
const validationResult = validateRoutes(routes); const validationResult = validateRoutes(routes);
expect(validationResult.valid).toBeTrue(); expect(validationResult.valid).toBeTrue();
expect(validationResult.errors.length).toEqual(0); expect(validationResult.errors.length).toEqual(0);
// Test route matching for different endpoints
// Web server (HTTPS)
const webServerMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 443 }); const webServerMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 443 });
expect(webServerMatch).not.toBeUndefined(); expect(webServerMatch).not.toBeUndefined();
if (webServerMatch) { if (webServerMatch) {
expect(webServerMatch.action.type).toEqual('forward'); expect(webServerMatch.action.type).toEqual('forward');
expect(webServerMatch.action.targets[0].host).toEqual('web-server'); expect(webServerMatch.action.targets[0].host).toEqual('web-server');
} }
// Web server (HTTP redirect via socket handler)
const webRedirectMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 }); const webRedirectMatch = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
expect(webRedirectMatch).not.toBeUndefined(); expect(webRedirectMatch).not.toBeUndefined();
if (webRedirectMatch) { if (webRedirectMatch) {
expect(webRedirectMatch.action.type).toEqual('socket-handler'); expect(webRedirectMatch.action.type).toEqual('socket-handler');
} }
// API server const apiMatch = findBestMatchingRoute(routes, {
const apiMatch = findBestMatchingRoute(routes, { domain: 'api.example.com',
domain: 'api.example.com',
port: 443, port: 443,
path: '/v1/users' path: '/v1/users'
}); });
@@ -534,10 +592,9 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
expect(apiMatch.action.type).toEqual('forward'); expect(apiMatch.action.type).toEqual('forward');
expect(apiMatch.action.targets[0].host).toEqual('api-server'); expect(apiMatch.action.targets[0].host).toEqual('api-server');
} }
// WebSocket server const wsMatch = findBestMatchingRoute(routes, {
const wsMatch = findBestMatchingRoute(routes, { domain: 'ws.example.com',
domain: 'ws.example.com',
port: 443, port: 443,
path: '/live' path: '/live'
}); });
@@ -547,12 +604,9 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
expect(wsMatch.action.targets[0].host).toEqual('websocket-server'); expect(wsMatch.action.targets[0].host).toEqual('websocket-server');
expect(wsMatch.action.websocket?.enabled).toBeTrue(); expect(wsMatch.action.websocket?.enabled).toBeTrue();
} }
// Static assets route was removed - static file serving should be handled externally const legacyMatch = findBestMatchingRoute(routes, {
domain: 'legacy.example.com',
// Legacy system
const legacyMatch = findBestMatchingRoute(routes, {
domain: 'legacy.example.com',
port: 443 port: 443
}); });
expect(legacyMatch).not.toBeUndefined(); expect(legacyMatch).not.toBeUndefined();
@@ -562,4 +616,157 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
} }
}); });
export default tap.start(); // --------------------------------- Protocol Match Field Tests ---------------------------------
tap.test('Routes: Should accept protocol field on route match', async () => {
const httpOnlyRoute: IRouteConfig = {
match: {
ports: 443,
domains: 'api.example.com',
protocol: 'http',
},
action: {
type: 'forward',
targets: [{ host: 'backend', port: 8080 }],
tls: {
mode: 'terminate',
certificate: 'auto',
},
},
name: 'HTTP-only Route',
};
const validation = validateRouteConfig(httpOnlyRoute);
expect(validation.valid).toBeTrue();
expect(httpOnlyRoute.match.protocol).toEqual('http');
});
tap.test('Routes: Should accept protocol tcp on route match', async () => {
const tcpOnlyRoute: IRouteConfig = {
match: {
ports: 443,
domains: 'db.example.com',
protocol: 'tcp',
},
action: {
type: 'forward',
targets: [{ host: 'db-server', port: 5432 }],
tls: {
mode: 'passthrough',
},
},
name: 'TCP-only Route',
};
const validation = validateRouteConfig(tcpOnlyRoute);
expect(validation.valid).toBeTrue();
expect(tcpOnlyRoute.match.protocol).toEqual('tcp');
});
tap.test('Routes: Protocol field should work with terminate-and-reencrypt', async () => {
const reencryptRoute: IRouteConfig = {
match: { ports: 443, domains: 'secure.example.com' },
action: {
type: 'forward',
targets: [{ host: 'backend', port: 443 }],
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' }
},
name: 'Reencrypt HTTP Route'
};
reencryptRoute.match.protocol = 'http';
const validation = validateRouteConfig(reencryptRoute);
expect(validation.valid).toBeTrue();
expect(reencryptRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
expect(reencryptRoute.match.protocol).toEqual('http');
});
tap.test('Routes: Protocol field should not affect domain/port matching', async () => {
const routeWithProtocol: IRouteConfig = {
match: {
ports: 443,
domains: 'example.com',
protocol: 'http',
},
action: {
type: 'forward',
targets: [{ host: 'backend', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
name: 'With Protocol',
priority: 10,
};
const routeWithoutProtocol: IRouteConfig = {
match: {
ports: 443,
domains: 'example.com',
},
action: {
type: 'forward',
targets: [{ host: 'fallback', port: 8081 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
name: 'Without Protocol',
priority: 5,
};
const routes = [routeWithProtocol, routeWithoutProtocol];
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 443 });
expect(matches.length).toEqual(2);
const best = findBestMatchingRoute(routes, { domain: 'example.com', port: 443 });
expect(best).not.toBeUndefined();
expect(best!.name).toEqual('With Protocol');
});
tap.test('Routes: Protocol field preserved through route cloning', async () => {
const original: IRouteConfig = {
match: {
ports: 8443,
domains: 'clone-test.example.com',
protocol: 'http',
},
action: {
type: 'forward',
targets: [{ host: 'backend', port: 3000 }],
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' },
},
name: 'Clone Test',
};
const cloned = cloneRoute(original);
expect(cloned.match.protocol).toEqual('http');
expect(cloned.action.tls?.mode).toEqual('terminate-and-reencrypt');
cloned.match.protocol = 'tcp';
expect(original.match.protocol).toEqual('http');
});
tap.test('Routes: Protocol field preserved through route merging', async () => {
const base: IRouteConfig = {
match: {
ports: 443,
domains: 'merge-test.example.com',
protocol: 'http',
},
action: {
type: 'forward',
targets: [{ host: 'backend', port: 3000 }],
tls: { mode: 'terminate-and-reencrypt', certificate: 'auto' },
},
name: 'Merge Base',
};
const merged = mergeRouteConfigs(base, { name: 'Merged Route' });
expect(merged.match.protocol).toEqual('http');
expect(merged.name).toEqual('Merged Route');
});
export default tap.start();

View File

@@ -1,21 +1,7 @@
import { tap, expect } from '@git.zone/tstest/tapbundle'; import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js'; import * as plugins from '../ts/plugins.js';
// Import from individual modules to avoid naming conflicts
import { import {
// Route helpers
createHttpRoute,
createHttpsTerminateRoute,
createApiRoute,
createWebSocketRoute,
createHttpToHttpsRedirect,
createHttpsPassthroughRoute,
createCompleteHttpsServer,
createLoadBalancerRoute
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import {
// Route validators
validateRouteConfig, validateRouteConfig,
validateRoutes, validateRoutes,
isValidDomain, isValidDomain,
@@ -27,7 +13,6 @@ import {
} from '../ts/proxies/smart-proxy/utils/route-validator.js'; } from '../ts/proxies/smart-proxy/utils/route-validator.js';
import { import {
// Route utilities
mergeRouteConfigs, mergeRouteConfigs,
findMatchingRoutes, findMatchingRoutes,
findBestMatchingRoute, findBestMatchingRoute,
@@ -39,16 +24,6 @@ import {
cloneRoute cloneRoute
} from '../ts/proxies/smart-proxy/utils/route-utils.js'; } from '../ts/proxies/smart-proxy/utils/route-utils.js';
import {
// Route patterns
createApiGatewayRoute,
createWebSocketRoute as createWebSocketPattern,
createLoadBalancerRoute as createLbPattern,
addRateLimiting,
addBasicAuth,
addJwtAuth
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
import type { import type {
IRouteConfig, IRouteConfig,
IRouteMatch, IRouteMatch,
@@ -84,7 +59,7 @@ tap.test('Route Validation - isValidPort', async () => {
expect(isValidPort(443)).toBeTrue(); expect(isValidPort(443)).toBeTrue();
expect(isValidPort(8080)).toBeTrue(); expect(isValidPort(8080)).toBeTrue();
expect(isValidPort([80, 443])).toBeTrue(); expect(isValidPort([80, 443])).toBeTrue();
// Invalid ports // Invalid ports
expect(isValidPort(0)).toBeFalse(); expect(isValidPort(0)).toBeFalse();
expect(isValidPort(65536)).toBeFalse(); expect(isValidPort(65536)).toBeFalse();
@@ -101,7 +76,7 @@ tap.test('Route Validation - validateRouteMatch', async () => {
const validResult = validateRouteMatch(validMatch); const validResult = validateRouteMatch(validMatch);
expect(validResult.valid).toBeTrue(); expect(validResult.valid).toBeTrue();
expect(validResult.errors.length).toEqual(0); expect(validResult.errors.length).toEqual(0);
// Invalid match configuration (invalid domain) // Invalid match configuration (invalid domain)
const invalidMatch: IRouteMatch = { const invalidMatch: IRouteMatch = {
ports: 80, ports: 80,
@@ -111,7 +86,7 @@ tap.test('Route Validation - validateRouteMatch', async () => {
expect(invalidResult.valid).toBeFalse(); expect(invalidResult.valid).toBeFalse();
expect(invalidResult.errors.length).toBeGreaterThan(0); expect(invalidResult.errors.length).toBeGreaterThan(0);
expect(invalidResult.errors[0]).toInclude('Invalid domain'); expect(invalidResult.errors[0]).toInclude('Invalid domain');
// Invalid match configuration (invalid port) // Invalid match configuration (invalid port)
const invalidPortMatch: IRouteMatch = { const invalidPortMatch: IRouteMatch = {
ports: 0, ports: 0,
@@ -121,7 +96,7 @@ tap.test('Route Validation - validateRouteMatch', async () => {
expect(invalidPortResult.valid).toBeFalse(); expect(invalidPortResult.valid).toBeFalse();
expect(invalidPortResult.errors.length).toBeGreaterThan(0); expect(invalidPortResult.errors.length).toBeGreaterThan(0);
expect(invalidPortResult.errors[0]).toInclude('Invalid port'); expect(invalidPortResult.errors[0]).toInclude('Invalid port');
// Test path validation // Test path validation
const invalidPathMatch: IRouteMatch = { const invalidPathMatch: IRouteMatch = {
ports: 80, ports: 80,
@@ -146,7 +121,7 @@ tap.test('Route Validation - validateRouteAction', async () => {
const validForwardResult = validateRouteAction(validForwardAction); const validForwardResult = validateRouteAction(validForwardAction);
expect(validForwardResult.valid).toBeTrue(); expect(validForwardResult.valid).toBeTrue();
expect(validForwardResult.errors.length).toEqual(0); expect(validForwardResult.errors.length).toEqual(0);
// Valid socket-handler action // Valid socket-handler action
const validSocketAction: IRouteAction = { const validSocketAction: IRouteAction = {
type: 'socket-handler', type: 'socket-handler',
@@ -157,7 +132,7 @@ tap.test('Route Validation - validateRouteAction', async () => {
const validSocketResult = validateRouteAction(validSocketAction); const validSocketResult = validateRouteAction(validSocketAction);
expect(validSocketResult.valid).toBeTrue(); expect(validSocketResult.valid).toBeTrue();
expect(validSocketResult.errors.length).toEqual(0); expect(validSocketResult.errors.length).toEqual(0);
// Invalid action (missing targets) // Invalid action (missing targets)
const invalidAction: IRouteAction = { const invalidAction: IRouteAction = {
type: 'forward' type: 'forward'
@@ -166,7 +141,7 @@ tap.test('Route Validation - validateRouteAction', async () => {
expect(invalidResult.valid).toBeFalse(); expect(invalidResult.valid).toBeFalse();
expect(invalidResult.errors.length).toBeGreaterThan(0); expect(invalidResult.errors.length).toBeGreaterThan(0);
expect(invalidResult.errors[0]).toInclude('Targets array is required'); expect(invalidResult.errors[0]).toInclude('Targets array is required');
// Invalid action (missing socket handler) // Invalid action (missing socket handler)
const invalidSocketAction: IRouteAction = { const invalidSocketAction: IRouteAction = {
type: 'socket-handler' type: 'socket-handler'
@@ -174,16 +149,20 @@ tap.test('Route Validation - validateRouteAction', async () => {
const invalidSocketResult = validateRouteAction(invalidSocketAction); const invalidSocketResult = validateRouteAction(invalidSocketAction);
expect(invalidSocketResult.valid).toBeFalse(); expect(invalidSocketResult.valid).toBeFalse();
expect(invalidSocketResult.errors.length).toBeGreaterThan(0); expect(invalidSocketResult.errors.length).toBeGreaterThan(0);
expect(invalidSocketResult.errors[0]).toInclude('Socket handler function is required'); expect(invalidSocketResult.errors[0]).toInclude('handler function is required');
}); });
tap.test('Route Validation - validateRouteConfig', async () => { tap.test('Route Validation - validateRouteConfig', async () => {
// Valid route config // Valid route config
const validRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }); const validRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
const validResult = validateRouteConfig(validRoute); const validResult = validateRouteConfig(validRoute);
expect(validResult.valid).toBeTrue(); expect(validResult.valid).toBeTrue();
expect(validResult.errors.length).toEqual(0); expect(validResult.errors.length).toEqual(0);
// Invalid route config (missing targets) // Invalid route config (missing targets)
const invalidRoute: IRouteConfig = { const invalidRoute: IRouteConfig = {
match: { match: {
@@ -203,7 +182,11 @@ tap.test('Route Validation - validateRouteConfig', async () => {
tap.test('Route Validation - validateRoutes', async () => { tap.test('Route Validation - validateRoutes', async () => {
// Create valid and invalid routes // Create valid and invalid routes
const routes = [ const routes = [
createHttpRoute('example.com', { host: 'localhost', port: 3000 }), {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
} as IRouteConfig,
{ {
match: { match: {
domains: 'invalid..domain', domains: 'invalid..domain',
@@ -217,9 +200,13 @@ tap.test('Route Validation - validateRoutes', async () => {
} }
} }
} as IRouteConfig, } as IRouteConfig,
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 3001 }) {
match: { ports: 443, domains: 'secure.example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }], tls: { mode: 'terminate', certificate: 'auto' } },
name: 'HTTPS Terminate Route for secure.example.com',
} as IRouteConfig
]; ];
const result = validateRoutes(routes); const result = validateRoutes(routes);
expect(result.valid).toBeFalse(); expect(result.valid).toBeFalse();
expect(result.errors.length).toEqual(1); expect(result.errors.length).toEqual(1);
@@ -230,13 +217,13 @@ tap.test('Route Validation - validateRoutes', async () => {
tap.test('Route Validation - hasRequiredPropertiesForAction', async () => { tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
// Forward action // Forward action
const forwardRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }); const forwardRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
expect(hasRequiredPropertiesForAction(forwardRoute, 'forward')).toBeTrue(); expect(hasRequiredPropertiesForAction(forwardRoute, 'forward')).toBeTrue();
// Socket handler action (redirect functionality)
const redirectRoute = createHttpToHttpsRedirect('example.com');
expect(hasRequiredPropertiesForAction(redirectRoute, 'socket-handler')).toBeTrue();
// Socket handler action // Socket handler action
const socketRoute: IRouteConfig = { const socketRoute: IRouteConfig = {
match: { match: {
@@ -252,7 +239,7 @@ tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
name: 'Socket Handler Route' name: 'Socket Handler Route'
}; };
expect(hasRequiredPropertiesForAction(socketRoute, 'socket-handler')).toBeTrue(); expect(hasRequiredPropertiesForAction(socketRoute, 'socket-handler')).toBeTrue();
// Missing required properties // Missing required properties
const invalidForwardRoute: IRouteConfig = { const invalidForwardRoute: IRouteConfig = {
match: { match: {
@@ -269,9 +256,13 @@ tap.test('Route Validation - hasRequiredPropertiesForAction', async () => {
tap.test('Route Validation - assertValidRoute', async () => { tap.test('Route Validation - assertValidRoute', async () => {
// Valid route // Valid route
const validRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }); const validRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
expect(() => assertValidRoute(validRoute)).not.toThrow(); expect(() => assertValidRoute(validRoute)).not.toThrow();
// Invalid route // Invalid route
const invalidRoute: IRouteConfig = { const invalidRoute: IRouteConfig = {
match: { match: {
@@ -290,8 +281,12 @@ tap.test('Route Validation - assertValidRoute', async () => {
tap.test('Route Utilities - mergeRouteConfigs', async () => { tap.test('Route Utilities - mergeRouteConfigs', async () => {
// Base route // Base route
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }); const baseRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
// Override with different name and port // Override with different name and port
const overrideRoute: Partial<IRouteConfig> = { const overrideRoute: Partial<IRouteConfig> = {
name: 'Merged Route', name: 'Merged Route',
@@ -299,16 +294,16 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
ports: 8080 ports: 8080
} }
}; };
// Merge configs // Merge configs
const mergedRoute = mergeRouteConfigs(baseRoute, overrideRoute); const mergedRoute = mergeRouteConfigs(baseRoute, overrideRoute);
// Check merged properties // Check merged properties
expect(mergedRoute.name).toEqual('Merged Route'); expect(mergedRoute.name).toEqual('Merged Route');
expect(mergedRoute.match.ports).toEqual(8080); expect(mergedRoute.match.ports).toEqual(8080);
expect(mergedRoute.match.domains).toEqual('example.com'); expect(mergedRoute.match.domains).toEqual('example.com');
expect(mergedRoute.action.type).toEqual('forward'); expect(mergedRoute.action.type).toEqual('forward');
// Test merging action properties // Test merging action properties
const actionOverride: Partial<IRouteConfig> = { const actionOverride: Partial<IRouteConfig> = {
action: { action: {
@@ -319,11 +314,11 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
}] }]
} }
}; };
const actionMergedRoute = mergeRouteConfigs(baseRoute, actionOverride); const actionMergedRoute = mergeRouteConfigs(baseRoute, actionOverride);
expect(actionMergedRoute.action.targets?.[0]?.host).toEqual('new-host.local'); expect(actionMergedRoute.action.targets?.[0]?.host).toEqual('new-host.local');
expect(actionMergedRoute.action.targets?.[0]?.port).toEqual(5000); expect(actionMergedRoute.action.targets?.[0]?.port).toEqual(5000);
// Test replacing action with socket handler // Test replacing action with socket handler
const typeChangeOverride: Partial<IRouteConfig> = { const typeChangeOverride: Partial<IRouteConfig> = {
action: { action: {
@@ -336,7 +331,7 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
} }
} }
}; };
const typeChangedRoute = mergeRouteConfigs(baseRoute, typeChangeOverride); const typeChangedRoute = mergeRouteConfigs(baseRoute, typeChangeOverride);
expect(typeChangedRoute.action.type).toEqual('socket-handler'); expect(typeChangedRoute.action.type).toEqual('socket-handler');
expect(typeChangedRoute.action.socketHandler).toBeDefined(); expect(typeChangedRoute.action.socketHandler).toBeDefined();
@@ -345,37 +340,53 @@ tap.test('Route Utilities - mergeRouteConfigs', async () => {
tap.test('Route Matching - routeMatchesDomain', async () => { tap.test('Route Matching - routeMatchesDomain', async () => {
// Create route with wildcard domain // Create route with wildcard domain
const wildcardRoute = createHttpRoute('*.example.com', { host: 'localhost', port: 3000 }); const wildcardRoute: IRouteConfig = {
match: { ports: 80, domains: '*.example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for *.example.com',
};
// Create route with exact domain // Create route with exact domain
const exactRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }); const exactRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
// Create route with multiple domains // Create route with multiple domains
const multiDomainRoute = createHttpRoute(['example.com', 'example.org'], { host: 'localhost', port: 3000 }); const multiDomainRoute: IRouteConfig = {
match: { ports: 80, domains: ['example.com', 'example.org'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com,example.org',
};
// Test wildcard domain matching // Test wildcard domain matching
expect(routeMatchesDomain(wildcardRoute, 'sub.example.com')).toBeTrue(); expect(routeMatchesDomain(wildcardRoute, 'sub.example.com')).toBeTrue();
expect(routeMatchesDomain(wildcardRoute, 'another.example.com')).toBeTrue(); expect(routeMatchesDomain(wildcardRoute, 'another.example.com')).toBeTrue();
expect(routeMatchesDomain(wildcardRoute, 'example.com')).toBeFalse(); expect(routeMatchesDomain(wildcardRoute, 'example.com')).toBeFalse();
expect(routeMatchesDomain(wildcardRoute, 'example.org')).toBeFalse(); expect(routeMatchesDomain(wildcardRoute, 'example.org')).toBeFalse();
// Test exact domain matching // Test exact domain matching
expect(routeMatchesDomain(exactRoute, 'example.com')).toBeTrue(); expect(routeMatchesDomain(exactRoute, 'example.com')).toBeTrue();
expect(routeMatchesDomain(exactRoute, 'sub.example.com')).toBeFalse(); expect(routeMatchesDomain(exactRoute, 'sub.example.com')).toBeFalse();
// Test multiple domains matching // Test multiple domains matching
expect(routeMatchesDomain(multiDomainRoute, 'example.com')).toBeTrue(); expect(routeMatchesDomain(multiDomainRoute, 'example.com')).toBeTrue();
expect(routeMatchesDomain(multiDomainRoute, 'example.org')).toBeTrue(); expect(routeMatchesDomain(multiDomainRoute, 'example.org')).toBeTrue();
expect(routeMatchesDomain(multiDomainRoute, 'example.net')).toBeFalse(); expect(routeMatchesDomain(multiDomainRoute, 'example.net')).toBeFalse();
// Test case insensitivity // Test case insensitivity
expect(routeMatchesDomain(exactRoute, 'Example.Com')).toBeTrue(); expect(routeMatchesDomain(exactRoute, 'Example.Com')).toBeTrue();
}); });
tap.test('Route Matching - routeMatchesPort', async () => { tap.test('Route Matching - routeMatchesPort', async () => {
// Create routes with different port configurations // Create routes with different port configurations
const singlePortRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }); const singlePortRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
const multiPortRoute: IRouteConfig = { const multiPortRoute: IRouteConfig = {
match: { match: {
domains: 'example.com', domains: 'example.com',
@@ -389,7 +400,7 @@ tap.test('Route Matching - routeMatchesPort', async () => {
}] }]
} }
}; };
const portRangeRoute: IRouteConfig = { const portRangeRoute: IRouteConfig = {
match: { match: {
domains: 'example.com', domains: 'example.com',
@@ -403,16 +414,16 @@ tap.test('Route Matching - routeMatchesPort', async () => {
}] }]
} }
}; };
// Test single port matching // Test single port matching
expect(routeMatchesPort(singlePortRoute, 80)).toBeTrue(); expect(routeMatchesPort(singlePortRoute, 80)).toBeTrue();
expect(routeMatchesPort(singlePortRoute, 443)).toBeFalse(); expect(routeMatchesPort(singlePortRoute, 443)).toBeFalse();
// Test multi-port matching // Test multi-port matching
expect(routeMatchesPort(multiPortRoute, 80)).toBeTrue(); expect(routeMatchesPort(multiPortRoute, 80)).toBeTrue();
expect(routeMatchesPort(multiPortRoute, 8080)).toBeTrue(); expect(routeMatchesPort(multiPortRoute, 8080)).toBeTrue();
expect(routeMatchesPort(multiPortRoute, 3000)).toBeFalse(); expect(routeMatchesPort(multiPortRoute, 3000)).toBeFalse();
// Test port range matching // Test port range matching
expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue(); expect(routeMatchesPort(portRangeRoute, 8000)).toBeTrue();
expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue(); expect(routeMatchesPort(portRangeRoute, 8500)).toBeTrue();
@@ -437,11 +448,11 @@ tap.test('Route Matching - routeMatchesPath', async () => {
}] }]
} }
}; };
// Test prefix matching with wildcard (not trailing slash) // Test prefix matching with wildcard (not trailing slash)
const prefixPathRoute: IRouteConfig = { const prefixPathRoute: IRouteConfig = {
match: { match: {
domains: 'example.com', domains: 'example.com',
ports: 80, ports: 80,
path: '/api/*' path: '/api/*'
}, },
@@ -453,7 +464,7 @@ tap.test('Route Matching - routeMatchesPath', async () => {
}] }]
} }
}; };
const wildcardPathRoute: IRouteConfig = { const wildcardPathRoute: IRouteConfig = {
match: { match: {
domains: 'example.com', domains: 'example.com',
@@ -468,17 +479,17 @@ tap.test('Route Matching - routeMatchesPath', async () => {
}] }]
} }
}; };
// Test exact path matching // Test exact path matching
expect(routeMatchesPath(exactPathRoute, '/api')).toBeTrue(); expect(routeMatchesPath(exactPathRoute, '/api')).toBeTrue();
expect(routeMatchesPath(exactPathRoute, '/api/users')).toBeFalse(); expect(routeMatchesPath(exactPathRoute, '/api/users')).toBeFalse();
expect(routeMatchesPath(exactPathRoute, '/app')).toBeFalse(); expect(routeMatchesPath(exactPathRoute, '/app')).toBeFalse();
// Test prefix path matching with wildcard // Test prefix path matching with wildcard
expect(routeMatchesPath(prefixPathRoute, '/api/')).toBeFalse(); // Wildcard requires content after /api/ expect(routeMatchesPath(prefixPathRoute, '/api/')).toBeFalse(); // Wildcard requires content after /api/
expect(routeMatchesPath(prefixPathRoute, '/api/users')).toBeTrue(); expect(routeMatchesPath(prefixPathRoute, '/api/users')).toBeTrue();
expect(routeMatchesPath(prefixPathRoute, '/app/')).toBeFalse(); expect(routeMatchesPath(prefixPathRoute, '/app/')).toBeFalse();
// Test wildcard path matching // Test wildcard path matching
expect(routeMatchesPath(wildcardPathRoute, '/api/users')).toBeTrue(); expect(routeMatchesPath(wildcardPathRoute, '/api/users')).toBeTrue();
expect(routeMatchesPath(wildcardPathRoute, '/api/products')).toBeTrue(); expect(routeMatchesPath(wildcardPathRoute, '/api/products')).toBeTrue();
@@ -504,30 +515,34 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
}] }]
} }
}; };
// Test header matching // Test header matching
expect(routeMatchesHeaders(headerRoute, { expect(routeMatchesHeaders(headerRoute, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Custom-Header': 'value' 'X-Custom-Header': 'value'
})).toBeTrue(); })).toBeTrue();
expect(routeMatchesHeaders(headerRoute, { expect(routeMatchesHeaders(headerRoute, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Custom-Header': 'value', 'X-Custom-Header': 'value',
'Extra-Header': 'something' 'Extra-Header': 'something'
})).toBeTrue(); })).toBeTrue();
expect(routeMatchesHeaders(headerRoute, { expect(routeMatchesHeaders(headerRoute, {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
})).toBeFalse(); })).toBeFalse();
expect(routeMatchesHeaders(headerRoute, { expect(routeMatchesHeaders(headerRoute, {
'Content-Type': 'text/html', 'Content-Type': 'text/html',
'X-Custom-Header': 'value' 'X-Custom-Header': 'value'
})).toBeFalse(); })).toBeFalse();
// Route without header matching should match any headers // Route without header matching should match any headers
const noHeaderRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }); const noHeaderRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
expect(routeMatchesHeaders(noHeaderRoute, { expect(routeMatchesHeaders(noHeaderRoute, {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
})).toBeTrue(); })).toBeTrue();
@@ -536,78 +551,118 @@ tap.test('Route Matching - routeMatchesHeaders', async () => {
tap.test('Route Finding - findMatchingRoutes', async () => { tap.test('Route Finding - findMatchingRoutes', async () => {
// Create multiple routes // Create multiple routes
const routes: IRouteConfig[] = [ const routes: IRouteConfig[] = [
createHttpRoute('example.com', { host: 'localhost', port: 3000 }), {
createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 3001 }), match: { ports: 80, domains: 'example.com' },
createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3002 }), action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 3003 }) name: 'HTTP Route for example.com',
},
{
match: { ports: 443, domains: 'secure.example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }], tls: { mode: 'terminate', certificate: 'auto' } },
name: 'HTTPS Route for secure.example.com',
},
{
match: { ports: 443, domains: 'api.example.com', path: '/v1/*' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3002 }], tls: { mode: 'terminate', certificate: 'auto' } },
name: 'API Route for api.example.com',
},
{
match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3003 }], tls: { mode: 'terminate', certificate: 'auto' }, websocket: { enabled: true } },
name: 'WebSocket Route for ws.example.com',
},
]; ];
// Set priorities // Set priorities
routes[0].priority = 10; routes[0].priority = 10;
routes[1].priority = 20; routes[1].priority = 20;
routes[2].priority = 30; routes[2].priority = 30;
routes[3].priority = 40; routes[3].priority = 40;
// Find routes for different criteria // Find routes for different criteria
const httpMatches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 }); const httpMatches = findMatchingRoutes(routes, { domain: 'example.com', port: 80 });
expect(httpMatches.length).toEqual(1); expect(httpMatches.length).toEqual(1);
expect(httpMatches[0].name).toInclude('HTTP Route'); expect(httpMatches[0].name).toInclude('HTTP Route');
const httpsMatches = findMatchingRoutes(routes, { domain: 'secure.example.com', port: 443 }); const httpsMatches = findMatchingRoutes(routes, { domain: 'secure.example.com', port: 443 });
expect(httpsMatches.length).toEqual(1); expect(httpsMatches.length).toEqual(1);
expect(httpsMatches[0].name).toInclude('HTTPS Route'); expect(httpsMatches[0].name).toInclude('HTTPS Route');
const apiMatches = findMatchingRoutes(routes, { domain: 'api.example.com', path: '/v1/users' }); const apiMatches = findMatchingRoutes(routes, { domain: 'api.example.com', path: '/v1/users' });
expect(apiMatches.length).toEqual(1); expect(apiMatches.length).toEqual(1);
expect(apiMatches[0].name).toInclude('API Route'); expect(apiMatches[0].name).toInclude('API Route');
const wsMatches = findMatchingRoutes(routes, { domain: 'ws.example.com', path: '/socket' }); const wsMatches = findMatchingRoutes(routes, { domain: 'ws.example.com', path: '/socket' });
expect(wsMatches.length).toEqual(1); expect(wsMatches.length).toEqual(1);
expect(wsMatches[0].name).toInclude('WebSocket Route'); expect(wsMatches[0].name).toInclude('WebSocket Route');
// Test finding multiple routes that match same criteria // Test finding multiple routes that match same criteria
const route1 = createHttpRoute('example.com', { host: 'localhost', port: 3000 }); const route1: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
route1.priority = 10; route1.priority = 10;
const route2 = createHttpRoute('example.com', { host: 'localhost', port: 3001 }); const route2: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
name: 'HTTP Route for example.com',
};
route2.priority = 20; route2.priority = 20;
route2.match.path = '/api'; route2.match.path = '/api';
const multiMatchRoutes = [route1, route2]; const multiMatchRoutes = [route1, route2];
const multiMatches = findMatchingRoutes(multiMatchRoutes, { domain: 'example.com', port: 80 }); const multiMatches = findMatchingRoutes(multiMatchRoutes, { domain: 'example.com', port: 80 });
expect(multiMatches.length).toEqual(2); expect(multiMatches.length).toEqual(2);
expect(multiMatches[0].priority).toEqual(20); // Higher priority should be first expect(multiMatches[0].priority).toEqual(20); // Higher priority should be first
expect(multiMatches[1].priority).toEqual(10); expect(multiMatches[1].priority).toEqual(10);
// Test disabled routes // Test disabled routes
const disabledRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }); const disabledRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
disabledRoute.enabled = false; disabledRoute.enabled = false;
const enabledRoutes = findMatchingRoutes([disabledRoute], { domain: 'example.com', port: 80 }); const enabledRoutes = findMatchingRoutes([disabledRoute], { domain: 'example.com', port: 80 });
expect(enabledRoutes.length).toEqual(0); expect(enabledRoutes.length).toEqual(0);
}); });
tap.test('Route Finding - findBestMatchingRoute', async () => { tap.test('Route Finding - findBestMatchingRoute', async () => {
// Create multiple routes with different priorities // Create multiple routes with different priorities
const route1 = createHttpRoute('example.com', { host: 'localhost', port: 3000 }); const route1: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
route1.priority = 10; route1.priority = 10;
const route2 = createHttpRoute('example.com', { host: 'localhost', port: 3001 }); const route2: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }] },
name: 'HTTP Route for example.com',
};
route2.priority = 20; route2.priority = 20;
route2.match.path = '/api'; route2.match.path = '/api';
const route3 = createHttpRoute('example.com', { host: 'localhost', port: 3002 }); const route3: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3002 }] },
name: 'HTTP Route for example.com',
};
route3.priority = 30; route3.priority = 30;
route3.match.path = '/api/users'; route3.match.path = '/api/users';
const routes = [route1, route2, route3]; const routes = [route1, route2, route3];
// Find best route for different criteria // Find best route for different criteria
const bestGeneral = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 }); const bestGeneral = findBestMatchingRoute(routes, { domain: 'example.com', port: 80 });
expect(bestGeneral).not.toBeUndefined(); expect(bestGeneral).not.toBeUndefined();
expect(bestGeneral?.priority).toEqual(30); expect(bestGeneral?.priority).toEqual(30);
// Test when no routes match // Test when no routes match
const noMatch = findBestMatchingRoute(routes, { domain: 'unknown.com', port: 80 }); const noMatch = findBestMatchingRoute(routes, { domain: 'unknown.com', port: 80 });
expect(noMatch).toBeUndefined(); expect(noMatch).toBeUndefined();
@@ -615,389 +670,54 @@ tap.test('Route Finding - findBestMatchingRoute', async () => {
tap.test('Route Utilities - generateRouteId', async () => { tap.test('Route Utilities - generateRouteId', async () => {
// Test ID generation for different route types // Test ID generation for different route types
const httpRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 }); const httpRoute: IRouteConfig = {
match: { ports: 80, domains: 'example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com',
};
const httpId = generateRouteId(httpRoute); const httpId = generateRouteId(httpRoute);
expect(httpId).toInclude('example-com'); expect(httpId).toInclude('example-com');
expect(httpId).toInclude('80'); expect(httpId).toInclude('80');
expect(httpId).toInclude('forward'); expect(httpId).toInclude('forward');
const httpsRoute = createHttpsTerminateRoute('secure.example.com', { host: 'localhost', port: 3001 }); const httpsRoute: IRouteConfig = {
match: { ports: 443, domains: 'secure.example.com' },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3001 }], tls: { mode: 'terminate', certificate: 'auto' } },
name: 'HTTPS Terminate Route for secure.example.com',
};
const httpsId = generateRouteId(httpsRoute); const httpsId = generateRouteId(httpsRoute);
expect(httpsId).toInclude('secure-example-com'); expect(httpsId).toInclude('secure-example-com');
expect(httpsId).toInclude('443'); expect(httpsId).toInclude('443');
expect(httpsId).toInclude('forward'); expect(httpsId).toInclude('forward');
const multiDomainRoute = createHttpRoute(['example.com', 'example.org'], { host: 'localhost', port: 3000 }); const multiDomainRoute: IRouteConfig = {
match: { ports: 80, domains: ['example.com', 'example.org'] },
action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }] },
name: 'HTTP Route for example.com,example.org',
};
const multiDomainId = generateRouteId(multiDomainRoute); const multiDomainId = generateRouteId(multiDomainRoute);
expect(multiDomainId).toInclude('example-com-example-org'); expect(multiDomainId).toInclude('example-com-example-org');
}); });
tap.test('Route Utilities - cloneRoute', async () => { tap.test('Route Utilities - cloneRoute', async () => {
// Create a route and clone it // Create a route and clone it
const originalRoute = createHttpsTerminateRoute('example.com', { host: 'localhost', port: 3000 }, { const originalRoute: IRouteConfig = {
certificate: 'auto', match: { ports: 443, domains: 'example.com' },
name: 'Original Route' action: { type: 'forward', targets: [{ host: 'localhost', port: 3000 }], tls: { mode: 'terminate', certificate: 'auto' } },
}); name: 'Original Route',
};
const clonedRoute = cloneRoute(originalRoute); const clonedRoute = cloneRoute(originalRoute);
// Check that the values are identical // Check that the values are identical
expect(clonedRoute.name).toEqual(originalRoute.name); expect(clonedRoute.name).toEqual(originalRoute.name);
expect(clonedRoute.match.domains).toEqual(originalRoute.match.domains); expect(clonedRoute.match.domains).toEqual(originalRoute.match.domains);
expect(clonedRoute.action.type).toEqual(originalRoute.action.type); expect(clonedRoute.action.type).toEqual(originalRoute.action.type);
expect(clonedRoute.action.targets?.[0]?.port).toEqual(originalRoute.action.targets?.[0]?.port); expect(clonedRoute.action.targets?.[0]?.port).toEqual(originalRoute.action.targets?.[0]?.port);
// Modify the clone and check that the original is unchanged // Modify the clone and check that the original is unchanged
clonedRoute.name = 'Modified Clone'; clonedRoute.name = 'Modified Clone';
expect(originalRoute.name).toEqual('Original Route'); expect(originalRoute.name).toEqual('Original Route');
}); });
// --------------------------------- Route Helper Tests --------------------------------- export default tap.start();
tap.test('Route Helpers - createHttpRoute', async () => {
const route = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
expect(route.match.domains).toEqual('example.com');
expect(route.match.ports).toEqual(80);
expect(route.action.type).toEqual('forward');
expect(route.action.targets?.[0]?.host).toEqual('localhost');
expect(route.action.targets?.[0]?.port).toEqual(3000);
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
tap.test('Route Helpers - createHttpsTerminateRoute', async () => {
const route = createHttpsTerminateRoute('example.com', { host: 'localhost', port: 3000 }, {
certificate: 'auto'
});
expect(route.match.domains).toEqual('example.com');
expect(route.match.ports).toEqual(443);
expect(route.action.type).toEqual('forward');
expect(route.action.tls.mode).toEqual('terminate');
expect(route.action.tls.certificate).toEqual('auto');
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
tap.test('Route Helpers - createHttpToHttpsRedirect', async () => {
const route = createHttpToHttpsRedirect('example.com');
expect(route.match.domains).toEqual('example.com');
expect(route.match.ports).toEqual(80);
expect(route.action.type).toEqual('socket-handler');
expect(route.action.socketHandler).toBeDefined();
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
tap.test('Route Helpers - createHttpsPassthroughRoute', async () => {
const route = createHttpsPassthroughRoute('example.com', { host: 'localhost', port: 3000 });
expect(route.match.domains).toEqual('example.com');
expect(route.match.ports).toEqual(443);
expect(route.action.type).toEqual('forward');
expect(route.action.tls.mode).toEqual('passthrough');
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
tap.test('Route Helpers - createCompleteHttpsServer', async () => {
const routes = createCompleteHttpsServer('example.com', { host: 'localhost', port: 3000 }, {
certificate: 'auto'
});
expect(routes.length).toEqual(2);
// HTTPS route
expect(routes[0].match.domains).toEqual('example.com');
expect(routes[0].match.ports).toEqual(443);
expect(routes[0].action.type).toEqual('forward');
expect(routes[0].action.tls.mode).toEqual('terminate');
// HTTP redirect route
expect(routes[1].match.domains).toEqual('example.com');
expect(routes[1].match.ports).toEqual(80);
expect(routes[1].action.type).toEqual('socket-handler');
const validation1 = validateRouteConfig(routes[0]);
const validation2 = validateRouteConfig(routes[1]);
expect(validation1.valid).toBeTrue();
expect(validation2.valid).toBeTrue();
});
// createStaticFileRoute has been removed - static file serving should be handled by
// external servers (nginx/apache) behind the proxy
tap.test('Route Helpers - createApiRoute', async () => {
const route = createApiRoute('api.example.com', '/v1', { host: 'localhost', port: 3000 }, {
useTls: true,
certificate: 'auto',
addCorsHeaders: true
});
expect(route.match.domains).toEqual('api.example.com');
expect(route.match.ports).toEqual(443);
expect(route.match.path).toEqual('/v1/*');
expect(route.action.type).toEqual('forward');
expect(route.action.tls.mode).toEqual('terminate');
// Check CORS headers if they exist
if (route.headers && route.headers.response) {
expect(route.headers.response['Access-Control-Allow-Origin']).toEqual('*');
}
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
tap.test('Route Helpers - createWebSocketRoute', async () => {
const route = createWebSocketRoute('ws.example.com', '/socket', { host: 'localhost', port: 3000 }, {
useTls: true,
certificate: 'auto',
pingInterval: 15000
});
expect(route.match.domains).toEqual('ws.example.com');
expect(route.match.ports).toEqual(443);
expect(route.match.path).toEqual('/socket');
expect(route.action.type).toEqual('forward');
expect(route.action.tls.mode).toEqual('terminate');
// Check websocket configuration if it exists
if (route.action.websocket) {
expect(route.action.websocket.enabled).toBeTrue();
expect(route.action.websocket.pingInterval).toEqual(15000);
}
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
tap.test('Route Helpers - createLoadBalancerRoute', async () => {
const route = createLoadBalancerRoute(
'loadbalancer.example.com',
['server1.local', 'server2.local', 'server3.local'],
8080,
{
tls: {
mode: 'terminate',
certificate: 'auto'
}
}
);
expect(route.match.domains).toEqual('loadbalancer.example.com');
expect(route.match.ports).toEqual(443);
expect(route.action.type).toEqual('forward');
expect(route.action.targets).toBeDefined();
if (route.action.targets && Array.isArray(route.action.targets[0]?.host)) {
expect((route.action.targets[0].host as string[]).length).toEqual(3);
}
expect(route.action.targets?.[0]?.port).toEqual(8080);
expect(route.action.tls.mode).toEqual('terminate');
const validationResult = validateRouteConfig(route);
expect(validationResult.valid).toBeTrue();
});
// --------------------------------- Route Pattern Tests ---------------------------------
tap.test('Route Patterns - createApiGatewayRoute', async () => {
// Create API Gateway route
const apiGatewayRoute = createApiGatewayRoute(
'api.example.com',
'/v1',
{ host: 'localhost', port: 3000 },
{
useTls: true,
addCorsHeaders: true
}
);
// Validate route configuration
expect(apiGatewayRoute.match.domains).toEqual('api.example.com');
expect(apiGatewayRoute.match.path).toInclude('/v1');
expect(apiGatewayRoute.action.type).toEqual('forward');
expect(apiGatewayRoute.action.targets?.[0]?.port).toEqual(3000);
// Check TLS configuration
if (apiGatewayRoute.action.tls) {
expect(apiGatewayRoute.action.tls.mode).toEqual('terminate');
}
// Check CORS headers
if (apiGatewayRoute.headers && apiGatewayRoute.headers.response) {
expect(apiGatewayRoute.headers.response['Access-Control-Allow-Origin']).toEqual('*');
}
const result = validateRouteConfig(apiGatewayRoute);
expect(result.valid).toBeTrue();
});
// createStaticFileServerRoute has been removed - static file serving should be handled by
// external servers (nginx/apache) behind the proxy
tap.test('Route Patterns - createWebSocketPattern', async () => {
// Create WebSocket route pattern
const wsRoute = createWebSocketPattern(
'ws.example.com',
{ host: 'localhost', port: 3000 },
{
useTls: true,
path: '/socket',
pingInterval: 10000
}
);
// Validate route configuration
expect(wsRoute.match.domains).toEqual('ws.example.com');
expect(wsRoute.match.path).toEqual('/socket');
expect(wsRoute.action.type).toEqual('forward');
expect(wsRoute.action.targets?.[0]?.port).toEqual(3000);
// Check TLS configuration
if (wsRoute.action.tls) {
expect(wsRoute.action.tls.mode).toEqual('terminate');
}
// Check websocket configuration if it exists
if (wsRoute.action.websocket) {
expect(wsRoute.action.websocket.enabled).toBeTrue();
expect(wsRoute.action.websocket.pingInterval).toEqual(10000);
}
const result = validateRouteConfig(wsRoute);
expect(result.valid).toBeTrue();
});
tap.test('Route Patterns - createLoadBalancerRoute pattern', async () => {
// Create load balancer route pattern with missing algorithm as it might not be implemented yet
try {
const lbRoute = createLbPattern(
'lb.example.com',
[
{ host: 'server1.local', port: 8080 },
{ host: 'server2.local', port: 8080 },
{ host: 'server3.local', port: 8080 }
],
{
useTls: true
}
);
// Validate route configuration
expect(lbRoute.match.domains).toEqual('lb.example.com');
expect(lbRoute.action.type).toEqual('forward');
// Check target hosts
if (lbRoute.action.targets && Array.isArray(lbRoute.action.targets[0]?.host)) {
expect((lbRoute.action.targets[0].host as string[]).length).toEqual(3);
}
// Check TLS configuration
if (lbRoute.action.tls) {
expect(lbRoute.action.tls.mode).toEqual('terminate');
}
const result = validateRouteConfig(lbRoute);
expect(result.valid).toBeTrue();
} catch (error) {
// If the pattern is not implemented yet, skip this test
console.log('Load balancer pattern might not be fully implemented yet');
}
});
tap.test('Route Security - addRateLimiting', async () => {
// Create base route
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
// Add rate limiting
const secureRoute = addRateLimiting(baseRoute, {
maxRequests: 100,
window: 60, // 1 minute
keyBy: 'ip'
});
// Check if rate limiting is applied
if (secureRoute.security) {
expect(secureRoute.security.rateLimit?.enabled).toBeTrue();
expect(secureRoute.security.rateLimit?.maxRequests).toEqual(100);
expect(secureRoute.security.rateLimit?.window).toEqual(60);
expect(secureRoute.security.rateLimit?.keyBy).toEqual('ip');
} else {
// Skip this test if security features are not implemented yet
console.log('Security features not implemented yet in route configuration');
}
// Just check that the route itself is valid
const result = validateRouteConfig(secureRoute);
expect(result.valid).toBeTrue();
});
tap.test('Route Security - addBasicAuth', async () => {
// Create base route
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
// Add basic authentication
const authRoute = addBasicAuth(baseRoute, {
users: [
{ username: 'admin', password: 'secret' },
{ username: 'user', password: 'password' }
],
realm: 'Protected Area',
excludePaths: ['/public']
});
// Check if basic auth is applied
if (authRoute.security) {
expect(authRoute.security.basicAuth?.enabled).toBeTrue();
expect(authRoute.security.basicAuth?.users.length).toEqual(2);
expect(authRoute.security.basicAuth?.realm).toEqual('Protected Area');
expect(authRoute.security.basicAuth?.excludePaths).toInclude('/public');
} else {
// Skip this test if security features are not implemented yet
console.log('Security features not implemented yet in route configuration');
}
// Check that the route itself is valid
const result = validateRouteConfig(authRoute);
expect(result.valid).toBeTrue();
});
tap.test('Route Security - addJwtAuth', async () => {
// Create base route
const baseRoute = createHttpRoute('example.com', { host: 'localhost', port: 3000 });
// Add JWT authentication
const jwtRoute = addJwtAuth(baseRoute, {
secret: 'your-jwt-secret-key',
algorithm: 'HS256',
issuer: 'auth.example.com',
audience: 'api.example.com',
expiresIn: 3600
});
// Check if JWT auth is applied
if (jwtRoute.security) {
expect(jwtRoute.security.jwtAuth?.enabled).toBeTrue();
expect(jwtRoute.security.jwtAuth?.secret).toEqual('your-jwt-secret-key');
expect(jwtRoute.security.jwtAuth?.algorithm).toEqual('HS256');
expect(jwtRoute.security.jwtAuth?.issuer).toEqual('auth.example.com');
expect(jwtRoute.security.jwtAuth?.audience).toEqual('api.example.com');
expect(jwtRoute.security.jwtAuth?.expiresIn).toEqual(3600);
} else {
// Skip this test if security features are not implemented yet
console.log('Security features not implemented yet in route configuration');
}
// Check that the route itself is valid
const result = validateRouteConfig(jwtRoute);
expect(result.valid).toBeTrue();
});
export default tap.start();

View File

@@ -1,403 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as http from 'http';
import { HttpRouter, type RouterResult } from '../ts/routing/router/http-router.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
// Test proxies and configurations
let router: HttpRouter;
// Sample hostname for testing
const TEST_DOMAIN = 'example.com';
const TEST_SUBDOMAIN = 'api.example.com';
const TEST_WILDCARD = '*.example.com';
// Helper: Creates a mock HTTP request for testing
function createMockRequest(host: string, url: string = '/'): http.IncomingMessage {
const req = {
headers: { host },
url,
socket: {
remoteAddress: '127.0.0.1'
}
} as any;
return req;
}
// Helper: Creates a test route configuration
function createRouteConfig(
hostname: string,
destinationIp: string = '10.0.0.1',
destinationPort: number = 8080
): IRouteConfig {
return {
name: `route-${hostname}`,
match: {
domains: [hostname],
ports: 443
},
action: {
type: 'forward',
targets: [{
host: destinationIp,
port: destinationPort
}]
}
};
}
// SETUP: Create an HttpRouter instance
tap.test('setup http router test environment', async () => {
router = new HttpRouter();
// Initialize with empty config
router.setRoutes([]);
});
// Test basic routing by hostname
tap.test('should route requests by hostname', async () => {
const config = createRouteConfig(TEST_DOMAIN);
router.setRoutes([config]);
const req = createMockRequest(TEST_DOMAIN);
const result = router.routeReq(req);
expect(result).toBeTruthy();
expect(result).toEqual(config);
});
// Test handling of hostname with port number
tap.test('should handle hostname with port number', async () => {
const config = createRouteConfig(TEST_DOMAIN);
router.setRoutes([config]);
const req = createMockRequest(`${TEST_DOMAIN}:443`);
const result = router.routeReq(req);
expect(result).toBeTruthy();
expect(result).toEqual(config);
});
// Test case-insensitive hostname matching
tap.test('should perform case-insensitive hostname matching', async () => {
const config = createRouteConfig(TEST_DOMAIN.toLowerCase());
router.setRoutes([config]);
const req = createMockRequest(TEST_DOMAIN.toUpperCase());
const result = router.routeReq(req);
expect(result).toBeTruthy();
expect(result).toEqual(config);
});
// Test handling of unmatched hostnames
tap.test('should return undefined for unmatched hostnames', async () => {
const config = createRouteConfig(TEST_DOMAIN);
router.setRoutes([config]);
const req = createMockRequest('unknown.domain.com');
const result = router.routeReq(req);
expect(result).toBeUndefined();
});
// Test adding path patterns
tap.test('should match requests using path patterns', async () => {
const config = createRouteConfig(TEST_DOMAIN);
config.match.path = '/api/users';
router.setRoutes([config]);
// Test that path matches
const req1 = createMockRequest(TEST_DOMAIN, '/api/users');
const result1 = router.routeReqWithDetails(req1);
expect(result1).toBeTruthy();
expect(result1.route).toEqual(config);
expect(result1.pathMatch).toEqual('/api/users');
// Test that non-matching path doesn't match
const req2 = createMockRequest(TEST_DOMAIN, '/web/users');
const result2 = router.routeReqWithDetails(req2);
expect(result2).toBeUndefined();
});
// Test handling wildcard patterns
tap.test('should support wildcard path patterns', async () => {
const config = createRouteConfig(TEST_DOMAIN);
config.match.path = '/api/*';
router.setRoutes([config]);
// Test with path that matches the wildcard pattern
const req = createMockRequest(TEST_DOMAIN, '/api/users/123');
const result = router.routeReqWithDetails(req);
expect(result).toBeTruthy();
expect(result.route).toEqual(config);
expect(result.pathMatch).toEqual('/api');
// Print the actual value to diagnose issues
console.log('Path remainder value:', result.pathRemainder);
expect(result.pathRemainder).toBeTruthy();
expect(result.pathRemainder).toEqual('/users/123');
});
// Test extracting path parameters
tap.test('should extract path parameters from URL', async () => {
const config = createRouteConfig(TEST_DOMAIN);
config.match.path = '/users/:id/profile';
router.setRoutes([config]);
const req = createMockRequest(TEST_DOMAIN, '/users/123/profile');
const result = router.routeReqWithDetails(req);
expect(result).toBeTruthy();
expect(result.route).toEqual(config);
expect(result.pathParams).toBeTruthy();
expect(result.pathParams.id).toEqual('123');
});
// Test multiple configs for same hostname with different paths
tap.test('should support multiple configs for same hostname with different paths', async () => {
const apiConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.1', 8001);
apiConfig.match.path = '/api/*';
apiConfig.name = 'api-route';
const webConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.2', 8002);
webConfig.match.path = '/web/*';
webConfig.name = 'web-route';
// Add both configs
router.setRoutes([apiConfig, webConfig]);
// Test API path routes to API config
const apiReq = createMockRequest(TEST_DOMAIN, '/api/users');
const apiResult = router.routeReq(apiReq);
expect(apiResult).toEqual(apiConfig);
// Test web path routes to web config
const webReq = createMockRequest(TEST_DOMAIN, '/web/dashboard');
const webResult = router.routeReq(webReq);
expect(webResult).toEqual(webConfig);
// Test unknown path returns undefined
const unknownReq = createMockRequest(TEST_DOMAIN, '/unknown');
const unknownResult = router.routeReq(unknownReq);
expect(unknownResult).toBeUndefined();
});
// Test wildcard subdomains
tap.test('should match wildcard subdomains', async () => {
const wildcardConfig = createRouteConfig(TEST_WILDCARD);
router.setRoutes([wildcardConfig]);
// Test that subdomain.example.com matches *.example.com
const req = createMockRequest('subdomain.example.com');
const result = router.routeReq(req);
expect(result).toBeTruthy();
expect(result).toEqual(wildcardConfig);
});
// Test TLD wildcards (example.*)
tap.test('should match TLD wildcards', async () => {
const tldWildcardConfig = createRouteConfig('example.*');
router.setRoutes([tldWildcardConfig]);
// Test that example.com matches example.*
const req1 = createMockRequest('example.com');
const result1 = router.routeReq(req1);
expect(result1).toBeTruthy();
expect(result1).toEqual(tldWildcardConfig);
// Test that example.org matches example.*
const req2 = createMockRequest('example.org');
const result2 = router.routeReq(req2);
expect(result2).toBeTruthy();
expect(result2).toEqual(tldWildcardConfig);
// Test that subdomain.example.com doesn't match example.*
const req3 = createMockRequest('subdomain.example.com');
const result3 = router.routeReq(req3);
expect(result3).toBeUndefined();
});
// Test complex pattern matching (*.lossless*)
tap.test('should match complex wildcard patterns', async () => {
const complexWildcardConfig = createRouteConfig('*.lossless*');
router.setRoutes([complexWildcardConfig]);
// Test that sub.lossless.com matches *.lossless*
const req1 = createMockRequest('sub.lossless.com');
const result1 = router.routeReq(req1);
expect(result1).toBeTruthy();
expect(result1).toEqual(complexWildcardConfig);
// Test that api.lossless.org matches *.lossless*
const req2 = createMockRequest('api.lossless.org');
const result2 = router.routeReq(req2);
expect(result2).toBeTruthy();
expect(result2).toEqual(complexWildcardConfig);
// Test that losslessapi.com matches *.lossless*
const req3 = createMockRequest('losslessapi.com');
const result3 = router.routeReq(req3);
expect(result3).toBeUndefined(); // Should not match as it doesn't have a subdomain
});
// Test default configuration fallback
tap.test('should fall back to default configuration', async () => {
const defaultConfig = createRouteConfig('*');
const specificConfig = createRouteConfig(TEST_DOMAIN);
router.setRoutes([specificConfig, defaultConfig]);
// Test specific domain routes to specific config
const specificReq = createMockRequest(TEST_DOMAIN);
const specificResult = router.routeReq(specificReq);
expect(specificResult).toEqual(specificConfig);
// Test unknown domain falls back to default config
const unknownReq = createMockRequest('unknown.com');
const unknownResult = router.routeReq(unknownReq);
expect(unknownResult).toEqual(defaultConfig);
});
// Test priority between exact and wildcard matches
tap.test('should prioritize exact hostname over wildcard', async () => {
const wildcardConfig = createRouteConfig(TEST_WILDCARD);
const exactConfig = createRouteConfig(TEST_SUBDOMAIN);
router.setRoutes([exactConfig, wildcardConfig]);
// Test that exact match takes priority
const req = createMockRequest(TEST_SUBDOMAIN);
const result = router.routeReq(req);
expect(result).toEqual(exactConfig);
});
// Test adding and removing configurations
tap.test('should manage configurations correctly', async () => {
router.setRoutes([]);
// Add a config
const config = createRouteConfig(TEST_DOMAIN);
router.setRoutes([config]);
// Verify routing works
const req = createMockRequest(TEST_DOMAIN);
let result = router.routeReq(req);
expect(result).toEqual(config);
// Remove the config and verify it no longer routes
router.setRoutes([]);
result = router.routeReq(req);
expect(result).toBeUndefined();
});
// Test path pattern specificity
tap.test('should prioritize more specific path patterns', async () => {
const genericConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.1', 8001);
genericConfig.match.path = '/api/*';
genericConfig.name = 'generic-api';
const specificConfig = createRouteConfig(TEST_DOMAIN, '10.0.0.2', 8002);
specificConfig.match.path = '/api/users';
specificConfig.name = 'specific-api';
specificConfig.priority = 10; // Higher priority
router.setRoutes([genericConfig, specificConfig]);
// The more specific '/api/users' should match before the '/api/*' wildcard
const req = createMockRequest(TEST_DOMAIN, '/api/users');
const result = router.routeReq(req);
expect(result).toEqual(specificConfig);
});
// Test multiple hostnames
tap.test('should handle multiple configured hostnames', async () => {
const routes = [
createRouteConfig(TEST_DOMAIN),
createRouteConfig(TEST_SUBDOMAIN)
];
router.setRoutes(routes);
// Test first domain routes correctly
const req1 = createMockRequest(TEST_DOMAIN);
const result1 = router.routeReq(req1);
expect(result1).toEqual(routes[0]);
// Test second domain routes correctly
const req2 = createMockRequest(TEST_SUBDOMAIN);
const result2 = router.routeReq(req2);
expect(result2).toEqual(routes[1]);
});
// Test handling missing host header
tap.test('should handle missing host header', async () => {
const defaultConfig = createRouteConfig('*');
router.setRoutes([defaultConfig]);
const req = createMockRequest('');
req.headers.host = undefined;
const result = router.routeReq(req);
expect(result).toEqual(defaultConfig);
});
// Test complex path parameters
tap.test('should handle complex path parameters', async () => {
const config = createRouteConfig(TEST_DOMAIN);
config.match.path = '/api/:version/users/:userId/posts/:postId';
router.setRoutes([config]);
const req = createMockRequest(TEST_DOMAIN, '/api/v1/users/123/posts/456');
const result = router.routeReqWithDetails(req);
expect(result).toBeTruthy();
expect(result.route).toEqual(config);
expect(result.pathParams).toBeTruthy();
expect(result.pathParams.version).toEqual('v1');
expect(result.pathParams.userId).toEqual('123');
expect(result.pathParams.postId).toEqual('456');
});
// Performance test
tap.test('should handle many configurations efficiently', async () => {
const configs = [];
// Create many configs with different hostnames
for (let i = 0; i < 100; i++) {
configs.push(createRouteConfig(`host-${i}.example.com`));
}
router.setRoutes(configs);
// Test middle of the list to avoid best/worst case
const req = createMockRequest('host-50.example.com');
const result = router.routeReq(req);
expect(result).toEqual(configs[50]);
});
// Test cleanup
tap.test('cleanup proxy router test environment', async () => {
// Clear all configurations
router.setRoutes([]);
// Verify empty state by testing that no routes match
const req = createMockRequest(TEST_DOMAIN);
const result = router.routeReq(req);
expect(result).toBeUndefined();
});
export default tap.start();

View File

@@ -1,157 +0,0 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SharedSecurityManager } from '../ts/core/utils/shared-security-manager.js';
import type { IRouteConfig, IRouteContext } from '../ts/proxies/smart-proxy/models/route-types.js';
let securityManager: SharedSecurityManager;
tap.test('Setup SharedSecurityManager', async () => {
securityManager = new SharedSecurityManager({
maxConnectionsPerIP: 5,
connectionRateLimitPerMinute: 10,
cleanupIntervalMs: 1000 // 1 second for faster testing
});
});
tap.test('IP connection tracking', async () => {
const testIP = '192.168.1.100';
// Track multiple connections
securityManager.trackConnectionByIP(testIP, 'conn1');
securityManager.trackConnectionByIP(testIP, 'conn2');
securityManager.trackConnectionByIP(testIP, 'conn3');
// Verify connection count
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(3);
// Remove a connection
securityManager.removeConnectionByIP(testIP, 'conn2');
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(2);
// Remove remaining connections
securityManager.removeConnectionByIP(testIP, 'conn1');
securityManager.removeConnectionByIP(testIP, 'conn3');
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(0);
});
tap.test('Per-IP connection limits validation', async () => {
const testIP = '192.168.1.101';
// Track connections up to limit
for (let i = 1; i <= 5; i++) {
// Validate BEFORE tracking the connection (checking if we can add a new connection)
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
// Now track the connection
securityManager.trackConnectionByIP(testIP, `conn${i}`);
}
// Verify we're at the limit
expect(securityManager.getConnectionCountByIP(testIP)).toEqual(5);
// Next connection should be rejected (we're already at 5)
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Maximum connections per IP');
// Clean up
for (let i = 1; i <= 5; i++) {
securityManager.removeConnectionByIP(testIP, `conn${i}`);
}
});
tap.test('Connection rate limiting', async () => {
const testIP = '192.168.1.102';
// Make connections at the rate limit
// Note: validateIP() already tracks timestamps internally for rate limiting
for (let i = 0; i < 10; i++) {
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
}
// Next connection should exceed rate limit
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeFalse();
expect(result.reason).toInclude('Connection rate limit');
});
tap.test('Route-level connection limits', async () => {
const route: IRouteConfig = {
name: 'test-route',
match: { ports: 443 },
action: { type: 'forward', targets: [{ host: 'localhost', port: 8080 }] },
security: {
maxConnections: 3
}
};
const context: IRouteContext = {
port: 443,
clientIp: '192.168.1.103',
serverIp: '0.0.0.0',
timestamp: Date.now(),
connectionId: 'test-conn',
isTls: true
};
// Test with connection counts below limit
expect(securityManager.isAllowed(route, context, 0)).toBeTrue();
expect(securityManager.isAllowed(route, context, 2)).toBeTrue();
// Test at limit
expect(securityManager.isAllowed(route, context, 3)).toBeFalse();
// Test above limit
expect(securityManager.isAllowed(route, context, 5)).toBeFalse();
});
tap.test('IPv4/IPv6 normalization', async () => {
const ipv4 = '127.0.0.1';
const ipv4Mapped = '::ffff:127.0.0.1';
// Track connection with IPv4
securityManager.trackConnectionByIP(ipv4, 'conn1');
// Both representations should show the same connection
expect(securityManager.getConnectionCountByIP(ipv4)).toEqual(1);
expect(securityManager.getConnectionCountByIP(ipv4Mapped)).toEqual(1);
// Track another connection with IPv6 representation
securityManager.trackConnectionByIP(ipv4Mapped, 'conn2');
// Both should show 2 connections
expect(securityManager.getConnectionCountByIP(ipv4)).toEqual(2);
expect(securityManager.getConnectionCountByIP(ipv4Mapped)).toEqual(2);
// Clean up
securityManager.removeConnectionByIP(ipv4, 'conn1');
securityManager.removeConnectionByIP(ipv4Mapped, 'conn2');
});
tap.test('Automatic cleanup of expired data', async (tools) => {
const testIP = '192.168.1.104';
// Track a connection and then remove it
securityManager.trackConnectionByIP(testIP, 'temp-conn');
securityManager.removeConnectionByIP(testIP, 'temp-conn');
// Add some rate limit entries (they expire after 1 minute)
for (let i = 0; i < 5; i++) {
securityManager.validateIP(testIP);
}
// Wait for cleanup interval (set to 1 second in our test)
await tools.delayFor(1500);
// The IP should be cleaned up since it has no connections
// Note: We can't directly check the internal map, but we can verify
// that a new connection is allowed (fresh rate limit)
const result = securityManager.validateIP(testIP);
expect(result.allowed).toBeTrue();
});
tap.test('Cleanup SharedSecurityManager', async () => {
securityManager.clearIPTracking();
});
export default tap.start();

View File

@@ -1,11 +1,19 @@
import { expect, tap } from '@git.zone/tstest/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net'; import * as net from 'net';
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js'; import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
let testServer: net.Server; let testServer: net.Server;
let smartProxy: SmartProxy; let smartProxy: SmartProxy;
const TEST_SERVER_PORT = 4000; let TEST_SERVER_PORT: number;
const PROXY_PORT = 4001; let PROXY_PORT: number;
let CUSTOM_HOST_PORT: number;
let CUSTOM_IP_PROXY_PORT: number;
let CUSTOM_IP_TARGET_PORT: number;
let CHAIN_DEFAULT_1_PORT: number;
let CHAIN_DEFAULT_2_PORT: number;
let CHAIN_PRESERVED_1_PORT: number;
let CHAIN_PRESERVED_2_PORT: number;
const TEST_DATA = 'Hello through port proxy!'; const TEST_DATA = 'Hello through port proxy!';
// Track all created servers and proxies for proper cleanup // Track all created servers and proxies for proper cleanup
@@ -64,6 +72,7 @@ function createTestClient(port: number, data: string): Promise<string> {
// SETUP: Create a test server and a PortProxy instance. // SETUP: Create a test server and a PortProxy instance.
tap.test('setup port proxy test environment', async () => { tap.test('setup port proxy test environment', async () => {
[TEST_SERVER_PORT, PROXY_PORT, CUSTOM_HOST_PORT, CUSTOM_IP_PROXY_PORT, CUSTOM_IP_TARGET_PORT, CHAIN_DEFAULT_1_PORT, CHAIN_DEFAULT_2_PORT, CHAIN_PRESERVED_1_PORT, CHAIN_PRESERVED_2_PORT] = await findFreePorts(9);
testServer = await createTestServer(TEST_SERVER_PORT); testServer = await createTestServer(TEST_SERVER_PORT);
smartProxy = new SmartProxy({ smartProxy = new SmartProxy({
routes: [ routes: [
@@ -110,7 +119,7 @@ tap.test('should forward TCP connections to custom host', async () => {
{ {
name: 'custom-host-route', name: 'custom-host-route',
match: { match: {
ports: PROXY_PORT + 1 ports: CUSTOM_HOST_PORT
}, },
action: { action: {
type: 'forward', type: 'forward',
@@ -128,9 +137,9 @@ tap.test('should forward TCP connections to custom host', async () => {
} }
}); });
allProxies.push(customHostProxy); // Track this proxy allProxies.push(customHostProxy); // Track this proxy
await customHostProxy.start(); await customHostProxy.start();
const response = await createTestClient(PROXY_PORT + 1, TEST_DATA); const response = await createTestClient(CUSTOM_HOST_PORT, TEST_DATA);
expect(response).toEqual(`Echo: ${TEST_DATA}`); expect(response).toEqual(`Echo: ${TEST_DATA}`);
await customHostProxy.stop(); await customHostProxy.stop();
@@ -143,8 +152,8 @@ tap.test('should forward TCP connections to custom host', async () => {
// Modified to work in Docker/CI environments without needing 127.0.0.2 // Modified to work in Docker/CI environments without needing 127.0.0.2
tap.test('should forward connections to custom IP', async () => { tap.test('should forward connections to custom IP', async () => {
// Set up ports that are FAR apart to avoid any possible confusion // Set up ports that are FAR apart to avoid any possible confusion
const forcedProxyPort = PROXY_PORT + 2; // 4003 - The port that our proxy listens on const forcedProxyPort = CUSTOM_IP_PROXY_PORT;
const targetServerPort = TEST_SERVER_PORT + 200; // 4200 - Target test server on different port const targetServerPort = CUSTOM_IP_TARGET_PORT;
// Create a test server listening on a unique port on 127.0.0.1 (works in all environments) // Create a test server listening on a unique port on 127.0.0.1 (works in all environments)
const testServer2 = await createTestServer(targetServerPort, '127.0.0.1'); const testServer2 = await createTestServer(targetServerPort, '127.0.0.1');
@@ -252,13 +261,13 @@ tap.test('should support optional source IP preservation in chained proxies', as
{ {
name: 'first-proxy-default-route', name: 'first-proxy-default-route',
match: { match: {
ports: PROXY_PORT + 4 ports: CHAIN_DEFAULT_1_PORT
}, },
action: { action: {
type: 'forward', type: 'forward',
targets: [{ targets: [{
host: 'localhost', host: 'localhost',
port: PROXY_PORT + 5 port: CHAIN_DEFAULT_2_PORT
}] }]
} }
} }
@@ -274,7 +283,7 @@ tap.test('should support optional source IP preservation in chained proxies', as
{ {
name: 'second-proxy-default-route', name: 'second-proxy-default-route',
match: { match: {
ports: PROXY_PORT + 5 ports: CHAIN_DEFAULT_2_PORT
}, },
action: { action: {
type: 'forward', type: 'forward',
@@ -296,7 +305,7 @@ tap.test('should support optional source IP preservation in chained proxies', as
await secondProxyDefault.start(); await secondProxyDefault.start();
await firstProxyDefault.start(); await firstProxyDefault.start();
const response1 = await createTestClient(PROXY_PORT + 4, TEST_DATA); const response1 = await createTestClient(CHAIN_DEFAULT_1_PORT, TEST_DATA);
expect(response1).toEqual(`Echo: ${TEST_DATA}`); expect(response1).toEqual(`Echo: ${TEST_DATA}`);
await firstProxyDefault.stop(); await firstProxyDefault.stop();
await secondProxyDefault.stop(); await secondProxyDefault.stop();
@@ -313,13 +322,13 @@ tap.test('should support optional source IP preservation in chained proxies', as
{ {
name: 'first-proxy-preserved-route', name: 'first-proxy-preserved-route',
match: { match: {
ports: PROXY_PORT + 6 ports: CHAIN_PRESERVED_1_PORT
}, },
action: { action: {
type: 'forward', type: 'forward',
targets: [{ targets: [{
host: 'localhost', host: 'localhost',
port: PROXY_PORT + 7 port: CHAIN_PRESERVED_2_PORT
}] }]
} }
} }
@@ -337,7 +346,7 @@ tap.test('should support optional source IP preservation in chained proxies', as
{ {
name: 'second-proxy-preserved-route', name: 'second-proxy-preserved-route',
match: { match: {
ports: PROXY_PORT + 7 ports: CHAIN_PRESERVED_2_PORT
}, },
action: { action: {
type: 'forward', type: 'forward',
@@ -361,7 +370,7 @@ tap.test('should support optional source IP preservation in chained proxies', as
await secondProxyPreserved.start(); await secondProxyPreserved.start();
await firstProxyPreserved.start(); await firstProxyPreserved.start();
const response2 = await createTestClient(PROXY_PORT + 6, TEST_DATA); const response2 = await createTestClient(CHAIN_PRESERVED_1_PORT, TEST_DATA);
expect(response2).toEqual(`Echo: ${TEST_DATA}`); expect(response2).toEqual(`Echo: ${TEST_DATA}`);
await firstProxyPreserved.stop(); await firstProxyPreserved.stop();
await secondProxyPreserved.stop(); await secondProxyPreserved.stop();
@@ -446,6 +455,8 @@ tap.test('cleanup port proxy test environment', async () => {
// Verify all resources are cleaned up // Verify all resources are cleaned up
expect(allProxies.length).toEqual(0); expect(allProxies.length).toEqual(0);
expect(allServers.length).toEqual(0); expect(allServers.length).toEqual(0);
await assertPortsFree([TEST_SERVER_PORT, PROXY_PORT, CUSTOM_HOST_PORT, CUSTOM_IP_PROXY_PORT, CUSTOM_IP_TARGET_PORT, CHAIN_DEFAULT_1_PORT, CHAIN_DEFAULT_2_PORT, CHAIN_PRESERVED_1_PORT, CHAIN_PRESERVED_2_PORT]);
}); });
export default tap.start(); export default tap.start();

View File

@@ -7,10 +7,15 @@
import { expect, tap } from '@git.zone/tstest/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/proxies/smart-proxy/index.js'; import { SmartProxy } from '../ts/proxies/smart-proxy/index.js';
import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js'; import type { IRouteConfig } from '../ts/proxies/smart-proxy/models/route-types.js';
import { findFreePorts } from './helpers/port-allocator.js';
// Use unique high ports for each test to avoid conflicts let testPorts: number[];
let testPort = 20000; let portIndex = 0;
const getNextPort = () => testPort++; const getNextPort = () => testPorts[portIndex++];
tap.test('setup - allocate ports', async () => {
testPorts = await findFreePorts(16);
});
// --------------------------------- Single Route, No Domain Restriction --------------------------------- // --------------------------------- Single Route, No Domain Restriction ---------------------------------

View File

@@ -1,12 +1,15 @@
import { expect, tap } from '@git.zone/tstest/tapbundle'; import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net'; import * as net from 'net';
import { SmartProxy } from '../ts/index.js'; import { SmartProxy } from '../ts/index.js';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
tap.test('should handle async handler that sets up listeners after delay', async () => { tap.test('should handle async handler that sets up listeners after delay', async () => {
const [PORT] = await findFreePorts(1);
const proxy = new SmartProxy({ const proxy = new SmartProxy({
routes: [{ routes: [{
name: 'delayed-setup-handler', name: 'delayed-setup-handler',
match: { ports: 7777 }, match: { ports: PORT },
action: { action: {
type: 'socket-handler', type: 'socket-handler',
socketHandler: async (socket, context) => { socketHandler: async (socket, context) => {
@@ -41,7 +44,7 @@ tap.test('should handle async handler that sets up listeners after delay', async
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
client.connect(7777, 'localhost', () => { client.connect(PORT, 'localhost', () => {
// Send initial data immediately - this tests the race condition // Send initial data immediately - this tests the race condition
client.write('initial-message\n'); client.write('initial-message\n');
resolve(); resolve();
@@ -78,6 +81,7 @@ tap.test('should handle async handler that sets up listeners after delay', async
expect(response).toContain('RECEIVED: test-message'); expect(response).toContain('RECEIVED: test-message');
await proxy.stop(); await proxy.stop();
await assertPortsFree([PORT]);
}); });
export default tap.start(); export default tap.start();

View File

@@ -2,15 +2,19 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as net from 'net'; import * as net from 'net';
import { SmartProxy } from '../ts/index.js'; import { SmartProxy } from '../ts/index.js';
import type { IRouteConfig } from '../ts/index.js'; import type { IRouteConfig } from '../ts/index.js';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
let proxy: SmartProxy; let proxy: SmartProxy;
let PORT: number;
tap.test('setup socket handler test', async () => { tap.test('setup socket handler test', async () => {
[PORT] = await findFreePorts(1);
// Create a simple socket handler route // Create a simple socket handler route
const routes: IRouteConfig[] = [{ const routes: IRouteConfig[] = [{
name: 'echo-handler', name: 'echo-handler',
match: { match: {
ports: 9999 ports: PORT
// No domains restriction - matches all connections // No domains restriction - matches all connections
}, },
action: { action: {
@@ -43,11 +47,11 @@ tap.test('should handle socket with custom function', async () => {
let response = ''; let response = '';
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
client.connect(9999, 'localhost', () => { client.connect(PORT, 'localhost', () => {
console.log('Client connected to proxy'); console.log('Client connected to proxy');
resolve(); resolve();
}); });
client.on('error', reject); client.on('error', reject);
}); });
@@ -78,7 +82,7 @@ tap.test('should handle async socket handler', async () => {
// Update route with async handler // Update route with async handler
await proxy.updateRoutes([{ await proxy.updateRoutes([{
name: 'async-handler', name: 'async-handler',
match: { ports: 9999 }, match: { ports: PORT },
action: { action: {
type: 'socket-handler', type: 'socket-handler',
socketHandler: async (socket, context) => { socketHandler: async (socket, context) => {
@@ -108,12 +112,12 @@ tap.test('should handle async socket handler', async () => {
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
client.connect(9999, 'localhost', () => { client.connect(PORT, 'localhost', () => {
// Send initial data to trigger the handler // Send initial data to trigger the handler
client.write('test data\n'); client.write('test data\n');
resolve(); resolve();
}); });
client.on('error', reject); client.on('error', reject);
}); });
@@ -131,7 +135,7 @@ tap.test('should handle errors in socket handler', async () => {
// Update route with error-throwing handler // Update route with error-throwing handler
await proxy.updateRoutes([{ await proxy.updateRoutes([{
name: 'error-handler', name: 'error-handler',
match: { ports: 9999 }, match: { ports: PORT },
action: { action: {
type: 'socket-handler', type: 'socket-handler',
socketHandler: (socket, context) => { socketHandler: (socket, context) => {
@@ -148,12 +152,12 @@ tap.test('should handle errors in socket handler', async () => {
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
client.connect(9999, 'localhost', () => { client.connect(PORT, 'localhost', () => {
// Connection established - send data to trigger handler // Connection established - send data to trigger handler
client.write('trigger\n'); client.write('trigger\n');
resolve(); resolve();
}); });
client.on('error', () => { client.on('error', () => {
// Ignore client errors - we expect the connection to be closed // Ignore client errors - we expect the connection to be closed
}); });
@@ -168,6 +172,7 @@ tap.test('should handle errors in socket handler', async () => {
tap.test('cleanup', async () => { tap.test('cleanup', async () => {
await proxy.stop(); await proxy.stop();
await assertPortsFree([PORT]);
}); });
export default tap.start(); export default tap.start();

709
test/test.throughput.ts Normal file
View File

@@ -0,0 +1,709 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '../ts/index.js';
import type { IRouteConfig } from '../ts/index.js';
import * as net from 'net';
import * as http from 'http';
import * as tls from 'tls';
import * as https from 'https';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ────────────────────────────────────────────────────────────────────────────
// Port assignments (dynamically allocated to avoid conflicts)
// ────────────────────────────────────────────────────────────────────────────
let TCP_ECHO_PORT: number;
let HTTP_ECHO_PORT: number;
let TLS_ECHO_PORT: number;
let PROXY_TCP_PORT: number;
let PROXY_HTTP_PORT: number;
let PROXY_TLS_PASS_PORT: number;
let PROXY_TLS_TERM_PORT: number;
let PROXY_SOCKET_PORT: number;
let PROXY_MULTI_A_PORT: number;
let PROXY_MULTI_B_PORT: number;
let PROXY_TP_HTTP_PORT: number;
// ────────────────────────────────────────────────────────────────────────────
// Test certificates
// ────────────────────────────────────────────────────────────────────────────
const CERT_PEM = fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'cert.pem'), 'utf8');
const KEY_PEM = fs.readFileSync(path.join(__dirname, '..', 'assets', 'certs', 'key.pem'), 'utf8');
// ────────────────────────────────────────────────────────────────────────────
// Backend servers
// ────────────────────────────────────────────────────────────────────────────
let tcpEchoServer: net.Server;
let httpEchoServer: http.Server;
let tlsEchoServer: tls.Server;
// Helper: force-poll the metrics adapter
async function pollMetrics(proxy: SmartProxy): Promise<void> {
await (proxy as any).metricsAdapter.poll();
}
// ════════════════════════════════════════════════════════════════════════════
// Setup: backend servers
// ════════════════════════════════════════════════════════════════════════════
tap.test('setup - TCP echo server', async () => {
[TCP_ECHO_PORT, HTTP_ECHO_PORT, TLS_ECHO_PORT, PROXY_TCP_PORT, PROXY_HTTP_PORT, PROXY_TLS_PASS_PORT, PROXY_TLS_TERM_PORT, PROXY_SOCKET_PORT, PROXY_MULTI_A_PORT, PROXY_MULTI_B_PORT, PROXY_TP_HTTP_PORT] = await findFreePorts(11);
tcpEchoServer = net.createServer((socket) => {
socket.on('data', (data) => socket.write(data));
socket.on('error', () => {});
});
await new Promise<void>((resolve) => {
tcpEchoServer.listen(TCP_ECHO_PORT, () => {
console.log(`TCP echo server on port ${TCP_ECHO_PORT}`);
resolve();
});
});
});
tap.test('setup - HTTP echo server', async () => {
httpEchoServer = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`echo:${body}`);
});
});
await new Promise<void>((resolve) => {
httpEchoServer.listen(HTTP_ECHO_PORT, () => {
console.log(`HTTP echo server on port ${HTTP_ECHO_PORT}`);
resolve();
});
});
});
tap.test('setup - TLS echo server', async () => {
tlsEchoServer = tls.createServer(
{ cert: CERT_PEM, key: KEY_PEM },
(socket) => {
socket.on('data', (data) => socket.write(data));
socket.on('error', () => {});
},
);
await new Promise<void>((resolve) => {
tlsEchoServer.listen(TLS_ECHO_PORT, () => {
console.log(`TLS echo server on port ${TLS_ECHO_PORT}`);
resolve();
});
});
});
// ════════════════════════════════════════════════════════════════════════════
// Group 1: TCP Forward (plain TCP passthrough — no domain, no TLS)
// ════════════════════════════════════════════════════════════════════════════
tap.test('TCP forward - real-time byte tracking', async (tools) => {
const proxy = new SmartProxy({
routes: [
{
id: 'tcp-forward',
name: 'tcp-forward',
match: { ports: PROXY_TCP_PORT },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: TCP_ECHO_PORT }],
},
},
],
metrics: { enabled: true, sampleIntervalMs: 100, retentionSeconds: 60 },
});
await proxy.start();
// Connect and send data
const client = new net.Socket();
await new Promise<void>((resolve, reject) => {
client.connect(PROXY_TCP_PORT, 'localhost', () => resolve());
client.on('error', reject);
});
let received = 0;
client.on('data', (data) => (received += data.length));
// Send 10 KB in chunks over 1 second
const chunk = Buffer.alloc(1024, 'A');
for (let i = 0; i < 10; i++) {
client.write(chunk);
await tools.delayFor(100);
}
// Wait for echo data and sampling to accumulate
await tools.delayFor(500);
// === Key assertion: metrics visible WHILE the connection is still open ===
// Before this change, TCP bytes were only reported after connection close.
// Now bytes are reported per-chunk in real-time.
await pollMetrics(proxy);
const mDuring = proxy.getMetrics();
const bytesInDuring = mDuring.totals.bytesIn();
const bytesOutDuring = mDuring.totals.bytesOut();
console.log(`TCP forward (during) — bytesIn: ${bytesInDuring}, bytesOut: ${bytesOutDuring}`);
expect(bytesInDuring).toBeGreaterThan(0);
expect(bytesOutDuring).toBeGreaterThan(0);
// Check that throughput is non-zero during active TCP traffic
const tpDuring = mDuring.throughput.recent();
console.log(`TCP forward (during) — recent throughput: in=${tpDuring.in}, out=${tpDuring.out}`);
expect(tpDuring.in + tpDuring.out).toBeGreaterThan(0);
// ── v25.2.0: Per-IP tracking (TCP connections) ──
// Must check WHILE connection is active — per-IP data is evicted on last close
const byIP = mDuring.connections.byIP();
console.log('TCP forward — connections byIP:', Array.from(byIP.entries()));
expect(byIP.size).toBeGreaterThan(0);
const topIPs = mDuring.connections.topIPs(10);
console.log('TCP forward — topIPs:', topIPs);
expect(topIPs.length).toBeGreaterThan(0);
expect(topIPs[0].ip).toBeTruthy();
// ── v25.2.0: Throughput history ──
const history = mDuring.throughput.history(10);
console.log('TCP forward — throughput history length:', history.length);
expect(history.length).toBeGreaterThan(0);
expect(history[0].timestamp).toBeGreaterThan(0);
// Close connection
client.destroy();
await tools.delayFor(500);
// Final check — totals persist even after connection close
await pollMetrics(proxy);
const m = proxy.getMetrics();
const bytesIn = m.totals.bytesIn();
const bytesOut = m.totals.bytesOut();
console.log(`TCP forward (final) — bytesIn: ${bytesIn}, bytesOut: ${bytesOut}`);
expect(bytesIn).toBeGreaterThanOrEqual(bytesInDuring);
expect(bytesOut).toBeGreaterThanOrEqual(bytesOutDuring);
// Check per-route tracking
const byRoute = m.throughput.byRoute();
console.log('TCP forward — throughput byRoute:', Array.from(byRoute.entries()));
// After close, per-IP data should be evicted (memory leak fix)
const byIPAfter = m.connections.byIP();
console.log('TCP forward — connections byIP after close:', Array.from(byIPAfter.entries()));
expect(byIPAfter.size).toEqual(0);
await proxy.stop();
await tools.delayFor(200);
});
// ════════════════════════════════════════════════════════════════════════════
// Group 2: HTTP Forward (plain HTTP proxy)
// ════════════════════════════════════════════════════════════════════════════
tap.test('HTTP forward - byte totals tracking', async (tools) => {
const proxy = new SmartProxy({
routes: [
{
id: 'http-forward',
name: 'http-forward',
match: { ports: PROXY_HTTP_PORT },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: HTTP_ECHO_PORT }],
},
},
],
metrics: { enabled: true, sampleIntervalMs: 100, retentionSeconds: 60 },
});
await proxy.start();
await tools.delayFor(300);
// Send 10 HTTP requests with 1 KB body each
for (let i = 0; i < 10; i++) {
const body = 'X'.repeat(1024);
await new Promise<void>((resolve, reject) => {
const req = http.request(
{
hostname: 'localhost',
port: PROXY_HTTP_PORT,
path: '/echo',
method: 'POST',
headers: { 'Content-Type': 'text/plain', 'Content-Length': String(body.length) },
},
(res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => resolve());
},
);
req.on('error', reject);
req.setTimeout(5000, () => {
req.destroy();
reject(new Error('HTTP request timeout'));
});
req.end(body);
});
}
// Wait for sampling + poll
await tools.delayFor(500);
await pollMetrics(proxy);
const m = proxy.getMetrics();
const bytesIn = m.totals.bytesIn();
const bytesOut = m.totals.bytesOut();
console.log(`HTTP forward — bytesIn: ${bytesIn}, bytesOut: ${bytesOut}`);
// Both directions should have bytes (CountingBody tracks request + response)
expect(bytesIn).toBeGreaterThan(0);
expect(bytesOut).toBeGreaterThan(0);
// ── v25.2.0: Per-IP tracking (HTTP connections) ──
const byIP = m.connections.byIP();
console.log('HTTP forward — connections byIP:', Array.from(byIP.entries()));
expect(byIP.size).toBeGreaterThan(0);
const topIPs = m.connections.topIPs(10);
console.log('HTTP forward — topIPs:', topIPs);
expect(topIPs.length).toBeGreaterThan(0);
expect(topIPs[0].ip).toBeTruthy();
// ── v25.2.0: HTTP request counting ──
const totalReqs = m.requests.total();
const rps = m.requests.perSecond();
console.log(`HTTP forward — requests total: ${totalReqs}, perSecond: ${rps}`);
expect(totalReqs).toBeGreaterThan(0);
await proxy.stop();
await tools.delayFor(200);
});
// ════════════════════════════════════════════════════════════════════════════
// Group 3: TLS Passthrough (SNI-based, Rust passes encrypted data through)
// ════════════════════════════════════════════════════════════════════════════
tap.test('TLS passthrough - byte totals tracking', async (tools) => {
const proxy = new SmartProxy({
routes: [
{
id: 'tls-passthrough',
name: 'tls-passthrough',
match: { ports: PROXY_TLS_PASS_PORT, domains: 'localhost' },
action: {
type: 'forward',
tls: { mode: 'passthrough' },
targets: [{ host: 'localhost', port: TLS_ECHO_PORT }],
},
},
],
metrics: { enabled: true, sampleIntervalMs: 100, retentionSeconds: 60 },
});
await proxy.start();
await tools.delayFor(300);
// Connect via TLS through the proxy (SNI: localhost)
const tlsClient = tls.connect(
{
host: 'localhost',
port: PROXY_TLS_PASS_PORT,
servername: 'localhost',
rejectUnauthorized: false,
},
);
await new Promise<void>((resolve, reject) => {
tlsClient.on('secureConnect', () => resolve());
tlsClient.on('error', reject);
});
// Send some data
const data = Buffer.alloc(2048, 'B');
tlsClient.write(data);
// Wait for echo
let received = 0;
tlsClient.on('data', (chunk) => (received += chunk.length));
await tools.delayFor(1000);
console.log(`TLS passthrough — received ${received} bytes back`);
expect(received).toBeGreaterThan(0);
tlsClient.destroy();
await tools.delayFor(500);
await pollMetrics(proxy);
const m = proxy.getMetrics();
const bytesIn = m.totals.bytesIn();
const bytesOut = m.totals.bytesOut();
console.log(`TLS passthrough — bytesIn: ${bytesIn}, bytesOut: ${bytesOut}`);
// TLS passthrough tracks encrypted bytes flowing through
expect(bytesIn).toBeGreaterThan(0);
expect(bytesOut).toBeGreaterThan(0);
await proxy.stop();
await tools.delayFor(200);
});
// ════════════════════════════════════════════════════════════════════════════
// Group 4: TLS Terminate + HTTP (Rust terminates TLS, forwards to HTTP backend)
// ════════════════════════════════════════════════════════════════════════════
tap.test('TLS terminate + HTTP forward - byte totals tracking', async (tools) => {
const proxy = new SmartProxy({
routes: [
{
id: 'tls-terminate',
name: 'tls-terminate',
match: { ports: PROXY_TLS_TERM_PORT, domains: 'localhost' },
action: {
type: 'forward',
tls: {
mode: 'terminate',
certificate: {
cert: CERT_PEM,
key: KEY_PEM,
},
},
targets: [{ host: 'localhost', port: HTTP_ECHO_PORT }],
},
},
],
metrics: { enabled: true, sampleIntervalMs: 100, retentionSeconds: 60 },
disableDefaultCert: true,
});
await proxy.start();
await tools.delayFor(300);
// Send HTTPS request through the proxy
const body = 'Z'.repeat(2048);
await new Promise<void>((resolve, reject) => {
const req = https.request(
{
hostname: 'localhost',
port: PROXY_TLS_TERM_PORT,
path: '/echo',
method: 'POST',
headers: { 'Content-Type': 'text/plain', 'Content-Length': String(body.length) },
rejectUnauthorized: false,
},
(res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
console.log(`TLS terminate — response: ${data.slice(0, 50)}...`);
resolve();
});
},
);
req.on('error', reject);
req.setTimeout(5000, () => {
req.destroy();
reject(new Error('HTTPS request timeout'));
});
req.end(body);
});
await tools.delayFor(500);
await pollMetrics(proxy);
const m = proxy.getMetrics();
const bytesIn = m.totals.bytesIn();
const bytesOut = m.totals.bytesOut();
console.log(`TLS terminate — bytesIn: ${bytesIn}, bytesOut: ${bytesOut}`);
// TLS terminate: request body (bytesIn) and response body (bytesOut) via CountingBody
expect(bytesIn).toBeGreaterThan(0);
expect(bytesOut).toBeGreaterThan(0);
await proxy.stop();
await tools.delayFor(200);
});
// ════════════════════════════════════════════════════════════════════════════
// Group 5: Socket Handler (JS callback handling)
// ════════════════════════════════════════════════════════════════════════════
tap.test('Socket handler - byte totals tracking', async (tools) => {
const proxy = new SmartProxy({
routes: [
{
id: 'socket-handler',
name: 'socket-handler',
match: { ports: PROXY_SOCKET_PORT },
action: {
type: 'socket-handler',
socketHandler: (socket, _context) => {
socket.on('data', (data) => socket.write(data)); // echo
socket.on('error', () => {});
},
},
},
],
metrics: { enabled: true, sampleIntervalMs: 100, retentionSeconds: 60 },
});
await proxy.start();
await tools.delayFor(300);
// Connect and send data
const client = new net.Socket();
await new Promise<void>((resolve, reject) => {
client.connect(PROXY_SOCKET_PORT, 'localhost', () => resolve());
client.on('error', reject);
});
const data = Buffer.alloc(4096, 'C');
client.write(data);
let received = 0;
client.on('data', (chunk) => (received += chunk.length));
await tools.delayFor(500);
console.log(`Socket handler — received ${received} bytes back`);
client.destroy();
await tools.delayFor(500);
await pollMetrics(proxy);
const m = proxy.getMetrics();
const bytesIn = m.totals.bytesIn();
const bytesOut = m.totals.bytesOut();
console.log(`Socket handler — bytesIn: ${bytesIn}, bytesOut: ${bytesOut}`);
// Socket handler relay now records bytes after copy_bidirectional completes
expect(bytesIn).toBeGreaterThan(0);
expect(bytesOut).toBeGreaterThan(0);
await proxy.stop();
await tools.delayFor(200);
});
// ════════════════════════════════════════════════════════════════════════════
// Group 6: Multi-route throughput isolation
// ════════════════════════════════════════════════════════════════════════════
tap.test('Multi-route throughput isolation', async (tools) => {
const proxy = new SmartProxy({
routes: [
{
id: 'route-alpha',
name: 'route-alpha',
match: { ports: PROXY_MULTI_A_PORT },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: TCP_ECHO_PORT }],
},
},
{
id: 'route-beta',
name: 'route-beta',
match: { ports: PROXY_MULTI_B_PORT },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: TCP_ECHO_PORT }],
},
},
],
metrics: { enabled: true, sampleIntervalMs: 100, retentionSeconds: 60 },
});
await proxy.start();
await tools.delayFor(300);
// Send different amounts to each route
// Route alpha: 8 KB
const clientA = new net.Socket();
await new Promise<void>((resolve, reject) => {
clientA.connect(PROXY_MULTI_A_PORT, 'localhost', () => resolve());
clientA.on('error', reject);
});
clientA.on('data', () => {}); // drain
for (let i = 0; i < 8; i++) {
clientA.write(Buffer.alloc(1024, 'A'));
await tools.delayFor(50);
}
// Route beta: 2 KB
const clientB = new net.Socket();
await new Promise<void>((resolve, reject) => {
clientB.connect(PROXY_MULTI_B_PORT, 'localhost', () => resolve());
clientB.on('error', reject);
});
clientB.on('data', () => {}); // drain
for (let i = 0; i < 2; i++) {
clientB.write(Buffer.alloc(1024, 'B'));
await tools.delayFor(50);
}
await tools.delayFor(500);
// Close both
clientA.destroy();
clientB.destroy();
await tools.delayFor(500);
await pollMetrics(proxy);
const m = proxy.getMetrics();
// Check per-route throughput exists for both
const byRoute = m.throughput.byRoute();
console.log('Multi-route — throughput byRoute:', Array.from(byRoute.entries()));
// Check per-route connection counts
const connByRoute = m.connections.byRoute();
console.log('Multi-route — connections byRoute:', Array.from(connByRoute.entries()));
// Both routes should have tracked data
const totalIn = m.totals.bytesIn();
const totalOut = m.totals.bytesOut();
console.log(`Multi-route — total bytesIn: ${totalIn}, bytesOut: ${totalOut}`);
expect(totalIn).toBeGreaterThan(0);
expect(totalOut).toBeGreaterThan(0);
await proxy.stop();
await tools.delayFor(200);
});
// ════════════════════════════════════════════════════════════════════════════
// Group 7: Throughput sampling over time (HTTP-based for real-time tracking)
//
// Uses HTTP proxy path where CountingBody reports bytes incrementally
// as each request/response body completes. This allows the sampling task
// to capture non-zero throughput during active traffic.
// ════════════════════════════════════════════════════════════════════════════
tap.test('Throughput sampling - values appear during active HTTP traffic', async (tools) => {
const proxy = new SmartProxy({
routes: [
{
id: 'sampling-test',
name: 'sampling-test',
match: { ports: PROXY_TP_HTTP_PORT },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: HTTP_ECHO_PORT }],
},
},
],
metrics: { enabled: true, sampleIntervalMs: 100, retentionSeconds: 60 },
});
await proxy.start();
await tools.delayFor(300);
// Send HTTP requests continuously for ~2 seconds
let sending = true;
let requestCount = 0;
const sendLoop = (async () => {
while (sending) {
const body = 'D'.repeat(5120); // 5 KB per request
try {
await new Promise<void>((resolve, reject) => {
const req = http.request(
{
hostname: 'localhost',
port: PROXY_TP_HTTP_PORT,
path: '/echo',
method: 'POST',
headers: { 'Content-Type': 'text/plain', 'Content-Length': String(body.length) },
},
(res) => {
res.on('data', () => {});
res.on('end', () => resolve());
},
);
req.on('error', reject);
req.setTimeout(3000, () => {
req.destroy();
reject(new Error('timeout'));
});
req.end(body);
});
requestCount++;
} catch {
// Ignore errors during shutdown
break;
}
}
})();
// After 1.5 seconds of active traffic, check throughput
await tools.delayFor(1500);
await pollMetrics(proxy);
const m = proxy.getMetrics();
const tp = m.throughput.instant();
const totalIn = m.totals.bytesIn();
const totalOut = m.totals.bytesOut();
console.log(`Sampling test — after 1.5s of traffic: instant in=${tp.in}, out=${tp.out}`);
console.log(`Sampling test — totals: bytesIn=${totalIn}, bytesOut=${totalOut}, requests=${requestCount}`);
// Totals should definitely be non-zero after 1.5s of HTTP requests
expect(totalIn + totalOut).toBeGreaterThan(0);
// Throughput instant should be non-zero during active traffic.
// The sampling interval is 100ms, so we've had ~15 samples by now.
// Each sample captures bytes from completed HTTP request/response bodies.
// Note: this can occasionally be 0 if sample boundaries don't align, so we
// also check that at least the throughput was non-zero for *some* recent window.
const tpRecent = m.throughput.recent();
console.log(`Sampling test — recent throughput: in=${tpRecent.in}, out=${tpRecent.out}`);
expect(tpRecent.in + tpRecent.out).toBeGreaterThan(0);
// ── v25.2.0: Per-IP tracking ──
const byIP = m.connections.byIP();
console.log('Sampling test — connections byIP:', Array.from(byIP.entries()));
expect(byIP.size).toBeGreaterThan(0);
const topIPs = m.connections.topIPs(10);
console.log('Sampling test — topIPs:', topIPs);
expect(topIPs.length).toBeGreaterThan(0);
expect(topIPs[0].ip).toBeTruthy();
expect(topIPs[0].count).toBeGreaterThanOrEqual(0);
// ── v25.2.0: Throughput history ──
const history = m.throughput.history(10);
console.log(`Sampling test — throughput history: ${history.length} points`);
if (history.length > 0) {
console.log(' first:', history[0], 'last:', history[history.length - 1]);
}
expect(history.length).toBeGreaterThan(0);
expect(history[0].timestamp).toBeGreaterThan(0);
// ── v25.2.0: Per-IP throughput ──
const tpByIP = m.throughput.byIP();
console.log('Sampling test — throughput byIP:', Array.from(tpByIP.entries()));
// ── v25.2.0: HTTP request counting ──
const totalReqs = m.requests.total();
const rps = m.requests.perSecond();
const rpm = m.requests.perMinute();
console.log(`Sampling test — HTTP requests: total=${totalReqs}, perSecond=${rps}, perMinute=${rpm}`);
expect(totalReqs).toBeGreaterThan(0);
// Stop sending
sending = false;
await sendLoop;
// After traffic stops, wait for metrics to settle
await tools.delayFor(500);
await pollMetrics(proxy);
const mAfter = proxy.getMetrics();
const tpAfter = mAfter.throughput.instant();
console.log(`Sampling test — after traffic stops: instant in=${tpAfter.in}, out=${tpAfter.out}`);
await proxy.stop();
await tools.delayFor(200);
});
// ════════════════════════════════════════════════════════════════════════════
// Cleanup
// ════════════════════════════════════════════════════════════════════════════
tap.test('cleanup - close backend servers', async () => {
await new Promise<void>((resolve) => tcpEchoServer.close(() => resolve()));
await new Promise<void>((resolve) => httpEchoServer.close(() => resolve()));
await new Promise<void>((resolve) => tlsEchoServer.close(() => resolve()));
console.log('All backend servers closed');
await assertPortsFree([TCP_ECHO_PORT, HTTP_ECHO_PORT, TLS_ECHO_PORT, PROXY_TCP_PORT, PROXY_HTTP_PORT, PROXY_TLS_PASS_PORT, PROXY_TLS_TERM_PORT, PROXY_SOCKET_PORT, PROXY_MULTI_A_PORT, PROXY_MULTI_B_PORT, PROXY_TP_HTTP_PORT]);
});
export default tap.start();

Some files were not shown because too many files have changed in this diff Show More