@johnhenry/dialback
v0.1.0
Published
Reverse proxy HTTP requests over web sockets -- an agent dials out, the server dials back through that connection to reach it
Maintainers
Readme
dialback
Full documentation: opensource.johnhenry.me/dialback
Library for creating a reverse proxy over websockets: an Agent dials out
(through a NAT/firewall it's behind), and the Server dials back through
that same connection to reach it -- the name is the mechanism, not just a
label.
Provenance: previously developed as
leproxy(itself a rename of the originalproxy-socks), but never actually published to npm under either name. Renamed and adopted into the@johnhenryscope, starting fresh at0.0.0.
Request/Response <-HTTP-> [Server] <-WS-> [Agent]
Contents
- Quick Start
- API Documentation
- Security model
- Optional: the
dialback/browsermeshtransport - Family
- Contributing
- License
Quick Start
Installation
npm install @johnhenry/dialbackUsage with Node.js
- Create Server (Node.js)
import { Server } from "@johnhenry/dialback";
import http from 'http';
import { WebSocketServer } from 'ws';
const server = new Server(() => new Response("no responder", { status: 500 }));
// Server#fetch() requires a real Request instance (or a URL string) --
// Node's IncomingMessage isn't one, so convert it first.
function toWebRequest(req) {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
const init = { method: req.method, headers: req.headers };
if (req.method !== "GET" && req.method !== "HEAD") {
init.body = req;
init.duplex = "half";
}
return new Request(url, init);
}
const httpServer = http.createServer(async (req, res) => {
const response = await server.fetch(toWebRequest(req));
res.writeHead(response.status, response.statusText, Object.fromEntries(response.headers));
response.body.pipe(res);
});
const wss = new WebSocketServer({ server: httpServer });
wss.on('connection', (ws) => {
server.addConnection(ws);
ws.on('close', () => {
server.removeConnection(ws);
});
});
httpServer.listen(8082, () => {
console.log('Server running on http://localhost:8082');
});
// Shutting down: `close()` is safe in this bring-your-own-listener mode. It
// closes and unregisters every agent connection and rejects requests still
// in flight; it only touches an HTTP/WebSocket listener if `server.listen()`
// created one. Close your own listener yourself.
// await server.close();
// wss.close(); httpServer.close();- Create Agent
import { Agent } from "@johnhenry/dialback";
const address = `ws://localhost:8082`;
const { serve } = new Agent(address, { reconnect: 1000, log: 2 });
serve(async (request, { id }) => {
return new Response(`Hello there!`, {
status: 200,
statusText: "OK",
headers: {
"content-type": "application/text",
etag: id,
},
});
});Usage with Deno
- Create Server (Deno)
import { Server } from "npm:@johnhenry/dialback";
const server = new Server(() => new Response("no responder", { status: 500 }));
Deno.serve({ port: 8082 }, (req) => {
if (req.headers.get("upgrade") !== "websocket") {
return server.fetch(req);
}
const { socket: connection, response } = Deno.upgradeWebSocket(req);
server.addConnection(connection);
connection.addEventListener("close", () => {
server.removeConnection(connection);
});
return response;
});- Create Agent (Same as Node.js version)
Usage in a browser bundle
The root entry (Server and Agent) and dialback/browsermesh contain no Node built-in imports that a browser bundler (Vite, esbuild, Rollup) cannot resolve. Two things are routed through the package's imports map: the Node-only HTTP/WebSocket listener behind Server#listen() (#node-listener), and the EventEmitter base class (#events, the real node:events under Node and Deno, a tiny built-in emitter elsewhere). The node condition gets the real implementations, so Node and Deno behave exactly as before; everything else gets import-free stand-ins, so a bundle pulls in neither node:stream/promises, node:events, http nor @johnhenry/webwire. In a page, drive the server with server.fetch(request) and server.addConnection(connection); server.listen() rejects with an error there. What this does not do is make the default Agent transport work in a browser: it still dials with the ws package, which is a stub in browser bundles, so in a page give the Agent a transport (for example the dialback/browsermesh one).
API Documentation
Server
The Server class is responsible for handling incoming HTTP requests and managing WebSocket connections to agents.
Constructor
new Server(defaultHandler, options)defaultHandler: A function that returns aResponseobject when no agent is available to handle the request.options: An object with the following properties:strategy(optional): A string specifying the agent selection strategy. Default is "first". One of"first","last","random","round-robin","last-used"(the agent that most recently completed a request/response cycle), or"most-recent"(the agent that most recently connected).secret: A string used for authentication between the server and agents. Required unlessallowUnauthenticatedAgentsis set — the constructor throws otherwise, since without a secret the server has nothing to validate an agent handshake against and would accept any agent unverified.allowUnauthenticatedAgents(optional): Set totrueto explicitly opt out of thesecretrequirement and accept any agent handshake with no verification. Default isfalse.log(optional): An integer log level (0-4, matchingLOG_LEVELS/Agent'slogoption). Default is0(no logging).
Methods
addConnection(connection): Registers a WebSocket-like connection (anything implementingsend/addEventListener/close) as a candidate agent connection and starts consuming its messages. Returns aPromisethat resolves to the connection.removeConnection(connection): Unregisters a connection. Any request still in flight against it is rejected instead of hanging forever.removeConnectionById(id): Removes the connection registered under the given agent id (theagentfield from that connection's handshake).removeConnectionByIndex(index): Removes the connection at the given position in connection order.getConnectionById(id): Returns the connection registered under the given agent id, orundefined.getConnectionByIndex(index): Returns the connection at the given position, orundefined.listen(port): Starts a built-in Node.js HTTP + WebSocket listener. Node only; in a browser bundle it rejects with an error telling you to usefetch()+addConnection()instead.close(): Closes and unregisters every registered agent connection (rejecting requests still in flight) and, iflisten()was used, stops the listener. Safe to call when you only ever usedaddConnection()(there is no listener to stop), and safe to call more than once.fetch(request): Handles an incoming HTTP request and returns aPromisethat resolves to aResponseobject.setStrategy(newStrategy): Sets the agent selection strategy; throws on an unrecognized value.
Properties
strategy: Gets or sets the current agent selection strategy (setting it validates the same way assetStrategy).fetch: A bound version of thefetchmethod that can be used directly with HTTP server libraries.
A note on authentication
The secret option is a single shared secret compared (in constant time) against whatever every agent sends in its handshake — there's no per-agent identity, rotation, or session/token model. That's an intentional simplification for now, not an oversight; if you need per-agent credentials or anything more sophisticated, put this behind your own auth layer (e.g. a reverse proxy or VPN in front of the WebSocket port) rather than expecting dialback to provide it.
secret is required precisely because omitting it isn't a safe default — a Server with no secret configured would accept a handshake from any agent with no verification at all. If that's genuinely what you want (e.g. local development, or a deployment secured entirely at the network layer), pass allowUnauthenticatedAgents: true explicitly so it's visible in the code that authentication was deliberately skipped, not merely forgotten.
Agent
The Agent class is responsible for connecting to a Server and handling proxied requests.
Constructor
new Agent(address, options)address: A string representing the WebSocket address of the server to connect to (or whatever address shape a customtransportexpects — see below).options: An object with the following properties:reconnect(optional): The number of milliseconds to wait before attempting to reconnect if the connection is lost.log(optional): An integer representing the log level (0-4).abort(optional): A function that returns aResponseobject when a request is aborted.secret(optional): A string used for authentication between the server and agents.transport(optional):(address) => Promise<Connection>. When provided, used instead ofnew WebSocket(address)to establish the connection — see Optional: thedialback/browsermeshtransport below.
Methods
serve(handler): Sets the request handler function for the agent. The handler function should accept aRequestobject and return aPromisethat resolves to aResponseobject.
Properties
serve: A bound version of theservemethod that can be used to set the request handler.
Utility Functions
upgradeWebSocket(req)
A utility function that creates a WebSocket connection from an HTTP request. This function is primarily used in Deno environments.
Security model
Server gates every connecting agent behind a single shared secret before
it's ever handed to application code. That's the whole boundary the core
transport draws -- everything past the handshake (which requests go to
which agent, what those requests contain) is unauthenticated at this layer.
What dialback guarantees:
- Every agent handshake is checked against
secret. The comparison usesnode:crypto'stimingSafeEqual(constant-time), not!==, so a wrong guess can't be timed to leak how many leading bytes matched. - Authentication cannot be silently skipped.
new Server(handler, options)throws unless eithersecretis set orallowUnauthenticatedAgents: trueis passed explicitly -- there is no code path that ends up accepting unverified agents by omission. See "A note on authentication" above. - A request in flight against a removed connection is rejected, not left
hanging.
removeConnection()/removeConnectionById()reject any pending request against that agent instead of leaving the caller to time out.
What is still yours:
- The
secretmodel has no per-agent identity, rotation, or session model. Every agent presents the same string; there's no way to tell agents apart at the auth layer, and revoking one agent means rotating the secret for all of them. If you need per-agent credentials, put dialback behind your own auth layer (a reverse proxy or VPN in front of the WebSocket port), or use thedialback/browsermeshtransport below, which replaces the shared secret with real per-agent Ed25519 identity. Tracked as a possible core replacement in dialback #2. - Transport confidentiality is your deployment's responsibility.
dialback does not manage TLS itself -- run the WebSocket server behind
wss://(or an equivalent terminating proxy) if the secret or proxied request/response bodies must not be visible on the wire. fetch(request)forwards whatever the selected agent returns.Serverdoes not inspect, sanitize, or rate-limit request/response bodies -- that's thedefaultHandler's and the agent's own handler's job.
Optional: the dialback/browsermesh transport
dialback's built-in transport is a WebSocket plus a single shared secret string, compared against whatever every connecting agent sends. dialback/browsermesh is an optional, additive module — never imported by dialback's own index.mjs/server.mjs/agent.mjs, so requiring plain dialback never touches it — that swaps that in for real, per-agent Ed25519 identity, built on @johnhenry/browsermesh-netway (virtual networking: StreamSocket/VirtualNetwork/Listener) and @johnhenry/browsermesh-primitives (PodIdentity, an Ed25519 keypair whose podId is a base64url hash of its public key).
Why: a shared secret authenticates that you're some agent this server trusts, not which agent — every agent presents the same string, there's no revocation short of rotating the secret for everyone, and no way to tell agents apart at the auth layer. dialback/browsermesh gives each agent its own keypair; the server verifies a signed challenge before the connection is ever handed to Server#addConnection(), so a compromised or retired agent's key can simply stop being trusted without affecting any other agent.
Both @johnhenry/browsermesh-netway and @johnhenry/browsermesh-primitives are optional peerDependencies — install them yourself to use this module:
npm install @johnhenry/browsermesh-netway @johnhenry/browsermesh-primitivesUsage
import { Server, Agent } from "@johnhenry/dialback";
import {
createBrowsermeshTransport,
acceptBrowsermeshConnections,
} from "@johnhenry/dialback/browsermesh";
import { VirtualNetwork } from "@johnhenry/browsermesh-netway";
import { PodIdentity } from "@johnhenry/browsermesh-primitives";
// A VirtualNetwork is *your* responsibility to construct and configure. The
// default one (used below) only has its built-in LoopbackBackend, i.e.
// `mem://` addresses — real, in-process networking, good for same-process
// use and tests, but not cross-machine. For real cross-machine deployment,
// configure a `GatewayBackend` on your `VirtualNetwork` yourself (see
// `@johnhenry/browsermesh-netway`'s own docs) — this module takes whatever
// `VirtualNetwork` it's given and doesn't care which backend is behind it.
const net = new VirtualNetwork();
const serverIdentity = await PodIdentity.generate();
const agentIdentity = await PodIdentity.generate();
// `allowUnauthenticatedAgents: true` is correct and intentional here, not a
// security downgrade: by the time acceptBrowsermeshConnections() ever calls
// server.addConnection(), the connecting agent has already passed the
// identity handshake below — a connection that fails it is closed and never
// reaches the Server at all.
const server = new Server(undefined, { allowUnauthenticatedAgents: true });
const listener = await net.listen("mem://localhost:9000");
acceptBrowsermeshConnections(listener, server, serverIdentity); // long-running; don't await
const transport = createBrowsermeshTransport(net, agentIdentity);
const { serve } = new Agent("mem://localhost:9000", { transport });
serve(async (request) => new Response("Hello there!", { status: 200 }));The handshake
Run entirely inside the transport, before either side ever sees a Connection: the listener (mirroring how Server already validates incoming agents against its secret today) sends a random nonce; the connecting peer signs it with its PodIdentity and replies with { podId, publicKey, signature }; the listener verifies the signature and that podId really is the hash of the supplied publicKey, then sends accept or reject. A rejected or malformed handshake closes the connection immediately — it's never wrapped as a Connection or handed to Server#addConnection(). See transports/handshake.mjs for the exact wire format and transports/framing.mjs for how dialback's message-oriented protocol is framed (newline-delimited JSON) over StreamSocket's raw byte stream.
Using the handshake directly
If you bring your own StreamSocket transport instead of using createBrowsermeshTransport / acceptBrowsermeshConnections, the two halves of the handshake are importable by subpath (no deep import into node_modules needed):
import {
challengeConnectingPeer, // listener side: challenge, verify, accept/reject
respondToChallenge, // connecting side: sign the nonce with your PodIdentity
} from "@johnhenry/dialback/transports/handshake"; // or the short alias "@johnhenry/dialback/handshake"They are also re-exported from @johnhenry/dialback/browsermesh, and both subpaths ship types (types/handshake.d.ts, types/browsermesh.d.ts). They are deliberately not re-exported from the package root, which stays transport-agnostic (the handshake is only meaningful with the optional @johnhenry/browsermesh-primitives peer dependency; @johnhenry/browsermesh-netway is not needed). See transports/handshake.mjs for signatures and error behaviour.
The socket you pass in needs only two methods, so the handshake runs over a plain WebSocket, a MessagePort, or an in-memory pair, not just a netway StreamSocket:
interface HandshakeSocket {
read(): Promise<Uint8Array | null>; // next chunk, or null at end of stream
write(bytes: Uint8Array): Promise<unknown> | unknown;
close?(): Promise<unknown> | unknown;
}// listener side, e.g. in your own WebSocket server's "connection" handler
const { podId, reader } = await challengeConnectingPeer(socket, serverIdentity);
// connecting side
await respondToChallenge(socket, agentIdentity);This handshake is intentionally one-directional — the listener authenticates the connecting agent, not the other way around — exactly matching the asymmetry of the existing shared-secret model (an agent today has no way to verify the server's secret either). The listener does include its own podId in the initial challenge, but only informationally (not signed) — a connecting agent can log/identify which listener it reached, but that isn't cryptographic proof of the listener's identity.
Honest limitations
- No real backpressure signal.
StreamSockethas nobufferedAmount-equivalent —write()either succeeds or throws once the peer's buffer has already overflowed, with no graduated "getting full" signal in between. This transport'sConnection.bufferedAmountalways reports0, soagent.mjs's/server.mjs's backpressure-wait loops never actually block on it; writes are attempted eagerly and fail (loudly) only once the peer is already overwhelmed. - No mutual authentication. As above, the connecting agent does not cryptographically verify the listener.
Family
dialback isn't just a standalone WebSocket reverse proxy -- its optional transport layer is the designed consumer of two sibling packages' identity and networking primitives.
@johnhenry/browsermesh-primitives--PodIdentity(an Ed25519 keypair whosepodIdis a base64url hash of its public key) is whatdialback/browsermesh's handshake signs and verifies, replacing the built-in transport's single sharedsecretstring with real per-agent identity. A real dependency on@johnhenry/browsermesh-primitives, not the other way around -- it's an optionalpeerDependency, never imported by plaindialback.@johnhenry/browsermesh-netway--VirtualNetwork/StreamSocket/Listenerare the virtual networking layerdialback/browsermesh's transport runs Server/Agent connections over, in place of a raw WebSocket. Same optional-peerDependencyrelationship as above. See Optional: thedialback/browsermeshtransport for the full usage and handshake protocol.@johnhenry/webwire-- the Nodereq/res<-> WebRequest/ResponseconversionServer.listen()uses. This used to be an independent, hand-rolled copy of the same logic@johnhenry/leservealso had (see webwire's own README for the full story); depending on webwire directly, rather than each side maintaining its own copy, fixed a real bug (multi-value response headers likeSet-Cookiewere being silently collapsed to one).
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
This project is licensed under the MIT License - see the LICENSE file for details.
