Compare commits

...

47 Commits

Author SHA1 Message Date
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
60 changed files with 11598 additions and 1548 deletions

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,208 @@
# Changelog # Changelog
## 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) ## 2026-02-11 - 23.1.1 - fix(rust-proxy)
increase rust proxy bridge maxPayloadSize to 100 MB and bump dependencies increase rust proxy bridge maxPayloadSize to 100 MB and bump dependencies

7069
deno.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "23.1.1", "version": "25.7.5",
"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",
@@ -25,25 +25,11 @@
"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/smartfile": "^13.1.2",
"@push.rocks/smartlog": "^3.1.10", "@push.rocks/smartlog": "^3.1.10",
"@push.rocks/smartnetwork": "^4.4.0", "@push.rocks/smartrust": "^1.2.1",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartrust": "^1.2.0",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstring": "^4.1.0",
"@push.rocks/taskbuffer": "^4.2.0",
"@tsclass/tsclass": "^9.3.0", "@tsclass/tsclass": "^9.3.0",
"@types/minimatch": "^6.0.0", "minimatch": "^10.2.0"
"@types/ws": "^8.18.1",
"minimatch": "^10.1.2",
"pretty-ms": "^9.3.0",
"ws": "^8.19.0"
}, },
"files": [ "files": [
"ts/**/*", "ts/**/*",

430
pnpm-lock.yaml generated
View File

@@ -8,63 +8,21 @@ importers:
.: .:
dependencies: dependencies:
'@push.rocks/lik':
specifier: ^6.2.2
version: 6.2.2
'@push.rocks/smartacme':
specifier: ^8.0.0
version: 8.0.0(socks@2.8.7)
'@push.rocks/smartcrypto': '@push.rocks/smartcrypto':
specifier: ^2.0.4 specifier: ^2.0.4
version: 2.0.4 version: 2.0.4
'@push.rocks/smartdelay':
specifier: ^3.0.5
version: 3.0.5
'@push.rocks/smartfile':
specifier: ^13.1.2
version: 13.1.2
'@push.rocks/smartlog': '@push.rocks/smartlog':
specifier: ^3.1.10 specifier: ^3.1.10
version: 3.1.10 version: 3.1.10
'@push.rocks/smartnetwork':
specifier: ^4.4.0
version: 4.4.0
'@push.rocks/smartpromise':
specifier: ^4.2.3
version: 4.2.3
'@push.rocks/smartrequest':
specifier: ^5.0.1
version: 5.0.1
'@push.rocks/smartrust': '@push.rocks/smartrust':
specifier: ^1.2.0 specifier: ^1.2.1
version: 1.2.0 version: 1.2.1
'@push.rocks/smartrx':
specifier: ^3.0.10
version: 3.0.10
'@push.rocks/smartstring':
specifier: ^4.1.0
version: 4.1.0
'@push.rocks/taskbuffer':
specifier: ^4.2.0
version: 4.2.0
'@tsclass/tsclass': '@tsclass/tsclass':
specifier: ^9.3.0 specifier: ^9.3.0
version: 9.3.0 version: 9.3.0
'@types/minimatch':
specifier: ^6.0.0
version: 6.0.0
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
minimatch: minimatch:
specifier: ^10.1.2 specifier: ^10.2.0
version: 10.1.2 version: 10.2.0
pretty-ms:
specifier: ^9.3.0
version: 9.3.0
ws:
specifier: ^8.19.0
version: 8.19.0
devDependencies: devDependencies:
'@git.zone/tsbuild': '@git.zone/tsbuild':
specifier: ^4.1.2 specifier: ^4.1.2
@@ -113,9 +71,6 @@ packages:
'@push.rocks/smartserve': '@push.rocks/smartserve':
optional: true optional: true
'@apiclient.xyz/cloudflare@6.4.3':
resolution: {integrity: sha512-ztegUdUO3Zd4mUoTSylKlCEKPBMHEcggrLelR+7CiblM4beHMwopMVlryBmiCY7bOVbUSPoK0xsVTF7VIy3p/A==}
'@aws-crypto/crc32@5.2.0': '@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -312,15 +267,9 @@ packages:
'@design.estate/dees-domtools@2.3.6': '@design.estate/dees-domtools@2.3.6':
resolution: {integrity: sha512-cKaPNtSpp/ZuuXVx2dXO3K2FU3/HjC4ZkqtXb8Kl6yy9rNDbgtjcI4PuOk9Ux1SJzw7FgcxqVh7OSEV60htbmg==} resolution: {integrity: sha512-cKaPNtSpp/ZuuXVx2dXO3K2FU3/HjC4ZkqtXb8Kl6yy9rNDbgtjcI4PuOk9Ux1SJzw7FgcxqVh7OSEV60htbmg==}
'@design.estate/dees-domtools@2.3.8':
resolution: {integrity: sha512-jUG9GMvPxKMwmRIZ9oLTL3c8hHvHuiwIk8cTrYnuZzGO/uJJ5/czk9o6LRXUuCOOG7TRLtqgOpK8EEQgaadfZA==}
'@design.estate/dees-element@2.1.3': '@design.estate/dees-element@2.1.3':
resolution: {integrity: sha512-TjXWxVcdSPaT1IOk31ckfxvAZnJLuTxhFGsNCKoh63/UE2FVf6slp8//UFvN+ADigiA9ZsY0azkY99XbJCwDDA==} resolution: {integrity: sha512-TjXWxVcdSPaT1IOk31ckfxvAZnJLuTxhFGsNCKoh63/UE2FVf6slp8//UFvN+ADigiA9ZsY0azkY99XbJCwDDA==}
'@design.estate/dees-element@2.1.6':
resolution: {integrity: sha512-7zyHkUjB8UEQgT9VbB2IJtc/yuPt9CI5JGel3b6BxA1kecY64ceIjFvof1uIkc0QP8q2fMLLY45r1c+9zDTjzg==}
'@emnapi/core@1.8.1': '@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -570,14 +519,6 @@ packages:
resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
'@isaacs/brace-expansion@5.0.1':
resolution: {integrity: sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==}
engines: {node: 20 || >=22}
'@isaacs/cliui@9.0.0': '@isaacs/cliui@9.0.0':
resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -588,15 +529,9 @@ packages:
'@lit-labs/ssr-dom-shim@1.4.0': '@lit-labs/ssr-dom-shim@1.4.0':
resolution: {integrity: sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==} resolution: {integrity: sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==}
'@lit-labs/ssr-dom-shim@1.5.1':
resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==}
'@lit/reactive-element@2.1.1': '@lit/reactive-element@2.1.1':
resolution: {integrity: sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==} resolution: {integrity: sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==}
'@lit/reactive-element@2.1.2':
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
'@mixmark-io/domino@2.2.0': '@mixmark-io/domino@2.2.0':
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
@@ -714,9 +649,6 @@ packages:
'@push.rocks/qenv@6.1.3': '@push.rocks/qenv@6.1.3':
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==} resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
'@push.rocks/smartacme@8.0.0':
resolution: {integrity: sha512-Oq+m+LX4IG0p4qCGZLEwa6UlMo5Hfq7paRjpREwQNsaGSKl23xsjsEJLxjxkePwaXnaIkHEwU/5MtrEkg2uKEQ==}
'@push.rocks/smartarchive@4.2.4': '@push.rocks/smartarchive@4.2.4':
resolution: {integrity: sha512-uiqVAXPxmr8G5rv3uZvZFMOCt8l7cZC3nzvsy4YQqKf/VkPhKIEX+b7LkAeNlxPSYUiBQUkNRoawg9+5BaMcHg==} resolution: {integrity: sha512-uiqVAXPxmr8G5rv3uZvZFMOCt8l7cZC3nzvsy4YQqKf/VkPhKIEX+b7LkAeNlxPSYUiBQUkNRoawg9+5BaMcHg==}
@@ -754,9 +686,6 @@ packages:
'@push.rocks/smartdelay@3.0.5': '@push.rocks/smartdelay@3.0.5':
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==} resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
'@push.rocks/smartdns@6.2.2':
resolution: {integrity: sha512-MhJcHujbyIuwIIFdnXb2OScGtRjNsliLUS8GoAurFsKtcCOaA0ytfP+PNzkukyBufjb1nMiJF3rjhswXdHakAQ==}
'@push.rocks/smartdns@7.6.1': '@push.rocks/smartdns@7.6.1':
resolution: {integrity: sha512-nnP5+A2GOt0WsHrYhtKERmjdEHUchc+QbCCBEqlyeQTn+mNfx2WZvKVI1DFRJt8lamvzxP6Hr/BSe3WHdh4Snw==} resolution: {integrity: sha512-nnP5+A2GOt0WsHrYhtKERmjdEHUchc+QbCCBEqlyeQTn+mNfx2WZvKVI1DFRJt8lamvzxP6Hr/BSe3WHdh4Snw==}
@@ -805,9 +734,6 @@ packages:
'@push.rocks/smartjson@5.2.0': '@push.rocks/smartjson@5.2.0':
resolution: {integrity: sha512-710e8UwovRfPgUtaBHcd6unaODUjV5fjxtGcGCqtaTcmvOV6VpasdVfT66xMDzQmWH2E9ZfHDJeso9HdDQzNQA==} resolution: {integrity: sha512-710e8UwovRfPgUtaBHcd6unaODUjV5fjxtGcGCqtaTcmvOV6VpasdVfT66xMDzQmWH2E9ZfHDJeso9HdDQzNQA==}
'@push.rocks/smartjson@6.0.0':
resolution: {integrity: sha512-FYfJnmukt66WePn6xrVZ3BLmRQl9W82LcsICK3VU9sGW7kasig090jKXPm+yX8ibQcZAO/KyR/Q8tMIYZNxGew==}
'@push.rocks/smartlog-destination-devtools@1.0.12': '@push.rocks/smartlog-destination-devtools@1.0.12':
resolution: {integrity: sha512-zvsIkrqByc0JRaBgIyhh+PSz2SY/e/bmhZdUcr/OW6pudgAcqe2sso68EzrKux0w9OMl1P9ZnzF3FpCZPFWD/A==} resolution: {integrity: sha512-zvsIkrqByc0JRaBgIyhh+PSz2SY/e/bmhZdUcr/OW6pudgAcqe2sso68EzrKux0w9OMl1P9ZnzF3FpCZPFWD/A==}
@@ -883,8 +809,8 @@ packages:
'@push.rocks/smartrouter@1.3.3': '@push.rocks/smartrouter@1.3.3':
resolution: {integrity: sha512-1+xZEnWlhzqLWAaJ1zFNhQ0zgbfCWQl1DBT72LygLxTs+P0K8AwJKgqo/IX6CT55kGCFnPAZIYSbVJlGsgrB0w==} resolution: {integrity: sha512-1+xZEnWlhzqLWAaJ1zFNhQ0zgbfCWQl1DBT72LygLxTs+P0K8AwJKgqo/IX6CT55kGCFnPAZIYSbVJlGsgrB0w==}
'@push.rocks/smartrust@1.2.0': '@push.rocks/smartrust@1.2.1':
resolution: {integrity: sha512-JlaALselIHoP6C3ceQbrvz424G21cND/QsH/KI3E/JrO4XphJiGZwM6f4yJWrijdPYR/YYMoaIiYN7ybZp0C4w==} resolution: {integrity: sha512-ANwXXibUwoHNWF1hhXhXVVrfzYlhgHYRa2205Jkd/s/wXzcWHftYZthilJj+52B7nkzSB76umfxKfK5eBYY2Ug==}
'@push.rocks/smartrx@3.0.10': '@push.rocks/smartrx@3.0.10':
resolution: {integrity: sha512-USjIYcsSfzn14cwOsxgq/bBmWDTTzy3ouWAnW5NdMyRRzEbmeNrvmy6TRqNeDlJ2PsYNTt1rr/zGUqvIy72ITg==} resolution: {integrity: sha512-USjIYcsSfzn14cwOsxgq/bBmWDTTzy3ouWAnW5NdMyRRzEbmeNrvmy6TRqNeDlJ2PsYNTt1rr/zGUqvIy72ITg==}
@@ -910,9 +836,6 @@ packages:
'@push.rocks/smartstate@2.0.27': '@push.rocks/smartstate@2.0.27':
resolution: {integrity: sha512-q4UKir7GV3hakJWXQR4DoA4tUVwT5GRkJ/MtanHYF0wZLHfS19+nGmyO9y974zk3eT9hmy3+Lq5cKtU2W6+Y3w==} resolution: {integrity: sha512-q4UKir7GV3hakJWXQR4DoA4tUVwT5GRkJ/MtanHYF0wZLHfS19+nGmyO9y974zk3eT9hmy3+Lq5cKtU2W6+Y3w==}
'@push.rocks/smartstate@2.0.30':
resolution: {integrity: sha512-IuNW8XtSumXIr7g7MIFyWg5PBwLF2mwsymTJbSEycK2Pa9ZLk4yjRHnR907xCilxgiMU9ixQZyNdpa5MMF999A==}
'@push.rocks/smartstream@3.2.5': '@push.rocks/smartstream@3.2.5':
resolution: {integrity: sha512-PLGGIFDy8JLNVUnnntMSIYN4W081YSbNC7Y/sWpvUT8PAXtbEXXUiDFgK5o3gcI0ptpKQxHAwxhzNlPj0sbFVg==} resolution: {integrity: sha512-PLGGIFDy8JLNVUnnntMSIYN4W081YSbNC7Y/sWpvUT8PAXtbEXXUiDFgK5o3gcI0ptpKQxHAwxhzNlPj0sbFVg==}
@@ -943,9 +866,6 @@ packages:
'@push.rocks/taskbuffer@3.5.0': '@push.rocks/taskbuffer@3.5.0':
resolution: {integrity: sha512-Y9WwIEIyp6oVFdj06j84tfrZIvjhbMb3DF52rYxlTeYLk3W7RPhSg1bGPCbtkXWeKdBrSe37V90BkOG7Qq8Pqg==} resolution: {integrity: sha512-Y9WwIEIyp6oVFdj06j84tfrZIvjhbMb3DF52rYxlTeYLk3W7RPhSg1bGPCbtkXWeKdBrSe37V90BkOG7Qq8Pqg==}
'@push.rocks/taskbuffer@4.2.0':
resolution: {integrity: sha512-ttoBe5y/WXkAo5/wSMcC/Y4Zbyw4XG8kwAsEaqnAPCxa3M9MI1oV/yM1e9gU1IH97HVPidzbTxRU5/PcHDdUsg==}
'@push.rocks/webrequest@3.0.37': '@push.rocks/webrequest@3.0.37':
resolution: {integrity: sha512-fLN7kP6GeHFxE4UH4r9C9pjcQb0QkJxHeAMwXvbOqB9hh0MFNKhtGU7GoaTn8SVRGRMPc9UqZVNwo6u5l8Wn0A==} resolution: {integrity: sha512-fLN7kP6GeHFxE4UH4r9C9pjcQb0QkJxHeAMwXvbOqB9hh0MFNKhtGU7GoaTn8SVRGRMPc9UqZVNwo6u5l8Wn0A==}
@@ -1376,20 +1296,6 @@ packages:
'@tempfix/idb@8.0.3': '@tempfix/idb@8.0.3':
resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==} resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==}
'@tempfix/lenis@1.3.20':
resolution: {integrity: sha512-ypeB0FuHLHOCQXW4d0RQ69txPJJH+1CHcpsZIUdcv2t1vR0IVyQr2vHihtde9UOXhjzqEnUphWon/UcJNsa0YA==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
react: '>=17.0.0'
vue: '>=3.0.0'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
react:
optional: true
vue:
optional: true
'@tokenizer/inflate@0.4.1': '@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1403,9 +1309,6 @@ packages:
'@tsclass/tsclass@4.4.4': '@tsclass/tsclass@4.4.4':
resolution: {integrity: sha512-YZOAF+u+r4u5rCev2uUd1KBTBdfyFdtDmcv4wuN+864lMccbdfRICR3SlJwCfYS1lbeV3QNLYGD30wjRXgvCJA==} resolution: {integrity: sha512-YZOAF+u+r4u5rCev2uUd1KBTBdfyFdtDmcv4wuN+864lMccbdfRICR3SlJwCfYS1lbeV3QNLYGD30wjRXgvCJA==}
'@tsclass/tsclass@5.0.0':
resolution: {integrity: sha512-2X66VCk0Oe1L01j6GQHC6F9Gj7lpZPPSUTDNax7e29lm4OqBTyAzTR3ePR8coSbWBwsmRV8awLRSrSI+swlqWA==}
'@tsclass/tsclass@9.3.0': '@tsclass/tsclass@9.3.0':
resolution: {integrity: sha512-KD3oTUN3RGu67tgjNHgWWZGsdYipr1RUDxQ9MMKSgIJ6oNZ4q5m2rg0ibrgyHWkAjTPlHVa6kHP3uVOY+8bnHw==} resolution: {integrity: sha512-KD3oTUN3RGu67tgjNHgWWZGsdYipr1RUDxQ9MMKSgIJ6oNZ4q5m2rg0ibrgyHWkAjTPlHVa6kHP3uVOY+8bnHw==}
@@ -1478,25 +1381,15 @@ packages:
'@types/minimatch@5.1.2': '@types/minimatch@5.1.2':
resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==}
'@types/minimatch@6.0.0':
resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==}
deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed.
'@types/ms@2.1.0': '@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/mute-stream@0.0.4': '@types/mute-stream@0.0.4':
resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==}
'@types/node-fetch@2.6.13':
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
'@types/node-forge@1.3.14': '@types/node-forge@1.3.14':
resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@22.19.11': '@types/node@22.19.11':
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==} resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
@@ -1572,10 +1465,6 @@ packages:
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
accepts@1.3.8: accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -1649,6 +1538,10 @@ packages:
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.2:
resolution: {integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==}
engines: {node: 20 || >=22}
bare-events@2.8.2: bare-events@2.8.2:
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
peerDependencies: peerDependencies:
@@ -1714,6 +1607,10 @@ packages:
brace-expansion@2.0.2: brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@5.0.2:
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
engines: {node: 20 || >=22}
broadcast-channel@7.2.0: broadcast-channel@7.2.0:
resolution: {integrity: sha512-JgraikEriG/TxBUi2W/w2O0jhHjXZUtXAvCZH0Yr3whjxYVgAg0hSe6r/teM+I5H5Q/q6RhyuKdC2pHNlFyepQ==} resolution: {integrity: sha512-JgraikEriG/TxBUi2W/w2O0jhHjXZUtXAvCZH0Yr3whjxYVgAg0hSe6r/teM+I5H5Q/q6RhyuKdC2pHNlFyepQ==}
@@ -1808,9 +1705,6 @@ packages:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
cloudflare@5.2.0:
resolution: {integrity: sha512-dVzqDpPFYR9ApEC9e+JJshFJZXcw4HzM8W+3DHzO5oy9+8rLC53G7x6fEf9A7/gSuSCxuvndzui5qJKftfIM9A==}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -2059,10 +1953,6 @@ packages:
resolution: {integrity: sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=} resolution: {integrity: sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@4.0.7: eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
@@ -2168,9 +2058,6 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'} engines: {node: '>=14'}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
form-data-encoder@2.1.4: form-data-encoder@2.1.4:
resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==}
engines: {node: '>= 14.17'} engines: {node: '>= 14.17'}
@@ -2183,10 +2070,6 @@ packages:
resolution: {integrity: sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs=} resolution: {integrity: sha1-1hcBB+nv3E7TDJ3DkBbflCtctYs=}
engines: {node: '>=0.4.x'} engines: {node: '>=0.4.x'}
formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
forwarded@0.2.0: forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -2478,21 +2361,12 @@ packages:
lit-element@4.2.1: lit-element@4.2.1:
resolution: {integrity: sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==} resolution: {integrity: sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==}
lit-element@4.2.2:
resolution: {integrity: sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==}
lit-html@3.3.1: lit-html@3.3.1:
resolution: {integrity: sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==} resolution: {integrity: sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==}
lit-html@3.3.2:
resolution: {integrity: sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==}
lit@3.3.1: lit@3.3.1:
resolution: {integrity: sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==} resolution: {integrity: sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==}
lit@3.3.2:
resolution: {integrity: sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==}
locate-path@5.0.0: locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2750,8 +2624,8 @@ packages:
minimalistic-crypto-utils@1.0.1: minimalistic-crypto-utils@1.0.1:
resolution: {integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=} resolution: {integrity: sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=}
minimatch@10.1.2: minimatch@10.2.0:
resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} resolution: {integrity: sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
minimatch@3.1.2: minimatch@3.1.2:
@@ -2848,19 +2722,6 @@ packages:
no-case@2.3.2: no-case@2.3.2:
resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-forge@1.3.3: node-forge@1.3.3:
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
engines: {node: '>= 6.13.0'} engines: {node: '>= 6.13.0'}
@@ -3383,9 +3244,6 @@ packages:
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
tr46@0.0.3:
resolution: {integrity: sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=}
tr46@5.1.1: tr46@5.1.1:
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3454,9 +3312,6 @@ packages:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'} engines: {node: '>=18'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -3516,16 +3371,9 @@ packages:
vfile@6.0.3: vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
webdriver-bidi-protocol@0.4.0: webdriver-bidi-protocol@0.4.0:
resolution: {integrity: sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA==} resolution: {integrity: sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA==}
webidl-conversions@3.0.1:
resolution: {integrity: sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=}
webidl-conversions@7.0.0: webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -3538,9 +3386,6 @@ packages:
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
engines: {node: '>=18'} engines: {node: '>=18'}
whatwg-url@5.0.0:
resolution: {integrity: sha1-lmRU6HZUYuN2RNNib2dCzotwll0=}
which@2.0.2: which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3719,18 +3564,6 @@ snapshots:
- utf-8-validate - utf-8-validate
- vue - vue
'@apiclient.xyz/cloudflare@6.4.3':
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 5.0.1
'@push.rocks/smartstring': 4.1.0
'@tsclass/tsclass': 9.3.0
cloudflare: 5.2.0
transitivePeerDependencies:
- encoding
'@aws-crypto/crc32@5.2.0': '@aws-crypto/crc32@5.2.0':
dependencies: dependencies:
'@aws-crypto/util': 5.2.0 '@aws-crypto/util': 5.2.0
@@ -4271,32 +4104,6 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@design.estate/dees-domtools@2.3.8':
dependencies:
'@api.global/typedrequest': 3.2.5
'@design.estate/dees-comms': 1.0.30
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartjson': 5.2.0
'@push.rocks/smartmarkdown': 3.0.3
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrouter': 1.3.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smartstate': 2.0.30
'@push.rocks/smartstring': 4.1.0
'@push.rocks/smarturl': 3.1.0
'@push.rocks/webrequest': 3.0.37
'@push.rocks/websetup': 3.0.19
'@push.rocks/webstore': 2.0.20
'@tempfix/lenis': 1.3.20
lit: 3.3.2
sweet-scroll: 4.0.0
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@design.estate/dees-element@2.1.3': '@design.estate/dees-element@2.1.3':
dependencies: dependencies:
'@design.estate/dees-domtools': 2.3.6 '@design.estate/dees-domtools': 2.3.6
@@ -4309,18 +4116,6 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@design.estate/dees-element@2.1.6':
dependencies:
'@design.estate/dees-domtools': 2.3.8
'@push.rocks/isounique': 1.0.5
'@push.rocks/smartrx': 3.0.10
lit: 3.3.2
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@emnapi/core@1.8.1': '@emnapi/core@1.8.1':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.1.0 '@emnapi/wasi-threads': 1.1.0
@@ -4654,28 +4449,16 @@ snapshots:
dependencies: dependencies:
mute-stream: 1.0.0 mute-stream: 1.0.0
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.1':
dependencies:
'@isaacs/balanced-match': 4.0.1
'@isaacs/cliui@9.0.0': {} '@isaacs/cliui@9.0.0': {}
'@leichtgewicht/ip-codec@2.0.5': {} '@leichtgewicht/ip-codec@2.0.5': {}
'@lit-labs/ssr-dom-shim@1.4.0': {} '@lit-labs/ssr-dom-shim@1.4.0': {}
'@lit-labs/ssr-dom-shim@1.5.1': {}
'@lit/reactive-element@2.1.1': '@lit/reactive-element@2.1.1':
dependencies: dependencies:
'@lit-labs/ssr-dom-shim': 1.4.0 '@lit-labs/ssr-dom-shim': 1.4.0
'@lit/reactive-element@2.1.2':
dependencies:
'@lit-labs/ssr-dom-shim': 1.5.1
'@mixmark-io/domino@2.2.0': {} '@mixmark-io/domino@2.2.0': {}
'@module-federation/error-codes@0.22.0': {} '@module-federation/error-codes@0.22.0': {}
@@ -4940,40 +4723,6 @@ snapshots:
'@push.rocks/smartlog': 3.1.10 '@push.rocks/smartlog': 3.1.10
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartacme@8.0.0(socks@2.8.7)':
dependencies:
'@api.global/typedserver': 3.0.80(@push.rocks/smartserve@2.0.1)
'@apiclient.xyz/cloudflare': 6.4.3
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdata': 5.16.7(socks@2.8.7)
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartdns': 6.2.2
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartnetwork': 4.4.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 2.1.0
'@push.rocks/smartstring': 4.1.0
'@push.rocks/smarttime': 4.1.1
'@push.rocks/smartunique': 3.0.9
'@tsclass/tsclass': 9.3.0
acme-client: 5.4.0
transitivePeerDependencies:
- '@aws-sdk/credential-providers'
- '@mongodb-js/zstd'
- '@nuxt/kit'
- bare-abort-controller
- encoding
- gcp-metadata
- kerberos
- mongodb-client-encryption
- react
- react-native-b4a
- snappy
- socks
- supports-color
- vue
'@push.rocks/smartarchive@4.2.4': '@push.rocks/smartarchive@4.2.4':
dependencies: dependencies:
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
@@ -5034,7 +4783,7 @@ snapshots:
'@push.rocks/smartstring': 4.1.0 '@push.rocks/smartstring': 4.1.0
'@push.rocks/smartunique': 3.0.9 '@push.rocks/smartunique': 3.0.9
'@tsclass/tsclass': 9.3.0 '@tsclass/tsclass': 9.3.0
minimatch: 10.1.2 minimatch: 10.2.0
transitivePeerDependencies: transitivePeerDependencies:
- aws-crt - aws-crt
@@ -5115,22 +4864,6 @@ snapshots:
dependencies: dependencies:
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartdns@6.2.2':
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartenv': 5.0.13
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrequest': 2.1.0
'@tsclass/tsclass': 5.0.0
'@types/dns-packet': 5.6.5
'@types/elliptic': 6.4.18
acme-client: 5.4.0
dns-packet: 5.6.1
elliptic: 6.6.1
minimatch: 10.1.2
transitivePeerDependencies:
- supports-color
'@push.rocks/smartdns@7.6.1': '@push.rocks/smartdns@7.6.1':
dependencies: dependencies:
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
@@ -5143,7 +4876,7 @@ snapshots:
acme-client: 5.4.0 acme-client: 5.4.0
dns-packet: 5.6.1 dns-packet: 5.6.1
elliptic: 6.6.1 elliptic: 6.6.1
minimatch: 10.1.2 minimatch: 10.2.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5253,13 +4986,6 @@ snapshots:
fast-json-stable-stringify: 2.1.0 fast-json-stable-stringify: 2.1.0
lodash.clonedeep: 4.5.0 lodash.clonedeep: 4.5.0
'@push.rocks/smartjson@6.0.0':
dependencies:
'@push.rocks/smartenv': 6.0.0
'@push.rocks/smartstring': 4.1.0
fast-json-stable-stringify: 2.1.0
lodash.clonedeep: 4.5.0
'@push.rocks/smartlog-destination-devtools@1.0.12': '@push.rocks/smartlog-destination-devtools@1.0.12':
dependencies: dependencies:
'@push.rocks/smartlog-interfaces': 3.0.2 '@push.rocks/smartlog-interfaces': 3.0.2
@@ -5497,7 +5223,7 @@ snapshots:
'@push.rocks/smartrx': 3.0.10 '@push.rocks/smartrx': 3.0.10
path-to-regexp: 8.3.0 path-to-regexp: 8.3.0
'@push.rocks/smartrust@1.2.0': '@push.rocks/smartrust@1.2.1':
dependencies: dependencies:
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
@@ -5590,15 +5316,6 @@ snapshots:
'@push.rocks/smartrx': 3.0.10 '@push.rocks/smartrx': 3.0.10
'@push.rocks/webstore': 2.0.20 '@push.rocks/webstore': 2.0.20
'@push.rocks/smartstate@2.0.30':
dependencies:
'@push.rocks/lik': 6.2.2
'@push.rocks/smarthash': 3.2.6
'@push.rocks/smartjson': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/webstore': 2.0.20
'@push.rocks/smartstream@3.2.5': '@push.rocks/smartstream@3.2.5':
dependencies: dependencies:
'@push.rocks/lik': 6.2.2 '@push.rocks/lik': 6.2.2
@@ -5663,22 +5380,6 @@ snapshots:
- supports-color - supports-color
- vue - vue
'@push.rocks/taskbuffer@4.2.0':
dependencies:
'@design.estate/dees-element': 2.1.6
'@push.rocks/lik': 6.2.2
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartlog': 3.1.10
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smarttime': 4.1.1
'@push.rocks/smartunique': 3.0.9
transitivePeerDependencies:
- '@nuxt/kit'
- react
- supports-color
- vue
'@push.rocks/webrequest@3.0.37': '@push.rocks/webrequest@3.0.37':
dependencies: dependencies:
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.0.5
@@ -6207,8 +5908,6 @@ snapshots:
'@tempfix/idb@8.0.3': {} '@tempfix/idb@8.0.3': {}
'@tempfix/lenis@1.3.20': {}
'@tokenizer/inflate@0.4.1': '@tokenizer/inflate@0.4.1':
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
@@ -6224,10 +5923,6 @@ snapshots:
dependencies: dependencies:
type-fest: 4.41.0 type-fest: 4.41.0
'@tsclass/tsclass@5.0.0':
dependencies:
type-fest: 4.41.0
'@tsclass/tsclass@9.3.0': '@tsclass/tsclass@9.3.0':
dependencies: dependencies:
type-fest: 4.41.0 type-fest: 4.41.0
@@ -6321,29 +6016,16 @@ snapshots:
'@types/minimatch@5.1.2': {} '@types/minimatch@5.1.2': {}
'@types/minimatch@6.0.0':
dependencies:
minimatch: 10.1.2
'@types/ms@2.1.0': {} '@types/ms@2.1.0': {}
'@types/mute-stream@0.0.4': '@types/mute-stream@0.0.4':
dependencies: dependencies:
'@types/node': 25.2.3 '@types/node': 25.2.3
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 25.2.3
form-data: 4.0.5
'@types/node-forge@1.3.14': '@types/node-forge@1.3.14':
dependencies: dependencies:
'@types/node': 25.2.3 '@types/node': 25.2.3
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@22.19.11': '@types/node@22.19.11':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@@ -6416,10 +6098,6 @@ snapshots:
'@ungap/structured-clone@1.3.0': {} '@ungap/structured-clone@1.3.0': {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
accepts@1.3.8: accepts@1.3.8:
dependencies: dependencies:
mime-types: 2.1.35 mime-types: 2.1.35
@@ -6494,6 +6172,10 @@ snapshots:
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
balanced-match@4.0.2:
dependencies:
jackspeak: 4.2.3
bare-events@2.8.2: {} bare-events@2.8.2: {}
bare-fs@4.5.3: bare-fs@4.5.3:
@@ -6564,6 +6246,10 @@ snapshots:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
brace-expansion@5.0.2:
dependencies:
balanced-match: 4.0.2
broadcast-channel@7.2.0: broadcast-channel@7.2.0:
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
@@ -6658,18 +6344,6 @@ snapshots:
strip-ansi: 6.0.1 strip-ansi: 6.0.1
wrap-ansi: 7.0.0 wrap-ansi: 7.0.0
cloudflare@5.2.0:
dependencies:
'@types/node': 18.19.130
'@types/node-fetch': 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -6921,8 +6595,6 @@ snapshots:
etag@1.8.1: {} etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {} eventemitter3@4.0.7: {}
events-universal@1.0.1: events-universal@1.0.1:
@@ -7074,8 +6746,6 @@ snapshots:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
signal-exit: 4.1.0 signal-exit: 4.1.0
form-data-encoder@1.7.2: {}
form-data-encoder@2.1.4: {} form-data-encoder@2.1.4: {}
form-data@4.0.5: form-data@4.0.5:
@@ -7088,11 +6758,6 @@ snapshots:
format@0.2.2: {} format@0.2.2: {}
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
forwarded@0.2.0: {} forwarded@0.2.0: {}
fresh@2.0.0: {} fresh@2.0.0: {}
@@ -7157,7 +6822,7 @@ snapshots:
dependencies: dependencies:
foreground-child: 3.3.1 foreground-child: 3.3.1
jackspeak: 4.2.3 jackspeak: 4.2.3
minimatch: 10.1.2 minimatch: 10.2.0
minipass: 7.1.2 minipass: 7.1.2
package-json-from-dist: 1.0.1 package-json-from-dist: 1.0.1
path-scurry: 2.0.1 path-scurry: 2.0.1
@@ -7410,32 +7075,16 @@ snapshots:
'@lit/reactive-element': 2.1.1 '@lit/reactive-element': 2.1.1
lit-html: 3.3.1 lit-html: 3.3.1
lit-element@4.2.2:
dependencies:
'@lit-labs/ssr-dom-shim': 1.5.1
'@lit/reactive-element': 2.1.2
lit-html: 3.3.2
lit-html@3.3.1: lit-html@3.3.1:
dependencies: dependencies:
'@types/trusted-types': 2.0.7 '@types/trusted-types': 2.0.7
lit-html@3.3.2:
dependencies:
'@types/trusted-types': 2.0.7
lit@3.3.1: lit@3.3.1:
dependencies: dependencies:
'@lit/reactive-element': 2.1.1 '@lit/reactive-element': 2.1.1
lit-element: 4.2.1 lit-element: 4.2.1
lit-html: 3.3.1 lit-html: 3.3.1
lit@3.3.2:
dependencies:
'@lit/reactive-element': 2.1.2
lit-element: 4.2.2
lit-html: 3.3.2
locate-path@5.0.0: locate-path@5.0.0:
dependencies: dependencies:
p-locate: 4.1.0 p-locate: 4.1.0
@@ -7862,9 +7511,9 @@ snapshots:
minimalistic-crypto-utils@1.0.1: {} minimalistic-crypto-utils@1.0.1: {}
minimatch@10.1.2: minimatch@10.2.0:
dependencies: dependencies:
'@isaacs/brace-expansion': 5.0.1 brace-expansion: 5.0.2
minimatch@3.1.2: minimatch@3.1.2:
dependencies: dependencies:
@@ -7999,12 +7648,6 @@ snapshots:
dependencies: dependencies:
lower-case: 1.1.4 lower-case: 1.1.4
node-domexception@1.0.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-forge@1.3.3: {} node-forge@1.3.3: {}
normalize-newline@4.1.0: normalize-newline@4.1.0:
@@ -8650,8 +8293,6 @@ snapshots:
'@tokenizer/token': 0.3.0 '@tokenizer/token': 0.3.0
ieee754: 1.2.1 ieee754: 1.2.1
tr46@0.0.3: {}
tr46@5.1.1: tr46@5.1.1:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
@@ -8703,8 +8344,6 @@ snapshots:
uint8array-extras@1.5.0: {} uint8array-extras@1.5.0: {}
undici-types@5.26.5: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici-types@7.16.0: {} undici-types@7.16.0: {}
@@ -8771,12 +8410,8 @@ snapshots:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
vfile-message: 4.0.3 vfile-message: 4.0.3
web-streams-polyfill@4.0.0-beta.3: {}
webdriver-bidi-protocol@0.4.0: {} webdriver-bidi-protocol@0.4.0: {}
webidl-conversions@3.0.1: {}
webidl-conversions@7.0.0: {} webidl-conversions@7.0.0: {}
whatwg-mimetype@3.0.0: {} whatwg-mimetype@3.0.0: {}
@@ -8786,11 +8421,6 @@ snapshots:
tr46: 5.1.1 tr46: 5.1.1
webidl-conversions: 7.0.0 webidl-conversions: 7.0.0
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2: which@2.0.2:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0

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.

153
readme.md
View File

@@ -27,7 +27,7 @@ Whether you're building microservices, deploying edge infrastructure, or need a
| 🦀 **Rust-Powered Engine** | All networking handled by a high-performance Rust binary via IPC | | 🦀 **Rust-Powered Engine** | All networking handled by a high-performance Rust binary via IPC |
| 🔀 **Unified Route-Based Config** | Clean match/action patterns for intuitive traffic routing | | 🔀 **Unified Route-Based Config** | Clean match/action patterns for intuitive traffic routing |
| 🔒 **Automatic SSL/TLS** | Zero-config HTTPS with Let's Encrypt ACME integration | | 🔒 **Automatic SSL/TLS** | Zero-config HTTPS with Let's Encrypt ACME integration |
| 🎯 **Flexible Matching** | Route by port, domain, path, client IP, TLS version, headers, or custom logic | | 🎯 **Flexible Matching** | Route by port, domain, path, protocol, client IP, TLS version, headers, or custom logic |
| 🚄 **High-Performance** | Choose between user-space or kernel-level (NFTables) forwarding | | 🚄 **High-Performance** | Choose between user-space or kernel-level (NFTables) forwarding |
| ⚖️ **Load Balancing** | Round-robin, least-connections, IP-hash with health checks | | ⚖️ **Load Balancing** | Round-robin, least-connections, IP-hash with health checks |
| 🛡️ **Enterprise Security** | IP filtering, rate limiting, basic auth, JWT auth, connection limits | | 🛡️ **Enterprise Security** | IP filtering, rate limiting, basic auth, JWT auth, connection limits |
@@ -36,6 +36,7 @@ Whether you're building microservices, deploying edge infrastructure, or need a
| 📊 **Live Metrics** | Real-time throughput, connection counts, and performance data | | 📊 **Live Metrics** | Real-time throughput, connection counts, and performance data |
| 🔧 **Dynamic Management** | Add/remove ports and routes at runtime without restarts | | 🔧 **Dynamic Management** | Add/remove ports and routes at runtime without restarts |
| 🔄 **PROXY Protocol** | Full PROXY protocol v1/v2 support for preserving client information | | 🔄 **PROXY Protocol** | Full PROXY protocol v1/v2 support for preserving client information |
| 💾 **Consumer Cert Storage** | Bring your own persistence — SmartProxy never writes certs to disk |
## 🚀 Quick Start ## 🚀 Quick Start
@@ -88,7 +89,7 @@ SmartProxy uses a powerful **match/action** pattern that makes routing predictab
``` ```
Every route consists of: Every route consists of:
- **Match** — What traffic to capture (ports, domains, paths, IPs, headers) - **Match** — What traffic to capture (ports, domains, paths, protocol, IPs, headers)
- **Action** — What to do with it (`forward` or `socket-handler`) - **Action** — What to do with it (`forward` or `socket-handler`)
- **Security** (optional) — IP allow/block lists, rate limits, authentication - **Security** (optional) — IP allow/block lists, rate limits, authentication
- **Headers** (optional) — Request/response header manipulation with template variables - **Headers** (optional) — Request/response header manipulation with template variables
@@ -102,7 +103,7 @@ SmartProxy supports three TLS handling modes:
|------|-------------|----------| |------|-------------|----------|
| `passthrough` | Forward encrypted traffic as-is (SNI-based routing) | Backend handles TLS | | `passthrough` | Forward encrypted traffic as-is (SNI-based routing) | Backend handles TLS |
| `terminate` | Decrypt at proxy, forward plain HTTP to backend | Standard reverse proxy | | `terminate` | Decrypt at proxy, forward plain HTTP to backend | Standard reverse proxy |
| `terminate-and-reencrypt` | Decrypt, then re-encrypt to backend | Zero-trust environments | | `terminate-and-reencrypt` | Decrypt at proxy, re-encrypt to backend. HTTP traffic gets full per-request routing (Host header, path matching) via the HTTP proxy; non-HTTP traffic uses a raw TLS-to-TLS tunnel | Zero-trust / defense-in-depth environments |
## 💡 Common Use Cases ## 💡 Common Use Cases
@@ -134,13 +135,13 @@ const proxy = new SmartProxy({
], ],
{ {
tls: { mode: 'terminate', certificate: 'auto' }, tls: { mode: 'terminate', certificate: 'auto' },
loadBalancing: { algorithm: 'round-robin',
algorithm: 'round-robin', healthCheck: {
healthCheck: { path: '/health',
path: '/health', interval: 30000,
interval: 30000, timeout: 5000,
timeout: 5000 unhealthyThreshold: 3,
} healthyThreshold: 2
} }
} }
) )
@@ -317,6 +318,42 @@ const proxy = new SmartProxy({
> **Note:** Routes with dynamic functions (host/port callbacks) are automatically relayed through the TypeScript socket handler server, since JavaScript functions can't be serialized to Rust. > **Note:** Routes with dynamic functions (host/port callbacks) are automatically relayed through the TypeScript socket handler server, since JavaScript functions can't be serialized to Rust.
### 🔀 Protocol-Specific Routing
Restrict routes to specific application-layer protocols. When `protocol` is set, the Rust engine detects the protocol after connection (or after TLS termination) and only matches routes that accept that protocol:
```typescript
// HTTP-only route (rejects raw TCP connections)
const httpOnlyRoute: IRouteConfig = {
name: 'http-api',
match: {
ports: 443,
domains: 'api.example.com',
protocol: 'http', // Only match HTTP/1.1, HTTP/2, and WebSocket upgrades
},
action: {
type: 'forward',
targets: [{ host: 'api-backend', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
}
};
// Raw TCP route (rejects HTTP traffic)
const tcpOnlyRoute: IRouteConfig = {
name: 'database-proxy',
match: {
ports: 5432,
protocol: 'tcp', // Only match non-HTTP TCP streams
},
action: {
type: 'forward',
targets: [{ host: 'db-server', port: 5432 }]
}
};
```
> **Note:** Omitting `protocol` (the default) matches any protocol. For TLS routes, protocol detection happens *after* TLS termination — during the initial SNI-based route match, `protocol` is not yet known and the route is allowed to match. The protocol restriction is enforced after the proxy peeks at the decrypted data.
### 🔒 Security Controls ### 🔒 Security Controls
Comprehensive per-route security options: Comprehensive per-route security options:
@@ -456,6 +493,51 @@ const proxy = new SmartProxy({
}); });
``` ```
### 💾 Consumer-Managed Certificate Storage
SmartProxy **never writes certificates to disk**. Instead, you own all persistence through the `certStore` interface. This gives you full control — store certs in a database, cloud KMS, encrypted vault, or wherever makes sense for your infrastructure:
```typescript
const proxy = new SmartProxy({
routes: [...],
certProvisionFunction: async (domain) => myAcme.provision(domain),
// Your persistence layer — SmartProxy calls these hooks
certStore: {
// Called once on startup to pre-load persisted certs
loadAll: async () => {
const certs = await myDb.getAllCerts();
return certs.map(c => ({
domain: c.domain,
publicKey: c.certPem,
privateKey: c.keyPem,
ca: c.caPem, // optional
}));
},
// Called after each successful cert provision
save: async (domain, publicKey, privateKey, ca) => {
await myDb.upsertCert({ domain, certPem: publicKey, keyPem: privateKey, caPem: ca });
},
// Optional: called when a cert should be removed
remove: async (domain) => {
await myDb.deleteCert(domain);
},
},
});
```
**Startup flow:**
1. Rust engine starts
2. Default self-signed `*` fallback cert is loaded (unless `disableDefaultCert: true`)
3. `certStore.loadAll()` is called → all returned certs are loaded into the Rust TLS stack
4. `certProvisionFunction` runs for any remaining `certificate: 'auto'` routes (skipping domains already loaded from the store)
5. After each successful provision, `certStore.save()` is called
This means your second startup is instant — no re-provisioning needed for domains that already have valid certs in your store.
## 🏛️ Architecture ## 🏛️ Architecture
SmartProxy uses a hybrid **Rust + TypeScript** architecture: SmartProxy uses a hybrid **Rust + TypeScript** architecture:
@@ -488,7 +570,7 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture:
- **Rust Engine** handles all networking, TLS, HTTP proxying, connection management, security, and metrics - **Rust Engine** handles all networking, TLS, HTTP proxying, connection management, security, and metrics
- **TypeScript** provides the npm API, configuration types, route helpers, validation, and socket handler callbacks - **TypeScript** provides the npm API, configuration types, route helpers, validation, and socket handler callbacks
- **IPC** — The TypeScript wrapper uses [`@push.rocks/smartrust`](https://code.foss.global/push.rocks/smartrust) for type-safe JSON commands/events over stdin/stdout - **IPC** — The TypeScript wrapper uses JSON commands/events over stdin/stdout to communicate with the Rust binary
- **Socket Relay** — A Unix domain socket server for routes requiring TypeScript-side handling (socket handlers, dynamic host/port functions) - **Socket Relay** — A Unix domain socket server for routes requiring TypeScript-side handling (socket handlers, dynamic host/port functions)
## 🎯 Route Configuration Reference ## 🎯 Route Configuration Reference
@@ -497,12 +579,13 @@ SmartProxy uses a hybrid **Rust + TypeScript** architecture:
```typescript ```typescript
interface IRouteMatch { interface IRouteMatch {
ports: number | number[] | Array<{ from: number; to: number }>; // Port(s) to listen on ports: number | number[] | Array<{ from: number; to: number }>; // Required — port(s) to listen on
domains?: string | string[]; // 'example.com', '*.example.com' domains?: string | string[]; // 'example.com', '*.example.com'
path?: string; // '/api/*', '/users/:id' path?: string; // '/api/*', '/users/:id'
clientIp?: string[]; // ['10.0.0.0/8', '192.168.*'] clientIp?: string[]; // ['10.0.0.0/8', '192.168.*']
tlsVersion?: string[]; // ['TLSv1.2', 'TLSv1.3'] tlsVersion?: string[]; // ['TLSv1.2', 'TLSv1.3']
headers?: Record<string, string | RegExp>; // Match by HTTP headers headers?: Record<string, string | RegExp>; // Match by HTTP headers
protocol?: 'http' | 'tcp'; // Match specific protocol ('http' includes h2 + WebSocket upgrades)
} }
``` ```
@@ -517,11 +600,16 @@ interface IRouteMatch {
```typescript ```typescript
interface IRouteTarget { interface IRouteTarget {
host: string | string[] | ((context: IRouteContext) => string); host: string | string[] | ((context: IRouteContext) => string | string[]);
port: number | 'preserve' | ((context: IRouteContext) => number); port: number | 'preserve' | ((context: IRouteContext) => number);
tls?: { ... }; // Per-target TLS override tls?: IRouteTls; // Per-target TLS override
priority?: number; // Target priority priority?: number; // Target priority
match?: ITargetMatch; // Sub-match within a route (by port, path, headers, method) match?: ITargetMatch; // Sub-match within a route (by port, path, headers, method)
websocket?: IRouteWebSocket;
loadBalancing?: IRouteLoadBalancing;
sendProxyProtocol?: boolean;
headers?: IRouteHeaders;
advanced?: IRouteAdvanced;
} }
``` ```
@@ -613,6 +701,7 @@ import {
createPortMappingRoute, // Port mapping with context createPortMappingRoute, // Port mapping with context
createOffsetPortMappingRoute, // Simple port offset createOffsetPortMappingRoute, // Simple port offset
createDynamicRoute, // Dynamic host/port via functions createDynamicRoute, // Dynamic host/port via functions
createPortOffset, // Port offset factory
// Security Modifiers // Security Modifiers
addRateLimiting, // Add rate limiting to any route addRateLimiting, // Add rate limiting to any route
@@ -680,7 +769,6 @@ interface ISmartProxyOptions {
port?: number; // HTTP-01 challenge port (default: 80) port?: number; // HTTP-01 challenge port (default: 80)
renewThresholdDays?: number; // Days before expiry to renew (default: 30) renewThresholdDays?: number; // Days before expiry to renew (default: 30)
autoRenew?: boolean; // Enable auto-renewal (default: true) autoRenew?: boolean; // Enable auto-renewal (default: true)
certificateStore?: string; // Directory to store certs (default: './certs')
renewCheckIntervalHours?: number; // Renewal check interval (default: 24) renewCheckIntervalHours?: number; // Renewal check interval (default: 24)
}; };
@@ -688,6 +776,12 @@ interface ISmartProxyOptions {
certProvisionFunction?: (domain: string) => Promise<ICert | 'http01'>; certProvisionFunction?: (domain: string) => Promise<ICert | 'http01'>;
certProvisionFallbackToAcme?: boolean; // Fall back to ACME on failure (default: true) certProvisionFallbackToAcme?: boolean; // Fall back to ACME on failure (default: true)
// Consumer-managed certificate persistence (see "Consumer-Managed Certificate Storage")
certStore?: ISmartProxyCertStore;
// Self-signed fallback
disableDefaultCert?: boolean; // Disable '*' self-signed fallback (default: false)
// Global defaults // Global defaults
defaults?: { defaults?: {
target?: { host: string; port: number }; target?: { host: string; port: number };
@@ -729,6 +823,26 @@ interface ISmartProxyOptions {
} }
``` ```
### ISmartProxyCertStore Interface
```typescript
interface ISmartProxyCertStore {
/** Called once on startup to pre-load persisted certs */
loadAll: () => Promise<Array<{
domain: string;
publicKey: string;
privateKey: string;
ca?: string;
}>>;
/** Called after each successful cert provision */
save: (domain: string, publicKey: string, privateKey: string, ca?: string) => Promise<void>;
/** Optional: remove a cert from storage */
remove?: (domain: string) => Promise<void>;
}
```
### IMetrics Interface ### IMetrics Interface
The `getMetrics()` method returns a cached metrics adapter that polls the Rust engine: The `getMetrics()` method returns a cached metrics adapter that polls the Rust engine:
@@ -758,6 +872,10 @@ metrics.requests.total(); // Total requests
metrics.totals.bytesIn(); // Total bytes received metrics.totals.bytesIn(); // Total bytes received
metrics.totals.bytesOut(); // Total bytes sent metrics.totals.bytesOut(); // Total bytes sent
metrics.totals.connections(); // Total connections metrics.totals.connections(); // Total connections
// Percentiles
metrics.percentiles.connectionDuration(); // { p50, p95, p99 }
metrics.percentiles.bytesTransferred(); // { in: { p50, p95, p99 }, out: { p50, p95, p99 } }
``` ```
## 🐛 Troubleshooting ## 🐛 Troubleshooting
@@ -802,6 +920,7 @@ SmartProxy searches for the Rust binary in this order:
7. **✅ Validate Routes** — Use `RouteValidator.validateRoutes()` to catch config errors before deployment 7. **✅ Validate Routes** — Use `RouteValidator.validateRoutes()` to catch config errors before deployment
8. **🔀 Atomic Updates** — Use `updateRoutes()` for hot-reloading routes (mutex-locked, no downtime) 8. **🔀 Atomic Updates** — Use `updateRoutes()` for hot-reloading routes (mutex-locked, no downtime)
9. **🎮 Use Socket Handlers** — For protocols beyond HTTP, implement custom socket handlers instead of fighting the proxy model 9. **🎮 Use Socket Handlers** — For protocols beyond HTTP, implement custom socket handlers instead of fighting the proxy model
10. **💾 Use `certStore`** — Persist certs in your own storage to avoid re-provisioning on every restart
## License and Legal Information ## License and Legal Information

44
rust/Cargo.lock generated
View File

@@ -285,12 +285,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -618,12 +612,6 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -866,19 +854,6 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rustix"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.36" version = "0.23.36"
@@ -986,16 +961,20 @@ dependencies = [
"arc-swap", "arc-swap",
"bytes", "bytes",
"dashmap", "dashmap",
"http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-util", "hyper-util",
"regex", "regex",
"rustls",
"rustproxy-config", "rustproxy-config",
"rustproxy-metrics", "rustproxy-metrics",
"rustproxy-routing", "rustproxy-routing",
"rustproxy-security", "rustproxy-security",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-rustls",
"tokio-util",
"tracing", "tracing",
] ]
@@ -1084,8 +1063,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",
@@ -1260,19 +1237,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"

View File

@@ -29,6 +29,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"

View File

@@ -17,6 +17,7 @@ pub fn create_http_route(
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,
@@ -108,6 +109,7 @@ pub fn create_http_to_https_redirect(
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,
@@ -200,6 +202,7 @@ pub fn create_load_balancer_route(
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,

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)
@@ -361,7 +358,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,
}), }),

View File

@@ -114,6 +114,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" (includes h2 + websocket) or "tcp"
#[serde(skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
} }
// ─── Target Match ──────────────────────────────────────────────────── // ─── Target Match ────────────────────────────────────────────────────

