modernssh

TCP/IP forwarding channels

forwardOut() opens an RFC 4254 direct-tcpip channel. It describes the original connection and the destination to the SSH server and resolves to a flow-controlled ClientTCPIPChannel, which is a Node.js Duplex stream.

const tunnel = await client.forwardOut(
    "127.0.0.1", // originator address
    51_234, // originator port
    "database.internal", // destination visible to the SSH server
    5432, // destination port
)

localSocket.pipe(tunnel).pipe(localSocket)

The addresses are protocol metadata; the library does not create the destination socket itself. The receiving server decides whether the request is allowed and connects or otherwise services the stream. Ports are validated as unsigned 16-bit TCP port numbers before a packet is sent. Address and socket-path text must be valid UTF-8 on the wire; malformed input is rejected before it reaches an authorization hook, listener lookup, or channel event. Direct TCP and stream-local opens validate this metadata before allocating a channel identifier. As Promise-only operations, they report invalid input through rejection rather than a synchronous throw.

Forwarding channels consume the same connection-wide maxChannels allowance as sessions, X11, agent forwarding, and other channels. This bounds accepted network connections even when an application or remote forwarding listener can produce them faster than they close.

HTTP and HTTPS agents

HTTPAgent and HTTPSAgent integrate direct forwarding with Node's http and https clients. The explicit SSHHTTPAgent and SSHHTTPSAgent names are aliases for the same classes. An agent lazily establishes one authenticated SSH connection, and each HTTP socket opens an independent direct-tcpip channel to its request destination. Concurrent sockets are therefore multiplexed over one SSH transport. Standard HTTP agent pooling can also retain and reuse each destination channel for later requests.

The HTTP channel adapter implements Node's socket inactivity timeout contract. Request and socket timeouts refresh on inbound and outbound activity, emit timeout without implicitly closing the channel, and release their timer when the channel closes. Calling setTimeout(0) disables the timer. TCP keepalive and Nagle controls remain no-ops at the individual channel layer because those settings belong to the shared SSH transport.

import { once } from "node:events"
import https from "node:https"
import { finished } from "node:stream/promises"
import { HTTPSAgent } from "@bunkerch/modernssh"

const agent = new HTTPSAgent(
    {
        hostname: "gateway.example",
        port: 22,
        username: "deploy",
        agent: signingAgent,
    },
    { keepAlive: true, sourceHost: "build-runner.example" },
)
agent.hooker.hook("hostKey", verifyGatewayHostKey)

const request = https.get("https://service.internal/health", { agent })
const [response] = await once(request, "response")
response.pipe(process.stdout)
await finished(response)

Configure the agent's hostKey Hooker before starting requests. Its awaited handlers apply to the initial SSH transport and every replacement transport created after a recoverable disconnect.

For HTTPS, TLS is negotiated end-to-end over the SSH channel; the SSH server does not terminate or inspect TLS. sourceHost and sourcePort set the originator metadata in the forwarding request and default to 127.0.0.1 and zero. Per-request localAddress and localPort override that metadata; they do not bind a local interface on the HTTP caller. Call agent.destroy() to close pooled HTTP channels and the current shared SSH connection. The same agent may be used again after destroy(); its next request establishes a fresh SSH connection. A peer-closed transport is handled the same way, so requests created after closure reconnect without retaining the old channel state. Apply the same host-key verification and destination allowlisting requirements as any other direct-forwarding client. The agent snapshots its SSH configuration during construction, including nested algorithm and authentication lists, so later caller mutations cannot change credentials or negotiation for a new request. Encoded private keys and certificates are parsed once into the configured signing agent; their source containers and passphrase are not retained. Do not supply sock: an agent may need to replace its shared connection after failure or explicit destruction, while an application-provided transport can be consumed only once.

Accepting direct connections

Direct forwarding is denied by the server's default channel-open policy. Inspect both the source and destination fields before allowing it. Destination allowlisting is strongly recommended to avoid turning an SSH service into an unrestricted network proxy.

import net from "node:net"
import { DirectTCPIPChannel } from "@bunkerch/modernssh"

server.hooker.hook("channelOpenRequest", (_hook, channel, decision) => {
    decision.allowOpen =
        channel instanceof DirectTCPIPChannel &&
        channel.details.destinationHost === "database.internal" &&
        channel.details.destinationPort === 5432
})

server.on("connection", (connection) => {
    connection.on("channel", (channel) => {
        if (!(channel instanceof DirectTCPIPChannel)) return

        const destination = net.connect({
            host: channel.details.destinationHost,
            port: channel.details.destinationPort,
        })
        channel.stream.pipe(destination).pipe(channel.stream)
    })
})

