These docs describe the main branch, including unreleased changes. Read the 0.6.0 docs.

SSH

@opentui/ssh gives each accepted SSH shell its own CliRenderer. The renderer reads input from the SSH channel, writes output to it, and tracks the size of the client’s PTY. Users connect with a standard SSH client and use the application without a local installation. The package depends on @opentui/core, not React or Solid. The same server can hand its renderer to the Core, React, or Solid API.

SSH is an access transport. It is not an application packaging format. See Deploy an OpenTUI application to choose a delivery model and Standalone executables to build an executable.

Install and runtime#

bun add @opentui/ssh @opentui/core

@opentui/core is a peer dependency. The SSH package declares Bun >=1.3.14 and Node.js >=26.4.0. Use Bun >=1.4.0 on native Windows arm64. The SSH Node CI lane runs on Linux x64. Its packed consumer test installs the packed Core and native packages and runs a real renderer session with Node.js and Bun. See Runtime and platform support before you run the native renderer with Node.js.

Basic server#

This first example uses auth: "open" for loopback development only. Open authentication accepts SSH none without credentials. Do not expose this policy on a public or untrusted network.

import { BoxRenderable, TextRenderable } from "@opentui/core"
import { createServer } from "@opentui/ssh"

const server = createServer({
  hostKey: { path: "./host_key" },
  auth: "open",
}).serve((session) => {
  const box = new BoxRenderable(session.renderer, {
    width: "100%",
    height: "100%",
    border: true,
    borderStyle: "rounded",
  })

  box.add(new TextRenderable(session.renderer, { content: `Hello, ${session.identity.username}!` }))
  session.renderer.root.add(box)

  session.renderer.keyInput.on("keypress", (key) => {
    if (key.name === "q" || (key.ctrl && key.name === "c")) session.end()
  })
})

await server.listen(2222)

Connect from another terminal:

ssh -p 2222 localhost

The server owns the renderers that it creates. It destroys each renderer when its session closes. Call server.close() to close all live sessions and stop the listener.

Builder lifecycle#

The public construction sequence is:

const builder = createServer(config)
const configured = builder.use(middleware)
const server = configured.serve(handler)

const info = await server.listen()
await server.close()

createServer(config) returns an immutable ServerBuilder. Each .use() call returns a new builder with one more middleware and a wider context type. The builder has no listen() method. .serve(handler) seals the chain and returns the startable Server. Omitting the session handler is a type error.

listen(port = 2222, host = "127.0.0.1") resolves to:

interface ListenInfo {
  host: string
  port: number
  fingerprints: string[]
}

Pass port 0 to request an ephemeral port. When authentication is open, a non-loopback listener logs a warning but still listens. The loopback names are localhost, 127.0.0.1, and ::1.

Server defaults#

Configuration Default Behavior
auth "open" Accept SSH none authentication
hostKey ephemeral ed25519 key Generated again by each .serve() call
idleTimeout disabled No inactivity timeout
maxTimeout disabled No absolute session timeout
limits.session.perConnection 1 Maximum live shell sessions per SSH connection
limits.session.global 100 Maximum live shell sessions across the server
startupBanner true Print listener, host-key, and authentication details after binding
onError console.error Report contained runtime errors
listen port 2222 Default SSH port for this server
listen host "127.0.0.1" Loopback-only default

Session limits must be positive safe integers. A shell counts toward the limits from the time the server accepts it, before middleware runs. The server rejects excess shell requests without closing the SSH connection or reporting a runtime error. A shell keeps its slot until the server closes its SSH channel.

idleTimeout and maxTimeout accept integer milliseconds or strings with ms, s, m, or h. Examples include "500ms", "30s", and "1h". Values must resolve to a positive safe integer from 1 millisecond through 24 hours. Client input rearms the idle timer. The maximum timer is an absolute lifetime.

Host keys#

Choose one host-key source:

createServer({ hostKey: { path: "./host_key" } })
createServer({ hostKey: { pem: privateKeyPem } })
createServer({ hostKey: { pem: [ed25519Pem, rsaPem] } })
createServer() // ephemeral ed25519 key
  • path: Load the existing key, or generate and persist an ed25519 key on first use. On POSIX systems, generated directories use mode 0700, and the key uses 0600. Windows uses the directory access control list.
  • pem: accept one PEM/Buffer or an array. Every configured key produces a SHA256 fingerprint in ListenInfo.fingerprints, preserving input order.
  • omitted: generate an ephemeral key that is not persisted.

.serve() builds the server. It throws ConfigError for an invalid or empty key configuration.

Authentication#

auth is either "open" or an object containing one or more credential methods:

Configuration Behavior
omit auth or use "open" Accept unauthenticated sessions
publicKey: "any" Verify proof of key ownership, then accept any verified key
publicKey: { authorizedKeys } Accept keys from a file path or array of public-key lines
publicKey: { allow } Run an async or synchronous policy after signature verification
publicKey: { authorizedKeys, allow } Accept when the key is allowlisted or the policy returns true
password Run (ctx: { username, password }) => boolean | Promise<boolean>
keyboardInteractive Run (ctx: { username, prompt }) => boolean | Promise<boolean>

An empty object configures no usable methods and throws ConfigError. Use "open" deliberately for no authentication. Authorized-key files allow blank lines and # comments. They do not support OpenSSH options. A line with options throws ConfigError.

Public-key authentication verifies the client’s signature before it sets the fingerprint or calls allow. The client still supplies the username, and the username is not bound to the key. Authorize public-key users by the verified fingerprint, or enforce a username-to-key pairing in allow.

const server = createServer({
  auth: {
    publicKey: {
      authorizedKeys: "./authorized_keys",
      allow: ({ username, fingerprint }) => username === "deploy" && trusted.has(fingerprint),
    },
    password: ({ username, password }) => username === "guest" && password === process.env.GUEST_PASSWORD,
  },
}).serve((session) => {
  if (session.identity.method === "publickey") {
    console.log(session.identity.fingerprint)
  }
})

If an authentication predicate throws, authentication fails closed and the error is reported through onError.

Typed identity#

createServer() infers session.identity from the configured authentication methods. IdentityFor<A> maps "open" to the none variant and an AuthMethods object to the union of the methods it enables:

type Identity =
  | {
      method: "none"
      username: string
    }
  | {
      method: "password"
      username: string
    }
  | {
      method: "keyboard-interactive"
      username: string
    }
  | {
      method: "publickey"
      username: string
      fingerprint: string
      publicKey: {
        algorithm: string
        blob: Buffer
      }
    }

A public-key-only configuration makes fingerprint directly available. Mixed methods produce a discriminated union:

createServer({ auth: { publicKey: "any", password: checkPassword } }).serve((session) => {
  if (session.identity.method === "publickey") {
    console.log(session.identity.fingerprint)
  }
})

Session API#

The handler passed to .serve() receives:

Member Description
renderer Live CliRenderer connected to this SSH channel. Available only to handlers.
identity Authentication identity narrowed from the server config
context Per-session object accumulated by middleware. It is {} without middleware.
term Client terminal name, with "xterm-256color" as the no-PTY fallback
cols, rows Requested PTY size until the renderer exists, then its accepted size. No-PTY: 80x24.
hasPty Whether the client requested a PTY
remoteAddress Client { address, port? }
onResize(callback) Runs after the renderer accepts a new size. Returns an unsubscribe function.
onClose(callback) Runs once when the session closes for any reason. Returns an unsubscribe function.
write(data) Send raw Buffer or string bytes without frame diffing. No effect after close.
end() Close this session

The server limits PTY dimensions to 500 columns and 200 rows. When the client requests a PTY, the renderer detects terminal capabilities from its TERM and from COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION env requests sent before the shell request. The server rejects other env requests and values over 256 bytes, and a TERM over 256 bytes sends none of these values. Use write() only for terminal control that the renderer does not model, such as a bell or an OSC sequence.

write() accepts all bytes or none. It throws OutputPressureError with code "OUTPUT_PRESSURE" when the session output is temporarily full. It throws RangeError when one write is larger than the session output limit. The server does not queue or replay a rejected write.

Middleware receives MiddlewareSession, which has the common fields, context, and deny(), but no renderer. The server creates the renderer only after the middleware chain reaches the handler. A denied session does not create a renderer or enter the alternate screen.

Middleware#

Middleware runs in registration order. The first registered function is the outermost layer. Use middleware for three patterns:

import { createServer, type Middleware } from "@opentui/ssh"

const timing: Middleware = async (session, next) => {
  const startedAt = Date.now()
  try {
    return await next()
  } finally {
    console.log(`${session.identity.username}: ${Date.now() - startedAt}ms`)
  }
}

const server = createServer({ auth: { publicKey: "any" } })
  .use(timing)
  .use((session, next) => {
    if (blocked.has(session.identity.fingerprint)) session.deny("This key is not authorized.")
    return next()
  })
  .use((session, next) => next({ tier: admins.has(session.identity.fingerprint) ? "admin" : "user" }))
  .serve((session) => {
    console.log(session.context.tier)
  })
  • Setup/teardown: await next() resolves when the session ends, so finally performs teardown.
  • Gate: session.deny(reason) writes the reason on the main screen, closes the session, and throws DenyError to unwind the chain. The server does not report DenyError as a failure. If the reason cannot be written, that error goes to onError, and the session still closes.
  • Enrich: next({ ... }) adds typed fields to downstream session.context. Return the resulting Handoff.