View File

@@ -14,11 +14,15 @@ 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 }

View File

@@ -0,0 +1,126 @@
//! 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;
/// Wraps any `http_body::Body` and counts data bytes passing through.
///
/// When the body is fully consumed or dropped, accumulated byte counts
/// are reported to the `MetricsCollector`.
///
/// The inner body is pinned on the heap to support `!Unpin` types like `hyper::body::Incoming`.
pub struct CountingBody<B> {
inner: Pin<Box<B>>,
counted_bytes: AtomicU64,
metrics: Arc<MetricsCollector>,
route_id: Option<String>,
source_ip: Option<String>,
/// Whether we count bytes as "in" (request body) or "out" (response body).
direction: Direction,
/// Whether we've already reported the bytes (to avoid double-reporting on drop).
reported: bool,
}
/// 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<String>,
source_ip: Option<String>,
direction: Direction,
) -> Self {
Self {
inner: Box::pin(inner),
counted_bytes: AtomicU64::new(0),
metrics,
route_id,
source_ip,
direction,
reported: false,
}
}
/// Report accumulated bytes to the metrics collector.
fn report(&mut self) {
if self.reported {
return;
}
self.reported = true;
let bytes = self.counted_bytes.load(Ordering::Relaxed);
if bytes == 0 {
return;
}
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),
}
}
}
impl<B> Drop for CountingBody<B> {
fn drop(&mut self) {
self.report();
}
}
// 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() {
this.counted_bytes.fetch_add(data.len() as u64, Ordering::Relaxed);
}
Poll::Ready(Some(Ok(frame)))
}
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
Poll::Ready(None) => {
// Body is fully consumed — report now
this.report();
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()
}
}