The server-side DirectTCPIPChannel.stream uses the same bounded window, packet splitting, backpressure, EOF, and CLOSE implementation as other server channels. A server-initiated direct-tcpip open is rejected by clients as recommended by RFC 4254.

Remote forwarding

forwardIn() asks the SSH server to listen on an address and port. Every matching incoming channel passes through the awaited tcpConnection Hooker policy and is denied by default. The hook receives the proposed ClientForwardedTCPIPChannel; its immutable details contain the destination and source endpoints. Set allowOpen only after asynchronous authorization and local setup succeed. After confirmation, the client emits the passive tcp connection event with the details and already-open channel. A rejected Hooker handler discards an earlier approval.

import net from "node:net"
import { ChannelOpenError, ChannelOpenFailureReasonCodes } from "@bunkerch/modernssh"

client.hooker.hook("tcpConnection", async (_hook, channel, decision) => {
    if (!(await authorizeForwardedSource(channel.details.sourceHost))) {
        decision.rejection = new ChannelOpenError(
            ChannelOpenFailureReasonCodes.SSH_OPEN_ADMINISTRATIVELY_PROHIBITED,
            "forwarded source denied by policy",
            "en-US",
        )
        return
    }

    const localService = net.connect({ host: "127.0.0.1", port: 8080 })
    localService.pipe(channel).pipe(localService)
    decision.allowOpen = true
})

const allocatedPort = await client.forwardIn("127.0.0.1", 0)
// Later, cancel the exact address and allocated port.
await client.unforwardIn("127.0.0.1", allocatedPort)

A port of zero requests dynamic allocation; forwardIn() resolves to the allocated port reported by the server. An active or still-pending fixed address/port request is rejected locally before a duplicate global request reaches the peer. Bind addresses have server-specific exposure rules. In particular, wildcard binds can expose a listener beyond loopback when the SSH server permits gateway ports, so validate both the requested bind and every connection's source metadata. Policy can set decision.rejection to a validated ChannelOpenError when the server should receive a specific uint32 reason, UTF-8 description, and RFC 3066 language tag. A policy decision that completes after transport teardown is discarded and its proposed channel is destroyed. Destroying the proposed channel during policy denies the open even if a later handler sets allowOpen.

Only a successful dynamic-port request carries response data: exactly one nonzero port in the unsigned 16-bit range. Successful fixed-port and cancellation replies carry no data. A malformed success closes the SSH connection because the peer may already have changed its listener state and the client cannot safely continue with an untracked forwarding.

Allowing remote forwarding on a server

Server-side remote forwarding is denied by default. The tcpipForward policy hook receives the requested bind before any TCP listener is created. Restrict both address and port; allowing a wildcard address grants the authenticated client network exposure through the SSH server. When several policy handlers run, the listener is created only if every handler completes without rejection and the final decision allows it; a contained later failure discards an earlier allow.

server.hooker.hook("tcpipForward", (_hook, context, decision, connection) => {
    decision.allow =
        connection.username === "deploy" &&
        context.bindAddress === "127.0.0.1" &&
        (context.bindPort === 0 || context.bindPort >= 40_000)
})

After approval, modernssh owns the TCP listener and opens a ForwardedTCPIPChannel back to the requesting client for each connection. Requests for port zero receive the allocated port. A matching cancel-tcpip-forward request stops accepting new connections immediately; disconnecting the SSH connection also closes all of its listeners. Existing tunnel channels retain the normal independent EOF and CLOSE lifecycle.

TCP and stream-local listeners share the server's per-connection maxRemoteForwardings capacity, which defaults to 64. A request beyond the limit fails before its policy hook or operating-system listener runs. Cancellation recovers the slot after stopping acceptance, and setting the limit to zero disables both forms of remote forwarding while leaving direct forwarding available for its separate channel-open policy.

Listener setup remains transactional through submission of the success reply. If that reply cannot be emitted, the provisional TCP or stream-local listener is removed and closed, and its shared capacity slot is released. A failed request therefore cannot leave an unacknowledged listener accepting connections. Cancellation has the matching guarantee: the listener remains active when its success reply cannot be emitted, so the client may safely retry the cancellation.

An application may also represent an incoming connection explicitly after the client has requested and the server has accepted the exact bind. ServerClient.forwardOut() checks that authorization, opens the RFC 4254 forwarded-tcpip channel, and returns its flow-controlled channel:

async function forwardIncomingConnection(socket, acceptedBind, connection) {
    const channel = await connection.forwardOut(
        acceptedBind.address,
        acceptedBind.port,
        socket.remoteAddress ?? "",
        socket.remotePort ?? 0,
    )
    socket.pipe(channel.stream).pipe(socket)
}

server.on("connection", (connection) => {
    incomingConnections.on("connection", (socket, acceptedBind) => {
        void forwardIncomingConnection(socket, acceptedBind, connection).catch((error) => {
            socket.destroy(error)
        })
    })
})

