High-performance TLS termination proxy with SNI-based routing, written in Rust (via napi-rs) and exposed as a Node.js native module.
Designed for Linux (x64 + arm64, glibc + musl), and will run on MacOS as well. Pre-built binaries are published for all targets.
symphony sits in front of your services and:
- Terminates TLS per route using per-route certificates (falls back to a listener-level default cert)
- Routes by SNI hostname — exact matches, wildcard prefixes (
*.example.com), and a catch-all default - Proxies TCP — either terminating TLS (decrypt + forward plaintext) or passing raw TLS bytes through
- Balances over Unix Domain Sockets (UDS) using least-connections weighted by thread CPU utilisation, with optional IP session affinity
- Limits routes with per-route token-bucket rate caps to prevent any one route from starving others
- Protects connections with per-IP token-bucket rate limiting, concurrency limits, CIDR allowlist/blocklist, JA3 fingerprint blocking, TLS handshake timeout, and SNI-required enforcement
- Suspends routes — hold incoming connections and fire an event; your code decides whether to proxy or reject each one
- Hot-swaps routes and protection config without restarting or dropping existing connections
- Scales to ~1 million concurrent connections via
SO_REUSEPORT, tokio's multi-thread runtime, and lock-free data structures
npm install symphonyPre-built binaries are downloaded automatically for your platform during install. No Rust toolchain required.
import { SymphonyProxy } from 'symphony';
import { readFileSync } from 'node:fs';
const proxy = new SymphonyProxy({
listeners: [{ port: 443 }],
routes: [
{
sni: 'api.example.com',
upstreams: [{ kind: 'tcp', host: '127.0.0.1', port: 3000 }],
terminateTls: true,
cert: {
certChain: readFileSync('/etc/ssl/api.pem', 'utf8'),
privateKey: readFileSync('/etc/ssl/api-key.pem', 'utf8'),
},
},
],
});
await proxy.start();
console.log('proxy listening on :443');| Field | Type | Default | Description |
|---|---|---|---|
listeners |
ListenerConfig[] |
required | One entry per listening address |
routes |
RouteConfig[] |
required | SNI routing table |
workerThreads |
number |
CPU count | Tokio worker threads; also controls SO_REUSEPORT socket count per listener |
readBufferSize |
number |
65536 |
Internal copy buffer size in bytes |
| Field | Type | Default | Description |
|---|---|---|---|
host |
string |
'0.0.0.0' |
Bind address |
port |
number |
required | Bind port |
defaultCert |
CertConfig |
— | Fallback cert for routes without their own cert |
mtls |
MtlsConfig |
— | Listener-level mTLS, used when a route doesn't override it |
maxConnections |
number |
0 (unlimited) |
Drop new connections when active count reaches this |
idleTimeoutMs |
number |
60000 |
Close connections silent for this many ms |
protection |
ProtectionConfig |
— | IP-level protection |
| Field | Type | Default | Description |
|---|---|---|---|
sni |
string |
required | Hostname for exact match, or '*.suffix' for wildcard, or '' for default |
upstreams |
Upstream[] |
required | Destination(s); multiple UDS upstreams are load-balanced |
terminateTls |
boolean |
required | true = decrypt TLS; false = TCP passthrough |
cert |
CertConfig |
— | Per-route cert, overrides listener defaultCert |
mtls |
MtlsConfig |
— | Per-route mTLS, overrides listener mtls |
suspended |
boolean |
false |
Hold connections and emit 'suspended' events |
suspendTimeoutMs |
number |
30000 |
Drop held connections after this ms if not resolved |
maxConnectionsPerSecond |
number |
— | Route-wide new-connection rate cap (token bucket). Connections are silently dropped when exhausted. |
burst |
number |
maxConnectionsPerSecond |
Token bucket burst ceiling for the route rate limit |
// TCP upstream
{ kind: 'tcp', host: string, port: number }
// Unix Domain Socket upstream
{
kind: 'uds',
path: string,
ipAffinity?: boolean, // route same-IP connections to same socket
ipAffinityTtlMs?: number, // evict affinity entry after this ms idle (default 300000)
pid?: number, // Linux PID of the worker process (enables CPU monitoring)
tid?: number, // Linux TID of the worker thread (must be set with pid)
}{ certChain: string | Buffer, privateKey: string | Buffer }Both fields accept PEM-encoded strings or Buffer. The cert chain may include intermediate certificates.
{ clientCaCert: string | Buffer, requireClientCert?: boolean }requireClientCert defaults to true. Set to false to accept connections without a client cert while still validating those that do present one.
| Field | Type | Default | Description |
|---|---|---|---|
rateLimit |
{ connectionsPerSecond, burst? } |
— | Token bucket per source IP |
maxConcurrentPerIp |
number |
0 (unlimited) |
Max simultaneous connections per source IP |
allowlist |
string[] |
[] |
CIDRs that bypass all checks |
blocklist |
string[] |
[] |
CIDRs that are always blocked |
ja3Blocklist |
string[] |
[] |
JA3 MD5 hex fingerprints to block (32 chars each) |
tlsHandshakeTimeoutMs |
number |
10000 |
Abort slow TLS handshakes |
requireSni |
boolean |
false |
Reject connections without an SNI extension |
Each route can have its own certificate. Routes without a cert use the listener's defaultCert.
const proxy = new SymphonyProxy({
listeners: [{
port: 443,
defaultCert: { certChain: wildcardCert, privateKey: wildcardKey },
}],
routes: [
// Uses its own cert
{ sni: 'special.example.com', cert: { certChain: specialCert, privateKey: specialKey }, ... },
// Falls back to listener defaultCert
{ sni: '*.example.com', ... },
],
});const proxy = new SymphonyProxy({
listeners: [{
port: 443,
mtls: { clientCaCert: readFileSync('ca.pem', 'utf8'), requireClientCert: true },
}],
routes: [
{
sni: 'internal.example.com',
terminateTls: true,
cert: { certChain, privateKey },
// Inherits listener mTLS; or override per-route:
// mtls: { clientCaCert: ..., requireClientCert: false },
},
],
});Set terminateTls: false to forward raw TLS bytes to the upstream without decryption. No cert needed.
{ sni: 'passthrough.example.com', terminateTls: false, upstreams: [{ kind: 'tcp', host: '10.0.0.5', port: 443 }] }Routes are checked in order: exact match → wildcard suffix → default (empty sni).
routes: [
{ sni: 'api.example.com', ... }, // exact
{ sni: '*.example.com', ... }, // matches foo.example.com, bar.example.com
{ sni: '', ... }, // catch-all default
]Use suspended routes to inspect or authorize connections before proxying them:
proxy.on('suspended', async (conn) => {
// conn.id, conn.sni, conn.peerIp, conn.peerPort, conn.listener
const allowed = await checkAuthority(conn);
if (allowed) {
proxy.resolveConnection(conn.id, {
upstreams: [{ kind: 'tcp', host: '127.0.0.1', port: 3000 }],
terminateTls: false,
});
} else {
proxy.resolveConnection(conn.id, null); // reject — TCP close
}
});
// Route declared as suspended
{ sni: 'gated.example.com', suspended: true, upstreams: [], terminateTls: true, cert: { ... } }Connections not resolved within suspendTimeoutMs are dropped automatically. Calling resolveConnection with an unknown or already-expired ID is a no-op.
Provide multiple uds upstreams for a route. symphony picks the socket with the lowest score, where score is:
score = active_connections × 1000 + cpu_utilisation_permille
Active connections are the primary factor; CPU utilisation (0–1000, representing 0–100%) is a tiebreaker that steers new connections away from overloaded threads when connection counts are equal.
upstreams: [
{ kind: 'uds', path: '/run/app/worker-0.sock' },
{ kind: 'uds', path: '/run/app/worker-1.sock' },
{ kind: 'uds', path: '/run/app/worker-2.sock' },
]Add ipAffinity: true to any UDS upstream entry to pin source IPs to the same socket:
upstreams: [
{ kind: 'uds', path: '/run/app/worker-0.sock', ipAffinity: true, ipAffinityTtlMs: 300000 },
{ kind: 'uds', path: '/run/app/worker-1.sock', ipAffinity: true },
]The same ipAffinity / ipAffinityTtlMs values apply to all sockets in the set (values from the first entry are used for the shared balancer).
When each UDS upstream serves a known worker thread, symphony can read its CPU utilisation from /proc/{pid}/task/{tid}/stat and incorporate it into socket selection:
upstreams: [
{ kind: 'uds', path: '/run/app/worker-0.sock', pid: 12345, tid: 12346 },
{ kind: 'uds', path: '/run/app/worker-1.sock', pid: 12345, tid: 12347 },
{ kind: 'uds', path: '/run/app/worker-2.sock', pid: 12345, tid: 12348 },
]Symphony samples /proc/{pid}/task/{tid}/stat every 250 ms and computes the thread's CPU utilisation over the interval. Sockets without pid/tid keep a CPU score of 0 and fall back to pure least-connections. Sampling stops gracefully when pid is gone (process exit, crash) — those slots simply keep their last measured value.
Use maxConnectionsPerSecond on a route to cap the rate of new connections accepted for that route, independent of source IP. This prevents a single busy route from starving other routes under high load:
routes: [
{
sni: 'api.example.com',
maxConnectionsPerSecond: 500, // route-wide cap; burst defaults to this value
burst: 1000, // allow short bursts up to 1000 conn/s
upstreams: [{ kind: 'uds', path: '/run/app/api.sock' }],
terminateTls: true,
cert: { certChain, privateKey },
},
{
sni: 'admin.example.com',
maxConnectionsPerSecond: 20,
upstreams: [{ kind: 'uds', path: '/run/app/admin.sock' }],
terminateTls: true,
cert: { certChain, privateKey },
},
]Connections that exceed the limit are silently dropped (TCP RST). This is a global token bucket per route — not per IP. For per-IP rate limiting use protection.rateLimit.
protection: {
rateLimit: { connectionsPerSecond: 50, burst: 100 },
maxConcurrentPerIp: 200,
allowlist: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
requireSni: true,
tlsHandshakeTimeoutMs: 5000,
}Collect JA3 fingerprints from your logs (the ja3 field is available in future log integrations) and add known-bad clients:
ja3Blocklist: [
'e7d705a3286e19ea42f587b344ee6865', // example known-bad scanner
]Protection config is per-listener and not currently hot-swappable via updateConfig (listeners would need to restart). To update protection, restart with a new config. Route changes do not require listener restarts.
const m = proxy.metrics();
// m.activeConnections — connections being proxied right now
// m.blockedConnections — total blocked since start
// m.pendingSuspended — connections currently held waiting for resolveConnection()
const blocked = proxy.blockedIps();
// blocked.rateLimited — IPs with depleted token buckets
// blocked.concurrencyLimited — IPs at their maxConcurrentPerIp limit
// blocked.cidrBlocklist — the configured static CIDR blocklist
setInterval(() => {
console.log('active:', proxy.metrics().activeConnections);
}, 10_000);// Replace the entire route table atomically — in-flight connections are unaffected.
proxy.updateConfig({
routes: newRoutes,
});What can be hot-swapped: routes (destinations, TLS certs, suspension state).
What requires a restart: listeners (bind address, port, protection config, idle timeout).
Requirements: Rust stable (1.70+), Node.js 18+, @napi-rs/cli.
npm install
npm run build:debug # builds a dev .node file
npm run build # release build (LTO, stripped)Use the napi-rs Docker images (same ones used in CI):
# x64 musl (Alpine)
docker run --rm -v $(pwd):/build -w /build \
ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine \
npm run build -- --target x86_64-unknown-linux-musl
# arm64 glibc
docker run --rm -v $(pwd):/build -w /build \
ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64 \
npm run build -- --target aarch64-unknown-linux-gnuTo reach ~1 million concurrent connections, the following system settings are required.
# Per-process (set before starting Node)
ulimit -n 2097152
# System-wide persistent — /etc/security/limits.conf
* soft nofile 2097152
* hard nofile 2097152symphony attempts to raise RLIMIT_NOFILE automatically at startup (to 2 × maxConnections + 1024), but the hard limit must be raised by the OS first.
# /etc/sysctl.d/99-symphony.conf
# TCP connection tracking
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
# Socket buffers (tune to your bandwidth)
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
# Accept queue depth per socket
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
# Max open files system-wide
fs.file-max = 4194304Apply with:
sudo sysctl --systemOn musl-libc systems (Alpine), the hard RLIMIT_NOFILE is often capped at 1048576 rather than the glibc default of 1073741816. symphony will log a warning if the desired limit exceeds the hard limit and fall back to the hard limit.