View File

@@ -3,12 +3,14 @@
//! 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 counting_body;
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 template; pub mod template;
pub mod upstream_selector; pub mod upstream_selector;
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

@@ -6,28 +6,172 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use bytes::Bytes; use bytes::Bytes;
use dashmap::DashMap;
use http_body_util::{BodyExt, Full, combinators::BoxBody}; use http_body_util::{BodyExt, Full, combinators::BoxBody};
use hyper::body::Incoming; use hyper::body::Incoming;
use hyper::{Request, Response, StatusCode}; use hyper::{Request, Response, StatusCode};
use hyper_util::rt::TokioIo; use hyper_util::rt::TokioIo;
use regex::Regex; use regex::Regex;
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use std::pin::Pin;
use std::task::{Context, Poll};
use rustproxy_routing::RouteManager; use rustproxy_routing::RouteManager;
use rustproxy_metrics::MetricsCollector; use rustproxy_metrics::MetricsCollector;
use rustproxy_security::RateLimiter;
use crate::counting_body::{CountingBody, Direction};
use crate::request_filter::RequestFilter; use crate::request_filter::RequestFilter;
use crate::response_filter::ResponseFilter; use crate::response_filter::ResponseFilter;
use crate::upstream_selector::UpstreamSelector; use crate::upstream_selector::UpstreamSelector;
/// Default upstream connect timeout (30 seconds).
const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Default WebSocket inactivity timeout (1 hour).
const DEFAULT_WS_INACTIVITY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3600);
/// Default WebSocket max lifetime (24 hours).
const DEFAULT_WS_MAX_LIFETIME: std::time::Duration = std::time::Duration::from_secs(86400);
/// Backend stream that can be either plain TCP or TLS-wrapped.
/// Used for `terminate-and-reencrypt` mode where the backend requires TLS.
pub(crate) enum BackendStream {
Plain(TcpStream),
Tls(tokio_rustls::client::TlsStream<TcpStream>),
}
impl tokio::io::AsyncRead for BackendStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
match self.get_mut() {
BackendStream::Plain(s) => Pin::new(s).poll_read(cx, buf),
BackendStream::Tls(s) => Pin::new(s).poll_read(cx, buf),
}
}
}
impl tokio::io::AsyncWrite for BackendStream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
match self.get_mut() {
BackendStream::Plain(s) => Pin::new(s).poll_write(cx, buf),
BackendStream::Tls(s) => Pin::new(s).poll_write(cx, buf),
}
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
match self.get_mut() {
BackendStream::Plain(s) => Pin::new(s).poll_flush(cx),
BackendStream::Tls(s) => Pin::new(s).poll_flush(cx),
}
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
match self.get_mut() {
BackendStream::Plain(s) => Pin::new(s).poll_shutdown(cx),
BackendStream::Tls(s) => Pin::new(s).poll_shutdown(cx),
}
}
}
/// Connect to a backend over TLS. Uses InsecureVerifier for internal backends
/// with self-signed certs (same pattern as tls_handler::connect_tls).
async fn connect_tls_backend(
host: &str,
port: u16,
) -> Result<tokio_rustls::client::TlsStream<TcpStream>, Box<dyn std::error::Error + Send + Sync>> {
let _ = rustls::crypto::ring::default_provider().install_default();
let config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(InsecureBackendVerifier))
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(config));
let stream = TcpStream::connect(format!("{}:{}", host, port)).await?;
stream.set_nodelay(true)?;
let server_name = rustls::pki_types::ServerName::try_from(host.to_string())?;
let tls_stream = connector.connect(server_name, stream).await?;
debug!("Backend TLS connection established to {}:{}", host, port);
Ok(tls_stream)
}
/// Insecure certificate verifier for backend TLS connections.
/// Internal backends may use self-signed certs.
#[derive(Debug)]
struct InsecureBackendVerifier;
impl rustls::client::danger::ServerCertVerifier for InsecureBackendVerifier {
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::RSA_PKCS1_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
]
}
}
/// HTTP proxy service that processes HTTP traffic. /// HTTP proxy service that processes HTTP traffic.
pub struct HttpProxyService { pub struct HttpProxyService {
route_manager: Arc<RouteManager>, route_manager: Arc<RouteManager>,
metrics: Arc<MetricsCollector>, metrics: Arc<MetricsCollector>,
upstream_selector: UpstreamSelector, upstream_selector: UpstreamSelector,
/// Timeout for connecting to upstream backends.
connect_timeout: std::time::Duration,
/// Per-route rate limiters (keyed by route ID).
route_rate_limiters: Arc<DashMap<String, Arc<RateLimiter>>>,
/// Request counter for periodic rate limiter cleanup.
request_counter: AtomicU64,
/// Cache of compiled URL rewrite regexes (keyed by pattern string).
regex_cache: DashMap<String, Regex>,
} }
impl HttpProxyService { impl HttpProxyService {
@@ -36,6 +180,27 @@ impl HttpProxyService {
route_manager, route_manager,
metrics, metrics,
upstream_selector: UpstreamSelector::new(), upstream_selector: UpstreamSelector::new(),
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
route_rate_limiters: Arc::new(DashMap::new()),
request_counter: AtomicU64::new(0),
regex_cache: DashMap::new(),
}
}
/// Create with a custom connect timeout.
pub fn with_connect_timeout(
route_manager: Arc<RouteManager>,
metrics: Arc<MetricsCollector>,
connect_timeout: std::time::Duration,
) -> Self {
Self {
route_manager,
metrics,
upstream_selector: UpstreamSelector::new(),
connect_timeout,
route_rate_limiters: Arc::new(DashMap::new()),
request_counter: AtomicU64::new(0),
regex_cache: DashMap::new(),
} }
} }
@@ -45,41 +210,59 @@ impl HttpProxyService {
stream: TcpStream, stream: TcpStream,
peer_addr: std::net::SocketAddr, peer_addr: std::net::SocketAddr,
port: u16, port: u16,
cancel: CancellationToken,
) { ) {
self.handle_io(stream, peer_addr, port).await; self.handle_io(stream, peer_addr, port, cancel).await;
} }
/// Handle an incoming HTTP connection on any IO type (plain TCP or TLS-terminated). /// Handle an incoming HTTP connection on any IO type (plain TCP or TLS-terminated).
/// ///
/// Uses HTTP/1.1 with upgrade support. For clients that negotiate HTTP/2, /// Uses HTTP/1.1 with upgrade support. Responds to graceful shutdown via the
/// use `handle_io_auto` instead. /// cancel token — in-flight requests complete, but no new requests are accepted.
pub async fn handle_io<I>( pub async fn handle_io<I>(
self: Arc<Self>, self: Arc<Self>,
stream: I, stream: I,
peer_addr: std::net::SocketAddr, peer_addr: std::net::SocketAddr,
port: u16, port: u16,
cancel: CancellationToken,
) )
where where
I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{ {
let io = TokioIo::new(stream); let io = TokioIo::new(stream);
let cancel_inner = cancel.clone();
let service = hyper::service::service_fn(move |req: Request<Incoming>| { let service = hyper::service::service_fn(move |req: Request<Incoming>| {
let svc = Arc::clone(&self); let svc = Arc::clone(&self);
let peer = peer_addr; let peer = peer_addr;
let cn = cancel_inner.clone();
async move { async move {
svc.handle_request(req, peer, port).await svc.handle_request(req, peer, port, cn).await
} }
}); });
// Use http1::Builder with upgrades for WebSocket support // Use http1::Builder with upgrades for WebSocket support
let conn = hyper::server::conn::http1::Builder::new() let mut conn = hyper::server::conn::http1::Builder::new()
.keep_alive(true) .keep_alive(true)
.serve_connection(io, service) .serve_connection(io, service)
.with_upgrades(); .with_upgrades();
if let Err(e) = conn.await { // Use select to support graceful shutdown via cancellation token
debug!("HTTP connection error from {}: {}", peer_addr, e); let conn_pin = std::pin::Pin::new(&mut conn);
tokio::select! {
result = conn_pin => {
if let Err(e) = result {
debug!("HTTP connection error from {}: {}", peer_addr, e);
}
}
_ = cancel.cancelled() => {
// Graceful shutdown: let in-flight request finish, stop accepting new ones
let conn_pin = std::pin::Pin::new(&mut conn);
conn_pin.graceful_shutdown();
if let Err(e) = conn.await {
debug!("HTTP connection error during shutdown from {}: {}", peer_addr, e);
}
}
} }
} }
@@ -89,6 +272,7 @@ impl HttpProxyService {
req: Request<Incoming>, req: Request<Incoming>,
peer_addr: std::net::SocketAddr, peer_addr: std::net::SocketAddr,
port: u16, port: u16,
cancel: CancellationToken,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let host = req.headers() let host = req.headers()
.get("host") .get("host")
@@ -125,6 +309,7 @@ impl HttpProxyService {
tls_version: None, tls_version: None,
headers: Some(&headers), headers: Some(&headers),
is_tls: false, is_tls: false,
protocol: Some("http"),
}; };
let route_match = match self.route_manager.find_route(&ctx) { let route_match = match self.route_manager.find_route(&ctx) {
@@ -136,20 +321,39 @@ impl HttpProxyService {
}; };
let route_id = route_match.route.id.as_deref(); let route_id = route_match.route.id.as_deref();
self.metrics.connection_opened(route_id); let ip_str = peer_addr.ip().to_string();
self.metrics.record_http_request();
// Apply request filters (IP check, rate limiting, auth) // Apply request filters (IP check, rate limiting, auth)
if let Some(ref security) = route_match.route.security { if let Some(ref security) = route_match.route.security {
if let Some(response) = RequestFilter::apply(security, &req, &peer_addr) { // Look up or create a shared rate limiter for this route
self.metrics.connection_closed(route_id); let rate_limiter = security.rate_limit.as_ref()
.filter(|rl| rl.enabled)
.map(|rl| {
let route_key = route_id.unwrap_or("__default__").to_string();
self.route_rate_limiters
.entry(route_key)
.or_insert_with(|| Arc::new(RateLimiter::new(rl.max_requests, rl.window)))
.clone()
});
if let Some(response) = RequestFilter::apply_with_rate_limiter(
security, &req, &peer_addr, rate_limiter.as_ref(),
) {
return Ok(response); return Ok(response);
} }
} }
// Periodic rate limiter cleanup (every 1000 requests)
let count = self.request_counter.fetch_add(1, Ordering::Relaxed);
if count % 1000 == 0 {
for entry in self.route_rate_limiters.iter() {
entry.value().cleanup();
}
}
// Check for test response (returns immediately, no upstream needed) // Check for test response (returns immediately, no upstream needed)
if let Some(ref advanced) = route_match.route.action.advanced { if let Some(ref advanced) = route_match.route.action.advanced {
if let Some(ref test_response) = advanced.test_response { if let Some(ref test_response) = advanced.test_response {
self.metrics.connection_closed(route_id);
return Ok(Self::build_test_response(test_response)); return Ok(Self::build_test_response(test_response));
} }
} }
@@ -157,7 +361,6 @@ impl HttpProxyService {
// Check for static file serving // Check for static file serving
if let Some(ref advanced) = route_match.route.action.advanced { if let Some(ref advanced) = route_match.route.action.advanced {
if let Some(ref static_files) = advanced.static_files { if let Some(ref static_files) = advanced.static_files {
self.metrics.connection_closed(route_id);
return Ok(Self::serve_static_file(&path, static_files)); return Ok(Self::serve_static_file(&path, static_files));
} }
} }
@@ -166,12 +369,19 @@ impl HttpProxyService {
let target = match route_match.target { let target = match route_match.target {
Some(t) => t, Some(t) => t,
None => { None => {
self.metrics.connection_closed(route_id);
return Ok(error_response(StatusCode::BAD_GATEWAY, "No target available")); return Ok(error_response(StatusCode::BAD_GATEWAY, "No target available"));
} }
}; };
let upstream = self.upstream_selector.select(target, &peer_addr, port); let mut upstream = self.upstream_selector.select(target, &peer_addr, port);
// If the route uses terminate-and-reencrypt, always re-encrypt to backend
if let Some(ref tls) = route_match.route.action.tls {
if tls.mode == rustproxy_config::TlsMode::TerminateAndReencrypt {
upstream.use_tls = true;
}
}
let upstream_key = format!("{}:{}", upstream.host, upstream.port); let upstream_key = format!("{}:{}", upstream.host, upstream.port);
self.upstream_selector.connection_started(&upstream_key); self.upstream_selector.connection_started(&upstream_key);
@@ -184,7 +394,7 @@ impl HttpProxyService {
if is_websocket { if is_websocket {
let result = self.handle_websocket_upgrade( let result = self.handle_websocket_upgrade(
req, peer_addr, &upstream, route_match.route, route_id, &upstream_key, req, peer_addr, &upstream, route_match.route, route_id, &upstream_key, cancel, &ip_str,
).await; ).await;
// Note: for WebSocket, connection_ended is called inside // Note: for WebSocket, connection_ended is called inside
// the spawned tunnel task when the connection closes. // the spawned tunnel task when the connection closes.
@@ -203,7 +413,7 @@ impl HttpProxyService {
Some(q) => format!("{}?{}", path, q), Some(q) => format!("{}?{}", path, q),
None => path.clone(), None => path.clone(),
}; };
Self::apply_url_rewrite(&raw_path, &route_match.route) self.apply_url_rewrite(&raw_path, &route_match.route)
}; };
// Build upstream request - stream body instead of buffering // Build upstream request - stream body instead of buffering
@@ -223,26 +433,99 @@ impl HttpProxyService {
} }
} }
// Connect to upstream // Add standard reverse-proxy headers (X-Forwarded-*)
let upstream_stream = match TcpStream::connect(format!("{}:{}", upstream.host, upstream.port)).await { {
Ok(s) => s, let original_host = parts.headers.get("host")
Err(e) => { .and_then(|h| h.to_str().ok())
error!("Failed to connect to upstream {}:{}: {}", upstream.host, upstream.port, e); .unwrap_or("");
self.upstream_selector.connection_ended(&upstream_key); let forwarded_proto = if route_match.route.action.tls.as_ref()
self.metrics.connection_closed(route_id); .map(|t| matches!(t.mode,
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable")); rustproxy_config::TlsMode::Terminate
| rustproxy_config::TlsMode::TerminateAndReencrypt))
.unwrap_or(false)
{
"https"
} else {
"http"
};
// X-Forwarded-For: append client IP to existing chain
let client_ip = peer_addr.ip().to_string();
let xff_value = if let Some(existing) = upstream_headers.get("x-forwarded-for") {
format!("{}, {}", existing.to_str().unwrap_or(""), client_ip)
} else {
client_ip
};
if let Ok(val) = hyper::header::HeaderValue::from_str(&xff_value) {
upstream_headers.insert(
hyper::header::HeaderName::from_static("x-forwarded-for"),
val,
);
}
// X-Forwarded-Host: original Host header
if let Ok(val) = hyper::header::HeaderValue::from_str(original_host) {
upstream_headers.insert(
hyper::header::HeaderName::from_static("x-forwarded-host"),
val,
);
}
// X-Forwarded-Proto: original client protocol
if let Ok(val) = hyper::header::HeaderValue::from_str(forwarded_proto) {
upstream_headers.insert(
hyper::header::HeaderName::from_static("x-forwarded-proto"),
val,
);
}
}
// Connect to upstream with timeout (TLS if upstream.use_tls is set)
let backend = if upstream.use_tls {
match tokio::time::timeout(
self.connect_timeout,
connect_tls_backend(&upstream.host, upstream.port),
).await {
Ok(Ok(tls)) => BackendStream::Tls(tls),
Ok(Err(e)) => {
error!("Failed TLS connect to upstream {}:{}: {}", upstream.host, upstream.port, e);
self.upstream_selector.connection_ended(&upstream_key);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend TLS unavailable"));
}
Err(_) => {
error!("Upstream TLS connect timeout for {}:{}", upstream.host, upstream.port);
self.upstream_selector.connection_ended(&upstream_key);
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend TLS connect timeout"));
}
}
} else {
match tokio::time::timeout(
self.connect_timeout,
TcpStream::connect(format!("{}:{}", upstream.host, upstream.port)),
).await {
Ok(Ok(s)) => {
s.set_nodelay(true).ok();
BackendStream::Plain(s)
}
Ok(Err(e)) => {
error!("Failed to connect to upstream {}:{}: {}", upstream.host, upstream.port, e);
self.upstream_selector.connection_ended(&upstream_key);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable"));
}
Err(_) => {
error!("Upstream connect timeout for {}:{}", upstream.host, upstream.port);
self.upstream_selector.connection_ended(&upstream_key);
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend connect timeout"));
}
} }
}; };
upstream_stream.set_nodelay(true).ok();
let io = TokioIo::new(upstream_stream); let io = TokioIo::new(backend);
let result = if use_h2 { let result = if use_h2 {
// HTTP/2 backend // HTTP/2 backend
self.forward_h2(io, parts, body, upstream_headers, &upstream_path, &upstream, route_match.route, route_id).await self.forward_h2(io, parts, body, upstream_headers, &upstream_path, &upstream, route_match.route, route_id, &ip_str).await
} else { } else {
// HTTP/1.1 backend (default) // HTTP/1.1 backend (default)
self.forward_h1(io, parts, body, upstream_headers, &upstream_path, &upstream, route_match.route, route_id).await self.forward_h1(io, parts, body, upstream_headers, &upstream_path, &upstream, route_match.route, route_id, &ip_str).await
}; };
self.upstream_selector.connection_ended(&upstream_key); self.upstream_selector.connection_ended(&upstream_key);
result result
@@ -251,20 +534,20 @@ impl HttpProxyService {
/// Forward request to backend via HTTP/1.1 with body streaming. /// Forward request to backend via HTTP/1.1 with body streaming.
async fn forward_h1( async fn forward_h1(
&self, &self,
io: TokioIo<TcpStream>, io: TokioIo<BackendStream>,
parts: hyper::http::request::Parts, parts: hyper::http::request::Parts,
body: Incoming, body: Incoming,
upstream_headers: hyper::HeaderMap, upstream_headers: hyper::HeaderMap,
upstream_path: &str, upstream_path: &str,
upstream: &crate::upstream_selector::UpstreamSelection, _upstream: &crate::upstream_selector::UpstreamSelection,
route: &rustproxy_config::RouteConfig, route: &rustproxy_config::RouteConfig,
route_id: Option<&str>, route_id: Option<&str>,
source_ip: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let (mut sender, conn) = match hyper::client::conn::http1::handshake(io).await { let (mut sender, conn) = match hyper::client::conn::http1::handshake(io).await {
Ok(h) => h, Ok(h) => h,
Err(e) => { Err(e) => {
error!("Upstream handshake failed: {}", e); error!("Upstream handshake failed: {}", e);
self.metrics.connection_closed(route_id);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend handshake failed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend handshake failed"));
} }
}; };
@@ -282,46 +565,49 @@ impl HttpProxyService {
if let Some(headers) = upstream_req.headers_mut() { if let Some(headers) = upstream_req.headers_mut() {
*headers = upstream_headers; *headers = upstream_headers;
if let Ok(host_val) = hyper::header::HeaderValue::from_str(
&format!("{}:{}", upstream.host, upstream.port)
) {
headers.insert(hyper::header::HOST, host_val);
}
} }
// Wrap the request body in CountingBody to track bytes_in
let counting_req_body = CountingBody::new(
body,
Arc::clone(&self.metrics),
route_id.map(|s| s.to_string()),
Some(source_ip.to_string()),
Direction::In,
);
// Stream the request body through to upstream // Stream the request body through to upstream
let upstream_req = upstream_req.body(body).unwrap(); let upstream_req = upstream_req.body(counting_req_body).unwrap();
let upstream_response = match sender.send_request(upstream_req).await { let upstream_response = match sender.send_request(upstream_req).await {
Ok(resp) => resp, Ok(resp) => resp,
Err(e) => { Err(e) => {
error!("Upstream request failed: {}", e); error!("Upstream request failed: {}", e);
self.metrics.connection_closed(route_id);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend request failed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend request failed"));
} }
}; };
self.build_streaming_response(upstream_response, route, route_id).await self.build_streaming_response(upstream_response, route, route_id, source_ip).await
} }
/// Forward request to backend via HTTP/2 with body streaming. /// Forward request to backend via HTTP/2 with body streaming.
async fn forward_h2( async fn forward_h2(
&self, &self,
io: TokioIo<TcpStream>, io: TokioIo<BackendStream>,
parts: hyper::http::request::Parts, parts: hyper::http::request::Parts,
body: Incoming, body: Incoming,
upstream_headers: hyper::HeaderMap, upstream_headers: hyper::HeaderMap,
upstream_path: &str, upstream_path: &str,
upstream: &crate::upstream_selector::UpstreamSelection, _upstream: &crate::upstream_selector::UpstreamSelection,
route: &rustproxy_config::RouteConfig, route: &rustproxy_config::RouteConfig,
route_id: Option<&str>, route_id: Option<&str>,
source_ip: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let exec = hyper_util::rt::TokioExecutor::new(); let exec = hyper_util::rt::TokioExecutor::new();
let (mut sender, conn) = match hyper::client::conn::http2::handshake(exec, io).await { let (mut sender, conn) = match hyper::client::conn::http2::handshake(exec, io).await {
Ok(h) => h, Ok(h) => h,
Err(e) => { Err(e) => {
error!("HTTP/2 upstream handshake failed: {}", e); error!("HTTP/2 upstream handshake failed: {}", e);
self.metrics.connection_closed(route_id);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H2 handshake failed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H2 handshake failed"));
} }
}; };
@@ -338,34 +624,41 @@ impl HttpProxyService {
if let Some(headers) = upstream_req.headers_mut() { if let Some(headers) = upstream_req.headers_mut() {
*headers = upstream_headers; *headers = upstream_headers;
if let Ok(host_val) = hyper::header::HeaderValue::from_str(
&format!("{}:{}", upstream.host, upstream.port)
) {
headers.insert(hyper::header::HOST, host_val);
}
} }
// Wrap the request body in CountingBody to track bytes_in
let counting_req_body = CountingBody::new(
body,
Arc::clone(&self.metrics),
route_id.map(|s| s.to_string()),
Some(source_ip.to_string()),
Direction::In,
);
// Stream the request body through to upstream // Stream the request body through to upstream
let upstream_req = upstream_req.body(body).unwrap(); let upstream_req = upstream_req.body(counting_req_body).unwrap();
let upstream_response = match sender.send_request(upstream_req).await { let upstream_response = match sender.send_request(upstream_req).await {
Ok(resp) => resp, Ok(resp) => resp,
Err(e) => { Err(e) => {
error!("HTTP/2 upstream request failed: {}", e); error!("HTTP/2 upstream request failed: {}", e);
self.metrics.connection_closed(route_id);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H2 request failed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend H2 request failed"));
} }
}; };
self.build_streaming_response(upstream_response, route, route_id).await self.build_streaming_response(upstream_response, route, route_id, source_ip).await
} }
/// Build the client-facing response from an upstream response, streaming the body. /// Build the client-facing response from an upstream response, streaming the body.
///
/// The response body is wrapped in a `CountingBody` that counts bytes as they
/// stream from upstream to client.
async fn build_streaming_response( async fn build_streaming_response(
&self, &self,
upstream_response: Response<Incoming>, upstream_response: Response<Incoming>,
route: &rustproxy_config::RouteConfig, route: &rustproxy_config::RouteConfig,
route_id: Option<&str>, route_id: Option<&str>,
source_ip: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let (resp_parts, resp_body) = upstream_response.into_parts(); let (resp_parts, resp_body) = upstream_response.into_parts();
@@ -377,10 +670,18 @@ impl HttpProxyService {
ResponseFilter::apply_headers(route, headers, None); ResponseFilter::apply_headers(route, headers, None);
} }
self.metrics.connection_closed(route_id); // Wrap the response body in CountingBody to track bytes_out.
// CountingBody will report bytes and we close the connection metric
// after the body stream completes (not before it even starts).
let counting_body = CountingBody::new(
resp_body,
Arc::clone(&self.metrics),
route_id.map(|s| s.to_string()),
Some(source_ip.to_string()),
Direction::Out,
);
// Stream the response body directly from upstream to client let body: BoxBody<Bytes, hyper::Error> = BoxBody::new(counting_body);
let body: BoxBody<Bytes, hyper::Error> = BoxBody::new(resp_body);
Ok(response.body(body).unwrap()) Ok(response.body(body).unwrap())
} }
@@ -394,6 +695,8 @@ impl HttpProxyService {
route: &rustproxy_config::RouteConfig, route: &rustproxy_config::RouteConfig,
route_id: Option<&str>, route_id: Option<&str>,
upstream_key: &str, upstream_key: &str,
cancel: CancellationToken,
source_ip: &str,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -409,7 +712,6 @@ impl HttpProxyService {
.unwrap_or(""); .unwrap_or("");
if !allowed_origins.is_empty() && !allowed_origins.iter().any(|o| o == "*" || o == origin) { if !allowed_origins.is_empty() && !allowed_origins.iter().any(|o| o == "*" || o == origin) {
self.upstream_selector.connection_ended(upstream_key); self.upstream_selector.connection_ended(upstream_key);
self.metrics.connection_closed(route_id);
return Ok(error_response(StatusCode::FORBIDDEN, "Origin not allowed")); return Ok(error_response(StatusCode::FORBIDDEN, "Origin not allowed"));
} }
} }
@@ -417,18 +719,45 @@ impl HttpProxyService {
info!("WebSocket upgrade from {} -> {}:{}", peer_addr, upstream.host, upstream.port); info!("WebSocket upgrade from {} -> {}:{}", peer_addr, upstream.host, upstream.port);
let mut upstream_stream = match TcpStream::connect( // Connect to upstream with timeout (TLS if upstream.use_tls is set)
format!("{}:{}", upstream.host, upstream.port) let mut upstream_stream: BackendStream = if upstream.use_tls {
).await { match tokio::time::timeout(
Ok(s) => s, self.connect_timeout,
Err(e) => { connect_tls_backend(&upstream.host, upstream.port),
error!("WebSocket: failed to connect upstream {}:{}: {}", upstream.host, upstream.port, e); ).await {
self.upstream_selector.connection_ended(upstream_key); Ok(Ok(tls)) => BackendStream::Tls(tls),
self.metrics.connection_closed(route_id); Ok(Err(e)) => {
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable")); error!("WebSocket: failed TLS connect upstream {}:{}: {}", upstream.host, upstream.port, e);
self.upstream_selector.connection_ended(upstream_key);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend TLS unavailable"));
}
Err(_) => {
error!("WebSocket: upstream TLS connect timeout for {}:{}", upstream.host, upstream.port);
self.upstream_selector.connection_ended(upstream_key);
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend TLS connect timeout"));
}
}
} else {
match tokio::time::timeout(
self.connect_timeout,
TcpStream::connect(format!("{}:{}", upstream.host, upstream.port)),
).await {
Ok(Ok(s)) => {
s.set_nodelay(true).ok();
BackendStream::Plain(s)
}
Ok(Err(e)) => {
error!("WebSocket: failed to connect upstream {}:{}: {}", upstream.host, upstream.port, e);
self.upstream_selector.connection_ended(upstream_key);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend unavailable"));
}
Err(_) => {
error!("WebSocket: upstream connect timeout for {}:{}", upstream.host, upstream.port);
self.upstream_selector.connection_ended(upstream_key);
return Ok(error_response(StatusCode::GATEWAY_TIMEOUT, "Backend connect timeout"));
}
} }
}; };
upstream_stream.set_nodelay(true).ok();
let path = req.uri().path().to_string(); let path = req.uri().path().to_string();
let upstream_path = { let upstream_path = {
@@ -455,13 +784,44 @@ impl HttpProxyService {
parts.method, upstream_path parts.method, upstream_path
); );
let upstream_host = format!("{}:{}", upstream.host, upstream.port); // Copy all original headers (preserving the client's Host header).
// Skip X-Forwarded-* since we set them ourselves below.
for (name, value) in parts.headers.iter() { for (name, value) in parts.headers.iter() {
if name == hyper::header::HOST { let name_str = name.as_str();
raw_request.push_str(&format!("host: {}\r\n", upstream_host)); if name_str == "x-forwarded-for"
} else { || name_str == "x-forwarded-host"
raw_request.push_str(&format!("{}: {}\r\n", name, value.to_str().unwrap_or(""))); || name_str == "x-forwarded-proto"
{
continue;
} }
raw_request.push_str(&format!("{}: {}\r\n", name, value.to_str().unwrap_or("")));
}
// Add standard reverse-proxy headers (X-Forwarded-*)
{
let original_host = parts.headers.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("");
let forwarded_proto = if route.action.tls.as_ref()
.map(|t| matches!(t.mode,
rustproxy_config::TlsMode::Terminate
| rustproxy_config::TlsMode::TerminateAndReencrypt))
.unwrap_or(false)
{
"https"
} else {
"http"
};
let client_ip = peer_addr.ip().to_string();
let xff_value = if let Some(existing) = parts.headers.get("x-forwarded-for") {
format!("{}, {}", existing.to_str().unwrap_or(""), client_ip)
} else {
client_ip
};
raw_request.push_str(&format!("x-forwarded-for: {}\r\n", xff_value));
raw_request.push_str(&format!("x-forwarded-host: {}\r\n", original_host));
raw_request.push_str(&format!("x-forwarded-proto: {}\r\n", forwarded_proto));
} }
if let Some(ref route_headers) = route.headers { if let Some(ref route_headers) = route.headers {
@@ -486,7 +846,6 @@ impl HttpProxyService {
if let Err(e) = upstream_stream.write_all(raw_request.as_bytes()).await { if let Err(e) = upstream_stream.write_all(raw_request.as_bytes()).await {
error!("WebSocket: failed to send upgrade request to upstream: {}", e); error!("WebSocket: failed to send upgrade request to upstream: {}", e);
self.upstream_selector.connection_ended(upstream_key); self.upstream_selector.connection_ended(upstream_key);
self.metrics.connection_closed(route_id);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend write failed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend write failed"));
} }
@@ -497,7 +856,6 @@ impl HttpProxyService {
Ok(0) => { Ok(0) => {
error!("WebSocket: upstream closed before completing handshake"); error!("WebSocket: upstream closed before completing handshake");
self.upstream_selector.connection_ended(upstream_key); self.upstream_selector.connection_ended(upstream_key);
self.metrics.connection_closed(route_id);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend closed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend closed"));
} }
Ok(_) => { Ok(_) => {
@@ -511,14 +869,12 @@ impl HttpProxyService {
if response_buf.len() > 8192 { if response_buf.len() > 8192 {
error!("WebSocket: upstream response headers too large"); error!("WebSocket: upstream response headers too large");
self.upstream_selector.connection_ended(upstream_key); self.upstream_selector.connection_ended(upstream_key);
self.metrics.connection_closed(route_id);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend response too large")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend response too large"));
} }
} }
Err(e) => { Err(e) => {
error!("WebSocket: failed to read upstream response: {}", e); error!("WebSocket: failed to read upstream response: {}", e);
self.upstream_selector.connection_ended(upstream_key); self.upstream_selector.connection_ended(upstream_key);
self.metrics.connection_closed(route_id);
return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend read failed")); return Ok(error_response(StatusCode::BAD_GATEWAY, "Backend read failed"));
} }
} }
@@ -536,7 +892,6 @@ impl HttpProxyService {
if status_code != 101 { if status_code != 101 {
debug!("WebSocket: upstream rejected upgrade with status {}", status_code); debug!("WebSocket: upstream rejected upgrade with status {}", status_code);
self.upstream_selector.connection_ended(upstream_key); self.upstream_selector.connection_ended(upstream_key);
self.metrics.connection_closed(route_id);
return Ok(error_response( return Ok(error_response(
StatusCode::from_u16(status_code).unwrap_or(StatusCode::BAD_GATEWAY), StatusCode::from_u16(status_code).unwrap_or(StatusCode::BAD_GATEWAY),
"WebSocket upgrade rejected by backend", "WebSocket upgrade rejected by backend",
@@ -570,6 +925,7 @@ impl HttpProxyService {
let metrics = Arc::clone(&self.metrics); let metrics = Arc::clone(&self.metrics);
let route_id_owned = route_id.map(|s| s.to_string()); let route_id_owned = route_id.map(|s| s.to_string());
let source_ip_owned = source_ip.to_string();
let upstream_selector = self.upstream_selector.clone(); let upstream_selector = self.upstream_selector.clone();
let upstream_key_owned = upstream_key.to_string(); let upstream_key_owned = upstream_key.to_string();
@@ -579,9 +935,6 @@ impl HttpProxyService {
Err(e) => { Err(e) => {
debug!("WebSocket: client upgrade failed: {}", e); debug!("WebSocket: client upgrade failed: {}", e);
upstream_selector.connection_ended(&upstream_key_owned); upstream_selector.connection_ended(&upstream_key_owned);
if let Some(ref rid) = route_id_owned {
metrics.connection_closed(Some(rid.as_str()));
}
return; return;
} }
}; };
@@ -591,6 +944,11 @@ impl HttpProxyService {
let (mut cr, mut cw) = tokio::io::split(client_io); let (mut cr, mut cw) = tokio::io::split(client_io);
let (mut ur, mut uw) = tokio::io::split(upstream_stream); let (mut ur, mut uw) = tokio::io::split(upstream_stream);
// Shared activity tracker for the watchdog
let last_activity = Arc::new(AtomicU64::new(0));
let start = std::time::Instant::now();
let la1 = Arc::clone(&last_activity);
let c2u = tokio::spawn(async move { let c2u = tokio::spawn(async move {
let mut buf = vec![0u8; 65536]; let mut buf = vec![0u8; 65536];
let mut total = 0u64; let mut total = 0u64;
@@ -603,11 +961,13 @@ impl HttpProxyService {
break; break;
} }
total += n as u64; total += n as u64;
la1.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
} }
let _ = uw.shutdown().await; let _ = uw.shutdown().await;
total total
}); });
let la2 = Arc::clone(&last_activity);
let u2c = tokio::spawn(async move { let u2c = tokio::spawn(async move {
let mut buf = vec![0u8; 65536]; let mut buf = vec![0u8; 65536];
let mut total = 0u64; let mut total = 0u64;
@@ -620,20 +980,65 @@ impl HttpProxyService {
break; break;
} }
total += n as u64; total += n as u64;
la2.store(start.elapsed().as_millis() as u64, Ordering::Relaxed);
} }
let _ = cw.shutdown().await; let _ = cw.shutdown().await;
total total
}); });
// Watchdog: monitors inactivity, max lifetime, and cancellation
let la_watch = Arc::clone(&last_activity);
let c2u_handle = c2u.abort_handle();
let u2c_handle = u2c.abort_handle();
let inactivity_timeout = DEFAULT_WS_INACTIVITY_TIMEOUT;
let max_lifetime = DEFAULT_WS_MAX_LIFETIME;
let watchdog = tokio::spawn(async move {
let check_interval = std::time::Duration::from_secs(5);
let mut last_seen = 0u64;
loop {
tokio::select! {
_ = tokio::time::sleep(check_interval) => {}
_ = cancel.cancelled() => {
debug!("WebSocket tunnel cancelled by shutdown");
c2u_handle.abort();
u2c_handle.abort();
break;
}
}
// Check max lifetime
if start.elapsed() >= max_lifetime {
debug!("WebSocket tunnel exceeded max lifetime, closing");
c2u_handle.abort();
u2c_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!("WebSocket tunnel inactive for {}ms, closing", elapsed_since_activity);
c2u_handle.abort();
u2c_handle.abort();
break;
}
}
last_seen = current;
}
});
let bytes_in = c2u.await.unwrap_or(0); let bytes_in = c2u.await.unwrap_or(0);
let bytes_out = u2c.await.unwrap_or(0); let bytes_out = u2c.await.unwrap_or(0);
watchdog.abort();
debug!("WebSocket tunnel closed: {} bytes in, {} bytes out", bytes_in, bytes_out); debug!("WebSocket tunnel closed: {} bytes in, {} bytes out", bytes_in, bytes_out);
upstream_selector.connection_ended(&upstream_key_owned); upstream_selector.connection_ended(&upstream_key_owned);
if let Some(ref rid) = route_id_owned { if let Some(ref rid) = route_id_owned {
metrics.record_bytes(bytes_in, bytes_out, Some(rid.as_str())); metrics.record_bytes(bytes_in, bytes_out, Some(rid.as_str()), Some(&source_ip_owned));
metrics.connection_closed(Some(rid.as_str()));
} }
}); });
@@ -663,8 +1068,8 @@ impl HttpProxyService {
response.body(BoxBody::new(body)).unwrap() response.body(BoxBody::new(body)).unwrap()
} }
/// Apply URL rewriting rules from route config. /// Apply URL rewriting rules from route config, using the compiled regex cache.
fn apply_url_rewrite(path: &str, route: &rustproxy_config::RouteConfig) -> String { fn apply_url_rewrite(&self, path: &str, route: &rustproxy_config::RouteConfig) -> String {
let rewrite = match route.action.advanced.as_ref() let rewrite = match route.action.advanced.as_ref()
.and_then(|a| a.url_rewrite.as_ref()) .and_then(|a| a.url_rewrite.as_ref())
{ {
@@ -683,10 +1088,20 @@ impl HttpProxyService {
(path.to_string(), String::new()) (path.to_string(), String::new())
}; };
// Look up or compile the regex, caching for future requests
let cached = self.regex_cache.get(&rewrite.pattern);
if let Some(re) = cached {
let result = re.replace_all(&subject, rewrite.target.as_str());
return format!("{}{}", result, suffix);
}
// Not cached — compile and insert
match Regex::new(&rewrite.pattern) { match Regex::new(&rewrite.pattern) {
Ok(re) => { Ok(re) => {
let result = re.replace_all(&subject, rewrite.target.as_str()); let result = re.replace_all(&subject, rewrite.target.as_str());
format!("{}{}", result, suffix) let out = format!("{}{}", result, suffix);
self.regex_cache.insert(rewrite.pattern.clone(), re);
out
} }
Err(e) => { Err(e) => {
warn!("Invalid URL rewrite pattern '{}': {}", rewrite.pattern, e); warn!("Invalid URL rewrite pattern '{}': {}", rewrite.pattern, e);
@@ -812,6 +1227,10 @@ impl Default for HttpProxyService {
route_manager: Arc::new(RouteManager::new(vec![])), route_manager: Arc::new(RouteManager::new(vec![])),
metrics: Arc::new(MetricsCollector::new()), metrics: Arc::new(MetricsCollector::new()),
upstream_selector: UpstreamSelector::new(), upstream_selector: UpstreamSelector::new(),
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
route_rate_limiters: Arc::new(DashMap::new()),
request_counter: AtomicU64::new(0),
regex_cache: DashMap::new(),
} }
} }
} }

View File

@@ -115,10 +115,18 @@ 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);
} }
} }
} }
@@ -204,6 +212,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();

