@push.rocks/smartproxy 🚀
A high-performance, Rust-powered proxy toolkit for Node.js — unified route-based configuration for SSL/TLS termination, HTTP/HTTPS reverse proxying, WebSocket support, UDP/QUIC/HTTP3, load balancing, custom protocol handlers, and kernel-level NFTables forwarding via @push.rocks/smartnftables.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
📦 Installation
pnpm add @push.rocks/smartproxy @push.rocks/smartchallenge
@push.rocks/smartchallenge is only required when you use the route challenge example below.
🎯 What is SmartProxy?
SmartProxy is a production-ready proxy solution that takes the complexity out of traffic management. Under the hood, all networking — TCP, UDP, TLS, HTTP reverse proxy, QUIC/HTTP3, connection tracking, security enforcement, and NFTables — is handled by a Rust engine for maximum performance, while you configure everything through a clean TypeScript API with full type safety.
Whether you're building microservices, deploying edge infrastructure, proxying UDP-based protocols, or need a battle-tested reverse proxy with automatic Let's Encrypt certificates, SmartProxy has you covered.
⚡ Key Features
| Feature | Description |
|---|---|
| 🦀 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 |
| 🔒 Automatic SSL/TLS | Zero-config HTTPS with Let's Encrypt ACME integration |
| 🎯 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 |
| 📡 UDP & QUIC/HTTP3 | First-class UDP transport, datagram handlers, QUIC tunneling, and HTTP/3 support |
| ⚖️ Load Balancing | Round-robin, least-connections, and IP-hash selection across host arrays |
| 🛡️ Enterprise Security | IP filtering, rate limiting, basic auth, JWT auth, connection limits |
| 🔌 WebSocket Support | First-class WebSocket proxying with ping/pong keep-alive |
| 🎮 Custom Protocols | Socket and datagram handlers for implementing any protocol in TypeScript |
| 📊 Live Metrics | Real-time throughput, connection counts, UDP sessions, and performance data |
| 🔧 Dynamic Management | Add/remove ports and routes at runtime without restarts |
| 🔄 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
Get up and running in 30 seconds:
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
// Create a proxy with automatic HTTPS
const proxy = new SmartProxy({
acme: {
email: 'ssl@yourdomain.com',
useProduction: true
},
routes: [
// HTTPS route with automatic Let's Encrypt cert
{
name: 'https-app',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: 'localhost', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' }
}
},
// HTTP → HTTPS redirect
{
name: 'http-redirect',
match: { ports: 80, domains: 'app.example.com' },
action: {
type: 'socket-handler',
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
}
}
]
});
await proxy.start();
console.log('🚀 Proxy running with automatic HTTPS!');
📚 Core Concepts
🏗️ Route-Based Architecture
SmartProxy uses a powerful match/action pattern that makes routing predictable and maintainable:
{
name: 'api-route',
match: {
ports: 443,
domains: 'api.example.com',
path: '/v1/*'
},
action: {
type: 'forward',
targets: [{ host: 'backend', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
}
}
Every route consists of:
- Match — What traffic to capture (ports, domains, paths, transport, protocol, IPs, headers)
- Action — What to do with it (
forward,socket-handlerorrespond) - Security (optional) — IP allow/block lists, rate limits, authentication
- Headers (optional) — Request/response header manipulation with template variables
- Name/Priority (optional) — For identification and ordering
🔄 TLS Modes
SmartProxy supports three TLS handling modes:
| Mode | Description | Use Case |
|---|---|---|
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-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 |
Backend TLS verification
The proxy verifies the certificate of every backend it connects to over TLS: terminate-and-reencrypt routes (HTTP, the raw TLS tunnel and raw QUIC streams), targets with tls behind a terminate route, WebSocket upgrades, and HTTP/3 backends reached over QUIC. By default it trusts the system root store, so a backend with a self-signed or private-CA certificate is refused (502, or a closed tunnel) until its route says how to trust it:
{
name: 'internal-api',
match: { ports: 443, domains: 'api.example.com' },
action: {
type: 'forward',
targets: [{ host: '10.0.0.5', port: 8443 }],
tls: {
mode: 'terminate-and-reencrypt',
certificate: 'auto',
backend: {
ca: internalCaPem, // replaces the system roots for this backend
serverName: 'api.internal', // sent as SNI; the certificate must carry it
},
},
},
}
careplaces the system root store, as Node'scaoption does. Its certificates are trusted as issuers, so a self-signed backend certificate must be an end-entity certificate (CA:FALSE); a self-signed CA certificate serving as the backend's own certificate is refused.serverNamedefaults to the requested domain on routes that match named domains, and to the target host on routes without domains or with*.rejectUnauthorized: falseaccepts any backend certificate; it cannot be combined withca.- A target's
tls.backendreplaces the route'saction.tls.backend, which replaces the proxy-widebackendTlsoption (ca,rejectUnauthorized). The whole object applies; fields are not merged. - Validation refuses
tls.backendon TLS passthrough and dynamic-target routes, which never connect to the backend over TLS, as well as an emptycaorserverNameand acawithout certificates. When the system has no root certificates, the routes that rely on them are refused atstart()andupdateRoutes().
💡 Common Use Cases
🌐 HTTP to HTTPS Redirect
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
const proxy = new SmartProxy({
routes: [{
name: 'http-to-https',
match: { ports: 80, domains: ['example.com', '*.example.com'] },
action: {
type: 'socket-handler',
socketHandler: SocketHandlers.httpRedirect('https://{domain}:443{path}', 301)
}
}]
});
🚧 Fixed Response (Maintenance / Stopped Service)
A respond route answers every HTTP request itself, without an upstream. Keep a stopped service's hostname routed and tell clients when to retry instead of failing with 502 or a TLS error:
const proxy = new SmartProxy({
routes: [{
name: 'app-stopped',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'respond',
tls: { mode: 'terminate', certificate: 'auto' },
response: {
status: 503,
headers: {
'Retry-After': '30',
'Cache-Control': 'no-store',
'Content-Type': 'application/json'
},
body: JSON.stringify({ error: 'service stopped' })
}
}
}]
});
statusis 200–599.headersmust not setconnection,keep-alive,transfer-encoding,upgrade,te,trailer,proxy-connectionorcontent-length(SmartProxy computes it).bodyis UTF-8 text of at most 64 KiB and needs an explicitcontent-typeheader; 204 and 304 carry no body.- TLS is optional; when set it must be
mode: 'terminate'(static certificate or'auto'). Responses go out over HTTP/1.1 and h2. Routesecurity, rate limits, challenges andmaxConnectionsapply before the response. - Respond routes are HTTP over TCP only:
action.udp,match.transport: 'udp' | 'all'and HTTP/3 are rejected in this version.targets, socket handlers and nftables forwarding are rejected too. - Switch a hostname between
forwardandrespondwithupdateRoutes().
Migrating from advanced.testResponse: SmartProxy 28 removes action.advanced.testResponse. Move its status, headers and body to action.response, set action.type: 'respond', drop the targets, and add a content-type header when the body is not empty.
⚖️ Load Balancer
For equivalent backends, put the backend hosts into one target's host array and choose a target-level load-balancing algorithm. Multiple targets are for sub-routing with target.match and priority.
import { SmartProxy } from '@push.rocks/smartproxy';
const proxy = new SmartProxy({
routes: [{
name: 'load-balancer',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{
host: ['server1.internal', 'server2.internal', 'server3.internal'],
port: 8080,
loadBalancing: { algorithm: 'round-robin' }
}],
tls: { mode: 'terminate', certificate: 'auto' }
}
}]
});
🔌 WebSocket Proxy
import { SmartProxy } from '@push.rocks/smartproxy';
const proxy = new SmartProxy({
routes: [{
name: 'websocket',
match: { ports: 443, domains: 'ws.example.com', path: '/socket' },
priority: 100,
action: {
type: 'forward',
targets: [{ host: 'websocket-server', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
websocket: {
enabled: true,
pingInterval: 30000,
pingTimeout: 10000
}
}
}]
});
🚦 API Gateway with Rate Limiting
import { SmartProxy } from '@push.rocks/smartproxy';
const proxy = new SmartProxy({
routes: [{
name: 'api-gateway',
match: { ports: 443, domains: 'api.example.com', path: '/api/*' },
priority: 100,
action: {
type: 'forward',
targets: [{ host: 'api-backend', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
headers: {
response: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400'
}
},
security: {
rateLimit: {
enabled: true,
maxRequests: 100,
window: 60,
keyBy: 'ip',
onExceeded: { type: '429' }
}
}
}]
});
Set rateLimit.onExceeded.type to challenge when browser traffic should complete a challenge instead of immediately receiving 429 after the limit is exceeded:
{
security: {
rateLimit: {
enabled: true,
maxRequests: 120,
window: 60,
keyBy: 'ip',
onExceeded: {
type: 'challenge',
challenge: {
providerId: 'smartchallenge',
challengeType: 'wait',
applyTo: { methods: ['GET'], browserNavigationsOnly: true },
clearance: { ttlSeconds: 300, bindToHost: true, bindToRoute: true },
},
clearanceEffect: 'bypass-rate-limit',
},
},
},
}
🎮 Custom Protocol Handler (TCP)
SmartProxy lets you implement any protocol with full socket control. Routes with JavaScript socket handlers are automatically relayed from the Rust engine back to your TypeScript code:
import { SmartProxy, SocketHandlers } from '@push.rocks/smartproxy';
const proxy = new SmartProxy({
routes: [
// Use pre-built handlers
{
name: 'echo-server',
match: { ports: 7777, domains: 'echo.example.com' },
action: { type: 'socket-handler', socketHandler: SocketHandlers.echo }
},
// Or create your own custom protocol
{
name: 'custom-protocol',
match: { ports: 9999, domains: 'custom.example.com' },
action: {
type: 'socket-handler',
socketHandler: async (socket) => {
console.log(`New connection on custom protocol`);
socket.write('Welcome to my custom protocol!\n');
socket.on('data', (data) => {
const command = data.toString().trim();
switch (command) {
case 'PING': socket.write('PONG\n'); break;
case 'TIME': socket.write(`${new Date().toISOString()}\n`); break;
case 'QUIT': socket.end('Goodbye!\n'); break;
default: socket.write(`Unknown: ${command}\n`);
}
});
}
}
}
]
});
Pre-built Socket Handlers:
| Handler | Description |
|---|---|
SocketHandlers.echo |
Echo server — returns everything sent |
SocketHandlers.proxy(host, port) |
TCP proxy to another server |
SocketHandlers.lineProtocol(handler) |
Line-based text protocol |
SocketHandlers.httpResponse(code, body) |
Simple HTTP response |
SocketHandlers.httpRedirect(url, code) |
HTTP redirect with template variables ({domain}, {path}, {port}, {clientIp}) |
SocketHandlers.httpServer(handler) |
Full HTTP request/response handling |
SocketHandlers.httpBlock(status, message) |
HTTP block response |
SocketHandlers.block(message) |
Block with optional message |
📡 UDP Datagram Handler
Handle raw UDP datagrams with custom TypeScript logic — perfect for DNS, game servers, IoT protocols, or any UDP-based service:
import { SmartProxy } from '@push.rocks/smartproxy';
import type { IRouteConfig, TDatagramHandler, IDatagramInfo } from '@push.rocks/smartproxy';
// Custom UDP echo handler
const udpHandler: TDatagramHandler = (datagram, info, reply) => {
console.log(`UDP from ${info.sourceIp}:${info.sourcePort} on port ${info.destPort}`);
reply(datagram); // Echo it back
};
const proxy = new SmartProxy({
routes: [{
name: 'udp-echo',
match: {
ports: 5353,
transport: 'udp' // 👈 Listen for UDP datagrams
},
action: {
type: 'socket-handler',
datagramHandler: udpHandler, // 👈 Process each datagram
udp: {
sessionTimeout: 60000, // Session idle timeout (ms)
maxSessionsPerIP: 100,
maxDatagramSize: 65535
}
}
}]
});
await proxy.start();
📡 QUIC / HTTP3 Forwarding
Forward QUIC traffic to backends with optional protocol translation (e.g., receive QUIC, forward as TCP/HTTP1):
import { SmartProxy } from '@push.rocks/smartproxy';
import type { IRouteConfig } from '@push.rocks/smartproxy';
const quicRoute: IRouteConfig = {
name: 'quic-to-backend',
match: {
ports: 443,
transport: 'udp',
protocol: 'quic' // 👈 Match QUIC protocol
},
action: {
type: 'forward',
targets: [{
host: 'backend-server',
port: 8443,
backendTransport: 'tcp' // 👈 Translate QUIC → TCP for backend
}],
tls: {
mode: 'terminate',
certificate: 'auto' // 👈 QUIC requires TLS 1.3
},
udp: {
quic: {
enableHttp3: true,
maxIdleTimeout: 30000,
maxConcurrentBidiStreams: 100,
altSvcPort: 443, // Advertise in Alt-Svc header
altSvcMaxAge: 86400
}
}
}
};
const proxy = new SmartProxy({
acme: { email: 'ssl@example.com' },
routes: [quicRoute]
});
🚄 Best-Effort Backend Protocol (H3 > H2 > H1)
SmartProxy automatically uses the highest protocol your backend supports for HTTP requests. The backend protocol is independent of the client protocol — a client using HTTP/1.1 can be forwarded over HTTP/3 to the backend, and vice versa.
const route: IRouteConfig = {
name: 'auto-protocol',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: 'backend', port: 8443 }],
tls: { mode: 'terminate', certificate: 'auto' },
options: {
backendProtocol: 'auto' // 👈 Default — best-effort selection
}
}
};
How protocol discovery works (browser model):
- First request → TLS ALPN probe detects H2 or H1
- Backend response inspected for
Alt-Svc: h3=":port"header - If H3 advertised → cached and used for subsequent requests via QUIC
- Graceful fallback: H3 failure → H2 → H1 with automatic cache invalidation
backendProtocol |
Behavior |
|---|---|
'auto' (default) |
Best-effort: H3 > H2 > H1 with Alt-Svc discovery |
'http1' |
Always HTTP/1.1 |
'http2' |
Always HTTP/2 (hard-fail if unsupported) |
'http3' |
Always HTTP/3 via QUIC (hard-fail if unsupported) |
🧦 WebSockets on Every Protocol (RFC 6455 / RFC 8441 / RFC 9220)
WebSockets work across all front/backend protocol combinations:
- Fronts: HTTP/1.1
Upgrade, HTTP/2 Extended CONNECT (RFC 8441), and HTTP/3 Extended CONNECT (RFC 9220). HTTP/3-enabled routes advertiseSETTINGS_ENABLE_CONNECT_PROTOCOL, so h3 clients can bootstrap WebSockets directly over QUIC. - Backends: HTTP/1.1
Upgradeby default. WithbackendProtocol: 'http3'— or'auto'once Alt-Svc discovery has established the upstream speaks h3 — WebSocket tunnels ride an RFC 9220 Extended CONNECT stream multiplexed onto the pooled QUIC connection (no extra TCP connection per socket).'auto'falls back to HTTP/1.1 if the h3 attempt fails;'http2'backends use HTTP/1.1 for WebSockets.
The proxy translates handshakes between generations transparently — e.g. an HTTP/1.1 client meeting an h3 backend still receives a correct 101 with a derived Sec-WebSocket-Accept, since Extended CONNECT has no key/accept exchange.
🔁 Dual-Stack TCP + UDP Route
Listen on both TCP and UDP with a single route — handle each transport with its own handler:
const dualStackRoute: IRouteConfig = {
name: 'dual-stack-dns',
match: {
ports: 53,
transport: 'all' // 👈 Listen on both TCP and UDP
},
action: {
type: 'socket-handler',
socketHandler: handleTcpDns, // 👈 TCP connections
datagramHandler: handleUdpDns, // 👈 UDP datagrams
}
};
⚡ High-Performance NFTables Forwarding
For ultra-low latency on Linux, use kernel-level forwarding via @push.rocks/smartnftables (requires root):
import { SmartProxy } from '@push.rocks/smartproxy';
const proxy = new SmartProxy({
routes: [{
name: 'nftables-fast',
match: { ports: 5432 },
action: {
type: 'forward',
forwardingEngine: 'nftables',
targets: [{ host: '10.0.0.20', port: 5432 }],
nftables: {
protocol: 'tcp',
preserveSourceIP: true // Backend sees real client IP
}
}
}]
});
The kernel forwards every packet on the listed ports to one IPv4 target before the proxy sees it, so an nftables route matches by port only. Route validation refuses an nftables route that sets security or headers; match.domains, path, headers, clientIp, tlsVersion, protocol or inboundProxyProtocol, or a port range; TLS termination, websocket, advanced or sendProxyProtocol; or more than one target, a target match, or a target host other than one IPv4 address. A route with enabled: false installs no rules.
🔒 SNI Passthrough (TLS Passthrough)
Forward encrypted traffic to backends without terminating TLS — the proxy routes based on the SNI hostname alone:
import { SmartProxy } from '@push.rocks/smartproxy';
const proxy = new SmartProxy({
routes: [{
name: 'sni-passthrough',
match: { ports: 443, domains: 'secure.example.com' },
action: {
type: 'forward',
targets: [{ host: 'backend-that-handles-tls', port: 8443 }],
tls: { mode: 'passthrough' }
}
}]
});
🔧 Advanced Features
🎯 Dynamic Routing
Route traffic based on runtime conditions using function-based host/port resolution:
const proxy = new SmartProxy({
routes: [{
name: 'dynamic-backend',
match: {
ports: 443,
domains: ['app.example.com', 'premium.example.com']
},
action: {
type: 'forward',
targets: [{
host: (context) => {
return context.domain === 'premium.example.com'
? 'premium-backend'
: 'standard-backend';
},
port: 8080
}],
tls: { mode: 'terminate', certificate: 'auto' }
}
}]
});
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. The relay resolves one target per connection and forwards the whole connection to it. The functions see
context.domain(the SNI, or theHostof a plaintext HTTP request),context.clientIpandcontext.port, but no request data such ascontext.path. Header and URL rewrites (headers,advanced) do not apply on these routes.Dynamic targets work with IP filtering,
maxConnections, TLSterminateandpassthrough, andport: 'preserve'. Route validation refuses dynamic targets together with settings the relay cannot honour: more than one target, a targetmatch,sendProxyProtocol, TLSterminate-and-reencryptortls.backend,backendTransport: 'udp',match.path,match.headers,match.transport: 'udp'or'all', and the HTTP-only controlsbasicAuth,jwtAuth,rateLimit,challengeandwebsocket.allowedOrigins. Use static targets for those.
🔀 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:
// 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,protocolis not yet known and the route is allowed to match. The protocol restriction is enforced after the proxy peeks at the decrypted data.
HTTP/3: HTTP/3 requests arrive over QUIC on the UDP listener, so only routes that listen on UDP (
transport: 'udp'or'all') serve them, and a request only a TCP route serves is answered421 Misdirected Request;protocol: 'quic','http3','http'and an omittedprotocolall match them. The QUIC connection itself is accepted under a UDP-listening route withprotocol: 'quic','http3'or noprotocol, or withprotocol: 'http'andaction.udp.quic.enableHttp3. Only routes withenableHttp3terminate HTTP/3; the others forward the QUIC connection raw.
🔒 Security Controls
Comprehensive per-route security options:
{
name: 'secure-api',
match: { ports: 443, domains: 'api.example.com' },
action: {
type: 'forward',
targets: [{ host: 'api-backend', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' }
},
security: {
// IP-based access control
ipAllowList: ['10.0.0.0/8', '2001:db8::/32'],
ipBlockList: ['192.168.1.100'],
// Connection limits
maxConnections: 1000,
// Rate limiting
rateLimit: {
enabled: true,
maxRequests: 100,
window: 60,
keyBy: 'ip',
onExceeded: { type: '429' }
},
// Authentication
basicAuth: { enabled: true, users: [{ username: 'admin', password: 'secret' }] },
jwtAuth: { enabled: true, secret: 'your-jwt-secret', algorithm: 'HS256' }
}
}
Security options are configured directly on each route's security property — no separate helpers needed.
maxConnections caps a route's concurrent connections across all listeners: an HTTP request or upgraded WebSocket holds a slot while it is in flight, and a raw TCP connection, a raw QUIC connection or a UDP session holds one for its lifetime. A connection above the cap is closed (HTTP requests get 429).
Basic auth, JWT auth, rate limits, challenges and action.websocket.allowedOrigins run in the HTTP layer only, so a route that enables one of them must serve HTTP. Validation refuses such a route when it is a socket-handler route, uses TLS passthrough, matches protocol: 'tcp', or listens on UDP (transport: 'udp' or 'all') without action.udp.quic.enableHttp3.
IP entries are validated against what the Rust engine can match, and an entry the engine would never match is refused:
- Security lists (
ipAllowListplain and domain-scoped,ipBlockList,defaults.security,securityPolicy.blockedIps/blockedCidrs):*, an IPv4 or IPv6 address in any textual form, or an address prefix such as192.0.2.0/24or2001:db8::1/128(prefix lengths up to 32 or 128; host bits may be set). Clients that arrive as IPv4-mapped IPv6 are matched as IPv4, so write192.0.2.1, not::ffff:192.0.2.1. Wildcards and ranges are refused. - Source matches (
match.clientIp, a route's or the globaltrustedProxyIPs): the same, plus a four-part IPv4 wildcard whose*parts trail (192.168.1.*,10.*.*.*). An exact address is compared as text, so it must be written as the engine writes addresses: lowercase, IPv6 compressed (2001:db8::1, not2001:DB8:0:0:0:0:0:1), IPv4-mapped as::ffff:192.0.2.1. An IPv4-mapped prefix is refused; write the IPv4 prefix. security.vpn.allowedAssignedIps: one address each, in the same canonical form, because it is compared as text with the VPN client's assigned address.- Everywhere: an IPv4 octet with a leading zero (
01.2.3.4/8,010.*.*.*) is refused as ambiguous, and so is a zone index (fe80::1%eth0). Each refusal names the reason and, where one exists, the form to write instead.
🛡️ Route Challenge Enforcement
SmartProxy can enforce browser-facing challenges through providers from @push.rocks/smartchallenge. Route configs store challenge intent only: providerId, challengeType, policyRef, settings, applyTo, and clearance. Runtime wiring such as provider endpoints, socket paths, ports, deployment IDs, secrets, API tokens, and credentials must stay outside security.challenge.
import { SmartProxy } from '@push.rocks/smartproxy';
import { SmartChallengeProvider, WaitChallengeType } from '@push.rocks/smartchallenge';
const provider = new SmartChallengeProvider({
challengeTypes: [new WaitChallengeType()],
});
const proxy = new SmartProxy({
routes: [{
name: 'wait-protected-app',
match: {
ports: 443,
domains: 'app.example.com',
protocol: 'http',
},
action: {
type: 'forward',
targets: [{ host: 'app-backend', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
security: {
challenge: {
providerId: 'smartchallenge',
challengeType: 'wait',
settings: { waitSeconds: 5 },
applyTo: { methods: ['GET'], browserNavigationsOnly: true },
clearance: { ttlSeconds: 1800, bindToHost: true, bindToRoute: true },
},
},
}],
challenge: {
pendingTtlSeconds: 300,
relayTimeoutMs: 5000,
},
});
proxy.registerChallengeProvider('smartchallenge', provider);
await proxy.start();
Challenge flow: the first matching protected request calls the provider assess operation, renders a provider response, and stores signed pending state in an HTTP-only cookie. Verification can happen by returning to the protected URL or by submitting to /.well-known/smartproxy-challenge/*; reserved verification requests pass query parameters plus JSON or application/x-www-form-urlencoded body fields as provider payload. Successful verification sets a signed clearance cookie and redirects to the original protected URL stored in the pending cookie.
Rate-limit-triggered challenges use the same provider pipeline through security.rateLimit.onExceeded. With type: 'challenge', SmartProxy challenges over-limit requests that match the nested challenge applyTo selector. If the request does not match applyTo, SmartProxy falls back to a normal 429 response. clearanceEffect defaults to bypass-rate-limit, so a valid clearance lets matching requests bypass the rate-limit check until the clearance expires; use clearanceEffect: 'none' when clearance should not bypass the limiter.
Security checks run access filters first (ipAllowList, ipBlockList, VPN requirements), then rate limiting, then authentication. This lets challenged browser traffic resolve rate limits before basic/JWT auth prompts while still keeping source access rules terminal.
Current challenge routes must set a stable name or id, use action.type: 'forward', use match.protocol: 'http', and use HTTP-visible traffic. TLS passthrough, pure UDP, match.transport: 'all' without action.udp.quic.enableHttp3, socket-handler routes, dynamic forwarding targets, and nftables forwarding are rejected for challenged routes. HTTP/3-enabled transport: 'all' routes are accepted.
📊 Runtime Management
Control your proxy without restarts:
// Dynamic port management
await proxy.addListeningPort(8443);
await proxy.removeListeningPort(8080);
const ports = await proxy.getListeningPorts();
// Update routes on the fly (atomic, mutex-locked)
await proxy.updateRoutes([...newRoutes]);
// Get real-time metrics
const metrics = proxy.getMetrics();
console.log(`Active connections: ${metrics.connections.active()}`);
console.log(`Bytes in: ${metrics.totals.bytesIn()}`);
console.log(`Requests/sec: ${metrics.requests.perSecond()}`);
console.log(`Throughput in: ${metrics.throughput.instant().in} bytes/sec`);
// UDP metrics
console.log(`UDP sessions: ${metrics.udp.activeSessions()}`);
console.log(`Datagrams in: ${metrics.udp.datagramsIn()}`);
// Get detailed statistics from the Rust engine
const stats = await proxy.getStatistics();
// Certificate management
await proxy.provisionCertificate('my-route-name');
await proxy.renewCertificate('my-route-name');
const certStatus = await proxy.getCertificateStatus('my-route-name');
// NFTables status
const nftStatus = await proxy.getNfTablesStatus();
🔄 Header Manipulation
Transform requests and responses with template variables:
{
action: {
type: 'forward',
targets: [{ host: 'backend', port: 8080 }]
},
headers: {
request: {
'X-Real-IP': '{clientIp}',
'X-Request-ID': '{uuid}',
'X-Forwarded-Proto': 'https'
},
response: {
'Strict-Transport-Security': 'max-age=31536000',
'X-Frame-Options': 'DENY'
}
}
}
A request header replaces every copy of its name the client sent, whatever its casing, on plain requests and WebSocket upgrades alike (action.websocket.customHeaders too), so a client cannot forge or duplicate a header the route sets.
🔀 PROXY Protocol Support
Preserve original client information through proxy chains:
const proxy = new SmartProxy({
// Shared trust list for explicit inbound PROXY listener policies
trustedProxyIPs: ['10.0.0.1', '10.0.0.2'],
// Forward PROXY protocol to backends
sendProxyProtocol: true,
routes: [{
name: 'smtp-from-edge',
match: {
ports: 25,
transport: 'tcp',
inboundProxyProtocol: {
mode: 'required'
}
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 10025 }]
}
}]
});
Inbound PROXY protocol is never enabled by the global trust list alone. Declare match.inboundProxyProtocol on the listener route with mode: 'optional' or mode: 'required'; omit it or use mode: 'reject' for direct-client listeners. A policy can define its own trustedProxyIPs; otherwise explicit optional and required policies inherit the global trustedProxyIPs list.
On a UDP listener that serves QUIC, an optional or required policy puts a relay in front of the QUIC endpoint to read the PROXY v2 headers. A new source address gets a relay session only for a datagram that starts a QUIC connection (a long-header packet in a datagram of at least 1200 bytes) or continues one the endpoint issued (a client that moved to a new address); other datagrams are dropped. The relay holds at most 16,384 sessions and 256 per client IP address (the PROXY source IP behind a trusted proxy), and closes a session after about two minutes without traffic.
🏗️ Custom Certificate Provisioning
Supply your own certificates or integrate with external certificate providers:
const proxy = new SmartProxy({
certProvisionFunction: async (domain, eventComms) => {
eventComms.setSource('custom-acme-provider');
// Return 'http01' to let the built-in ACME handle it
if (domain.endsWith('.example.com')) return 'http01';
// Return 'defer' while your provider cannot issue yet; the next sweep asks again
if (!myProvider.isReady()) return 'defer';
// Or return a static certificate object
return {
publicKey: myPemCert,
privateKey: myPemKey,
};
},
certProvisionFallbackToAcme: true, // Fall back to ACME if callback fails
acme: { email: 'ssl@example.com' }, // 'http01' and the fallback order through the built-in ACME client
routes: [...]
});
'http01' and the ACME fallback order the domain's certificate through the Rust engine's ACME client, which answers the HTTP-01 challenge on acme.port. Both need acme with an email. The engine serves the issued certificate, and SmartProxy records its validity, so sweeps skip the domain until its renewal window. While a certProvisionFunction is set, the engine orders only on these requests and the provisioning sweeps own renewal. A failed order emits certificate-failed and starts the failure cooldown, and a failed 'http01' order takes no ACME fallback. Certificates the engine issues are not passed to certStore.save(), so a restart orders them again.
💾 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:
const proxy = new SmartProxy({
routes: [...],
certProvisionFunction: async (domain, eventComms) => {
const cert = await myAcme.provision(domain);
eventComms.setExpiryDate(new Date(cert.validUntil));
return cert;
},
// 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:
- Rust engine starts
- Default self-signed
*fallback cert is loaded (unlessdisableDefaultCert: true) certStore.loadAll()is called → all returned certs are loaded into the Rust TLS stack, and their parsed expiry feeds the skip-valid filtercertProvisionFunctionruns for any remainingcertificate: 'auto'routes- 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.
Provisioning sweeps are conservative:
- A sweep skips every domain whose loaded cert is still valid beyond
acme.renewThresholdDays(default 30), soupdateRoutes()does not re-provision unchanged domains. - Domains whose provisioning failed wait out
certProvisionFailureCooldownMs(default 30 minutes) before the next attempt, so a permanently failing domain cannot generate continuous ACME orders. - A callback that cannot provision a domain yet, for example because its own certificate provider is still starting, returns
'defer'. That is not a failure: SmartProxy starts no cooldown, emits nocertificate-failedand takes no ACME fallback, and the next sweep calls the callback for the domain again. Once the provider is ready, the consumer callsupdateRoutes()to start that sweep right away; otherwise the renewal check retries the domain. - When the cause of a failure is resolved outside SmartProxy, for example because a certificate for the domain was issued out of band,
invalidateCertProvisionCooldown(domain)lifts that domain's cooldown so the next sweep retries it at once. It returnstrueif the domain was cooling down. - Sweeps coalesce: at most one runs at a time plus one queued follow-up, no matter how often routes are updated.
- A renewal sweep runs every
acme.renewCheckIntervalHours(default 24h), so near-expiry certs renew even when routes never change. - A sweep that calls the callback ends with one log line counting its outcomes, for example
Cert provisioning sweep finished: 2 loaded, 1 deferred.
🏛️ Architecture
SmartProxy uses a hybrid Rust + TypeScript architecture:
┌─────────────────────────────────────────────────────┐
│ Your Application │
│ (TypeScript — routes, config, handlers) │
└──────────────────┬──────────────────────────────────┘
│ IPC (JSON over stdin/stdout)
┌──────────────────▼──────────────────────────────────┐
│ Rust Proxy Engine │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐ │
│ │ TCP/TLS │ │ HTTP │ │ Route │ │ ACME │ │
│ │ Listener│ │ Reverse │ │ Matcher │ │ Cert Mgr │ │
│ │ │ │ Proxy │ │ │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ └──────────┘ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ UDP │ │ Security│ │ Metrics │ │
│ │ QUIC │ │ Enforce │ │ Collect │ │
│ │ HTTP/3 │ │ │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │
└──────────────────┬──────────────────────────────────┘
│ Unix Socket Relay
┌──────────────────▼──────────────────────────────────┐
│ TypeScript Socket & Datagram Handler Servers │
│ (for JS socket handlers, datagram handlers, │
│ and dynamic routes) │
├─────────────────────────────────────────────────────┤
│ @push.rocks/smartnftables (kernel-level NFTables) │
│ (DNAT/SNAT, firewall, rate limiting via nft CLI) │
└─────────────────────────────────────────────────────┘
- Rust Engine handles all networking: TCP, UDP, TLS, QUIC, HTTP proxying, connection management, security, and metrics
- TypeScript provides the npm API, configuration types, validation, and handler callbacks
- NFTables managed by
@push.rocks/smartnftables— kernel-level DNAT/SNAT forwarding, firewall rules, and rate limiting via thenftCLI - IPC — The TypeScript wrapper uses JSON commands/events over stdin/stdout to communicate with the Rust binary
- Socket/Datagram Relay — Unix domain socket servers for routes requiring TypeScript-side handling (socket handlers, datagram handlers, dynamic host/port functions)
🎯 Route Configuration Reference
Match Criteria
interface IRouteMatch {
ports: TPortRange; // Required — port(s) to listen on
transport?: 'tcp' | 'udp' | 'all'; // Transport protocol (default: 'tcp')
inboundProxyProtocol?: { // Explicit inbound PROXY policy for this listener
mode: 'reject' | 'optional' | 'required';
trustedProxyIPs?: string[];
};
domains?: string | string[]; // 'example.com', '*.example.com'
path?: string; // '/api/*', '/users/:id'
clientIp?: string[]; // ['10.0.0.0/8', '192.168.1.*', '2001:db8::/32']
tlsVersion?: string[]; // ['TLSv1.2', 'TLSv1.3']
headers?: Record<string, string | RegExp>; // Match by HTTP headers
protocol?: 'http' | 'tcp' | 'udp' | 'quic' | 'http3'; // Application-layer protocol
}
// Port range supports single numbers, arrays, and ranges
type TPortRange = number | Array<number | { from: number; to: number }>;
Path and headers: A TCP connection is routed by its port, SNI or
Host, and client address before its requests are read;pathandheadersare matched for each HTTP request. A connection whose TLS a route terminates (or plain HTTP to a route without TLS) is admitted under that route while only its path or headers are unknown, and each request then goes to the route that matches it, under that route's security; a request no route serves is answered502 No route matched. A route withpathorheadersserves only HTTP, so other data on its connections is closed.
Action Types
| Type | Description |
|---|---|
forward |
Proxy to one or more backend targets (with optional TLS, WebSocket, load balancing, UDP/QUIC) |
socket-handler |
Custom socket/datagram handling function in TypeScript |
respond |
Answer every HTTP request with the fixed action.response (status, headers, body); no upstream, TCP only |
targets are evaluated as route-internal sub-routes by target.match and target.priority. For load balancing across equivalent upstreams, use a single target with host: ['a', 'b', 'c'] and target-level loadBalancing.
Target Options
interface IRouteTarget {
host: string | string[] | ((context: IRouteContext) => string | string[]);
port: number | 'preserve' | ((context: IRouteContext) => number);
tls?: IRouteTls; // Per-target TLS override
priority?: number; // Target priority
match?: ITargetMatch; // Sub-match within a route (by port, path, headers, method)
websocket?: IRouteWebSocket;
loadBalancing?: IRouteLoadBalancing;
sendProxyProtocol?: boolean;
headers?: IRouteHeaders;
advanced?: IRouteAdvanced;
backendTransport?: 'tcp' | 'udp'; // Backend transport (e.g., receive QUIC, forward as TCP)
}
TLS Options
interface IRouteTls {
mode: 'passthrough' | 'terminate' | 'terminate-and-reencrypt';
certificate?: 'auto' | {
key: string;
cert: string;
ca?: string;
keyFile?: string;
certFile?: string;
};
acme?: {
email: string;
useProduction?: boolean;
challengePort?: number;
renewBeforeDays?: number;
};
versions?: string[];
ciphers?: string;
honorCipherOrder?: boolean;
sessionTimeout?: number;
backend?: IRouteBackendTls; // Backend certificate verification (see "Backend TLS verification")
}
interface IRouteBackendTls {
rejectUnauthorized?: boolean; // false accepts any backend certificate (default: verify)
ca?: string; // PEM certificates replacing the system root store
serverName?: string; // SNI and checked name (default: requested domain, else target host)
}
WebSocket Options
interface IRouteWebSocket {
enabled: boolean;
pingInterval?: number; // ms between pings
pingTimeout?: number; // ms to wait for pong
maxPayloadSize?: number; // Maximum frame payload
subprotocols?: string[]; // Allowed subprotocols
allowedOrigins?: string[]; // CORS origins
}
Load Balancing Options
interface IRouteLoadBalancing {
algorithm: 'round-robin' | 'least-connections' | 'ip-hash';
healthCheck?: {
path: string;
interval: number; // ms
timeout: number; // ms
unhealthyThreshold: number;
healthyThreshold: number;
};
}
Use this on an IRouteTarget with host as a string array. The healthCheck shape is accepted by the type layer, but active backend health polling is not currently performed by the Rust selector.
Backend Protocol Options
// Set on action.options
{
action: {
type: 'forward',
targets: [...],
options: {
backendProtocol: 'auto' | 'http1' | 'http2' | 'http3'
}
}
}
| Value | Backend Behavior |
|---|---|
'auto' |
Best-effort: discovers H3 via Alt-Svc, probes H2 via ALPN, falls back to H1 |
'http1' |
Always HTTP/1.1 (no ALPN probe) |
'http2' |
Always HTTP/2 (hard-fail if handshake fails) |
'http3' |
Always HTTP/3 over QUIC (3s connect timeout, hard-fail if unreachable) |
Rate Limit Options
interface IRouteRateLimit {
enabled: boolean;
maxRequests: number;
window: number; // Time window in seconds
keyBy?: 'ip' | 'path' | 'header';
headerName?: string;
errorMessage?: string;
onExceeded?: {
type: '429' | 'challenge';
challenge?: IRouteChallengeConfig;
clearanceEffect?: 'bypass-rate-limit' | 'none';
};
}
When onExceeded is omitted, over-limit requests use the normal 429 behavior and errorMessage if present. onExceeded.type: 'challenge' requires a nested challenge config and only challenges requests accepted by that challenge's applyTo matcher; other over-limit requests receive 429.
UDP & QUIC Options
interface IRouteUdp {
sessionTimeout?: number; // Idle timeout per UDP session (ms, default: 60000)
maxSessionsPerIP?: number; // Max concurrent sessions per IP (default: 1000)
maxDatagramSize?: number; // Max datagram size in bytes (default: 65535)
quic?: IRouteQuic;
}
interface IRouteQuic {
maxIdleTimeout?: number; // QUIC idle timeout (ms, default: 30000)
maxConcurrentBidiStreams?: number; // Max bidi streams (default: 100)
maxConcurrentUniStreams?: number; // Max uni streams (default: 100)
enableHttp3?: boolean; // Enable HTTP/3 (default: false)
altSvcPort?: number; // Port for Alt-Svc header
altSvcMaxAge?: number; // Alt-Svc max age in seconds (default: 86400)
initialCongestionWindow?: number; // Initial congestion window (bytes)
}
🛠️ Exports Reference
import {
// Core
SmartProxy, // Main proxy class
SocketHandlers, // Pre-built socket handlers (echo, proxy, block, httpRedirect, httpServer, etc.)
// Route Utilities
mergeRouteConfigs, // Deep-merge two route configs
findMatchingRoutes, // Find routes matching criteria
findBestMatchingRoute, // Find best matching route
cloneRoute, // Deep-clone a route
generateRouteId, // Generate deterministic route ID
RouteValidator, // Validate route configurations (validateRoutes, getRouteLabels, validateSettings, validateSecurityPolicy)
RouteValidationError, // Thrown when SmartProxy refuses a route set; carries the per-route reasons
SettingsValidationError, // Thrown when SmartProxy refuses IP entries outside routes; carries every reason in messages
MAX_FIXED_RESPONSE_BODY_BYTES, // Largest `respond` route body in UTF-8 bytes (65536)
} from '@push.rocks/smartproxy';
import type {
IRouteConfig, // A route: match + action
IRouteFixedResponse, // Fixed response of a `respond` route (status, headers, body)
IRouteValidationErrorEntry, // One rejected route in RouteValidationError.errors (index, route, messages)
} from '@push.rocks/smartproxy';
All routes are configured as plain IRouteConfig objects with match and action properties — see the examples throughout this document.
📖 API Documentation
SmartProxy Class
class SmartProxy extends EventEmitter {
constructor(options: ISmartProxyOptions); // Throws SettingsValidationError for invalid IP settings, RouteValidationError for invalid routes
// Lifecycle
start(): Promise<void>;
stop(): Promise<void>;
// Route Management (atomic, mutex-locked)
updateRoutes(routes: IRouteConfig[]): Promise<void>; // Rejects with RouteValidationError for invalid routes
updateSecurityPolicy(policy: ISmartProxySecurityPolicy): Promise<void>; // Rejects with SettingsValidationError for invalid entries
registerChallengeProvider(providerId: string, provider: IChallengeProvider): void;
// Port Management
addListeningPort(port: number): Promise<void>;
removeListeningPort(port: number): Promise<void>;
getListeningPorts(): Promise<number[]>;
// Monitoring & Metrics
getMetrics(): IMetrics; // Sync — returns cached metrics adapter
getStatistics(): Promise<IRustStatistics>; // Async — queries Rust engine
// Certificate Management
provisionCertificate(routeName: string): Promise<void>;
renewCertificate(routeName: string): Promise<void>;
getCertificateStatus(routeName: string): Promise<any>;
getEligibleDomainsForCertificates(): string[];
invalidateCertProvisionCooldown(domain: string): boolean; // Lift one domain's provisioning failure cooldown
// NFTables (managed by @push.rocks/smartnftables)
getNfTablesStatus(): INftStatus | null;
// Events
on(event: 'error', handler: (err: Error) => void): this;
on(event: 'certificate-issued', handler: (ev: ICertificateIssuedEvent) => void): this;
on(event: 'certificate-failed', handler: (ev: ICertificateFailedEvent) => void): this;
}
Configuration Options
interface ISmartProxyOptions {
routes: IRouteConfig[]; // Required: array of route configs
// ACME/Let's Encrypt
acme?: {
email: string; // Contact email for Let's Encrypt
useProduction?: boolean; // Use production servers (default: false)
port?: number; // HTTP-01 challenge port (default: 80)
renewThresholdDays?: number; // Days before expiry to renew (default: 30)
autoRenew?: boolean; // Enable auto-renewal (default: true)
renewCheckIntervalHours?: number; // Renewal check interval (default: 24)
};
// Custom certificate provisioning
certProvisionFunction?: (
domain: string,
eventComms: ICertProvisionEventComms
) => Promise<TSmartProxyCertProvisionObject>;
certProvisionFallbackToAcme?: boolean; // Fall back to ACME on failure (default: true)
certProvisionTimeout?: number; // Timeout per provision call (ms)
certProvisionConcurrency?: number; // Max concurrent provisions
certProvisionFailureCooldownMs?: number; // Per-domain cooldown after a failed provision (default: 1800000)
// Consumer-managed certificate persistence (see "Consumer-Managed Certificate Storage")
certStore?: ISmartProxyCertStore;
// Self-signed fallback
disableDefaultCert?: boolean; // Disable '*' self-signed fallback (default: false)
// Rust binary path override
rustBinaryPath?: string; // Custom path to the Rust proxy binary
// Global defaults
defaults?: {
target?: { host: string; port: number };
security?: { ipAllowList?: string[]; ipBlockList?: string[]; maxConnections?: number };
};
// PROXY protocol
trustedProxyIPs?: string[]; // Global trusted IP/CIDR list for explicit inbound policies
sendProxyProtocol?: boolean; // Send PROXY protocol to targets
// Default backend certificate verification; a route or target tls.backend replaces it
backendTls?: { ca?: string; rejectUnauthorized?: boolean };
// Timeouts
connectionTimeout?: number; // Backend connection timeout (default: 60s)
initialDataTimeout?: number; // Initial data/SNI timeout (default: 60s)
socketTimeout?: number; // Socket inactivity timeout (default: 60s)
maxConnectionLifetime?: number; // Max connection lifetime (default: 1h)
inactivityTimeout?: number; // Inactivity timeout (default: 75s)
gracefulShutdownTimeout?: number; // Shutdown grace period (default: 30s)
// Connection limits
maxConnectionsPerIP?: number | null; // Per-IP simultaneous connections (default: 100; null: no limit)
connectionRateLimitPerMinute?: number | null; // Per-IP new connections per minute (default: 300; null: no limit)
securityPolicy?: {
blockedIps?: string[];
blockedCidrs?: string[];
}; // Global ingress block policy
// Runtime-only challenge enforcement settings
challenge?: {
cookieSigningKey?: string; // Generated ephemerally when omitted
pendingCookieName?: string; // Default: __smartproxy_challenge_pending
clearanceCookieName?: string; // Default: __smartproxy_clearance
reservedPathPrefix?: string; // Default: /.well-known/smartproxy-challenge
relaySocketPath?: string; // Internal Unix socket path; normally generated by SmartProxy
relayTimeoutMs?: number; // Provider relay timeout (default: 5000)
pendingTtlSeconds?: number; // Pending challenge cookie TTL (default: 300)
};
// Keep-alive
keepAliveTreatment?: 'standard' | 'extended' | 'immortal';
keepAliveInactivityMultiplier?: number; // (default: 4)
extendedKeepAliveLifetime?: number; // (default: 1h)
// Metrics
metrics?: {
enabled?: boolean;
sampleIntervalMs?: number;
retentionSeconds?: number;
};
// Behavior
enableDetailedLogging?: boolean; // Verbose connection logging
enableTlsDebugLogging?: boolean; // TLS handshake debug logging
}
ISmartProxyCertStore Interface
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
The getMetrics() method returns a cached metrics adapter that polls the Rust engine:
const metrics = proxy.getMetrics();
// Connection metrics
metrics.connections.active(); // Current active connections
metrics.connections.total(); // Total connections since start
metrics.connections.byRoute(); // Map<routeName, activeCount>
metrics.connections.byIP(); // Map<ip, activeCount>
metrics.connections.topIPs(10); // Top N IPs by connection count
metrics.connections.domainRequestsByIP(); // Map<ip, Map<domain, requestCount>>
metrics.connections.topDomainRequests(20); // Top IP/domain pairs by request count
metrics.connections.frontendProtocols(); // H1/H2/H3/WS frontend distribution
metrics.connections.backendProtocols(); // H1/H2/H3/WS backend distribution
// Throughput (bytes/sec)
metrics.throughput.instant(); // { in: number, out: number }
metrics.throughput.recent(); // Recent average
metrics.throughput.average(); // Overall average
metrics.throughput.custom(30); // Custom window, if provided by Rust cache
metrics.throughput.history(60); // Recent throughput samples
metrics.throughput.byRoute(); // Map<routeName, { in, out }>
metrics.throughput.byIP(); // Map<ip, { in, out }>
// Request rates
metrics.requests.perSecond(); // Requests per second
metrics.requests.perMinute(); // Requests per minute
metrics.requests.total(); // Total requests
metrics.requests.byDomain(); // Map<domain, { perSecond, lastMinute }>
// UDP metrics
metrics.udp.activeSessions(); // Current active UDP sessions
metrics.udp.totalSessions(); // Total UDP sessions since start
metrics.udp.datagramsIn(); // Datagrams received
metrics.udp.datagramsOut(); // Datagrams sent
// Cumulative totals
metrics.totals.bytesIn(); // Total bytes received
metrics.totals.bytesOut(); // Total bytes sent
metrics.totals.connections(); // Total connections
// Backend metrics
metrics.backends.byBackend(); // Map<backend, IBackendMetrics>
metrics.backends.protocols(); // Map<backend, protocol>
metrics.backends.topByErrors(10); // Top N error-prone backends
metrics.backends.detectedProtocols(); // Backend protocol discovery cache
// Percentiles
metrics.percentiles.connectionDuration(); // { p50, p95, p99 }
metrics.percentiles.bytesTransferred(); // { in: { p50, p95, p99 }, out: { p50, p95, p99 } }
The percentile methods are part of the public metrics shape. In the current Rust adapter they return zeroed values until percentile collection is implemented in the Rust metrics snapshot.
🐛 Troubleshooting
Route Validation Errors
SmartProxy validates every route set before it applies it, first in TypeScript and then in the Rust engine. When either refuses one, the constructor, start() or updateRoutes() throws a RouteValidationError and the running routes stay unchanged. assertValidRoute() throws the same error for a single route:
import { RouteValidationError } from '@push.rocks/smartproxy';
try {
await proxy.updateRoutes(newRoutes);
} catch (error) {
if (error instanceof RouteValidationError) {
// A configuration problem, e.g. answer an admin API call with 422
for (const { index, route, messages } of error.errors) {
console.error(`#${index} ${route}: ${messages.join('; ')}`);
}
}
throw error;
}
error.nameis'RouteValidationError'.error.errorslists every rejected route with all its reasons, for exampleresponse.body requires an explicit content-type header.indexis the route's position in the list you passed.routeis its label fromRouteValidator.getRouteLabels(): the name, else the id, elseroute[<index>]; a name or id that several routes share gets[<index>]appended, so no route's reasons hide another's.error.messagenames the first 10 routes with up to 5 reasons each, shortens long names and reasons to 200 characters and counts the rest, so it stays bounded for large route sets. Readerror.errorsfor the complete list.- IP entries outside routes (global
trustedProxyIPs,defaults.security,securityPolicy) are refused with aSettingsValidationErrorby the constructor andupdateSecurityPolicy();error.messageslists every reason. - Checks that only the Rust engine makes, such as duplicate route ids or an
inboundProxyProtocolpolicy without route-level or globaltrustedProxyIPs, arrive the same way; their message starts withRoute validation failed.
Certificate Issues
- ✅ Ensure domain DNS points to your server
- ✅ Port 80 (
acme.port) must be reachable for ACME HTTP-01 challenges. When a route listens on that port, the proxy answers pending challenge requests there before route matching; otherwise it binds the port only while an order runs - ✅ Check DNS propagation with
digornslookup - ✅ Verify the email in ACME configuration is valid
- ✅ Use
getCertificateStatus('route-name')to check cert state
Connection Problems
- ✅ Check route priorities (higher number = matched first)
- ✅ Verify security rules aren't blocking legitimate traffic
- ✅ Test with
curl -vfor detailed connection output - ✅ Enable debug logging with
enableDetailedLogging: true - ✅ A
502from a TLS backend with a self-signed or private-CA certificate: settls.backend.caorrejectUnauthorized: false(see "Backend TLS verification")
Rust Binary Not Found
SmartProxy searches for the Rust binary in this order:
rustBinaryPathoption inISmartProxyOptionsSMARTPROXY_RUST_BINARYenvironment variable- Platform-specific npm package (
@push.rocks/smartproxy-linux-x64, etc.) dist_rust/rustproxy_linux_amd64_musl/dist_rust/rustproxy_linux_arm64_muslrelative to the package root (static musl binaries built byrsrepoand shipped in the package; they run on glibc and musl hosts alike), thendist_rust/rustproxy- Local dev build (
./rust/target/release/rustproxy) - System PATH (
rustproxy)
rustBinaryPath is an explicit choice, not a first guess: when it is set, SmartProxy uses only that file. If it is missing or not executable, start() rejects with a RustBinaryLocatorError naming the path and does not fall back to the other locations.
QUIC / HTTP3 Caveats
- GREASE frames are disabled. The underlying h3 crate sends GREASE frames by default to test protocol extensibility. However, some HTTP/3 clients and servers don't properly ignore unknown frame types, causing 400/500 errors or stream hangs (h3#206). SmartProxy disables GREASE on both the server side (for incoming H3 requests) and the client side (for H3 backend connections) to maximize compatibility.
- Raw QUIC routes open a TCP connection per stream. A route without
enableHttp3forwards each bidirectional QUIC stream to its first target over its own TCP connection: plain withtls.mode: 'terminate', verified TLS with'terminate-and-reencrypt'(see Backend TLS verification). A stream that sees no data for 60 seconds, counting the backend connect and handshake, is closed. - Route security follows the SNI. Before the QUIC handshake the client's hostname is unknown, so only a route that matches every hostname has its
securityapplied there. Otherwise the route is chosen from the handshake's SNI, as on TCP, and that route'ssecurityapplies; a connection without SNI matches no domain-restricted route. HTTP/3 requests are then routed and checked by their:authority. - HTTP/3 is pre-release. The h3 ecosystem (h3 0.0.8, h3-quinn 0.0.10, quinn 0.11) is still pre-1.0. Expect rough edges.
Performance Tuning
- ✅ Use NFTables forwarding for high-traffic routes (Linux only)
- ✅ Enable connection keep-alive where appropriate
- ✅ Use
getMetrics()andgetStatistics()to identify bottlenecks - ✅ Adjust
maxConnectionsPerIPandconnectionRateLimitPerMinutebased on your workload. Both count connections per source IP as the listener sees it, before the SNI or any request is read. Behind a reverse proxy that does not send PROXY protocol (such as an HTTP hop that pools its backend connections), every client arrives from the proxy's address and shares one budget; set the limits tonullthere to turn them off and enforce them at the edge that sees the real clients. Omitting a limit keeps its default;0, negative and non-integer values are refused,0never read as "no limit". - ✅ Use
passthroughTLS mode when backend can handle TLS directly
🏆 Best Practices
- 📝 Use Helper Functions — They provide sensible defaults and prevent common mistakes
- 🎯 Set Route Priorities — More specific routes should have higher priority values
- 🔒 Enable Security — Always use IP filtering and rate limiting for public-facing services
- 📊 Monitor Metrics — Use the built-in metrics to catch issues early
- 🔄 Certificate Monitoring — Set up alerts before certificates expire
- 🛑 Graceful Shutdown — Always call
proxy.stop()for clean connection termination - ✅ Validate Routes — Use
RouteValidator.validateRoutes()to catch config errors before deployment - 🔀 Atomic Updates — Use
updateRoutes()for hot-reloading routes (mutex-locked, no downtime) - 🎮 Use Socket Handlers — For protocols beyond HTTP, implement custom socket handlers instead of fighting the proxy model
- 💾 Use
certStore— Persist certs in your own storage to avoid re-provisioning on every restart
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.