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 localhostThe 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 keypath: Load the existing key, or generate and persist an ed25519 key on first use. On POSIX systems, generated directories use mode0700, and the key uses0600. Windows uses the directory access control list.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.
.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, sofinallyperforms teardown. - Gate:
session.deny(reason)writes the reason on the main screen, closes the session, and throwsDenyErrorto unwind the chain. The server does not reportDenyErroras a failure. If the reason cannot be written, that error goes toonError, and the session still closes. - 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. 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()beforeonClosecallbacks 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. onClosemarks the logical close, not completed terminal restoration. Awaitsession.renderer.closedto 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
onErrorand 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
onClosecallbacks but does not await promises returned by them. It also does not track asynchronous middleware work afterawait next()resolves. Await application-owned asynchronous cleanup separately before process exit. 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.OutputPressureErroruses"OUTPUT_PRESSURE"for a rejectedwrite().DenyErroris intentional middleware control flow. Session handling swallows it after it tries to write the reason.
Public exports#
Runtime exports from @opentui/ssh:
createServerloggingSshErrorConfigErrorOutputPressureErrorDenyError
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.