View File

@@ -1,6 +1,10 @@
use dashmap::DashMap; use dashmap::DashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use crate::throughput::{ThroughputSample, ThroughputTracker};
/// Aggregated metrics snapshot. /// Aggregated metrics snapshot.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -12,7 +16,14 @@ pub struct Metrics {
pub bytes_out: u64, pub bytes_out: u64,
pub throughput_in_bytes_per_sec: u64, pub throughput_in_bytes_per_sec: u64,
pub throughput_out_bytes_per_sec: u64, pub throughput_out_bytes_per_sec: u64,
pub throughput_recent_in_bytes_per_sec: u64,
pub throughput_recent_out_bytes_per_sec: u64,
pub routes: std::collections::HashMap<String, RouteMetrics>, pub routes: std::collections::HashMap<String, RouteMetrics>,
pub ips: std::collections::HashMap<String, IpMetrics>,
pub throughput_history: Vec<ThroughputSample>,
pub total_http_requests: u64,
pub http_requests_per_sec: u64,
pub http_requests_per_sec_recent: u64,
} }
/// Per-route metrics. /// Per-route metrics.
@@ -25,6 +36,20 @@ pub struct RouteMetrics {
pub bytes_out: u64, pub bytes_out: u64,
pub throughput_in_bytes_per_sec: u64, pub throughput_in_bytes_per_sec: u64,
pub throughput_out_bytes_per_sec: u64, pub throughput_out_bytes_per_sec: u64,
pub throughput_recent_in_bytes_per_sec: u64,
pub throughput_recent_out_bytes_per_sec: u64,
}
/// Per-IP metrics.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IpMetrics {
pub active_connections: u64,
pub total_connections: u64,
pub bytes_in: u64,
pub bytes_out: u64,
pub throughput_in_bytes_per_sec: u64,
pub throughput_out_bytes_per_sec: u64,
} }
/// Statistics snapshot. /// Statistics snapshot.
@@ -38,7 +63,18 @@ pub struct Statistics {
pub uptime_seconds: u64, pub uptime_seconds: u64,
} }
/// Default retention for throughput samples (1 hour).
const DEFAULT_RETENTION_SECONDS: usize = 3600;
/// Maximum number of IPs to include in a snapshot (top by active connections).
const MAX_IPS_IN_SNAPSHOT: usize = 100;
/// Metrics collector tracking connections and throughput. /// Metrics collector tracking connections and throughput.
///
/// Design: The hot path (`record_bytes`) is entirely lock-free — it only touches
/// `AtomicU64` counters. The cold path (`sample_all`, called at 1Hz) drains
/// those atomics and feeds the throughput trackers under a Mutex. This avoids
/// contention when `record_bytes` is called per-chunk in the TCP copy loop.
pub struct MetricsCollector { pub struct MetricsCollector {
active_connections: AtomicU64, active_connections: AtomicU64,
total_connections: AtomicU64, total_connections: AtomicU64,
@@ -51,10 +87,38 @@ pub struct MetricsCollector {
/// Per-route byte counters /// Per-route byte counters
route_bytes_in: DashMap<String, AtomicU64>, route_bytes_in: DashMap<String, AtomicU64>,
route_bytes_out: DashMap<String, AtomicU64>, route_bytes_out: DashMap<String, AtomicU64>,
// ── Per-IP tracking ──
ip_connections: DashMap<String, AtomicU64>,
ip_total_connections: DashMap<String, AtomicU64>,
ip_bytes_in: DashMap<String, AtomicU64>,
ip_bytes_out: DashMap<String, AtomicU64>,
ip_pending_tp: DashMap<String, (AtomicU64, AtomicU64)>,
ip_throughput: DashMap<String, Mutex<ThroughputTracker>>,
// ── HTTP request tracking ──
total_http_requests: AtomicU64,
pending_http_requests: AtomicU64,
http_request_throughput: Mutex<ThroughputTracker>,
// ── Lock-free pending throughput counters (hot path) ──
global_pending_tp_in: AtomicU64,
global_pending_tp_out: AtomicU64,
route_pending_tp: DashMap<String, (AtomicU64, AtomicU64)>,
// ── Throughput history — only locked during sampling (cold path) ──
global_throughput: Mutex<ThroughputTracker>,
route_throughput: DashMap<String, Mutex<ThroughputTracker>>,
retention_seconds: usize,
} }
impl MetricsCollector { impl MetricsCollector {
pub fn new() -> Self { pub fn new() -> Self {
Self::with_retention(DEFAULT_RETENTION_SECONDS)
}
/// Create a MetricsCollector with a custom retention period for throughput history.
pub fn with_retention(retention_seconds: usize) -> Self {
Self { Self {
active_connections: AtomicU64::new(0), active_connections: AtomicU64::new(0),
total_connections: AtomicU64::new(0), total_connections: AtomicU64::new(0),
@@ -64,11 +128,26 @@ impl MetricsCollector {
route_total_connections: DashMap::new(), route_total_connections: DashMap::new(),
route_bytes_in: DashMap::new(), route_bytes_in: DashMap::new(),
route_bytes_out: DashMap::new(), route_bytes_out: DashMap::new(),
ip_connections: DashMap::new(),
ip_total_connections: DashMap::new(),
ip_bytes_in: DashMap::new(),
ip_bytes_out: DashMap::new(),
ip_pending_tp: DashMap::new(),
ip_throughput: DashMap::new(),
total_http_requests: AtomicU64::new(0),
pending_http_requests: AtomicU64::new(0),
http_request_throughput: Mutex::new(ThroughputTracker::new(retention_seconds)),
global_pending_tp_in: AtomicU64::new(0),
global_pending_tp_out: AtomicU64::new(0),
route_pending_tp: DashMap::new(),
global_throughput: Mutex::new(ThroughputTracker::new(retention_seconds)),
route_throughput: DashMap::new(),
retention_seconds,
} }
} }
/// Record a new connection. /// Record a new connection.
pub fn connection_opened(&self, route_id: Option<&str>) { pub fn connection_opened(&self, route_id: Option<&str>, source_ip: Option<&str>) {
self.active_connections.fetch_add(1, Ordering::Relaxed); self.active_connections.fetch_add(1, Ordering::Relaxed);
self.total_connections.fetch_add(1, Ordering::Relaxed); self.total_connections.fetch_add(1, Ordering::Relaxed);
@@ -82,10 +161,21 @@ impl MetricsCollector {
.or_insert_with(|| AtomicU64::new(0)) .or_insert_with(|| AtomicU64::new(0))
.fetch_add(1, Ordering::Relaxed); .fetch_add(1, Ordering::Relaxed);
} }
if let Some(ip) = source_ip {
self.ip_connections
.entry(ip.to_string())
.or_insert_with(|| AtomicU64::new(0))
.fetch_add(1, Ordering::Relaxed);
self.ip_total_connections
.entry(ip.to_string())
.or_insert_with(|| AtomicU64::new(0))
.fetch_add(1, Ordering::Relaxed);
}
} }
/// Record a connection closing. /// Record a connection closing.
pub fn connection_closed(&self, route_id: Option<&str>) { pub fn connection_closed(&self, route_id: Option<&str>, source_ip: Option<&str>) {
self.active_connections.fetch_sub(1, Ordering::Relaxed); self.active_connections.fetch_sub(1, Ordering::Relaxed);
if let Some(route_id) = route_id { if let Some(route_id) = route_id {
@@ -96,13 +186,40 @@ impl MetricsCollector {
} }
} }
} }
if let Some(ip) = source_ip {
if let Some(counter) = self.ip_connections.get(ip) {
let val = counter.load(Ordering::Relaxed);
if val > 0 {
counter.fetch_sub(1, Ordering::Relaxed);
}
// Clean up zero-count entries to prevent memory growth
if val <= 1 {
drop(counter);
self.ip_connections.remove(ip);
// Evict all per-IP tracking data for this IP
self.ip_total_connections.remove(ip);
self.ip_bytes_in.remove(ip);
self.ip_bytes_out.remove(ip);
self.ip_pending_tp.remove(ip);
self.ip_throughput.remove(ip);
}
}
}
} }
/// Record bytes transferred. /// Record bytes transferred (lock-free hot path).
pub fn record_bytes(&self, bytes_in: u64, bytes_out: u64, route_id: Option<&str>) { ///
/// Called per-chunk in the TCP copy loop. Only touches AtomicU64 counters —
/// no Mutex is taken. The throughput trackers are fed during `sample_all()`.
pub fn record_bytes(&self, bytes_in: u64, bytes_out: u64, route_id: Option<&str>, source_ip: Option<&str>) {
self.total_bytes_in.fetch_add(bytes_in, Ordering::Relaxed); self.total_bytes_in.fetch_add(bytes_in, Ordering::Relaxed);
self.total_bytes_out.fetch_add(bytes_out, Ordering::Relaxed); self.total_bytes_out.fetch_add(bytes_out, Ordering::Relaxed);
// Accumulate into lock-free pending throughput counters
self.global_pending_tp_in.fetch_add(bytes_in, Ordering::Relaxed);
self.global_pending_tp_out.fetch_add(bytes_out, Ordering::Relaxed);
if let Some(route_id) = route_id { if let Some(route_id) = route_id {
self.route_bytes_in self.route_bytes_in
.entry(route_id.to_string()) .entry(route_id.to_string())
@@ -112,7 +229,135 @@ impl MetricsCollector {
.entry(route_id.to_string()) .entry(route_id.to_string())
.or_insert_with(|| AtomicU64::new(0)) .or_insert_with(|| AtomicU64::new(0))
.fetch_add(bytes_out, Ordering::Relaxed); .fetch_add(bytes_out, Ordering::Relaxed);
// Accumulate into per-route pending throughput counters (lock-free)
let entry = self.route_pending_tp
.entry(route_id.to_string())
.or_insert_with(|| (AtomicU64::new(0), AtomicU64::new(0)));
entry.0.fetch_add(bytes_in, Ordering::Relaxed);
entry.1.fetch_add(bytes_out, Ordering::Relaxed);
} }
if let Some(ip) = source_ip {
self.ip_bytes_in
.entry(ip.to_string())
.or_insert_with(|| AtomicU64::new(0))
.fetch_add(bytes_in, Ordering::Relaxed);
self.ip_bytes_out
.entry(ip.to_string())
.or_insert_with(|| AtomicU64::new(0))
.fetch_add(bytes_out, Ordering::Relaxed);
// Accumulate into per-IP pending throughput counters (lock-free)
let entry = self.ip_pending_tp
.entry(ip.to_string())
.or_insert_with(|| (AtomicU64::new(0), AtomicU64::new(0)));
entry.0.fetch_add(bytes_in, Ordering::Relaxed);
entry.1.fetch_add(bytes_out, Ordering::Relaxed);
}
}
/// Record an HTTP request (called once per request in the HTTP proxy).
pub fn record_http_request(&self) {
self.total_http_requests.fetch_add(1, Ordering::Relaxed);
self.pending_http_requests.fetch_add(1, Ordering::Relaxed);
}
/// Take a throughput sample on all trackers (cold path, call at 1Hz or configured interval).
///
/// Drains the lock-free pending counters and feeds the accumulated bytes
/// into the throughput trackers (under Mutex). This is the only place
/// the Mutex is locked.
pub fn sample_all(&self) {
// Drain global pending bytes and feed into the tracker
let global_in = self.global_pending_tp_in.swap(0, Ordering::Relaxed);
let global_out = self.global_pending_tp_out.swap(0, Ordering::Relaxed);
if let Ok(mut tracker) = self.global_throughput.lock() {
tracker.record_bytes(global_in, global_out);
tracker.sample();
}
// Drain per-route pending bytes; collect into a Vec to avoid holding DashMap shards
let mut route_samples: Vec<(String, u64, u64)> = Vec::new();
for entry in self.route_pending_tp.iter() {
let route_id = entry.key().clone();
let pending_in = entry.value().0.swap(0, Ordering::Relaxed);
let pending_out = entry.value().1.swap(0, Ordering::Relaxed);
route_samples.push((route_id, pending_in, pending_out));
}
// Feed pending bytes into route trackers and sample
let retention = self.retention_seconds;
for (route_id, pending_in, pending_out) in &route_samples {
// Ensure the tracker exists
self.route_throughput
.entry(route_id.clone())
.or_insert_with(|| Mutex::new(ThroughputTracker::new(retention)));
// Now get a separate ref and lock it
if let Some(tracker_ref) = self.route_throughput.get(route_id) {
if let Ok(mut tracker) = tracker_ref.value().lock() {
tracker.record_bytes(*pending_in, *pending_out);
tracker.sample();
}
}
}
// Also sample any route trackers that had no new pending bytes
// (to keep their sample window advancing)
for entry in self.route_throughput.iter() {
if !self.route_pending_tp.contains_key(entry.key()) {
if let Ok(mut tracker) = entry.value().lock() {
tracker.sample();
}
}
}
// Drain per-IP pending bytes and feed into IP throughput trackers
let mut ip_samples: Vec<(String, u64, u64)> = Vec::new();
for entry in self.ip_pending_tp.iter() {
let ip = entry.key().clone();
let pending_in = entry.value().0.swap(0, Ordering::Relaxed);
let pending_out = entry.value().1.swap(0, Ordering::Relaxed);
ip_samples.push((ip, pending_in, pending_out));
}
for (ip, pending_in, pending_out) in &ip_samples {
self.ip_throughput
.entry(ip.clone())
.or_insert_with(|| Mutex::new(ThroughputTracker::new(retention)));
if let Some(tracker_ref) = self.ip_throughput.get(ip) {
if let Ok(mut tracker) = tracker_ref.value().lock() {
tracker.record_bytes(*pending_in, *pending_out);
tracker.sample();
}
}
}
// Sample idle IP trackers
for entry in self.ip_throughput.iter() {
if !self.ip_pending_tp.contains_key(entry.key()) {
if let Ok(mut tracker) = entry.value().lock() {
tracker.sample();
}
}
}
// Drain pending HTTP request count and feed into HTTP throughput tracker
let pending_reqs = self.pending_http_requests.swap(0, Ordering::Relaxed);
if let Ok(mut tracker) = self.http_request_throughput.lock() {
// Use bytes_in field to track request count (each request = 1 "byte")
tracker.record_bytes(pending_reqs, 0);
tracker.sample();
}
}
/// Remove per-route metrics for route IDs that are no longer active.
/// Call this after `update_routes()` to prune stale entries.
pub fn retain_routes(&self, active_route_ids: &HashSet<String>) {
self.route_connections.retain(|k, _| active_route_ids.contains(k));
self.route_total_connections.retain(|k, _| active_route_ids.contains(k));
self.route_bytes_in.retain(|k, _| active_route_ids.contains(k));
self.route_bytes_out.retain(|k, _| active_route_ids.contains(k));
self.route_pending_tp.retain(|k, _| active_route_ids.contains(k));
self.route_throughput.retain(|k, _| active_route_ids.contains(k));
} }
/// Get current active connection count. /// Get current active connection count.
@@ -135,10 +380,22 @@ impl MetricsCollector {
self.total_bytes_out.load(Ordering::Relaxed) self.total_bytes_out.load(Ordering::Relaxed)
} }
/// Get a full metrics snapshot including per-route data. /// Get a full metrics snapshot including per-route and per-IP data.
pub fn snapshot(&self) -> Metrics { pub fn snapshot(&self) -> Metrics {
let mut routes = std::collections::HashMap::new(); let mut routes = std::collections::HashMap::new();
// Get global throughput (instant = last 1 sample, recent = last 10 samples)
let (global_tp_in, global_tp_out, global_recent_in, global_recent_out, throughput_history) =
self.global_throughput
.lock()
.map(|t| {
let (i_in, i_out) = t.instant();
let (r_in, r_out) = t.recent();
let history = t.history(60);
(i_in, i_out, r_in, r_out, history)
})
.unwrap_or((0, 0, 0, 0, Vec::new()));
// Collect per-route metrics // Collect per-route metrics
for entry in self.route_total_connections.iter() { for entry in self.route_total_connections.iter() {
let route_id = entry.key().clone(); let route_id = entry.key().clone();
@@ -156,24 +413,92 @@ impl MetricsCollector {
.map(|c| c.load(Ordering::Relaxed)) .map(|c| c.load(Ordering::Relaxed))
.unwrap_or(0); .unwrap_or(0);
let (route_tp_in, route_tp_out, route_recent_in, route_recent_out) = self.route_throughput
.get(&route_id)
.and_then(|entry| entry.value().lock().ok().map(|t| {
let (i_in, i_out) = t.instant();
let (r_in, r_out) = t.recent();
(i_in, i_out, r_in, r_out)
}))
.unwrap_or((0, 0, 0, 0));
routes.insert(route_id, RouteMetrics { routes.insert(route_id, RouteMetrics {
active_connections: active, active_connections: active,
total_connections: total, total_connections: total,
bytes_in, bytes_in,
bytes_out, bytes_out,
throughput_in_bytes_per_sec: 0, throughput_in_bytes_per_sec: route_tp_in,
throughput_out_bytes_per_sec: 0, throughput_out_bytes_per_sec: route_tp_out,
throughput_recent_in_bytes_per_sec: route_recent_in,
throughput_recent_out_bytes_per_sec: route_recent_out,
}); });
} }
// Collect per-IP metrics — only IPs with active connections or total > 0,
// capped at top MAX_IPS_IN_SNAPSHOT sorted by active count
let mut ip_entries: Vec<(String, u64, u64, u64, u64, u64, u64)> = Vec::new();
for entry in self.ip_total_connections.iter() {
let ip = entry.key().clone();
let total = entry.value().load(Ordering::Relaxed);
let active = self.ip_connections
.get(&ip)
.map(|c| c.load(Ordering::Relaxed))
.unwrap_or(0);
let bytes_in = self.ip_bytes_in
.get(&ip)
.map(|c| c.load(Ordering::Relaxed))
.unwrap_or(0);
let bytes_out = self.ip_bytes_out
.get(&ip)
.map(|c| c.load(Ordering::Relaxed))
.unwrap_or(0);
let (tp_in, tp_out) = self.ip_throughput
.get(&ip)
.and_then(|entry| entry.value().lock().ok().map(|t| t.instant()))
.unwrap_or((0, 0));
ip_entries.push((ip, active, total, bytes_in, bytes_out, tp_in, tp_out));
}
// Sort by active connections descending, then cap
ip_entries.sort_by(|a, b| b.1.cmp(&a.1));
ip_entries.truncate(MAX_IPS_IN_SNAPSHOT);
let mut ips = std::collections::HashMap::new();
for (ip, active, total, bytes_in, bytes_out, tp_in, tp_out) in ip_entries {
ips.insert(ip, IpMetrics {
active_connections: active,
total_connections: total,
bytes_in,
bytes_out,
throughput_in_bytes_per_sec: tp_in,
throughput_out_bytes_per_sec: tp_out,
});
}
// HTTP request rates
let (http_rps, http_rps_recent) = self.http_request_throughput
.lock()
.map(|t| {
let (instant, _) = t.instant();
let (recent, _) = t.recent();
(instant, recent)
})
.unwrap_or((0, 0));
Metrics { Metrics {
active_connections: self.active_connections(), active_connections: self.active_connections(),
total_connections: self.total_connections(), total_connections: self.total_connections(),
bytes_in: self.total_bytes_in(), bytes_in: self.total_bytes_in(),
bytes_out: self.total_bytes_out(), bytes_out: self.total_bytes_out(),
throughput_in_bytes_per_sec: 0, throughput_in_bytes_per_sec: global_tp_in,
throughput_out_bytes_per_sec: 0, throughput_out_bytes_per_sec: global_tp_out,
throughput_recent_in_bytes_per_sec: global_recent_in,
throughput_recent_out_bytes_per_sec: global_recent_out,
routes, routes,
ips,
throughput_history,
total_http_requests: self.total_http_requests.load(Ordering::Relaxed),
http_requests_per_sec: http_rps,
http_requests_per_sec_recent: http_rps_recent,
} }
} }
} }
@@ -198,10 +523,10 @@ mod tests {
#[test] #[test]
fn test_connection_opened_increments() { fn test_connection_opened_increments() {
let collector = MetricsCollector::new(); let collector = MetricsCollector::new();
collector.connection_opened(None); collector.connection_opened(None, None);
assert_eq!(collector.active_connections(), 1); assert_eq!(collector.active_connections(), 1);
assert_eq!(collector.total_connections(), 1); assert_eq!(collector.total_connections(), 1);
collector.connection_opened(None); collector.connection_opened(None, None);
assert_eq!(collector.active_connections(), 2); assert_eq!(collector.active_connections(), 2);
assert_eq!(collector.total_connections(), 2); assert_eq!(collector.total_connections(), 2);
} }
@@ -209,10 +534,10 @@ mod tests {
#[test] #[test]
fn test_connection_closed_decrements() { fn test_connection_closed_decrements() {
let collector = MetricsCollector::new(); let collector = MetricsCollector::new();
collector.connection_opened(None); collector.connection_opened(None, None);
collector.connection_opened(None); collector.connection_opened(None, None);
assert_eq!(collector.active_connections(), 2); assert_eq!(collector.active_connections(), 2);
collector.connection_closed(None); collector.connection_closed(None, None);
assert_eq!(collector.active_connections(), 1); assert_eq!(collector.active_connections(), 1);
// total_connections should stay at 2 // total_connections should stay at 2
assert_eq!(collector.total_connections(), 2); assert_eq!(collector.total_connections(), 2);
@@ -221,23 +546,23 @@ mod tests {
#[test] #[test]
fn test_route_specific_tracking() { fn test_route_specific_tracking() {
let collector = MetricsCollector::new(); let collector = MetricsCollector::new();
collector.connection_opened(Some("route-a")); collector.connection_opened(Some("route-a"), None);
collector.connection_opened(Some("route-a")); collector.connection_opened(Some("route-a"), None);
collector.connection_opened(Some("route-b")); collector.connection_opened(Some("route-b"), None);
assert_eq!(collector.active_connections(), 3); assert_eq!(collector.active_connections(), 3);
assert_eq!(collector.total_connections(), 3); assert_eq!(collector.total_connections(), 3);
collector.connection_closed(Some("route-a")); collector.connection_closed(Some("route-a"), None);
assert_eq!(collector.active_connections(), 2); assert_eq!(collector.active_connections(), 2);
} }
#[test] #[test]
fn test_record_bytes() { fn test_record_bytes() {
let collector = MetricsCollector::new(); let collector = MetricsCollector::new();
collector.record_bytes(100, 200, Some("route-a")); collector.record_bytes(100, 200, Some("route-a"), None);
collector.record_bytes(50, 75, Some("route-a")); collector.record_bytes(50, 75, Some("route-a"), None);
collector.record_bytes(25, 30, None); collector.record_bytes(25, 30, None, None);
let total_in = collector.total_bytes_in.load(Ordering::Relaxed); let total_in = collector.total_bytes_in.load(Ordering::Relaxed);
let total_out = collector.total_bytes_out.load(Ordering::Relaxed); let total_out = collector.total_bytes_out.load(Ordering::Relaxed);
@@ -248,4 +573,179 @@ mod tests {
let route_in = collector.route_bytes_in.get("route-a").unwrap(); let route_in = collector.route_bytes_in.get("route-a").unwrap();
assert_eq!(route_in.load(Ordering::Relaxed), 150); assert_eq!(route_in.load(Ordering::Relaxed), 150);
} }
#[test]
fn test_throughput_tracking() {
let collector = MetricsCollector::with_retention(60);
// Open a connection so the route appears in the snapshot
collector.connection_opened(Some("route-a"), None);
// Record some bytes
collector.record_bytes(1000, 2000, Some("route-a"), None);
collector.record_bytes(500, 750, None, None);
// Take a sample (simulates the 1Hz tick)
collector.sample_all();
// Check global throughput
let snapshot = collector.snapshot();
assert_eq!(snapshot.throughput_in_bytes_per_sec, 1500);
assert_eq!(snapshot.throughput_out_bytes_per_sec, 2750);
// Check per-route throughput
let route_a = snapshot.routes.get("route-a").unwrap();
assert_eq!(route_a.throughput_in_bytes_per_sec, 1000);
assert_eq!(route_a.throughput_out_bytes_per_sec, 2000);
}
#[test]
fn test_throughput_zero_before_sampling() {
let collector = MetricsCollector::with_retention(60);
collector.record_bytes(1000, 2000, None, None);
// Without sampling, throughput should be 0
let snapshot = collector.snapshot();
assert_eq!(snapshot.throughput_in_bytes_per_sec, 0);
assert_eq!(snapshot.throughput_out_bytes_per_sec, 0);
}
#[test]
fn test_per_ip_tracking() {
let collector = MetricsCollector::with_retention(60);
collector.connection_opened(Some("route-a"), Some("1.2.3.4"));
collector.connection_opened(Some("route-a"), Some("1.2.3.4"));
collector.connection_opened(Some("route-b"), Some("5.6.7.8"));
// Check IP active connections (drop DashMap refs immediately to avoid deadlock)
assert_eq!(
collector.ip_connections.get("1.2.3.4").unwrap().load(Ordering::Relaxed),
2
);
assert_eq!(
collector.ip_connections.get("5.6.7.8").unwrap().load(Ordering::Relaxed),
1
);
// Record bytes per IP
collector.record_bytes(100, 200, Some("route-a"), Some("1.2.3.4"));
collector.record_bytes(300, 400, Some("route-b"), Some("5.6.7.8"));
collector.sample_all();
let snapshot = collector.snapshot();
assert_eq!(snapshot.ips.len(), 2);
let ip1_metrics = snapshot.ips.get("1.2.3.4").unwrap();
assert_eq!(ip1_metrics.active_connections, 2);
assert_eq!(ip1_metrics.bytes_in, 100);
// Close connections
collector.connection_closed(Some("route-a"), Some("1.2.3.4"));
assert_eq!(
collector.ip_connections.get("1.2.3.4").unwrap().load(Ordering::Relaxed),
1
);
// Close last connection for IP — should be cleaned up
collector.connection_closed(Some("route-a"), Some("1.2.3.4"));
assert!(collector.ip_connections.get("1.2.3.4").is_none());
}
#[test]
fn test_per_ip_full_eviction_on_last_close() {
let collector = MetricsCollector::with_retention(60);
// Open connections from two IPs
collector.connection_opened(Some("route-a"), Some("10.0.0.1"));
collector.connection_opened(Some("route-a"), Some("10.0.0.1"));
collector.connection_opened(Some("route-b"), Some("10.0.0.2"));
// Record bytes to populate per-IP DashMaps
collector.record_bytes(100, 200, Some("route-a"), Some("10.0.0.1"));
collector.record_bytes(300, 400, Some("route-b"), Some("10.0.0.2"));
collector.sample_all();
// Verify per-IP data exists
assert!(collector.ip_total_connections.get("10.0.0.1").is_some());
assert!(collector.ip_bytes_in.get("10.0.0.1").is_some());
assert!(collector.ip_throughput.get("10.0.0.1").is_some());
// Close all connections for 10.0.0.1
collector.connection_closed(Some("route-a"), Some("10.0.0.1"));
collector.connection_closed(Some("route-a"), Some("10.0.0.1"));
// All per-IP data for 10.0.0.1 should be evicted
assert!(collector.ip_connections.get("10.0.0.1").is_none());
assert!(collector.ip_total_connections.get("10.0.0.1").is_none());
assert!(collector.ip_bytes_in.get("10.0.0.1").is_none());
assert!(collector.ip_bytes_out.get("10.0.0.1").is_none());
assert!(collector.ip_pending_tp.get("10.0.0.1").is_none());
assert!(collector.ip_throughput.get("10.0.0.1").is_none());
// 10.0.0.2 should still have data
assert!(collector.ip_connections.get("10.0.0.2").is_some());
assert!(collector.ip_total_connections.get("10.0.0.2").is_some());
}
#[test]
fn test_http_request_tracking() {
let collector = MetricsCollector::with_retention(60);
collector.record_http_request();
collector.record_http_request();
collector.record_http_request();
assert_eq!(collector.total_http_requests.load(Ordering::Relaxed), 3);
collector.sample_all();
let snapshot = collector.snapshot();
assert_eq!(snapshot.total_http_requests, 3);
assert_eq!(snapshot.http_requests_per_sec, 3);
}
#[test]
fn test_retain_routes_prunes_stale() {
let collector = MetricsCollector::with_retention(60);
// Create metrics for 3 routes
collector.connection_opened(Some("route-a"), None);
collector.connection_opened(Some("route-b"), None);
collector.connection_opened(Some("route-c"), None);
collector.record_bytes(100, 200, Some("route-a"), None);
collector.record_bytes(100, 200, Some("route-b"), None);
collector.record_bytes(100, 200, Some("route-c"), None);
collector.sample_all();
// Now "route-b" is removed from config
let active = HashSet::from(["route-a".to_string(), "route-c".to_string()]);
collector.retain_routes(&active);
// route-b entries should be gone
assert!(collector.route_connections.get("route-b").is_none());
assert!(collector.route_total_connections.get("route-b").is_none());
assert!(collector.route_bytes_in.get("route-b").is_none());
assert!(collector.route_bytes_out.get("route-b").is_none());
assert!(collector.route_throughput.get("route-b").is_none());
// route-a and route-c should still exist
assert!(collector.route_total_connections.get("route-a").is_some());
assert!(collector.route_total_connections.get("route-c").is_some());
}
#[test]
fn test_throughput_history_in_snapshot() {
let collector = MetricsCollector::with_retention(60);
for i in 1..=5 {
collector.record_bytes(i * 100, i * 200, None, None);
collector.sample_all();
}
let snapshot = collector.snapshot();
assert_eq!(snapshot.throughput_history.len(), 5);
// History should be chronological (oldest first)
assert_eq!(snapshot.throughput_history[0].bytes_in, 100);
assert_eq!(snapshot.throughput_history[4].bytes_in, 500);
}
} }

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

@@ -95,10 +95,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);
} }
} }
} }
@@ -205,10 +206,13 @@ impl ConnectionTracker {
let zombies = tracker.scan_zombies(); let zombies = tracker.scan_zombies();
if !zombies.is_empty() { if !zombies.is_empty() {
warn!( warn!(
"Detected {} zombie connection(s): {:?}", "Cleaning up {} zombie connection(s): {:?}",
zombies.len(), zombies.len(),
zombies zombies
); );
for id in &zombies {
tracker.unregister_connection(*id);
}
} }
} }
} }
@@ -304,6 +308,30 @@ mod tests {
assert_eq!(tracker.tracked_ips(), 1); assert_eq!(tracker.tracked_ips(), 1);
} }
#[test]
fn test_timestamps_cleaned_on_last_close() {
let tracker = ConnectionTracker::new(None, Some(100));
let ip: IpAddr = "10.0.0.1".parse().unwrap();
// try_accept populates the timestamps map (when rate limiting is enabled)
assert!(tracker.try_accept(&ip));
tracker.connection_opened(&ip);
assert!(tracker.try_accept(&ip));
tracker.connection_opened(&ip);
// Timestamps should exist
assert!(tracker.timestamps.get(&ip).is_some());
// Close one connection — timestamps should still exist
tracker.connection_closed(&ip);
assert!(tracker.timestamps.get(&ip).is_some());
// Close last connection — timestamps should be cleaned up
tracker.connection_closed(&ip);
assert!(tracker.timestamps.get(&ip).is_none());
assert!(tracker.active.get(&ip).is_none());
}
#[test] #[test]
fn test_register_unregister_connection() { fn test_register_unregister_connection() {
let tracker = ConnectionTracker::new(None, None); let tracker = ConnectionTracker::new(None, None);

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();
@@ -90,6 +99,7 @@ pub async fn forward_bidirectional_with_timeouts(
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 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;
@@ -103,12 +113,16 @@ pub async fn forward_bidirectional_with_timeouts(
} }
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; let _ = backend_write.shutdown().await;
total total
}); });
let la2 = Arc::clone(&last_activity); let la2 = Arc::clone(&last_activity);
let metrics_b2c = metrics;
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;
@@ -122,6 +136,9 @@ pub async fn forward_bidirectional_with_timeouts(
} }
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; let _ = client_write.shutdown().await;
total total
@@ -173,153 +190,3 @@ pub async fn forward_bidirectional_with_timeouts(
watchdog.abort(); watchdog.abort();
Ok((bytes_in, bytes_out)) 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;
}
}
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))
}

