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
| Configuration | Default | Behavior |
|---|---|---|
auth | "open" | Accept SSH none authentication |
hostKey | ephemeral ed25519 key | Regenerated each server construction |
idleTimeout | disabled | No inactivity timeout |
maxTimeout | disabled | No absolute session timeout |
limits.session.perConnection | 1 | Maximum live renderer-backed shells per SSH connection |
limits.session.global | 100 | Maximum live renderer-backed shells 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. 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 mode0700and the key uses0600; Windows uses the directory ACL.pem: accept one PEM/Buffer or an array. Every configured key produces a SHA256 fingerprint inListenInfo.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:
| 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, 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:
| Member | Description |
|---|---|
renderer | Live CliRenderer connected to this SSH channel; handler-only |
identity | Authentication identity narrowed from the server config |
context | Per-session object accumulated by middleware; {} without middleware |
term | Client terminal name, with "xterm-256color" as the no-PTY fallback |
cols, rows | Current PTY dimensions; no-PTY fallback is 80x24 |
hasPty | Whether the client requested a PTY |
remoteAddress | Client { 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, sofinallyperforms teardown. - Gate:
session.deny(reason)writes the reason on the main screen, closes the session, and unwinds throughDenyErrorwithout reporting a failure. - Enrich:
next({ ... })adds typed fields to downstreamsession.context. Return the resultingHandoff.
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
onClosecallbacks 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.onErrorreports contained handler, middleware, auth predicate, resize/close callback, connection, transport, and post-listen server errors. It defaults toconsole.error.- A bind failure rejects
listen()instead of going toonError. logging()observes connection lifecycle only; it is not an error sink.SshErrorcarries a stablecode;ConfigErroruses"CONFIG"for invalid startup configuration.DenyErroris intentional middleware control flow and is swallowed by session handling after delivering the reason.
Public exports
Runtime exports from @opentui/ssh:
createServerloggingSshErrorConfigErrorDenyError
Type exports:
AuthConfig,AuthMethodsIdentity,IdentityFor,PublicKey,PublicKeyPolicy,RemoteAddressSession,SessionHandler,MiddlewareSessionMiddleware,MiddlewareFunction,Next,HandoffServerConfig,ServerBuilder,Server,ListenInfoLogEvent,LoggingOptions
Internal helpers such as isDeny, SessionCommon, CredentialMethods, and KeyboardPrompt are not exported from the package root.