This file is the primary entry point for any AI assistant working on this codebase.
symphony is a napi-rs cdylib loaded by Node.js. The tokio multi-thread runtime is embedded via napi's tokio_rt feature. The JS API is a thin EventEmitter wrapper (ts/proxy.ts: SymphonyProxy) over the napi class (src/proxy.rs: SymphonyProxyWrap).
For consumers that want symphony as its own OS process rather than embedded in their Node app, the package ships a symphony-server bin. It reads a JSON config file ({ version, proxies: [{ listeners, routes }] } — one entry per port-set, since the route table is per-proxy), constructs a SymphonyProxy per entry, and watches the config file to hot-reload (route change → updateConfig; listener change → recreate that proxy). Cert material may be given inline (certChain/privateKey) or by path (certChainFile/privateKeyFile) — the path form is resolved in server.ts only, so the napi CertConfig stays inline-only. It writes a status.json ({ pid, version, ports, ... }) for supervisors, and handles SIGHUP (reload) / SIGTERM/SIGINT (graceful stop). host-manager uses this to supervise symphony out-of-process.
The server also watches the cert/key files referenced by the config (grouped by parent dir, deduped, re-derived on every reconcile so watchers don't leak) → a debounced reconcile() on change, so an on-disk cert renewal is picked up live without a config.json write or restart. Two details make a listener-level cert rotation actually apply: the per-proxy listenerSig is computed over the resolved listeners (cert contents included), so a rotated defaultCert/mTLS file changes the signature and forces a recreate rather than a route-only hot-swap against the frozen default_listener_tls. Basename-filtered dir watching handles in-place / rename rotation (what host-manager does); k8s projected-volume ..data symlink swaps are not yet covered. Cert-failure resilience lives in router.rs::build_route_table: a route whose cert can't be built (e.g. rustls KeyMismatch from a rotated key vs a stale inlined chain) is isolated — one bad tenant cert never aborts the whole table; on a hot-swap the last-good route is carried forward for that SNI (mid-rotation the old cert is still valid), and on initial build the SNI is simply dropped.
TCP accept (SO_REUSEPORT per worker thread)
└─ sni.rs peek() — 1 syscall, 512-byte stack buf → PeekInfo { sni, ja3 }
└─ protection.rs check() → Block (emit 'blocked', drop) | Allow
└─ router.rs RouteTable.resolve(sni) → Route
└─ [suspended.rs register, emit 'suspended', await oneshot]
└─ tls.rs TlsAcceptor::accept() with handshake timeout (if terminate_tls)
└─ upstream.rs connect(Destination, peer_ip) → UpstreamStream
└─ tokio::io::copy_bidirectional wrapped in idle_timeout
└─ RAII drop: BalancerGuard, ActiveGuard — all counter decrements happen here
| File | Responsibility |
|---|---|
src/lib.rs |
Crate root; mod declarations; #[macro_use] napi_derive |
src/proxy.rs |
All #[napi]-exposed types and methods; config parsing helpers |
src/listener.rs |
TCP accept loop for TLS listeners; SO_REUSEPORT per worker; RLIMIT_NOFILE |
src/http_listener.rs |
Plaintext HTTP/1.1 accept loop (mode: 'http'): ACME-proxy or 301 redirect |
src/http_proxy.rs |
HTTP/1.1 header framing and rewrite helpers shared by the HTTP listener |
src/sni.rs |
MSG_PEEK ClientHello parser; SNI extraction; JA3 fingerprint |
src/router.rs |
RouteTable (exact + wildcard HashMap); ArcSwap hot-swap |
src/upstream.rs |
UpstreamStream enum (Tcp/Uds); connect(); TCP_NODELAY |
src/balancer.rs |
UdsBalancer: AtomicU32 least-connections; IP affinity DashMap |
src/tls.rs |
rustls ServerConfig builder; SHA-256 deduplication cache |
src/mtls.rs |
SymphonyClientVerifier wrapping WebPkiClientVerifier |
src/proxy_conn.rs |
Per-connection handler: the full 7-step flow |
src/protection.rs |
IP rate limiting, concurrency, CIDR lists, JA3 blocking |
src/suspended.rs |
Pending-connection registry (DashMap + oneshot channels) |
src/metrics.rs |
AtomicU64 counters: active, accepted, errors, blocked |
src/error.rs |
SymphonyError enum → napi::Error conversion |
A single stream.peek(&mut buf[..512]) reads the ClientHello without consuming any bytes. Cost: 1 syscall, 512-byte stack buffer, zero heap allocation. This gives us both the SNI (for routing) and the JA3 fingerprint (for protection) before the TLS handshake begins. The alternative — a custom TLS acceptor that extracts SNI internally — would require modifying rustls internals.
ArcSwap<RouteTable> gives us a pointer-swap on writes (single atomic store) and a single load() on reads — no lock contention on the hot path. With ≤100 routes, rebuilding the full table on updateConfig costs ~microseconds (Arc pointer clones only). A partial-update scheme would be more complex without meaningful benefit.
DashMap provides lock-free concurrent access via internal sharding. Used for:
protection.rs: per-IP state (ip_table: DashMap<IpAddr, Arc<IpState>>)balancer.rs: IP affinity map (DashMap<IpAddr, Arc<AffinityEntry>>)suspended.rs: pending connection registry (DashMap<u64, oneshot::Sender<...>>)
The rate limit uses a fixed-point token count (×1000) in an AtomicU32 with CAS retry loops. Relaxed ordering is correct here because the token bucket is inherently approximate — a small window of double-allowing at refill time is acceptable and expected. No mutex needed on the hot path.
Each tokio worker thread gets its own listening socket on the same address via SO_REUSEPORT. The kernel distributes incoming connections across them using a hash of the 4-tuple. This eliminates the accept lock contention that would occur with a single accepting socket + channel dispatch, and scales linearly with CPU count.
Each suspended connection gets a tokio::sync::oneshot::channel. The sender is stored in a DashMap<u64, Sender>. resolveConnection() removes the sender and fires it — synchronous from the JS side (no async needed). oneshot is used rather than mpsc because exactly one resolution is possible per connection. If no resolution arrives within suspendTimeoutMs, the timeout(rx.await) in proxy_conn.rs returns an error and the TCP stream is dropped.
Routes that share the same cert+mTLS combination share a single Arc<ServerConfig> allocation. The cache key is (sha256(cert_pem + key_pem), sha256(mtls_ca_pem)). Built at config-parse time in tls.rs::TlsConfigCache. Important for deployments where many routes share a wildcard cert.
napi Buffer contains raw pointers (*mut napi_env__, *mut napi_ref__) that are not Sync. The SymphonyProxyWrap struct must be Send + Sync. Solution: napi types (Buffer, JsCertConfig, etc.) are only used as constructor/method parameters, immediately converted to plain Rust (Vec<u8>, ListenerTlsSpec, etc.), and never stored in the struct. The struct only holds types that are provably Send + Sync.
- All
#[napi]-annotated items live insrc/proxy.rs. Other modules are pure Rust with no napi imports. - Background tasks (affinity eviction, IP state eviction) are spawned in
start()and cancelled via theshutdown_txbroadcast channel. - All counter decrements use RAII guards (
BalancerGuard,ActiveGuard). Never decrement in a finally-style chain. Relaxedordering for per-connection counters (active, accepted, errors).AcqRelonly where cross-thread ordering is required — each such site has a comment explaining why.- Error type:
SymphonyError→napi::ErrorviaFrom. Nounwrap()on paths reachable from JS. #![deny(clippy::all)]is set inlib.rs. Fix clippy warnings before committing.
- Add a variant to
protection::BlockReason - Add the check in
ProtectionState::check()in the correct position (allowlist first, then blocklist, then JA3, then requireSni, then rate limit, then concurrency — cheapest/most-common rejections first) - Add a field to
ProtectionConfiginprotection.rs - Add the field to
JsProtectionConfiginproxy.rsand toProtectionConfigints/types.ts - Wire the field in
parse_protection_config()inproxy.rs - Add a test in
__test__/protection.spec.ts
- Add a variant to
upstream::UpstreamStreamandrouter::Destination - Implement
connect()for the new variant inupstream.rs - Add a new
*Upstreaminterface tots/types.tsand add it to theUpstreamunion - Add a new
kindcase inparse_upstream_spec()inproxy.rs - Add a test
- Implement in
proxy.rswith#[napi] - Add the corresponding method to
SymphonyProxyints/proxy.ts - Add types to
ts/types.tsif needed - Run
npm run build:debugto regeneratets/addon.d.ts
Tests live in __test__/ and use Node's built-in node:test runner.
util.ts— self-signed cert generation viaopenssl(or a fallback baked-in cert if openssl is unavailable), free-port helper, echo servers, TLS/TCP round-trip helpersproxy.spec.ts— TLS termination, wildcard SNI routing,updateConfighot-swapprotection.spec.ts— rate limit token bucket exhaustion, CIDR blocklist inblockedIps()suspended.spec.ts— hold → resolve → proxy, hold → null → close, hold → timeout → drop
Build and run:
npm run build:debug
npm testTests bind on random high ports (port: 0) to avoid conflicts. Suspended-route tests use short suspendTimeoutMs (200ms) to keep the suite fast.
copy_bidirectionalhalf-close: it returns when either side closes, including on RST. TheActiveGuarddrop handles both clean close and error paths.- Affinity bounds check: after
updateConfigshrinks the socket list, a stale affinitysocket_idxmay be out of bounds.balancer.rs::pick()always bounds-checks before using the affinity index and falls back to least-connections if out of range. resolveConnectionwith unknown ID: a no-op, not an error. The connection has already timed out and been dropped by the time JS calls this with a stale ID.ringvsmd-5:ring 0.17removed MD5 support. JA3 fingerprints usemd-5 0.10(RustCrypto). SHA-256 for cert deduplication still usesring::digest::SHA256(ring is a direct dep for this).- musl RLIMIT_NOFILE: musl hard limit is often 1048576. symphony logs a warning at startup if the requested limit exceeds the hard limit and uses the hard limit instead.
- napi async methods must not take
&mut self: useMutex<>for fields that need mutation after construction (currentlyshutdown_tx: Mutex<Option<broadcast::Sender<()>>>). JsUpstreamis a flat struct: the TypeScriptUpstreamdiscriminated union is mapped to a single flatJsUpstream { kind, host?, port?, path?, ipAffinity?, ipAffinityTtlMs? }struct on the Rust side to avoid napi union complexity. Fields not relevant to a givenkindwill beNone.