View File

@@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio_rustls::TlsAcceptor;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::{info, error, debug, warn}; use tracing::{info, error, debug, warn};
use thiserror::Error; use thiserror::Error;
@@ -15,6 +16,30 @@ use crate::forwarder;
use crate::tls_handler; use crate::tls_handler;
use crate::connection_tracker::ConnectionTracker; use crate::connection_tracker::ConnectionTracker;
/// RAII guard that decrements the active connection metric on drop.
/// Ensures connection_closed is called on ALL exit paths — normal, error, or panic.
struct ConnectionGuard {
metrics: Arc<MetricsCollector>,
route_id: Option<String>,
source_ip: Option<String>,
}
impl ConnectionGuard {
fn new(metrics: Arc<MetricsCollector>, route_id: Option<&str>, source_ip: Option<&str>) -> Self {
Self {
metrics,
route_id: route_id.map(|s| s.to_string()),
source_ip: source_ip.map(|s| s.to_string()),
}
}
}
impl Drop for ConnectionGuard {
fn drop(&mut self) {
self.metrics.connection_closed(self.route_id.as_deref(), self.source_ip.as_deref());
}
}
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ListenerError { pub enum ListenerError {
#[error("Failed to bind port {port}: {source}")] #[error("Failed to bind port {port}: {source}")]
@@ -88,8 +113,10 @@ pub struct TcpListenerManager {
route_manager: Arc<ArcSwap<RouteManager>>, route_manager: Arc<ArcSwap<RouteManager>>,
/// Shared metrics collector /// Shared metrics collector
metrics: Arc<MetricsCollector>, metrics: Arc<MetricsCollector>,
/// TLS acceptors indexed by domain /// Raw PEM TLS configs indexed by domain (kept for fallback with custom TLS versions)
tls_configs: Arc<HashMap<String, TlsCertConfig>>, tls_configs: Arc<ArcSwap<HashMap<String, TlsCertConfig>>>,
/// Shared TLS acceptor (pre-parsed certs + session cache). None when no certs configured.
shared_tls_acceptor: Arc<ArcSwap<Option<TlsAcceptor>>>,
/// HTTP proxy service for HTTP-level forwarding /// HTTP proxy service for HTTP-level forwarding
http_proxy: Arc<HttpProxyService>, http_proxy: Arc<HttpProxyService>,
/// Connection configuration /// Connection configuration
@@ -105,11 +132,12 @@ pub struct TcpListenerManager {
impl TcpListenerManager { impl TcpListenerManager {
pub fn new(route_manager: Arc<RouteManager>) -> Self { pub fn new(route_manager: Arc<RouteManager>) -> Self {
let metrics = Arc::new(MetricsCollector::new()); let metrics = Arc::new(MetricsCollector::new());
let http_proxy = Arc::new(HttpProxyService::new( let conn_config = ConnectionConfig::default();
let http_proxy = Arc::new(HttpProxyService::with_connect_timeout(
Arc::clone(&route_manager), Arc::clone(&route_manager),
Arc::clone(&metrics), Arc::clone(&metrics),
std::time::Duration::from_millis(conn_config.connection_timeout_ms),
)); ));
let conn_config = ConnectionConfig::default();
let conn_tracker = Arc::new(ConnectionTracker::new( let conn_tracker = Arc::new(ConnectionTracker::new(
conn_config.max_connections_per_ip, conn_config.max_connections_per_ip,
conn_config.connection_rate_limit_per_minute, conn_config.connection_rate_limit_per_minute,
@@ -118,7 +146,8 @@ impl TcpListenerManager {
listeners: HashMap::new(), listeners: HashMap::new(),
route_manager: Arc::new(ArcSwap::from(route_manager)), route_manager: Arc::new(ArcSwap::from(route_manager)),
metrics, metrics,
tls_configs: Arc::new(HashMap::new()), tls_configs: Arc::new(ArcSwap::from(Arc::new(HashMap::new()))),
shared_tls_acceptor: Arc::new(ArcSwap::from(Arc::new(None))),
http_proxy, http_proxy,
conn_config: Arc::new(conn_config), conn_config: Arc::new(conn_config),
conn_tracker, conn_tracker,
@@ -129,11 +158,12 @@ impl TcpListenerManager {
/// Create with a metrics collector. /// Create with a metrics collector.
pub fn with_metrics(route_manager: Arc<RouteManager>, metrics: Arc<MetricsCollector>) -> Self { pub fn with_metrics(route_manager: Arc<RouteManager>, metrics: Arc<MetricsCollector>) -> Self {
let http_proxy = Arc::new(HttpProxyService::new( let conn_config = ConnectionConfig::default();
let http_proxy = Arc::new(HttpProxyService::with_connect_timeout(
Arc::clone(&route_manager), Arc::clone(&route_manager),
Arc::clone(&metrics), Arc::clone(&metrics),
std::time::Duration::from_millis(conn_config.connection_timeout_ms),
)); ));
let conn_config = ConnectionConfig::default();
let conn_tracker = Arc::new(ConnectionTracker::new( let conn_tracker = Arc::new(ConnectionTracker::new(
conn_config.max_connections_per_ip, conn_config.max_connections_per_ip,
conn_config.connection_rate_limit_per_minute, conn_config.connection_rate_limit_per_minute,
@@ -142,7 +172,8 @@ impl TcpListenerManager {
listeners: HashMap::new(), listeners: HashMap::new(),
route_manager: Arc::new(ArcSwap::from(route_manager)), route_manager: Arc::new(ArcSwap::from(route_manager)),
metrics, metrics,
tls_configs: Arc::new(HashMap::new()), tls_configs: Arc::new(ArcSwap::from(Arc::new(HashMap::new()))),
shared_tls_acceptor: Arc::new(ArcSwap::from(Arc::new(None))),
http_proxy, http_proxy,
conn_config: Arc::new(conn_config), conn_config: Arc::new(conn_config),
conn_tracker, conn_tracker,
@@ -161,8 +192,27 @@ impl TcpListenerManager {
} }
/// Set TLS certificate configurations. /// Set TLS certificate configurations.
pub fn set_tls_configs(&mut self, configs: HashMap<String, TlsCertConfig>) { /// Builds a shared TLS acceptor with pre-parsed certs and session resumption support.
self.tls_configs = Arc::new(configs); /// Uses ArcSwap so running accept loops immediately see the new certs.
pub fn set_tls_configs(&self, configs: HashMap<String, TlsCertConfig>) {
if !configs.is_empty() {
match tls_handler::CertResolver::new(&configs)
.and_then(tls_handler::build_shared_tls_acceptor)
{
Ok(acceptor) => {
info!("Built shared TLS acceptor for {} domain(s)", configs.len());
self.shared_tls_acceptor.store(Arc::new(Some(acceptor)));
}
Err(e) => {
warn!("Failed to build shared TLS acceptor: {}, falling back to per-connection", e);
self.shared_tls_acceptor.store(Arc::new(None));
}
}
} else {
self.shared_tls_acceptor.store(Arc::new(None));
}
// Keep raw PEM configs for fallback (routes with custom TLS versions)
self.tls_configs.store(Arc::new(configs));
} }
/// Set the shared socket-handler relay path. /// Set the shared socket-handler relay path.
@@ -187,6 +237,7 @@ impl TcpListenerManager {
let route_manager_swap = Arc::clone(&self.route_manager); let route_manager_swap = Arc::clone(&self.route_manager);
let metrics = Arc::clone(&self.metrics); let metrics = Arc::clone(&self.metrics);
let tls_configs = Arc::clone(&self.tls_configs); let tls_configs = Arc::clone(&self.tls_configs);
let shared_tls_acceptor = Arc::clone(&self.shared_tls_acceptor);
let http_proxy = Arc::clone(&self.http_proxy); let http_proxy = Arc::clone(&self.http_proxy);
let conn_config = Arc::clone(&self.conn_config); let conn_config = Arc::clone(&self.conn_config);
let conn_tracker = Arc::clone(&self.conn_tracker); let conn_tracker = Arc::clone(&self.conn_tracker);
@@ -196,7 +247,7 @@ impl TcpListenerManager {
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
Self::accept_loop( Self::accept_loop(
listener, port, route_manager_swap, metrics, tls_configs, listener, port, route_manager_swap, metrics, tls_configs,
http_proxy, conn_config, conn_tracker, cancel, relay, shared_tls_acceptor, http_proxy, conn_config, conn_tracker, cancel, relay,
).await; ).await;
}); });
@@ -284,7 +335,8 @@ impl TcpListenerManager {
port: u16, port: u16,
route_manager_swap: Arc<ArcSwap<RouteManager>>, route_manager_swap: Arc<ArcSwap<RouteManager>>,
metrics: Arc<MetricsCollector>, metrics: Arc<MetricsCollector>,
tls_configs: Arc<HashMap<String, TlsCertConfig>>, tls_configs: Arc<ArcSwap<HashMap<String, TlsCertConfig>>>,
shared_tls_acceptor: Arc<ArcSwap<Option<TlsAcceptor>>>,
http_proxy: Arc<HttpProxyService>, http_proxy: Arc<HttpProxyService>,
conn_config: Arc<ConnectionConfig>, conn_config: Arc<ConnectionConfig>,
conn_tracker: Arc<ConnectionTracker>, conn_tracker: Arc<ConnectionTracker>,
@@ -314,7 +366,10 @@ impl TcpListenerManager {
// Load the latest route manager from ArcSwap on each connection // Load the latest route manager from ArcSwap on each connection
let rm = route_manager_swap.load_full(); let rm = route_manager_swap.load_full();
let m = Arc::clone(&metrics); let m = Arc::clone(&metrics);
let tc = Arc::clone(&tls_configs); // Load the latest TLS configs from ArcSwap on each connection
let tc = tls_configs.load_full();
// Load the latest shared TLS acceptor from ArcSwap
let sa = shared_tls_acceptor.load_full();
let hp = Arc::clone(&http_proxy); let hp = Arc::clone(&http_proxy);
let cc = Arc::clone(&conn_config); let cc = Arc::clone(&conn_config);
let ct = Arc::clone(&conn_tracker); let ct = Arc::clone(&conn_tracker);
@@ -324,7 +379,7 @@ impl TcpListenerManager {
tokio::spawn(async move { tokio::spawn(async move {
let result = Self::handle_connection( let result = Self::handle_connection(
stream, port, peer_addr, rm, m, tc, hp, cc, cn, sr, stream, port, peer_addr, rm, m, tc, sa, hp, cc, cn, sr,
).await; ).await;
if let Err(e) = result { if let Err(e) = result {
debug!("Connection error from {}: {}", peer_addr, e); debug!("Connection error from {}: {}", peer_addr, e);
@@ -350,6 +405,7 @@ impl TcpListenerManager {
route_manager: Arc<RouteManager>, route_manager: Arc<RouteManager>,
metrics: Arc<MetricsCollector>, metrics: Arc<MetricsCollector>,
tls_configs: Arc<HashMap<String, TlsCertConfig>>, tls_configs: Arc<HashMap<String, TlsCertConfig>>,
shared_tls_acceptor: Arc<Option<TlsAcceptor>>,
http_proxy: Arc<HttpProxyService>, http_proxy: Arc<HttpProxyService>,
conn_config: Arc<ConnectionConfig>, conn_config: Arc<ConnectionConfig>,
cancel: CancellationToken, cancel: CancellationToken,
@@ -359,11 +415,18 @@ impl TcpListenerManager {
stream.set_nodelay(true)?; stream.set_nodelay(true)?;
// Extract source IP once for all metric calls
let ip_str = peer_addr.ip().to_string();
// === Fast path: try port-only matching before peeking at data === // === Fast path: try port-only matching before peeking at data ===
// This handles "server-speaks-first" protocols where the client // This handles "server-speaks-first" protocols where the client
// doesn't send initial data (e.g., SMTP, greeting-based protocols). // doesn't send initial data (e.g., SMTP, greeting-based protocols).
// If a route matches by port alone and doesn't need domain/path/TLS info, // If a route matches by port alone and doesn't need domain/path/TLS info,
// we can forward immediately without waiting for client data. // we can forward immediately without waiting for client data.
//
// IMPORTANT: HTTP connections must NOT use this path — they need the HTTP
// proxy for proper error responses, CORS handling, and request-level routing.
// We detect HTTP via a non-blocking peek before committing to raw forwarding.
{ {
let quick_ctx = rustproxy_routing::MatchContext { let quick_ctx = rustproxy_routing::MatchContext {
port, port,
@@ -373,6 +436,7 @@ impl TcpListenerManager {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None,
}; };
if let Some(quick_match) = route_manager.find_route(&quick_ctx) { if let Some(quick_match) = route_manager.find_route(&quick_ctx) {
@@ -384,7 +448,28 @@ impl TcpListenerManager {
// Only use fast path for simple port-only forward routes with no TLS // Only use fast path for simple port-only forward routes with no TLS
if has_no_domain && has_no_path && is_forward && has_no_tls { if has_no_domain && has_no_path && is_forward && has_no_tls {
if let Some(target) = quick_match.target { // Non-blocking peek: if client has already sent data that looks
// like HTTP, skip fast path and let the normal path handle it
// through the HTTP proxy (for CORS, error responses, path routing).
let is_likely_http = {
let mut probe = [0u8; 16];
// Brief peek: HTTP clients send data immediately after connect.
// Server-speaks-first protocols (SMTP etc.) send nothing initially.
// 10ms is ample for any HTTP client while negligible for
// server-speaks-first protocols (which wait seconds for greeting).
match tokio::time::timeout(
std::time::Duration::from_millis(10),
stream.peek(&mut probe),
).await {
Ok(Ok(n)) if n > 0 => sni_parser::is_http(&probe[..n]),
_ => false,
}
};
if is_likely_http {
debug!("Fast-path skipped: HTTP detected from {}, using HTTP proxy", peer_addr);
// Fall through to normal path for HTTP proxy handling
} else if let Some(target) = quick_match.target {
let target_host = target.host.first().to_string(); let target_host = target.host.first().to_string();
let target_port = target.port.resolve(port); let target_port = target.port.resolve(port);
let route_id = quick_match.route.id.as_deref(); let route_id = quick_match.route.id.as_deref();
@@ -399,7 +484,8 @@ impl TcpListenerManager {
} }
} }
metrics.connection_opened(route_id); metrics.connection_opened(route_id, Some(&ip_str));
let _fast_guard = ConnectionGuard::new(Arc::clone(&metrics), route_id, Some(&ip_str));
let connect_timeout = std::time::Duration::from_millis(conn_config.connection_timeout_ms); let connect_timeout = std::time::Duration::from_millis(conn_config.connection_timeout_ms);
let inactivity_timeout = std::time::Duration::from_millis(conn_config.socket_timeout_ms); let inactivity_timeout = std::time::Duration::from_millis(conn_config.socket_timeout_ms);
@@ -415,14 +501,8 @@ impl TcpListenerManager {
tokio::net::TcpStream::connect(format!("{}:{}", target_host, target_port)), tokio::net::TcpStream::connect(format!("{}:{}", target_host, target_port)),
).await { ).await {
Ok(Ok(s)) => s, Ok(Ok(s)) => s,
Ok(Err(e)) => { Ok(Err(e)) => return Err(e.into()),
metrics.connection_closed(route_id); Err(_) => return Err("Backend connection timeout".into()),
return Err(e.into());
}
Err(_) => {
metrics.connection_closed(route_id);
return Err("Backend connection timeout".into());
}
}; };
backend.set_nodelay(true)?; backend.set_nodelay(true)?;
@@ -440,20 +520,27 @@ impl TcpListenerManager {
let mut backend_w = backend; let mut backend_w = backend;
backend_w.write_all(header.as_bytes()).await?; backend_w.write_all(header.as_bytes()).await?;
let (bytes_in, bytes_out) = forwarder::forward_bidirectional_with_timeouts( let (_bytes_in, _bytes_out) = forwarder::forward_bidirectional_with_timeouts(
stream, backend_w, None, stream, backend_w, None,
inactivity_timeout, max_lifetime, cancel, inactivity_timeout, max_lifetime, cancel,
Some(forwarder::ForwardMetricsCtx {
collector: Arc::clone(&metrics),
route_id: route_id.map(|s| s.to_string()),
source_ip: Some(ip_str.clone()),
}),
).await?; ).await?;
metrics.record_bytes(bytes_in, bytes_out, route_id);
} else { } else {
let (bytes_in, bytes_out) = forwarder::forward_bidirectional_with_timeouts( let (_bytes_in, _bytes_out) = forwarder::forward_bidirectional_with_timeouts(
stream, backend, None, stream, backend, None,
inactivity_timeout, max_lifetime, cancel, inactivity_timeout, max_lifetime, cancel,
Some(forwarder::ForwardMetricsCtx {
collector: Arc::clone(&metrics),
route_id: route_id.map(|s| s.to_string()),
source_ip: Some(ip_str.clone()),
}),
).await?; ).await?;
metrics.record_bytes(bytes_in, bytes_out, route_id);
} }
metrics.connection_closed(route_id);
return Ok(()); return Ok(());
} }
} }
@@ -554,6 +641,8 @@ impl TcpListenerManager {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls, is_tls,
// For TLS connections, protocol is unknown until after termination
protocol: if is_http { Some("http") } else if !is_tls { Some("tcp") } else { None },
}; };
let route_match = route_manager.find_route(&ctx); let route_match = route_manager.find_route(&ctx);
@@ -562,6 +651,17 @@ impl TcpListenerManager {
Some(rm) => rm, Some(rm) => rm,
None => { None => {
debug!("No route matched for port {} domain {:?}", port, domain); debug!("No route matched for port {} domain {:?}", port, domain);
if is_http {
// Send a proper HTTP error instead of dropping the connection
use tokio::io::AsyncWriteExt;
let body = "No route matched";
let resp = format!(
"HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body
);
let _ = stream.write_all(resp.as_bytes()).await;
let _ = stream.shutdown().await;
}
return Ok(()); return Ok(());
} }
}; };
@@ -579,8 +679,9 @@ impl TcpListenerManager {
} }
} }
// Track connection in metrics // Track connection in metrics — guard ensures connection_closed on all exit paths
metrics.connection_opened(route_id); metrics.connection_opened(route_id, Some(&ip_str));
let _conn_guard = ConnectionGuard::new(Arc::clone(&metrics), route_id, Some(&ip_str));
// Check if this is a socket-handler route that should be relayed to TypeScript // Check if this is a socket-handler route that should be relayed to TypeScript
if route_match.route.action.action_type == RouteActionType::SocketHandler { if route_match.route.action.action_type == RouteActionType::SocketHandler {
@@ -590,16 +691,14 @@ impl TcpListenerManager {
}; };
if let Some(relay_socket_path) = relay_path { if let Some(relay_socket_path) = relay_path {
let result = Self::relay_to_socket_handler( return Self::relay_to_socket_handler(
stream, n, port, peer_addr, stream, n, port, peer_addr,
&route_match, domain.as_deref(), is_tls, &route_match, domain.as_deref(), is_tls,
&relay_socket_path, &relay_socket_path,
&metrics, route_id,
).await; ).await;
metrics.connection_closed(route_id);
return result;
} else { } else {
debug!("Socket-handler route matched but no relay path configured"); debug!("Socket-handler route matched but no relay path configured");
metrics.connection_closed(route_id);
return Ok(()); return Ok(());
} }
} }
@@ -608,7 +707,6 @@ impl TcpListenerManager {
Some(t) => t, Some(t) => t,
None => { None => {
debug!("Route matched but no target available"); debug!("Route matched but no target available");
metrics.connection_closed(route_id);
return Ok(()); return Ok(());
} }
}; };
@@ -688,21 +786,21 @@ impl TcpListenerManager {
let mut actual_buf = vec![0u8; n]; let mut actual_buf = vec![0u8; n];
stream.read_exact(&mut actual_buf).await?; stream.read_exact(&mut actual_buf).await?;
let (bytes_in, bytes_out) = forwarder::forward_bidirectional_with_timeouts( let (_bytes_in, _bytes_out) = forwarder::forward_bidirectional_with_timeouts(
stream, backend, Some(&actual_buf), stream, backend, Some(&actual_buf),
inactivity_timeout, max_lifetime, cancel, inactivity_timeout, max_lifetime, cancel,
Some(forwarder::ForwardMetricsCtx {
collector: Arc::clone(&metrics),
route_id: route_id.map(|s| s.to_string()),
source_ip: Some(ip_str.clone()),
}),
).await?; ).await?;
metrics.record_bytes(bytes_in, bytes_out, route_id);
Ok(()) Ok(())
} }
Some(rustproxy_config::TlsMode::Terminate) => { Some(rustproxy_config::TlsMode::Terminate) => {
let tls_config = Self::find_tls_config(&domain, &tls_configs)?; // Use shared acceptor (session resumption) or fall back to per-connection
// TLS accept with timeout, applying route-level TLS settings
let route_tls = route_match.route.action.tls.as_ref(); let route_tls = route_match.route.action.tls.as_ref();
let acceptor = tls_handler::build_tls_acceptor_with_config( let acceptor = Self::get_tls_acceptor(&domain, &tls_configs, &*shared_tls_acceptor, route_tls)?;
&tls_config.cert_pem, &tls_config.key_pem, route_tls,
)?;
let tls_stream = match tokio::time::timeout( let tls_stream = match tokio::time::timeout(
std::time::Duration::from_millis(conn_config.initial_data_timeout_ms), std::time::Duration::from_millis(conn_config.initial_data_timeout_ms),
tls_handler::accept_tls(stream, &acceptor), tls_handler::accept_tls(stream, &acceptor),
@@ -722,12 +820,21 @@ impl TcpListenerManager {
} }
}; };
// Check protocol restriction from route config
if let Some(ref required_protocol) = route_match.route.route_match.protocol {
let detected = if peeked { "http" } else { "tcp" };
if required_protocol != detected {
debug!("Protocol mismatch: route requires '{}', got '{}'", required_protocol, detected);
return Err("Protocol mismatch".into());
}
}
if peeked { if peeked {
debug!( debug!(
"TLS Terminate + HTTP: {} -> {}:{} (domain: {:?})", "TLS Terminate + HTTP: {} -> {}:{} (domain: {:?})",
peer_addr, target_host, target_port, domain peer_addr, target_host, target_port, domain
); );
http_proxy.handle_io(buf_stream, peer_addr, port).await; http_proxy.handle_io(buf_stream, peer_addr, port, cancel.clone()).await;
} else { } else {
debug!( debug!(
"TLS Terminate + TCP: {} -> {}:{} (domain: {:?})", "TLS Terminate + TCP: {} -> {}:{} (domain: {:?})",
@@ -747,27 +854,77 @@ impl TcpListenerManager {
let (tls_read, tls_write) = tokio::io::split(buf_stream); let (tls_read, tls_write) = tokio::io::split(buf_stream);
let (backend_read, backend_write) = tokio::io::split(backend); let (backend_read, backend_write) = tokio::io::split(backend);
let (bytes_in, bytes_out) = Self::forward_bidirectional_split_with_timeouts( let (_bytes_in, _bytes_out) = Self::forward_bidirectional_split_with_timeouts(
tls_read, tls_write, backend_read, backend_write, tls_read, tls_write, backend_read, backend_write,
inactivity_timeout, max_lifetime, inactivity_timeout, max_lifetime,
Some(forwarder::ForwardMetricsCtx {
collector: Arc::clone(&metrics),
route_id: route_id.map(|s| s.to_string()),
source_ip: Some(ip_str.clone()),
}),
).await; ).await;
metrics.record_bytes(bytes_in, bytes_out, route_id);
} }
Ok(()) Ok(())
} }
Some(rustproxy_config::TlsMode::TerminateAndReencrypt) => { Some(rustproxy_config::TlsMode::TerminateAndReencrypt) => {
// Inline TLS accept + HTTP detection (same pattern as Terminate mode)
let route_tls = route_match.route.action.tls.as_ref(); let route_tls = route_match.route.action.tls.as_ref();
Self::handle_tls_terminate_reencrypt( let acceptor = Self::get_tls_acceptor(&domain, &tls_configs, &*shared_tls_acceptor, route_tls)?;
stream, n, &domain, &target_host, target_port, let tls_stream = match tokio::time::timeout(
peer_addr, &tls_configs, &metrics, route_id, &conn_config, route_tls, std::time::Duration::from_millis(conn_config.initial_data_timeout_ms),
).await tls_handler::accept_tls(stream, &acceptor),
).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => return Err(e),
Err(_) => return Err("TLS handshake timeout".into()),
};
// Peek at decrypted data to detect protocol
let mut buf_stream = tokio::io::BufReader::new(tls_stream);
let is_http_data = {
use tokio::io::AsyncBufReadExt;
match buf_stream.fill_buf().await {
Ok(data) => sni_parser::is_http(data),
Err(_) => false,
}
};
// Check protocol restriction from route config
if let Some(ref required_protocol) = route_match.route.route_match.protocol {
let detected = if is_http_data { "http" } else { "tcp" };
if required_protocol != detected {
debug!("Protocol mismatch: route requires '{}', got '{}'", required_protocol, detected);
return Err("Protocol mismatch".into());
}
}
if is_http_data {
// HTTP: full per-request routing via HttpProxyService
// (backend TLS handled by HttpProxyService when upstream.use_tls is set)
debug!(
"TLS Terminate+Reencrypt + HTTP: {} (domain: {:?})",
peer_addr, domain
);
http_proxy.handle_io(buf_stream, peer_addr, port, cancel.clone()).await;
} else {
// Non-HTTP: TLS-to-TLS tunnel (existing behavior for raw TCP protocols)
debug!(
"TLS Terminate+Reencrypt + TCP: {} -> {}:{}",
peer_addr, target_host, target_port
);
Self::handle_tls_reencrypt_tunnel(
buf_stream, &target_host, target_port,
peer_addr, Arc::clone(&metrics), route_id,
&conn_config,
).await?;
}
Ok(())
} }
None => { None => {
if is_http { if is_http {
// Plain HTTP - use HTTP proxy for request-level routing // Plain HTTP - use HTTP proxy for request-level routing
debug!("HTTP proxy: {} on port {}", peer_addr, port); debug!("HTTP proxy: {} on port {}", peer_addr, port);
http_proxy.handle_connection(stream, peer_addr, port).await; http_proxy.handle_connection(stream, peer_addr, port, cancel.clone()).await;
Ok(()) Ok(())
} else { } else {
// Plain TCP forwarding (non-HTTP) // Plain TCP forwarding (non-HTTP)
@@ -795,17 +952,21 @@ impl TcpListenerManager {
let mut actual_buf = vec![0u8; n]; let mut actual_buf = vec![0u8; n];
stream.read_exact(&mut actual_buf).await?; stream.read_exact(&mut actual_buf).await?;
let (bytes_in, bytes_out) = forwarder::forward_bidirectional_with_timeouts( let (_bytes_in, _bytes_out) = forwarder::forward_bidirectional_with_timeouts(
stream, backend, Some(&actual_buf), stream, backend, Some(&actual_buf),
inactivity_timeout, max_lifetime, cancel, inactivity_timeout, max_lifetime, cancel,
Some(forwarder::ForwardMetricsCtx {
collector: Arc::clone(&metrics),
route_id: route_id.map(|s| s.to_string()),
source_ip: Some(ip_str.clone()),
}),
).await?; ).await?;
metrics.record_bytes(bytes_in, bytes_out, route_id);
Ok(()) Ok(())
} }
} }
}; };
metrics.connection_closed(route_id); // ConnectionGuard handles metrics.connection_closed() on drop
result result
} }
@@ -825,6 +986,8 @@ impl TcpListenerManager {
domain: Option<&str>, domain: Option<&str>,
is_tls: bool, is_tls: bool,
relay_path: &str, relay_path: &str,
metrics: &MetricsCollector,
route_id: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream; use tokio::net::UnixStream;
@@ -865,12 +1028,22 @@ impl TcpListenerManager {
unix_stream.write_all(&initial_buf).await?; unix_stream.write_all(&initial_buf).await?;
// Bidirectional relay between TCP client and Unix socket handler // Bidirectional relay between TCP client and Unix socket handler
let initial_len = initial_buf.len() as u64;
match tokio::io::copy_bidirectional(&mut stream, &mut unix_stream).await { match tokio::io::copy_bidirectional(&mut stream, &mut unix_stream).await {
Ok((c2s, s2c)) => { Ok((c2s, s2c)) => {
// Include initial data bytes that were forwarded before copy_bidirectional
let total_in = c2s + initial_len;
debug!("Socket handler relay complete for {}: {} bytes in, {} bytes out", debug!("Socket handler relay complete for {}: {} bytes in, {} bytes out",
route_key, c2s, s2c); route_key, total_in, s2c);
let ip = peer_addr.ip().to_string();
metrics.record_bytes(total_in, s2c, route_id, Some(&ip));
} }
Err(e) => { Err(e) => {
// Still record the initial data even on error
if initial_len > 0 {
let ip = peer_addr.ip().to_string();
metrics.record_bytes(initial_len, 0, route_id, Some(&ip));
}
debug!("Socket handler relay ended for {}: {}", route_key, e); debug!("Socket handler relay ended for {}: {}", route_key, e);
} }
} }
@@ -878,40 +1051,18 @@ impl TcpListenerManager {
Ok(()) Ok(())
} }
/// Handle TLS terminate-and-reencrypt: accept TLS from client, connect TLS to backend. /// Handle non-HTTP TLS-to-TLS tunnel for terminate-and-reencrypt mode.
async fn handle_tls_terminate_reencrypt( /// TLS accept has already been done by the caller; this only connects to the
stream: tokio::net::TcpStream, /// backend over TLS and forwards bidirectionally.
_peek_len: usize, async fn handle_tls_reencrypt_tunnel(
domain: &Option<String>, buf_stream: tokio::io::BufReader<tokio_rustls::server::TlsStream<tokio::net::TcpStream>>,
target_host: &str, target_host: &str,
target_port: u16, target_port: u16,
peer_addr: std::net::SocketAddr, peer_addr: std::net::SocketAddr,
tls_configs: &HashMap<String, TlsCertConfig>, metrics: Arc<MetricsCollector>,
metrics: &MetricsCollector,
route_id: Option<&str>, route_id: Option<&str>,
conn_config: &ConnectionConfig, conn_config: &ConnectionConfig,
route_tls: Option<&rustproxy_config::RouteTls>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let tls_config = Self::find_tls_config(domain, tls_configs)?;
let acceptor = tls_handler::build_tls_acceptor_with_config(
&tls_config.cert_pem, &tls_config.key_pem, route_tls,
)?;
// Accept TLS from client with timeout
let client_tls = match tokio::time::timeout(
std::time::Duration::from_millis(conn_config.initial_data_timeout_ms),
tls_handler::accept_tls(stream, &acceptor),
).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => return Err(e),
Err(_) => return Err("TLS handshake timeout".into()),
};
debug!(
"TLS Terminate+Reencrypt: {} -> {}:{} (domain: {:?})",
peer_addr, target_host, target_port, domain
);
// Connect to backend over TLS with timeout // Connect to backend over TLS with timeout
let backend_tls = match tokio::time::timeout( let backend_tls = match tokio::time::timeout(
std::time::Duration::from_millis(conn_config.connection_timeout_ms), std::time::Duration::from_millis(conn_config.connection_timeout_ms),
@@ -922,8 +1073,9 @@ impl TcpListenerManager {
Err(_) => return Err("Backend TLS connection timeout".into()), Err(_) => return Err("Backend TLS connection timeout".into()),
}; };
// Forward between two TLS streams // Forward between decrypted client stream and backend TLS stream
let (client_read, client_write) = tokio::io::split(client_tls); // (BufReader preserves any already-buffered data from the peek)
let (client_read, client_write) = tokio::io::split(buf_stream);
let (backend_read, backend_write) = tokio::io::split(backend_tls); let (backend_read, backend_write) = tokio::io::split(backend_tls);
let base_inactivity_ms = conn_config.socket_timeout_ms; let base_inactivity_ms = conn_config.socket_timeout_ms;
@@ -952,15 +1104,43 @@ impl TcpListenerManager {
} }
}; };
let (bytes_in, bytes_out) = Self::forward_bidirectional_split_with_timeouts( let (_bytes_in, _bytes_out) = Self::forward_bidirectional_split_with_timeouts(
client_read, client_write, backend_read, backend_write, client_read, client_write, backend_read, backend_write,
inactivity_timeout, max_lifetime, inactivity_timeout, max_lifetime,
Some(forwarder::ForwardMetricsCtx {
collector: metrics,
route_id: route_id.map(|s| s.to_string()),
source_ip: Some(peer_addr.ip().to_string()),
}),
).await; ).await;
metrics.record_bytes(bytes_in, bytes_out, route_id);
Ok(()) Ok(())
} }
/// Get a TLS acceptor, preferring the shared one (with session resumption)
/// and falling back to per-connection when custom TLS versions are configured.
fn get_tls_acceptor(
domain: &Option<String>,
tls_configs: &HashMap<String, TlsCertConfig>,
shared_tls_acceptor: &Option<TlsAcceptor>,
route_tls: Option<&rustproxy_config::RouteTls>,
) -> Result<TlsAcceptor, Box<dyn std::error::Error + Send + Sync>> {
let has_custom_versions = route_tls
.and_then(|t| t.versions.as_ref())
.map(|v| !v.is_empty())
.unwrap_or(false);
if !has_custom_versions {
if let Some(shared) = shared_tls_acceptor {
return Ok(shared.clone()); // TlsAcceptor wraps Arc<ServerConfig>, clone is cheap
}
}
// Fallback: per-connection acceptor (custom TLS versions or shared build failed)
let tls_config = Self::find_tls_config(domain, tls_configs)?;
tls_handler::build_tls_acceptor_with_config(&tls_config.cert_pem, &tls_config.key_pem, route_tls)
}
/// Find the TLS config for a given domain. /// Find the TLS config for a given domain.
fn find_tls_config<'a>( fn find_tls_config<'a>(
domain: &Option<String>, domain: &Option<String>,
@@ -991,6 +1171,9 @@ impl TcpListenerManager {
} }
/// Forward bidirectional between two split streams with inactivity and lifetime timeouts. /// Forward bidirectional between two split streams with inactivity and lifetime timeouts.
///
/// When `metrics` is provided, bytes are reported per-chunk (lock-free) for
/// real-time throughput measurement.
async fn forward_bidirectional_split_with_timeouts<R1, W1, R2, W2>( async fn forward_bidirectional_split_with_timeouts<R1, W1, R2, W2>(
mut client_read: R1, mut client_read: R1,
mut client_write: W1, mut client_write: W1,
@@ -998,6 +1181,7 @@ impl TcpListenerManager {
mut backend_write: W2, mut backend_write: W2,
inactivity_timeout: std::time::Duration, inactivity_timeout: std::time::Duration,
max_lifetime: std::time::Duration, max_lifetime: std::time::Duration,
metrics: Option<forwarder::ForwardMetricsCtx>,
) -> (u64, u64) ) -> (u64, u64)
where where
R1: tokio::io::AsyncRead + Unpin + Send + 'static, R1: tokio::io::AsyncRead + Unpin + Send + 'static,
@@ -1013,6 +1197,7 @@ impl TcpListenerManager {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let la1 = Arc::clone(&last_activity); let la1 = Arc::clone(&last_activity);
let metrics_c2b = metrics.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 = 0u64; let mut total = 0u64;
@@ -1029,12 +1214,16 @@ impl TcpListenerManager {
start.elapsed().as_millis() as u64, start.elapsed().as_millis() as u64,
Ordering::Relaxed, 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; let _ = backend_write.shutdown().await;
total total
}); });
let la2 = Arc::clone(&last_activity); let la2 = Arc::clone(&last_activity);
let metrics_b2c = metrics;
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;
@@ -1051,6 +1240,9 @@ impl TcpListenerManager {
start.elapsed().as_millis() as u64, start.elapsed().as_millis() as u64,
Ordering::Relaxed, 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; let _ = client_write.shutdown().await;
total total

View File

@@ -1,17 +1,99 @@
use std::collections::HashMap;
use std::io::BufReader; use std::io::BufReader;
use std::sync::Arc; use std::sync::Arc;
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));
// 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) and ticket support");
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.
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)

View File

@@ -12,6 +12,8 @@ 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" or "tcp". None when unknown (e.g. pre-TLS-termination).
pub protocol: Option<&'a str>,
} }
/// Result of a route match. /// Result of a route match.
@@ -87,9 +89,17 @@ 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.
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 +147,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
} }
@@ -277,6 +298,7 @@ mod tests {
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,
@@ -327,6 +349,7 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None,
}; };
let result = manager.find_route(&ctx); let result = manager.find_route(&ctx);
@@ -349,6 +372,7 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None,
}; };
let result = manager.find_route(&ctx).unwrap(); let result = manager.find_route(&ctx).unwrap();
@@ -372,6 +396,7 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None,
}; };
assert!(manager.find_route(&ctx).is_none()); assert!(manager.find_route(&ctx).is_none());
@@ -457,6 +482,116 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: 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,
};
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,
};
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,
};
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,
};
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,
}; };
assert!(manager.find_route(&ctx).is_some()); assert!(manager.find_route(&ctx).is_some());
@@ -475,6 +610,7 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: None,
}; };
assert!(manager.find_route(&ctx).is_some()); assert!(manager.find_route(&ctx).is_some());
@@ -525,6 +661,7 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: 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 +675,102 @@ mod tests {
tls_version: None, tls_version: None,
headers: None, headers: None,
is_tls: false, is_tls: false,
protocol: 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"),
};
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"),
};
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"),
};
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"),
};
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,
};
assert!(manager.find_route(&ctx).is_some());
}
} }

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