The bound address and port must identify a currently active forwarding request. This prevents an application bug from opening an unsolicited server-initiated channel. Channel-open rejection is reported through the returned Promise. Destroying the returned server-side stream sends channel CLOSE to its peer; peer CLOSE and transport teardown destroy the stream without trying to close the channel a second time. The SSH connection remains available for unrelated channels.

OpenSSH UNIX-domain socket forwarding

OpenSSH's streamlocal extension applies the same direct and remote forwarding model to UNIX-domain sockets. It is not part of RFC 4254, so these APIs require a peer that implements the OpenSSH extension and retain vendor gating when strictVendor is enabled.

Client defaults strictVendor to true, so these methods reject before sending a request unless the peer advertises a compatible OpenSSH identification. Explicitly set strictVendor: false for a trusted alternative implementation of the same extension.

forwardOutStreamLocal() opens a direct-streamlocal@openssh.com channel to a socket on the SSH server:

const socket = await client.forwardOutStreamLocal("/run/app/control.sock")
localSocket.pipe(socket).pipe(localSocket)

For remote UNIX-socket forwarding, every incoming channel is denied unless its path exactly matches a successful request and the awaited streamLocalConnection Hooker policy approves it. The hook receives the proposed channel, so policy can authorize the immutable socket-path details and finish asynchronous local setup before setting allowOpen. The passive unix connection event runs only after confirmation and receives the already-open channel.

client.hooker.hook("streamLocalConnection", async (_hook, channel, decision) => {
    if (!(await authorizeSocketPath(channel.details.socketPath))) {
        return
    }

    const localSocket = net.connect("/run/app/control.sock")
    localSocket.pipe(channel).pipe(localSocket)
    decision.allowOpen = true
})

await client.forwardInStreamLocal("/run/user/1000/modernssh.sock")
await client.unforwardInStreamLocal("/run/user/1000/modernssh.sock")

The older openssh_forwardOutStreamLocal(), openssh_forwardInStreamLocal(), and openssh_unforwardInStreamLocal() spellings remain Promise-returning aliases.

Socket paths must be non-empty and cannot contain NUL. An active or pending request for the same path rejects locally before sending a duplicate global request. Filesystem ownership, permissions, stale socket replacement, and path visibility are controlled by the SSH server and its operating system. Policy can provide a ChannelOpenError in decision.rejection for a specific failure reason, description, and language tag. Decisions completed after transport teardown are discarded and the proposed channel is destroyed. A channel destroyed during policy cannot be confirmed by a later approval.

Stream-local forwarding and cancellation success replies carry no response data. A malformed success closes the connection so an accepted but untracked remote listener cannot survive.

Allowing UNIX-socket forwarding on a server

Server-side stream-local forwarding is denied by default. Allow only paths owned by the authenticated principal; broad writable directories can let a client replace or impersonate local services.

server.hooker.hook("streamLocalForward", (_hook, context, decision, connection) => {
    decision.allow =
        connection.username === "deploy" && context.socketPath.startsWith("/run/modernssh/deploy/")
})

After approval, modernssh owns the UNIX listener and opens a ForwardedStreamLocalChannel back to the requesting client for each connection. A matching cancellation stops accepting new connections, and disconnecting SSH closes every listener owned by that connection. Existing paths are never unlinked to make room for a listener: a stale or occupied path makes the request fail. The policy chain must complete without a rejected handler before the listener is created, so an earlier allow cannot survive a contained backend failure. These listeners consume the same maxRemoteForwardings capacity as RFC 4254 TCP listeners.

For an explicitly represented incoming UNIX connection, call connection.forwardOutStreamLocal(socketPath). The path must exactly match a currently accepted stream-local forwarding request and contain no NUL. It resolves to a ForwardedStreamLocalChannel; pipe the local socket through channel.stream as with TCP. The older connection.openssh_forwardOutStreamLocal() spelling remains an alias.

Incoming direct-streamlocal@openssh.com channels use the normal channelOpenRequest policy and are also denied by default. Inspect the exact destination before connecting it to a local socket:

import net from "node:net"
import { DirectStreamLocalChannel } from "@bunkerch/modernssh"

server.hooker.hook("channelOpenRequest", (_hook, channel, decision) => {
    decision.allowOpen =
        channel instanceof DirectStreamLocalChannel &&
        channel.details.socketPath === "/run/app/control.sock"
})

server.on("connection", (connection) => {
    connection.on("channel", (channel) => {
        if (!(channel instanceof DirectStreamLocalChannel)) return
        const destination = net.connect(channel.details.socketPath)
        channel.stream.pipe(destination).pipe(channel.stream)
    })
})

On this page