SSH

@opentui/ssh turns each accepted SSH shell into a CliRenderer whose input and output use the SSH channel and whose dimensions track the client’s PTY. The package depends on @opentui/core, not React or Solid, so the same server can hand its renderer to any of the three APIs.

Install and runtime

bun add @opentui/ssh @opentui/core

@opentui/core is a peer dependency. The SSH package declares support for Bun >= 1.3.0 and Node.js 26.4.0. Creating its native renderer under Node also follows the OpenTUI Node runtime requirements, including experimental FFI and any required permissions.

Basic server

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

const server = createServer({
  hostKey: { path: "./host_key" },
  auth: { publicKey: "any" },
}).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 it creates and destroys them when their sessions disconnect. Call server.close() when the process should stop accepting connections and close all live sessions.

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. Every .use() returns a new builder with one more middleware and a widened context type. The builder intentionally has no listen() method. .serve(handler) seals the chain and returns the startable Server, so 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. An open server listening on anything other than localhost, 127.0.0.1, or ::1 logs a warning but still listens.

Server defaults

ConfigurationDefaultBehavior
auth"open"Accept SSH none authentication
hostKeyephemeral ed25519 keyRegenerated each server construction
idleTimeoutdisabledNo inactivity timeout
maxTimeoutdisabledNo absolute session timeout
limits.session.perConnection1Maximum live renderer-backed shells per SSH connection
limits.session.global100Maximum live renderer-backed shells across the server
startupBannertruePrint listener, host-key, and authentication details after binding
onErrorconsole.errorReport contained runtime errors
listen port2222Default SSH port for this server
listen host"127.0.0.1"Loopback-only default

Session limits must be positive safe integers. Excess shell requests are rejected without closing the SSH connection or reporting a runtime error. Capacity remains occupied until that shell’s transport teardown completes.

idleTimeout and maxTimeout accept integer milliseconds or strings with ms, s, m, or h, such as "500ms", "30s", or "1h". Values must resolve to a positive safe integer from 1 millisecond through 24 hours. The idle timer is rearmed by client input; 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 ACL.
  • 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.

Invalid or empty key configuration throws ConfigError while the server is being constructed.

Authentication

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

ConfigurationBehavior
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
passwordRun (ctx: { username, password }) => boolean | Promise<boolean>
keyboardInteractiveRun (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, but OpenSSH options are not interpreted.

Public-key authentication verifies the client’s signature before setting its fingerprint or invoking allow. The username is still supplied by the client and is not bound to that key. Authorize public-key users by the verified fingerprint, or explicitly enforce a username-to-key relationship 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:

MemberDescription
rendererLive CliRenderer connected to this SSH channel; handler-only
identityAuthentication identity narrowed from the server config
contextPer-session object accumulated by middleware; {} without middleware
termClient terminal name, with "xterm-256color" as the no-PTY fallback
cols, rowsCurrent PTY dimensions; no-PTY fallback is 80x24
hasPtyWhether the client requested a PTY
remoteAddressClient { address, port? }
onResize(callback)Subscribe after renderer resize handling; returns an unsubscribe function
onClose(callback)Subscribe to disconnect cleanup; returns an unsubscribe function
write(data)Send raw Buffer or string bytes, bypassing frame diffing; no-op after close
end()Close this session

PTY dimensions are bounded by the implementation to 500 columns and 200 rows. Use write() only for terminal control that the renderer does not model, such as a bell or an OSC sequence.

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

Middleware

Middleware executes in registration order, with the first registered function as the outermost layer. It supports three source-backed 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 unwinds through DenyError without reporting a failure.
  • 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 and a disconnect event with durationMs during teardown. Events also contain identity, remoteAddress, term, cols, and rows. Without a custom sink, it writes one formatted line to console.log. Sink failures are isolated and ignored; application and transport errors remain the responsibility of onError.

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. The Solid root is disposed when that renderer is destroyed, so this handoff does not add a separate onClose disposer.

Cleanup, errors, and shutdown

  • Session disconnect destroys the renderer before onClose callbacks run. Use those callbacks for app-owned roots, timers, counters, and listeners, not renderer destruction.
  • server.close() stops accepting connections, destroys live renderers, waits for their session teardown, and closes the SSH listener.
  • 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.
  • DenyError is intentional middleware control flow and is swallowed by session handling after delivering the reason.

Public exports

Runtime exports from @opentui/ssh:

  • createServer
  • logging
  • SshError
  • ConfigError
  • 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.