@@ -27,7 +27,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;
@@ -71,11 +71,14 @@ pub struct RustProxy {
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<()>>,
sampling_handle: Option<tokio::task::JoinHandle<()>>,
nft_manager: Option<NftManager>, nft_manager: Option<NftManager>,
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>,
} }
impl RustProxy { impl RustProxy {
@@ -100,18 +103,24 @@ 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()), metrics: Arc::new(MetricsCollector::with_retention(retention)),
cert_manager, cert_manager,
challenge_server: None, challenge_server: None,
renewal_handle: None, renewal_handle: None,
sampling_handle: None,
nft_manager: None, nft_manager: 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(),
}) })
} }
@@ -184,15 +193,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))
} }
@@ -222,19 +228,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;
@@ -278,6 +271,13 @@ 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());
}
}
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);
@@ -292,6 +292,21 @@ impl RustProxy {
self.started = true; self.started = true;
self.started_at = Some(Instant::now()); self.started_at = Some(Instant::now());
// Start the throughput sampling task
let metrics = Arc::clone(&self.metrics);
let interval_ms = self.options.metrics.as_ref()
.and_then(|m| m.sample_interval_ms)
.unwrap_or(1000);
self.sampling_handle = Some(tokio::spawn(async move {
let mut interval = tokio::time::interval(
std::time::Duration::from_millis(interval_ms)
);
loop {
interval.tick().await;
metrics.sample_all();
}
}));
// Apply NFTables rules for routes using nftables forwarding engine // Apply NFTables rules for routes using nftables forwarding engine
self.apply_nftables_rules(&self.options.routes.clone()).await; self.apply_nftables_rules(&self.options.routes.clone()).await;
@@ -396,9 +411,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);
} }
@@ -496,6 +509,11 @@ impl RustProxy {
info!("Stopping RustProxy..."); info!("Stopping RustProxy...");
// Stop sampling task
if let Some(handle) = self.sampling_handle.take() {
handle.abort();
}
// Stop renewal timer // Stop renewal timer
if let Some(handle) = self.renewal_handle.take() { if let Some(handle) = self.renewal_handle.take() {
handle.abort(); handle.abort();
@@ -547,6 +565,12 @@ 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);
// 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));
@@ -568,6 +592,12 @@ 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);
// Add new ports // Add new ports
@@ -775,10 +805,15 @@ 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))?;
} }
// Persist in loaded_certs so future rebuild calls include this cert
self.loaded_certs.insert(domain.to_string(), TlsCertConfig {
cert_pem: cert_pem.clone(),
key_pem: key_pem.clone(),
});
// Hot-swap TLS config on the listener // Hot-swap TLS config on the listener
if let Some(ref mut listener) = self.listener_manager { 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);
@@ -802,6 +837,13 @@ impl RustProxy {
} }
} }
// Merge dynamically loaded certs from previous loadCertificate calls
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);
} }