Reusable middleware can use Middleware. Use MiddlewareFunction when it requires a known upstream context type.

Logging middleware#

import { createServer, logging } from "@opentui/ssh"

const events = []

createServer({ auth: { publicKey: "any" } })
  .use(logging({ log: (event) => events.push(event) }))
  .serve((session) => {
    console.log(session.identity.username)
  })

logging() emits a connect event on entry. During teardown, it emits a disconnect event with durationMs. Events also contain identity, remoteAddress, term, cols, and rows. Without a custom sink, it writes one formatted line to console.log. The logger isolates and ignores sink failures. onError remains responsible for application and transport errors.

React and Solid handoff#

@opentui/ssh does not depend on either framework. Install the framework binding in the application and pass the existing session renderer to it.

React#

import { createRoot } from "@opentui/react"
import { createServer } from "@opentui/ssh"

const App = ({ name }: { name: string }) => <text>Hello, {name}!</text>

createServer().serve((session) => {
  const root = createRoot(session.renderer)
  root.render(<App name={session.identity.username} />)
  session.onClose(() => root.unmount())
})

Create one React root per session and unmount the app-owned React tree in onClose. The SSH package independently owns and destroys the CliRenderer.

Solid#

import { render } from "@opentui/solid"
import { createServer } from "@opentui/ssh"

const App = (props: { name: string }) => <text>Hello, {props.name}!</text>

createServer().serve(async (session) => {
  await render(() => <App name={session.identity.username} />, session.renderer)
})

Solid’s render(node, renderer) adopts the existing renderer. Solid disposes its root when that renderer is destroyed, so this handoff does not add a separate onClose disposer.

Cleanup, errors, and shutdown#

  • When a renderer exists, every close path calls renderer.destroy() before onClose callbacks run. Use those callbacks for app-owned roots, timers, counters, and listeners, not renderer destruction.
  • If your code calls renderer.destroy(), the session also closes.
  • onClose marks the logical close, not completed terminal restoration. Await session.renderer.closed to see when output finishes. It rejects if output fails.
  • The server closes the SSH channel after the session output finishes. If output does not finish within one second of the close, Core cancels it without terminal restoration. The server reports that error to onError and closes the channel. If the connection drops, the server cancels the output at once.
  • server.close() rejects new shell requests and closes every live session as above. Then it ends every client connection and closes the SSH listener.
  • The server invokes onClose callbacks but does not await promises returned by them. It also does not track asynchronous middleware work after await next() resolves. Await application-owned asynchronous cleanup separately before process exit.
  • onError reports contained handler, middleware, auth predicate, resize/close callback, connection, transport, and post-listen server errors. It defaults to console.error.
  • A bind failure rejects listen() instead of going to onError.
  • logging() observes connection lifecycle only. It is not an error sink.
  • SshError carries a stable code. ConfigError uses "CONFIG" for invalid startup configuration. OutputPressureError uses "OUTPUT_PRESSURE" for a rejected write().
  • DenyError is intentional middleware control flow. Session handling swallows it after it tries to write the reason.

Public exports#

Runtime exports from @opentui/ssh:

  • createServer
  • logging
  • SshError
  • ConfigError
  • OutputPressureError
  • DenyError

Type exports:

  • AuthConfig, AuthMethods
  • Identity, IdentityFor, PublicKey, PublicKeyPolicy, RemoteAddress
  • Session, SessionHandler, MiddlewareSession
  • Middleware, MiddlewareFunction, Next, Handoff
  • ServerConfig, ServerBuilder, Server, ListenInfo
  • LogEvent, LoggingOptions

Internal helpers such as isDeny, SessionCommon, CredentialMethods, and KeyboardPrompt are not exported from the package root.

Changes0.6.0, 0.5.13
0.6.0
ssh: session.write() can throw OutputPressureError or RangeError. cols and rows hold the requested size until the renderer attaches, and onClose runs at logical close, before the renderer finishes closing. See SSH. (#1479)
ssh: The server accepts COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION env requests that arrive before the shell request, with values of at most 256 bytes. It rejects other keys. The renderer gets these values with the client’s PTY TERM, so capability detection sees the client’s color depth and hyperlink support, and GNU Screen or tmux from the TERM prefix. A client with TERM=xterm-256color and no COLORTERM now gets 256-color output instead of truecolor. To keep truecolor, add SendEnv COLORTERM to the client’s ssh configuration. See SSH. (#1603)
Added OutputPressureError.
0.5.13
ssh: Accepted connections disable Nagle’s algorithm. On Node, this removes a delay of about 40 ms on each reply. (#1531)