View File

@@ -29,6 +29,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

@@ -185,6 +185,76 @@ 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;
let acceptor = rustproxy_passthrough::build_tls_acceptor(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,
@@ -201,6 +271,7 @@ pub fn make_test_route(
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,
@@ -381,6 +452,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

@@ -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;

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

@@ -0,0 +1,123 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
createHttpsTerminateRoute,
createCompleteHttpsServer,
createHttpRoute,
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
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 - createHttpsTerminateRoute produces correct structure', async () => {
const route = createHttpsTerminateRoute('secure.example.com', { host: '127.0.0.1', port: 8443 });
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 creation - createCompleteHttpsServer returns redirect and main route', async () => {
const routes = createCompleteHttpsServer('app.example.com', { host: '127.0.0.1', port: 3000 });
expect(routes).toBeArray();
expect(routes.length).toBeGreaterThanOrEqual(2);
// Should have an HTTP→HTTPS redirect and an HTTPS route
const hasRedirect = routes.some((r) => r.action.type === 'forward' && r.action.redirect !== undefined);
const hasHttps = routes.some((r) => r.action.tls?.mode === 'terminate');
expect(hasRedirect || hasHttps).toBeTrue();
});
tap.test('route validation - validateRoutes on a set of routes', async () => {
const routes: IRouteConfig[] = [
createHttpRoute('a.com', { host: '127.0.0.1', port: 3000 }),
createHttpRoute('b.com', { 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[] = [
createHttpRoute('valid.com', { 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 = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
route.match.path = '/api';
expect(routeMatchesPath(route, '/api')).toBeTrue();
expect(routeMatchesPath(route, '/other')).toBeFalse();
});
tap.test('path matching - route without path matches everything', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
// No path set, should match any path
expect(routeMatchesPath(route, '/anything')).toBeTrue();
expect(routeMatchesPath(route, '/')).toBeTrue();
});
tap.test('route merging - mergeRouteConfigs combines routes', async () => {
const base = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
base.priority = 10;
base.name = 'base-route';
const merged = mergeRouteConfigs(base, {
priority: 50,
name: 'merged-route',
});
expect(merged.priority).toEqual(50);
expect(merged.name).toEqual('merged-route');
// Original route fields should be preserved
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 = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
base.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 = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
original.priority = 42;
original.name = 'original-route';
const cloned = cloneRoute(original);
// Should be equal in value
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);
// Should be independent - modifying clone shouldn't affect original
cloned.name = 'cloned-route';
cloned.priority = 99;
expect(original.name).toEqual('original-route');
expect(original.priority).toEqual(42);
});
export default tap.start();

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

@@ -0,0 +1,111 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
createHttpRoute,
createHttpsTerminateRoute,
createLoadBalancerRoute,
} from '../ts/proxies/smart-proxy/utils/route-helpers.js';
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 creation - createHttpRoute produces correct structure', async () => {
const route = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
expect(route).toHaveProperty('match');
expect(route).toHaveProperty('action');
expect(route.match.domains).toEqual('example.com');
expect(route.action.type).toEqual('forward');
expect(route.action.targets).toBeArray();
expect(route.action.targets![0].host).toEqual('127.0.0.1');
expect(route.action.targets![0].port).toEqual(3000);
});
tap.test('route creation - createHttpRoute with array of domains', async () => {
const route = createHttpRoute(['a.com', 'b.com'], { host: 'localhost', port: 8080 });
expect(route.match.domains).toEqual(['a.com', 'b.com']);
});
tap.test('route validation - validateRouteConfig accepts valid route', async () => {
const route = createHttpRoute('valid.example.com', { 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 = createHttpRoute('example.com', { 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 = createHttpRoute('*.example.com', { 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 = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
// createHttpRoute defaults to port 80
expect(routeMatchesPort(route, 80)).toBeTrue();
expect(routeMatchesPort(route, 443)).toBeFalse();
});
tap.test('route finding - findBestMatchingRoute selects by priority', async () => {
const lowPriority = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
lowPriority.priority = 10;
const highPriority = createHttpRoute('example.com', { host: '127.0.0.1', port: 4000 });
highPriority.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 = createHttpRoute('example.com', { host: '127.0.0.1', port: 3000 });
const route2 = createHttpRoute('example.com', { host: '127.0.0.1', port: 4000 });
const route3 = createHttpRoute('other.com', { 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

@@ -6,42 +6,49 @@ import { SmartProxy } from '../ts/index.js';
let testProxy: SmartProxy; let testProxy: SmartProxy;
let targetServer: net.Server; let targetServer: net.Server;
const ECHO_PORT = 47200;
const PROXY_PORT = 47201;
// 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 () => {
// 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 +56,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 +66,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,7 +141,7 @@ 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');

View File

@@ -5,8 +5,8 @@ import * as net from 'net';
let smartProxyInstance: SmartProxy; let smartProxyInstance: SmartProxy;
let echoServer: net.Server; let echoServer: net.Server;
const echoServerPort = 9876; const echoServerPort = 47300;
const proxyPort = 8080; const proxyPort = 47301;
// 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 () => {
@@ -16,7 +16,11 @@ tap.test('should create echo server for testing', async () => {
}); });
}); });
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();
@@ -265,4 +269,4 @@ tap.test('should clean up resources', async () => {
}); });
}); });
export default tap.start(); export default tap.start();

View File

@@ -5,19 +5,27 @@ import { SmartProxy } from '../ts/proxies/smart-proxy/smart-proxy.js';
let echoServer: net.Server; let echoServer: net.Server;
let proxy: SmartProxy; let proxy: SmartProxy;
const ECHO_PORT = 47400;
const PROXY_PORT_1 = 47401;
const PROXY_PORT_2 = 47402;
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
// 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 +34,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 +45,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 +70,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 +96,6 @@ tap.test('cleanup', async () => {
}); });
}); });
} }
if (proxy) {
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

@@ -562,4 +562,168 @@ tap.test('Route Integration - Combining Multiple Route Types', async () => {
} }
}); });
// --------------------------------- Protocol Match Field Tests ---------------------------------
tap.test('Routes: Should accept protocol field on route match', async () => {
// Create a route with protocol: 'http'
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',
};
// Validate the route - protocol field should not cause errors
const validation = validateRouteConfig(httpOnlyRoute);
expect(validation.valid).toBeTrue();
// Verify the protocol field is preserved
expect(httpOnlyRoute.match.protocol).toEqual('http');
});
tap.test('Routes: Should accept protocol tcp on route match', async () => {
// Create a route with protocol: 'tcp'
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 () => {
// Create a terminate-and-reencrypt route that only accepts HTTP
const reencryptRoute = createHttpsTerminateRoute(
'secure.example.com',
{ host: 'backend', port: 443 },
{ reencrypt: true, certificate: 'auto', name: 'Reencrypt HTTP Route' }
);
// Set protocol restriction to http
reencryptRoute.match.protocol = 'http';
// Validate the route
const validation = validateRouteConfig(reencryptRoute);
expect(validation.valid).toBeTrue();
// Verify TLS mode
expect(reencryptRoute.action.tls?.mode).toEqual('terminate-and-reencrypt');
// Verify protocol field is preserved
expect(reencryptRoute.match.protocol).toEqual('http');
});
tap.test('Routes: Protocol field should not affect domain/port matching', async () => {
// Routes with and without protocol field should both match the same domain/port
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];
// Both routes should match the domain/port (protocol is a hint for Rust-side matching)
const matches = findMatchingRoutes(routes, { domain: 'example.com', port: 443 });
expect(matches.length).toEqual(2);
// The one with higher priority should be first
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);
// Verify protocol is preserved in clone
expect(cloned.match.protocol).toEqual('http');
expect(cloned.action.tls?.mode).toEqual('terminate-and-reencrypt');
// Modify clone should not affect original
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',
};
// Merge with override that changes name but not protocol
const merged = mergeRouteConfigs(base, { name: 'Merged Route' });
expect(merged.match.protocol).toEqual('http');
expect(merged.name).toEqual('Merged Route');
});
export default tap.start(); export default tap.start();

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

@@ -0,0 +1,699 @@
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';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ────────────────────────────────────────────────────────────────────────────
// Port assignments (unique to avoid conflicts with other tests)
// ────────────────────────────────────────────────────────────────────────────
const TCP_ECHO_PORT = 47500;
const HTTP_ECHO_PORT = 47501;
const TLS_ECHO_PORT = 47502;
const PROXY_TCP_PORT = 47510;
const PROXY_HTTP_PORT = 47511;
const PROXY_TLS_PASS_PORT = 47512;
const PROXY_TLS_TERM_PORT = 47513;
const PROXY_SOCKET_PORT = 47514;
const PROXY_MULTI_A_PORT = 47515;
const PROXY_MULTI_B_PORT = 47516;
const PROXY_TP_HTTP_PORT = 47517;
// ────────────────────────────────────────────────────────────────────────────
// 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 () => {
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);
// Close connection
client.destroy();
await tools.delayFor(500);
// Final check
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()));
// ── v25.2.0: Per-IP tracking (TCP connections) ──
const byIP = m.connections.byIP();
console.log('TCP forward — connections byIP:', Array.from(byIP.entries()));
expect(byIP.size).toBeGreaterThan(0);
const topIPs = m.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 = m.throughput.history(10);
console.log('TCP forward — throughput history length:', history.length);
expect(history.length).toBeGreaterThan(0);
expect(history[0].timestamp).toBeGreaterThan(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');
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', name: '@push.rocks/smartproxy',
version: '23.1.1', version: '25.7.5',
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.'
} }

View File

@@ -85,7 +85,6 @@ export interface IAcmeOptions {
renewThresholdDays?: number; // Days before expiry to renew certificates renewThresholdDays?: number; // Days before expiry to renew certificates
renewCheckIntervalHours?: number; // How often to check for renewals (in hours) renewCheckIntervalHours?: number; // How often to check for renewals (in hours)
autoRenew?: boolean; // Whether to automatically renew certificates autoRenew?: boolean; // Whether to automatically renew certificates
certificateStore?: string; // Directory to store certificates
skipConfiguredCerts?: boolean; // Skip domains with existing certificates skipConfiguredCerts?: boolean; // Skip domains with existing certificates
domainForwards?: IDomainForwardConfig[]; // Domain-specific forwarding configs domainForwards?: IDomainForwardConfig[]; // Domain-specific forwarding configs
} }

View File

@@ -1,4 +1,4 @@
import * as net from 'net'; import * as net from 'node:net';
import { WrappedSocket } from './wrapped-socket.js'; import { WrappedSocket } from './wrapped-socket.js';
/** /**

View File

@@ -1,7 +1,7 @@
import { LifecycleComponent } from './lifecycle-component.js'; import { LifecycleComponent } from './lifecycle-component.js';
import { BinaryHeap } from './binary-heap.js'; import { BinaryHeap } from './binary-heap.js';
import { AsyncMutex } from './async-utils.js'; import { AsyncMutex } from './async-utils.js';
import { EventEmitter } from 'events'; import { EventEmitter } from 'node:events';
/** /**
* Interface for pooled connection * Interface for pooled connection

View File

@@ -3,7 +3,7 @@
* Provides standardized socket cleanup with proper listener and timer management * Provides standardized socket cleanup with proper listener and timer management
*/ */
import type { Socket } from 'net'; import type { Socket } from 'node:net';
export type SocketTracked = { export type SocketTracked = {
cleanup: () => void; cleanup: () => void;

View File

@@ -8,7 +8,7 @@ export { SharedRouteManager as RouteManager } from './core/routing/route-manager
// Export smart-proxy models // Export smart-proxy models
export type { ISmartProxyOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext } from './proxies/smart-proxy/models/index.js'; export type { ISmartProxyOptions, IConnectionRecord, IRouteConfig, IRouteMatch, IRouteAction, IRouteTls, IRouteContext } from './proxies/smart-proxy/models/index.js';
export type { TSmartProxyCertProvisionObject } from './proxies/smart-proxy/models/interfaces.js'; export type { TSmartProxyCertProvisionObject, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './proxies/smart-proxy/models/interfaces.js';
export * from './proxies/smart-proxy/utils/index.js'; export * from './proxies/smart-proxy/utils/index.js';
// Original: export * from './smartproxy/classes.pp.snihandler.js' // Original: export * from './smartproxy/classes.pp.snihandler.js'

View File

@@ -1,15 +1,14 @@
// node native scope // node native scope
import { EventEmitter } from 'events'; import { EventEmitter } from 'node:events';
import * as fs from 'fs'; import * as fs from 'node:fs';
import * as http from 'http'; import * as http from 'node:http';
import * as https from 'https'; import * as net from 'node:net';
import * as net from 'net'; import * as path from 'node:path';
import * as path from 'path'; import * as tls from 'node:tls';
import * as tls from 'tls'; import * as url from 'node:url';
import * as url from 'url'; import * as http2 from 'node:http2';
import * as http2 from 'http2';
export { EventEmitter, fs, http, https, net, path, tls, url, http2 }; export { EventEmitter, fs, http, net, path, tls, url, http2 };
// tsclass scope // tsclass scope
import * as tsclass from '@tsclass/tsclass'; import * as tsclass from '@tsclass/tsclass';
@@ -17,44 +16,19 @@ import * as tsclass from '@tsclass/tsclass';
export { tsclass }; export { tsclass };
// pushrocks scope // pushrocks scope
import * as lik from '@push.rocks/lik';
import * as smartdelay from '@push.rocks/smartdelay';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrequest from '@push.rocks/smartrequest';
import * as smartstring from '@push.rocks/smartstring';
import * as smartfile from '@push.rocks/smartfile';
import * as smartcrypto from '@push.rocks/smartcrypto'; import * as smartcrypto from '@push.rocks/smartcrypto';
import * as smartacme from '@push.rocks/smartacme';
import * as smartacmePlugins from '@push.rocks/smartacme/dist_ts/smartacme.plugins.js';
import * as smartacmeHandlers from '@push.rocks/smartacme/dist_ts/handlers/index.js';
import * as smartlog from '@push.rocks/smartlog'; import * as smartlog from '@push.rocks/smartlog';
import * as smartlogDestinationLocal from '@push.rocks/smartlog/destination-local'; import * as smartlogDestinationLocal from '@push.rocks/smartlog/destination-local';
import * as taskbuffer from '@push.rocks/taskbuffer';
import * as smartrx from '@push.rocks/smartrx';
import * as smartrust from '@push.rocks/smartrust'; import * as smartrust from '@push.rocks/smartrust';
export { export {
lik,
smartdelay,
smartrequest,
smartpromise,
smartstring,
smartfile,
smartcrypto, smartcrypto,
smartacme,
smartacmePlugins,
smartacmeHandlers,
smartlog, smartlog,
smartlogDestinationLocal, smartlogDestinationLocal,
taskbuffer,
smartrx,
smartrust, smartrust,
}; };
// third party scope // third party scope
import prettyMs from 'pretty-ms';
import * as ws from 'ws';
import wsDefault from 'ws';
import { minimatch } from 'minimatch'; import { minimatch } from 'minimatch';
export { prettyMs, ws, wsDefault, minimatch }; export { minimatch };

View File

@@ -5,7 +5,7 @@
* that may span multiple TCP packets. * that may span multiple TCP packets.
*/ */
import { Buffer } from 'buffer'; import { Buffer } from 'node:buffer';
/** /**
* Fragment tracking information * Fragment tracking information

View File

@@ -1,4 +1,4 @@
import { Buffer } from 'buffer'; import { Buffer } from 'node:buffer';
import { import {
TlsRecordType, TlsRecordType,
TlsHandshakeType, TlsHandshakeType,

View File

@@ -1,4 +1,4 @@
import { Buffer } from 'buffer'; import { Buffer } from 'node:buffer';
import { TlsExtensionType, TlsUtils } from '../utils/tls-utils.js'; import { TlsExtensionType, TlsUtils } from '../utils/tls-utils.js';
import { import {
ClientHelloParser, ClientHelloParser,

View File

@@ -2,7 +2,7 @@
* WebSocket Protocol Utilities * WebSocket Protocol Utilities
*/ */
import * as crypto from 'crypto'; import * as crypto from 'node:crypto';
import { WEBSOCKET_MAGIC_STRING } from './constants.js'; import { WEBSOCKET_MAGIC_STRING } from './constants.js';
import type { RawData } from './types.js'; import type { RawData } from './types.js';

View File

@@ -2,6 +2,6 @@
* SmartProxy models * SmartProxy models
*/ */
// Export everything except IAcmeOptions from interfaces // Export everything except IAcmeOptions from interfaces
export type { ISmartProxyOptions, IConnectionRecord, TSmartProxyCertProvisionObject } from './interfaces.js'; export type { ISmartProxyOptions, ISmartProxyCertStore, IConnectionRecord, TSmartProxyCertProvisionObject, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './interfaces.js';
export * from './route-types.js'; export * from './route-types.js';
export * from './metrics-types.js'; export * from './metrics-types.js';

View File

@@ -10,11 +10,23 @@ export interface IAcmeOptions {
useProduction?: boolean; // Use Let's Encrypt production (default: false) useProduction?: boolean; // Use Let's Encrypt production (default: false)
renewThresholdDays?: number; // Days before expiry to renew (default: 30) renewThresholdDays?: number; // Days before expiry to renew (default: 30)
autoRenew?: boolean; // Enable automatic renewal (default: true) autoRenew?: boolean; // Enable automatic renewal (default: true)
certificateStore?: string; // Directory to store certificates (default: './certs')
skipConfiguredCerts?: boolean; skipConfiguredCerts?: boolean;
renewCheckIntervalHours?: number; // How often to check for renewals (default: 24) renewCheckIntervalHours?: number; // How often to check for renewals (default: 24)
routeForwards?: any[]; routeForwards?: any[];
} }
/**
* Consumer-provided certificate storage.
* SmartProxy never writes certs to disk — the consumer owns all persistence.
*/
export interface ISmartProxyCertStore {
/** Load all stored certs on startup (called once before cert provisioning) */
loadAll: () => Promise<Array<{ domain: string; publicKey: string; privateKey: string; ca?: string }>>;
/** Save a cert after successful provisioning */
save: (domain: string, publicKey: string, privateKey: string, ca?: string) => Promise<void>;
/** Remove a cert (optional) */
remove?: (domain: string) => Promise<void>;
}
import type { IRouteConfig } from './route-types.js'; import type { IRouteConfig } from './route-types.js';
/** /**
@@ -22,6 +34,38 @@ import type { IRouteConfig } from './route-types.js';
*/ */
export type TSmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01'; export type TSmartProxyCertProvisionObject = plugins.tsclass.network.ICert | 'http01';
/**
* Communication channel passed as second argument to certProvisionFunction.
* Allows the callback to report metadata back to SmartProxy for event emission.
*/
export interface ICertProvisionEventComms {
/** Informational log */
log: (message: string) => void;
/** Warning (non-fatal) */
warn: (message: string) => void;
/** Error */
error: (message: string) => void;
/** Set the certificate expiry date (for the issued event) */
setExpiryDate: (date: Date) => void;
/** Set the source/method used for provisioning (e.g. 'smartacme-dns-01') */
setSource: (source: string) => void;
}
/** Payload for 'certificate-issued' and 'certificate-renewed' events */
export interface ICertificateIssuedEvent {
domain: string;
expiryDate?: string; // ISO 8601
source: string; // e.g. 'certProvisionFunction', 'smartacme-dns-01'
isRenewal?: boolean;
}
/** Payload for 'certificate-failed' event */
export interface ICertificateFailedEvent {
domain: string;
error: string;
source: string;
}
// Legacy options and type checking functions have been removed // Legacy options and type checking functions have been removed
/** /**
@@ -128,7 +172,7 @@ export interface ISmartProxyOptions {
* Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges, * Optional certificate provider callback. Return 'http01' to use HTTP-01 challenges,
* or a static certificate object for immediate provisioning. * or a static certificate object for immediate provisioning.
*/ */
certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>; certProvisionFunction?: (domain: string, eventComms: ICertProvisionEventComms) => Promise<TSmartProxyCertProvisionObject>;
/** /**
* Whether to fallback to ACME if custom certificate provision fails. * Whether to fallback to ACME if custom certificate provision fails.
@@ -136,6 +180,35 @@ export interface ISmartProxyOptions {
*/ */
certProvisionFallbackToAcme?: boolean; certProvisionFallbackToAcme?: boolean;
/**
* Per-domain timeout in ms for certProvisionFunction calls.
* If a single domain's provisioning takes longer than this, it's aborted
* and a certificate-failed event is emitted.
* Default: 300000 (5 minutes)
*/
certProvisionTimeout?: number;
/**
* Maximum number of domains to provision certificates for concurrently.
* Prevents overwhelming ACME providers when many domains provision at once.
* Default: 4
*/
certProvisionConcurrency?: number;
/**
* Disable the default self-signed fallback certificate.
* When false (default), a self-signed cert is generated at startup and loaded
* as '*' so TLS handshakes never fail due to missing certs.
*/
disableDefaultCert?: boolean;
/**
* Consumer-provided cert storage. SmartProxy never writes certs to disk.
* On startup, loadAll() is called to pre-load persisted certs.
* After each successful cert provision, save() is called.
*/
certStore?: ISmartProxyCertStore;
/** /**
* Path to the RustProxy binary. If not set, the binary is located * Path to the RustProxy binary. If not set, the binary is located
* automatically via env var, platform package, local build, or PATH. * automatically via env var, platform package, local build, or PATH.

View File

@@ -39,6 +39,7 @@ export interface IRouteMatch {
clientIp?: string[]; // Match specific client IPs clientIp?: string[]; // Match specific client IPs
tlsVersion?: string[]; // Match specific TLS versions tlsVersion?: string[]; // Match specific TLS versions
headers?: Record<string, string | RegExp>; // Match specific HTTP headers headers?: Record<string, string | RegExp>; // Match specific HTTP headers
protocol?: 'http' | 'tcp'; // Match specific protocol (http includes h2 + websocket upgrades)
} }

View File

@@ -72,12 +72,23 @@ export class RustMetricsAdapter implements IMetrics {
return result; return result;
}, },
byIP: (): Map<string, number> => { byIP: (): Map<string, number> => {
// Per-IP tracking not yet available from Rust const result = new Map<string, number>();
return new Map(); if (this.cache?.ips) {
for (const [ip, im] of Object.entries(this.cache.ips)) {
result.set(ip, (im as any).activeConnections ?? 0);
}
}
return result;
}, },
topIPs: (_limit?: number): Array<{ ip: string; count: number }> => { topIPs: (limit: number = 10): Array<{ ip: string; count: number }> => {
// Per-IP tracking not yet available from Rust const result: Array<{ ip: string; count: number }> = [];
return []; if (this.cache?.ips) {
for (const [ip, im] of Object.entries(this.cache.ips)) {
result.push({ ip, count: (im as any).activeConnections ?? 0 });
}
}
result.sort((a, b) => b.count - a.count);
return result.slice(0, limit);
}, },
}; };
@@ -89,7 +100,10 @@ export class RustMetricsAdapter implements IMetrics {
}; };
}, },
recent: (): IThroughputData => { recent: (): IThroughputData => {
return this.throughput.instant(); return {
in: this.cache?.throughputRecentInBytesPerSec ?? 0,
out: this.cache?.throughputRecentOutBytesPerSec ?? 0,
};
}, },
average: (): IThroughputData => { average: (): IThroughputData => {
return this.throughput.instant(); return this.throughput.instant();
@@ -97,9 +111,13 @@ export class RustMetricsAdapter implements IMetrics {
custom: (_seconds: number): IThroughputData => { custom: (_seconds: number): IThroughputData => {
return this.throughput.instant(); return this.throughput.instant();
}, },
history: (_seconds: number): Array<IThroughputHistoryPoint> => { history: (seconds: number): Array<IThroughputHistoryPoint> => {
// Throughput history not yet available from Rust if (!this.cache?.throughputHistory) return [];
return []; return this.cache.throughputHistory.slice(-seconds).map((p: any) => ({
timestamp: p.timestampMs,
in: p.bytesIn,
out: p.bytesOut,
}));
}, },
byRoute: (_windowSeconds?: number): Map<string, IThroughputData> => { byRoute: (_windowSeconds?: number): Map<string, IThroughputData> => {
const result = new Map<string, IThroughputData>(); const result = new Map<string, IThroughputData>();
@@ -114,21 +132,28 @@ export class RustMetricsAdapter implements IMetrics {
return result; return result;
}, },
byIP: (_windowSeconds?: number): Map<string, IThroughputData> => { byIP: (_windowSeconds?: number): Map<string, IThroughputData> => {
return new Map(); const result = new Map<string, IThroughputData>();
if (this.cache?.ips) {
for (const [ip, im] of Object.entries(this.cache.ips)) {
result.set(ip, {
in: (im as any).throughputInBytesPerSec ?? 0,
out: (im as any).throughputOutBytesPerSec ?? 0,
});
}
}
return result;
}, },
}; };
public requests = { public requests = {
perSecond: (): number => { perSecond: (): number => {
// Rust tracks connections, not HTTP requests (TCP-level proxy) return this.cache?.httpRequestsPerSec ?? 0;
return 0;
}, },
perMinute: (): number => { perMinute: (): number => {
return 0; return (this.cache?.httpRequestsPerSecRecent ?? 0) * 60;
}, },
total: (): number => { total: (): number => {
// Use total connections as a proxy for total requests return this.cache?.totalHttpRequests ?? this.cache?.totalConnections ?? 0;
return this.cache?.totalConnections ?? 0;
}, },
}; };

View File

@@ -10,10 +10,12 @@ import { RustMetricsAdapter } from './rust-metrics-adapter.js';
// Route management // Route management
import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js'; import { SharedRouteManager as RouteManager } from '../../core/routing/route-manager.js';
import { RouteValidator } from './utils/route-validator.js'; import { RouteValidator } from './utils/route-validator.js';
import { generateDefaultCertificate } from './utils/default-cert-generator.js';
import { Mutex } from './utils/mutex.js'; import { Mutex } from './utils/mutex.js';
import { ConcurrencySemaphore } from './utils/concurrency-semaphore.js';
// Types // Types
import type { ISmartProxyOptions, TSmartProxyCertProvisionObject } from './models/interfaces.js'; import type { ISmartProxyOptions, TSmartProxyCertProvisionObject, IAcmeOptions, ICertProvisionEventComms, ICertificateIssuedEvent, ICertificateFailedEvent } from './models/interfaces.js';
import type { IRouteConfig } from './models/route-types.js'; import type { IRouteConfig } from './models/route-types.js';
import type { IMetrics } from './models/metrics-types.js'; import type { IMetrics } from './models/metrics-types.js';
@@ -37,6 +39,7 @@ export class SmartProxy extends plugins.EventEmitter {
private metricsAdapter: RustMetricsAdapter; private metricsAdapter: RustMetricsAdapter;
private routeUpdateLock: Mutex; private routeUpdateLock: Mutex;
private stopping = false; private stopping = false;
private certProvisionPromise: Promise<void> | null = null;
constructor(settingsArg: ISmartProxyOptions) { constructor(settingsArg: ISmartProxyOptions) {
super(); super();
@@ -68,7 +71,6 @@ export class SmartProxy extends plugins.EventEmitter {
useProduction: this.settings.acme.useProduction || false, useProduction: this.settings.acme.useProduction || false,
renewThresholdDays: this.settings.acme.renewThresholdDays || 30, renewThresholdDays: this.settings.acme.renewThresholdDays || 30,
autoRenew: this.settings.acme.autoRenew !== false, autoRenew: this.settings.acme.autoRenew !== false,
certificateStore: this.settings.acme.certificateStore || './certs',
skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false, skipConfiguredCerts: this.settings.acme.skipConfiguredCerts || false,
renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24, renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours || 24,
routeForwards: this.settings.acme.routeForwards || [], routeForwards: this.settings.acme.routeForwards || [],
@@ -146,8 +148,16 @@ export class SmartProxy extends plugins.EventEmitter {
// Preprocess routes (strip JS functions, convert socket-handler routes) // Preprocess routes (strip JS functions, convert socket-handler routes)
const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes); const rustRoutes = this.preprocessor.preprocessForRust(this.settings.routes);
// When certProvisionFunction handles cert provisioning,
// disable Rust's built-in ACME to prevent race condition.
let acmeForRust = this.settings.acme;
if (this.settings.certProvisionFunction && acmeForRust?.enabled) {
acmeForRust = { ...acmeForRust, enabled: false };
logger.log('info', 'Rust ACME disabled — certProvisionFunction will handle certificate provisioning', { component: 'smart-proxy' });
}
// Build Rust config // Build Rust config
const config = this.buildRustConfig(rustRoutes); const config = this.buildRustConfig(rustRoutes, acmeForRust);
// Start the Rust proxy // Start the Rust proxy
await this.bridge.startProxy(config); await this.bridge.startProxy(config);
@@ -157,13 +167,44 @@ export class SmartProxy extends plugins.EventEmitter {
await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath()); await this.bridge.setSocketHandlerRelay(this.socketHandlerServer.getSocketPath());
} }
// Handle certProvisionFunction // Load default self-signed fallback certificate (domain: '*')
await this.provisionCertificatesViaCallback(); if (!this.settings.disableDefaultCert) {
try {
const defaultCert = generateDefaultCertificate();
await this.bridge.loadCertificate('*', defaultCert.cert, defaultCert.key);
logger.log('info', 'Default self-signed fallback certificate loaded', { component: 'smart-proxy' });
} catch (err: any) {
logger.log('warn', `Failed to generate default certificate: ${err.message}`, { component: 'smart-proxy' });
}
}
// Start metrics polling // Load consumer-stored certificates
const preloadedDomains = new Set<string>();
if (this.settings.certStore) {
try {
const stored = await this.settings.certStore.loadAll();
for (const entry of stored) {
await this.bridge.loadCertificate(entry.domain, entry.publicKey, entry.privateKey, entry.ca);
preloadedDomains.add(entry.domain);
}
logger.log('info', `Loaded ${stored.length} certificate(s) from consumer store`, { component: 'smart-proxy' });
} catch (err: any) {
logger.log('warn', `Failed to load certificates from consumer store: ${err.message}`, { component: 'smart-proxy' });
}
}
// Start metrics polling BEFORE cert provisioning — the Rust engine is already
// running and accepting connections, so metrics should be available immediately.
// Cert provisioning can hang indefinitely (e.g. DNS-01 ACME timeouts) and must
// not block metrics collection.
this.metricsAdapter.startPolling(); this.metricsAdapter.startPolling();
logger.log('info', 'SmartProxy started (Rust engine)', { component: 'smart-proxy' }); logger.log('info', 'SmartProxy started (Rust engine)', { component: 'smart-proxy' });
// Fire-and-forget cert provisioning — Rust engine is already running and serving traffic.
// Events (certificate-issued / certificate-failed) fire independently per domain.
this.certProvisionPromise = this.provisionCertificatesViaCallback(preloadedDomains)
.catch((err) => logger.log('error', `Unexpected error in cert provisioning: ${err.message}`, { component: 'smart-proxy' }));
} }
/** /**
@@ -173,6 +214,12 @@ export class SmartProxy extends plugins.EventEmitter {
logger.log('info', 'SmartProxy shutting down...', { component: 'smart-proxy' }); logger.log('info', 'SmartProxy shutting down...', { component: 'smart-proxy' });
this.stopping = true; this.stopping = true;
// Wait for in-flight cert provisioning to bail out (it checks this.stopping)
if (this.certProvisionPromise) {
await this.certProvisionPromise;
this.certProvisionPromise = null;
}
// Stop metrics polling // Stop metrics polling
this.metricsAdapter.stopPolling(); this.metricsAdapter.stopPolling();
@@ -200,7 +247,7 @@ export class SmartProxy extends plugins.EventEmitter {
* Update routes atomically. * Update routes atomically.
*/ */
public async updateRoutes(newRoutes: IRouteConfig[]): Promise<void> { public async updateRoutes(newRoutes: IRouteConfig[]): Promise<void> {
return this.routeUpdateLock.runExclusive(async () => { await this.routeUpdateLock.runExclusive(async () => {
// Validate // Validate
const validation = RouteValidator.validateRoutes(newRoutes); const validation = RouteValidator.validateRoutes(newRoutes);
if (!validation.valid) { if (!validation.valid) {
@@ -236,11 +283,13 @@ export class SmartProxy extends plugins.EventEmitter {
// Update stored routes // Update stored routes
this.settings.routes = newRoutes; this.settings.routes = newRoutes;
// Handle cert provisioning for new routes
await this.provisionCertificatesViaCallback();
logger.log('info', `Routes updated (${newRoutes.length} routes)`, { component: 'smart-proxy' }); logger.log('info', `Routes updated (${newRoutes.length} routes)`, { component: 'smart-proxy' });
}); });
// Fire-and-forget cert provisioning outside the mutex — routes are already updated,
// cert provisioning doesn't need the route update lock and may be slow.
this.certProvisionPromise = this.provisionCertificatesViaCallback()
.catch((err) => logger.log('error', `Unexpected error in cert provisioning after route update: ${err.message}`, { component: 'smart-proxy' }));
} }
/** /**
@@ -334,20 +383,20 @@ export class SmartProxy extends plugins.EventEmitter {
/** /**
* Build the Rust configuration object from TS settings. * Build the Rust configuration object from TS settings.
*/ */
private buildRustConfig(routes: IRouteConfig[]): any { private buildRustConfig(routes: IRouteConfig[], acmeOverride?: IAcmeOptions): any {
const acme = acmeOverride !== undefined ? acmeOverride : this.settings.acme;
return { return {
routes, routes,
defaults: this.settings.defaults, defaults: this.settings.defaults,
acme: this.settings.acme acme: acme
? { ? {
enabled: this.settings.acme.enabled, enabled: acme.enabled,
email: this.settings.acme.email, email: acme.email,
useProduction: this.settings.acme.useProduction, useProduction: acme.useProduction,
port: this.settings.acme.port, port: acme.port,
renewThresholdDays: this.settings.acme.renewThresholdDays, renewThresholdDays: acme.renewThresholdDays,
autoRenew: this.settings.acme.autoRenew, autoRenew: acme.autoRenew,
certificateStore: this.settings.acme.certificateStore, renewCheckIntervalHours: acme.renewCheckIntervalHours,
renewCheckIntervalHours: this.settings.acme.renewCheckIntervalHours,
} }
: undefined, : undefined,
connectionTimeout: this.settings.connectionTimeout, connectionTimeout: this.settings.connectionTimeout,
@@ -360,8 +409,10 @@ export class SmartProxy extends plugins.EventEmitter {
keepAliveTreatment: this.settings.keepAliveTreatment, keepAliveTreatment: this.settings.keepAliveTreatment,
keepAliveInactivityMultiplier: this.settings.keepAliveInactivityMultiplier, keepAliveInactivityMultiplier: this.settings.keepAliveInactivityMultiplier,
extendedKeepAliveLifetime: this.settings.extendedKeepAliveLifetime, extendedKeepAliveLifetime: this.settings.extendedKeepAliveLifetime,
proxyIps: this.settings.proxyIPs,
acceptProxyProtocol: this.settings.acceptProxyProtocol, acceptProxyProtocol: this.settings.acceptProxyProtocol,
sendProxyProtocol: this.settings.sendProxyProtocol, sendProxyProtocol: this.settings.sendProxyProtocol,
metrics: this.settings.metrics,
}; };
} }
@@ -370,49 +421,192 @@ export class SmartProxy extends plugins.EventEmitter {
* If the callback returns a cert object, load it into Rust. * If the callback returns a cert object, load it into Rust.
* If it returns 'http01', let Rust handle ACME. * If it returns 'http01', let Rust handle ACME.
*/ */
private async provisionCertificatesViaCallback(): Promise<void> { private async provisionCertificatesViaCallback(skipDomains: Set<string> = new Set()): Promise<void> {
const provisionFn = this.settings.certProvisionFunction; const provisionFn = this.settings.certProvisionFunction;
if (!provisionFn) return; if (!provisionFn) return;
// Phase 1: Collect all unique (domain, route) pairs that need provisioning
const seen = new Set<string>(skipDomains);
const tasks: Array<{ domain: string; route: IRouteConfig }> = [];
for (const route of this.settings.routes) { for (const route of this.settings.routes) {
if (route.action.tls?.certificate !== 'auto') continue; if (route.action.tls?.certificate !== 'auto') continue;
if (!route.match.domains) continue; if (!route.match.domains) continue;
const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; const rawDomains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
const certDomains = this.normalizeDomainsForCertProvisioning(rawDomains);
for (const domain of domains) { for (const domain of certDomains) {
if (domain.includes('*')) continue; if (seen.has(domain)) continue;
seen.add(domain);
tasks.push({ domain, route });
}
}
if (tasks.length === 0) return;
// Phase 2: Process all domains in parallel with concurrency limit
const concurrency = this.settings.certProvisionConcurrency ?? 4;
const semaphore = new ConcurrencySemaphore(concurrency);
const promises = tasks.map(async ({ domain, route }) => {
await semaphore.acquire();
try {
await this.provisionSingleDomain(domain, route, provisionFn);
} finally {
semaphore.release();
}
});
await Promise.allSettled(promises);
}
/**
* Provision a single domain's certificate via the callback.
* Includes per-domain timeout and shutdown checks.
*/
private async provisionSingleDomain(
domain: string,
route: IRouteConfig,
provisionFn: (domain: string, eventComms: ICertProvisionEventComms) => Promise<TSmartProxyCertProvisionObject>,
): Promise<void> {
if (this.stopping) return;
let expiryDate: string | undefined;
let source = 'certProvisionFunction';
const eventComms: ICertProvisionEventComms = {
log: (msg) => logger.log('info', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }),
warn: (msg) => logger.log('warn', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }),
error: (msg) => logger.log('error', `[certProvision ${domain}] ${msg}`, { component: 'smart-proxy' }),
setExpiryDate: (date) => { expiryDate = date.toISOString(); },
setSource: (s) => { source = s; },
};
const timeoutMs = this.settings.certProvisionTimeout ?? 300_000; // 5 min default
try {
const result: TSmartProxyCertProvisionObject = await this.withTimeout(
provisionFn(domain, eventComms),
timeoutMs,
`Certificate provisioning timed out for ${domain} after ${timeoutMs}ms`,
);
if (this.stopping) return;
if (result === 'http01') {
if (route.name) {
try {
await this.bridge.provisionCertificate(route.name);
logger.log('info', `Triggered Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
} catch (provisionErr: any) {
logger.log('warn', `Cannot provision cert for ${domain} — callback returned 'http01' but Rust ACME failed: ${provisionErr.message}. ` +
'Note: Rust ACME is disabled when certProvisionFunction is set.', { component: 'smart-proxy' });
}
}
return;
}
if (result && typeof result === 'object') {
if (this.stopping) return;
const certObj = result as plugins.tsclass.network.ICert;
await this.bridge.loadCertificate(
domain,
certObj.publicKey,
certObj.privateKey,
);
logger.log('info', `Certificate loaded via provision function for ${domain}`, { component: 'smart-proxy' });
// Persist to consumer store
if (this.settings.certStore?.save) {
try {
await this.settings.certStore.save(domain, certObj.publicKey, certObj.privateKey);
} catch (storeErr: any) {
logger.log('warn', `certStore.save() failed for ${domain}: ${storeErr.message}`, { component: 'smart-proxy' });
}
}
this.emit('certificate-issued', {
domain,
expiryDate: expiryDate || (certObj.validUntil ? new Date(certObj.validUntil).toISOString() : undefined),
source,
} satisfies ICertificateIssuedEvent);
}
} catch (err: any) {
logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' });
this.emit('certificate-failed', {
domain,
error: err.message,
source,
} satisfies ICertificateFailedEvent);
// Fallback to ACME if enabled and route has a name
if (this.settings.certProvisionFallbackToAcme !== false && route.name) {
try { try {
const result: TSmartProxyCertProvisionObject = await provisionFn(domain); await this.bridge.provisionCertificate(route.name);
logger.log('info', `Falling back to Rust ACME for ${domain} (route: ${route.name})`, { component: 'smart-proxy' });
if (result === 'http01') { } catch (acmeErr: any) {
// Rust handles ACME for this domain logger.log('warn', `ACME fallback also failed for ${domain}: ${acmeErr.message}` +
continue; (this.settings.disableDefaultCert
} ? ' — TLS will fail for this domain (disableDefaultCert is true)'
: ' — default self-signed fallback cert will be used'), { component: 'smart-proxy' });
// Got a static cert object - load it into Rust
if (result && typeof result === 'object') {
const certObj = result as plugins.tsclass.network.ICert;
await this.bridge.loadCertificate(
domain,
certObj.publicKey,
certObj.privateKey,
);
logger.log('info', `Certificate loaded via provision function for ${domain}`, { component: 'smart-proxy' });
}
} catch (err: any) {
logger.log('warn', `certProvisionFunction failed for ${domain}: ${err.message}`, { component: 'smart-proxy' });
// Fallback to ACME if enabled
if (this.settings.certProvisionFallbackToAcme !== false) {
logger.log('info', `Falling back to ACME for ${domain}`, { component: 'smart-proxy' });
}
} }
} }
} }
} }
/**
* Race a promise against a timeout. Rejects with the given message if the timeout fires first.
*/
private withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), ms);
promise.then(
(val) => { clearTimeout(timer); resolve(val); },
(err) => { clearTimeout(timer); reject(err); },
);
});
}
/**
* Normalize routing glob patterns into valid domain identifiers for cert provisioning.
* - `*nevermind.cloud` → `['nevermind.cloud', '*.nevermind.cloud']`
* - `*.lossless.digital` → `['*.lossless.digital']` (already valid wildcard)
* - `code.foss.global` → `['code.foss.global']` (plain domain)
* - `*mid*.example.com` → skipped with warning (unsupported glob)
*/
private normalizeDomainsForCertProvisioning(rawDomains: string[]): string[] {
const result: string[] = [];
for (const raw of rawDomains) {
// Plain domain — no glob characters
if (!raw.includes('*')) {
result.push(raw);
continue;
}
// Valid wildcard: *.example.com
if (raw.startsWith('*.') && !raw.slice(2).includes('*')) {
result.push(raw);
continue;
}
// Routing glob like *example.com (leading star, no dot after it)
// Convert to bare domain + wildcard pair
if (raw.startsWith('*') && !raw.startsWith('*.') && !raw.slice(1).includes('*')) {
const baseDomain = raw.slice(1); // Remove leading *
result.push(baseDomain);
result.push(`*.${baseDomain}`);
continue;
}
// Unsupported glob pattern (e.g. *mid*.example.com)
logger.log('warn', `Skipping unsupported glob pattern for cert provisioning: ${raw}`, { component: 'smart-proxy' });
}
return result;
}
private isValidDomain(domain: string): boolean { private isValidDomain(domain: string): boolean {
if (!domain || domain.length === 0) return false; if (!domain || domain.length === 0) return false;
if (domain.includes('*')) return false; if (domain.includes('*')) return false;

View File

@@ -0,0 +1,28 @@
/**
* Async concurrency semaphore — limits the number of concurrent async operations.
*/
export class ConcurrencySemaphore {
private running = 0;
private waitQueue: Array<() => void> = [];
constructor(private readonly maxConcurrency: number) {}
async acquire(): Promise<void> {
if (this.running < this.maxConcurrency) {
this.running++;
return;
}
return new Promise<void>((resolve) => {
this.waitQueue.push(() => {
this.running++;
resolve();
});
});
}
release(): void {
this.running--;
const next = this.waitQueue.shift();
if (next) next();
}
}

View File

@@ -0,0 +1,36 @@
import * as plugins from '../../../plugins.js';
/**
* Generate a self-signed fallback certificate (CN=SmartProxy Default Certificate, SAN=*).
* Used as the '*' wildcard fallback so TLS handshakes never reset due to missing certs.
*/
export function generateDefaultCertificate(): { cert: string; key: string } {
const forge = plugins.smartcrypto.nodeForge;
// Generate 2048-bit RSA keypair
const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 });
// Create self-signed X.509 certificate
const cert = forge.pki.createCertificate();
cert.publicKey = keypair.publicKey;
cert.serialNumber = '01';
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
const attrs = [{ name: 'commonName', value: 'SmartProxy Default Certificate' }];
cert.setSubject(attrs);
cert.setIssuer(attrs);
// Add wildcard SAN
cert.setExtensions([
{ name: 'subjectAltName', altNames: [{ type: 2 /* DNS */, value: '*' }] },
]);
cert.sign(keypair.privateKey, forge.md.sha256.create());
return {
cert: forge.pki.certificateToPem(cert),
key: forge.pki.privateKeyToPem(keypair.privateKey),
};
}

View File

@@ -14,6 +14,12 @@ export * from './route-validator.js';
// Export route utilities for route operations // Export route utilities for route operations
export * from './route-utils.js'; export * from './route-utils.js';
// Export default certificate generator
export { generateDefaultCertificate } from './default-cert-generator.js';
// Export concurrency semaphore
export { ConcurrencySemaphore } from './concurrency-semaphore.js';
// Export additional functions from route-helpers that weren't already exported // Export additional functions from route-helpers that weren't already exported
export { export {
createApiGatewayRoute, createApiGatewayRoute,

View File

@@ -1,4 +1,4 @@
import { Buffer } from 'buffer'; import { Buffer } from 'node:buffer';
import { import {
TlsRecordType, TlsRecordType,
TlsHandshakeType, TlsHandshakeType,