@opentui/ssh 0.0.0-20260612-c3be6d8c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 opentui
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,350 @@
1
+ # @opentui/ssh
2
+
3
+ Serve OpenTUI apps over SSH.
4
+
5
+ `@opentui/ssh` turns an incoming SSH session into a fully-wired OpenTUI
6
+ [`CliRenderer`](../core) whose input/output is the SSH channel and whose
7
+ dimensions track the client's PTY. What you render onto it is up to you — the
8
+ package is **renderer-agnostic**: it depends only on `@opentui/core`, never on
9
+ `@opentui/react` or `@opentui/solid`, so the same server works with all three.
10
+
11
+ ```ts
12
+ import { createServer } from "@opentui/ssh"
13
+ import { BoxRenderable, TextRenderable } from "@opentui/core"
14
+
15
+ const server = createServer({
16
+ hostKey: { path: "./host_key" }, // auto-generated & persisted on first run
17
+ auth: { publicKey: "any" }, // open, but every client gets an identity
18
+ }).serve((session) => {
19
+ const { renderer, identity } = session
20
+ const box = new BoxRenderable(renderer, { width: "100%", height: "100%", border: true })
21
+ box.add(new TextRenderable(renderer, { content: `Hello, ${identity.username}!` }))
22
+ renderer.root.add(box)
23
+ // the renderer is destroyed for you on disconnect — wire onClose only for your own cleanup
24
+ })
25
+
26
+ await server.listen(2222)
27
+ ```
28
+
29
+ ```
30
+ ssh -p 2222 localhost
31
+ ```
32
+
33
+ ## Install
34
+
35
+ ```sh
36
+ bun add @opentui/ssh
37
+ # or
38
+ npm install @opentui/ssh
39
+ ```
40
+
41
+ `@opentui/core` is a peer dependency. Supported runtimes are Bun ≥ 1.3.0 and
42
+ Node.js 26.3.0. CI runs the SSH integration suite with Bun on macOS, Linux,
43
+ and Windows, and installs, imports, starts, and closes the packed ESM package
44
+ with Node.js 26.3.0.
45
+
46
+ ## The shape: `createServer(config).serve(handler)`
47
+
48
+ Static setup goes in the `createServer({...})` config object; cross-cutting
49
+ concerns are layered on with `.use()`; **`serve(handler)` seals the chain with
50
+ the per-session handler and returns a startable server.** The handler lives on
51
+ `serve()` — not the config — so the builder accumulates the typed `context` each
52
+ `use()` contributes and flows it into the handler, and a handler-less server is a
53
+ compile error (the builder has no `listen()` until you `serve`).
54
+
55
+ ```ts
56
+ const server = createServer({
57
+ // optional, all with sensible defaults:
58
+ // auth, hostKey, idleTimeout, maxTimeout, limits, startupBanner, onError
59
+ })
60
+ .use(logging()) // optional middleware (see "Middleware" below)
61
+ .serve((session) => {
62
+ /* mount your app on session.renderer — REQUIRED */
63
+ })
64
+
65
+ await server.listen() // defaults to port 2222 on 127.0.0.1; pass (port, host) to change
66
+ ```
67
+
68
+ `listen(port = 2222, host = "127.0.0.1")` returns `{ host, port, fingerprints }`.
69
+ Pass `0` for an ephemeral port. Pass a host like `"0.0.0.0"` or `"::"` to listen
70
+ on all interfaces, which is common in containers. Listening on a host other than
71
+ `localhost`, `127.0.0.1`, or `::1` with no auth logs a warning (it never throws — an
72
+ intentionally exposed TUI is legitimate).
73
+
74
+ ### `Session`
75
+
76
+ The handler you pass to `serve()` receives a `Session` with
77
+ the live `renderer`. Middleware receive a `MiddlewareSession` **without** `renderer`
78
+ (it's the app's resource, created only once the chain authorizes the session — so
79
+ a gating middleware that declines never spins one up). Everything else is shared:
80
+
81
+ | Field | What it is |
82
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
83
+ | `renderer` | A `CliRenderer` bound to this SSH channel, sized to the client PTY. **Handler-only** — present on the handler's `Session`, absent on a middleware's `MiddlewareSession` (a middleware must `next()` first). Destroyed for you on disconnect. |
84
+ | `identity` | Who connected and how — **narrowed to your configured auth** (see below). |
85
+ | `context` | Per-session bag of the typed fields upstream middleware contributed via `next({...})`; `{}` with no middleware. |
86
+ | `term` | The client's `TERM` (e.g. `"xterm-256color"`). |
87
+ | `cols` / `rows` | Current terminal size; updated on resize. |
88
+ | `hasPty` | Whether the client requested a PTY; use it for `requirePty`-style middleware. |
89
+ | `remoteAddress` | `{ address, port }` client socket endpoint for logging, rate limiting, and policy. |
90
+ | `onResize(cb)` | Fires on client resize; the renderer is already resized for you. |
91
+ | `onClose(cb)` | Fires when the client disconnects; do your OWN per-session cleanup here (the renderer is torn down for you). |
92
+ | `write(data)` | Raw bytes straight to the client, bypassing the renderer's frame diffing — the escape hatch for terminal control the renderer doesn't model (OSC 52 clipboard, window title, a bell). |
93
+ | `end()` | Force-close just this session. |
94
+
95
+ ## The three hand-offs
96
+
97
+ Because the package's job ends at producing a `CliRenderer`, you mount whatever
98
+ front-end you like onto `session.renderer`. Runnable versions of all three live
99
+ in [`examples/`](./examples).
100
+
101
+ ### Imperative (`@opentui/core`)
102
+
103
+ ```ts
104
+ createServer().serve((session) => {
105
+ const box = new BoxRenderable(session.renderer, { border: true })
106
+ session.renderer.root.add(box)
107
+ // no teardown to wire — the renderer is destroyed for you on disconnect
108
+ })
109
+ ```
110
+
111
+ ### React (`@opentui/react`)
112
+
113
+ `createRoot` adopts the existing renderer as-is — see
114
+ [`examples/react.tsx`](./examples/react.tsx).
115
+
116
+ ```tsx
117
+ import { createRoot } from "@opentui/react"
118
+
119
+ createServer().serve((session) => {
120
+ const root = createRoot(session.renderer)
121
+ root.render(<App name={session.identity.username} />)
122
+ session.onClose(() => root.unmount()) // your own teardown
123
+ })
124
+ ```
125
+
126
+ ### Solid (`@opentui/solid`)
127
+
128
+ `render(node, renderer)` checks `instanceof CliRenderer` and **adopts** the
129
+ renderer you pass — so the app draws onto the SSH channel, not the host terminal.
130
+ See [`examples/solid.tsx`](./examples/solid.tsx).
131
+
132
+ ```tsx
133
+ import { render } from "@opentui/solid"
134
+
135
+ createServer().serve(async (session) => {
136
+ // Solid disposes its root when the renderer is destroyed — nothing to wire.
137
+ await render(() => <App name={session.identity.username} />, session.renderer)
138
+ })
139
+ ```
140
+
141
+ > `@opentui/react` / `@opentui/solid` are **not** runtime dependencies of this
142
+ > package — the framework examples use workspace dev dependencies only to
143
+ > demonstrate the hand-off. Run the Solid example with
144
+ > `bun run packages/ssh/examples/solid.tsx`; its launcher registers the required
145
+ > JSX transform before loading the app.
146
+
147
+ ## Auth & type-flowing identity
148
+
149
+ `createServer` infers the identity type from your `auth` config, so
150
+ `session.identity` is narrowed to exactly the methods you enabled — you can only
151
+ read a field you actually required. `auth` is optional and defaults to `"open"`
152
+ (no auth), so the getting-started snippet just works on localhost.
153
+
154
+ ```ts
155
+ // publickey-only → fingerprint is guaranteed present
156
+ createServer({ auth: { publicKey: "any" } }).serve((s) => s.identity.fingerprint) // ✅ string, no null check
157
+
158
+ // publickey + password → a union; discriminate on .method
159
+ createServer({ auth: { publicKey: "any", password: checkPw } }).serve((s) => {
160
+ if (s.identity.method === "publickey") s.identity.fingerprint // ✅ narrowed
161
+ })
162
+ ```
163
+
164
+ Supported methods (mix freely; the server advertises exactly what you configure):
165
+
166
+ | Config | Behavior |
167
+ | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
168
+ | `auth: "open"` (or omit) | Allow unauthenticated access. The default when `auth` is omitted; listening on `0.0.0.0`/`::` with it warns. |
169
+ | `publicKey: "any"` | Accept **and identify** any key. The signature is verified (proof of possession), then `identity.fingerprint` is set. |
170
+ | `publicKey: { allow: (ctx) => boolean }` | Your own allow/deny over `{ username, fingerprint, publicKey }`, run after the signature verifies. |
171
+ | `publicKey: { authorizedKeys: path \| string[] }` | Allowlist from plain public-key lines; blanks/comments allowed, OpenSSH options are not interpreted. |
172
+ | `publicKey: { authorizedKeys, allow }` | Both — OR-merged: admit if on the allowlist **or** `allow` returns true. |
173
+ | `password: (ctx) => boolean` | Password check over `{ username, password }`. |
174
+ | `keyboardInteractive: (ctx) => boolean` | Prompt/response flow. |
175
+
176
+ > Publickey auth is verified end-to-end: `@opentui/ssh` checks the client's
177
+ > signature itself (`ssh2` does not), so `publicKey: "any"` proves possession of
178
+ > the private key rather than trusting a claimed key.
179
+
180
+ For a restricted public-key server, see [`examples/authorized-keys.ts`](./examples/authorized-keys.ts).
181
+
182
+ ## Middleware (`.use()`)
183
+
184
+ `.use()` wraps your handler with cross-cutting concerns — gating, enrichment,
185
+ logging. A middleware is an onion: `(session, next) => Handoff`. Call `next()` to
186
+ continue (or `next({ ... })` to contribute typed fields to `session.context`) and
187
+ **return that hand-off** — forgetting to is a compile error. To gate, call
188
+ `session.deny(reason)`, which throws to unwind the chain (you needn't return it).
189
+
190
+ 1. **Registration order === execution order.** The _first_-registered middleware
191
+ is the _outermost_ link.
192
+ 2. **`await next()` resolves when the session ends.** The handler is the innermost
193
+ link, wrapped so it doesn't resolve until disconnect — so a `try { return await
194
+ next() } finally { ... }` runs its cleanup as teardown.
195
+ 3. **Contributions are inferred.** `next({ tier: "free" })` widens
196
+ `session.context` by `{ tier: string }` with no generic to declare; each `.use`
197
+ accumulates, so a later link reads earlier links' fields typed and the handler
198
+ reads the sum.
199
+
200
+ ```ts
201
+ import { createServer, type Middleware } from "@opentui/ssh"
202
+
203
+ // SETUP/TEARDOWN — author a reusable middleware by typing it as `Middleware`. Before
204
+ // next() is setup; the finally (after next() resolves at disconnect) is teardown.
205
+ const logging: Middleware = async (session, next) => {
206
+ const start = Date.now()
207
+ try {
208
+ return await next() // resolves at disconnect, to the accumulated context
209
+ } finally {
210
+ console.log(`${session.identity.username} stayed ${Date.now() - start}ms`)
211
+ }
212
+ }
213
+
214
+ createServer({ auth: "open" })
215
+ .use(logging)
216
+ // GATE — deny() throws to bounce; otherwise continue with next().
217
+ .use((s, next) => {
218
+ if (s.identity.username === "banned") s.deny("no entry")
219
+ return next()
220
+ })
221
+ // ENRICH — next({...}) contributes typed context the handler reads.
222
+ .use((_s, next) => next({ tier: "free" as const }))
223
+ .serve((s) => {
224
+ s.context.tier // "free" — typed, no cast
225
+ })
226
+ ```
227
+
228
+ Those three patterns — **setup/teardown**, **gate**, **enrich** — cover almost
229
+ everything. Inline arrows need no annotation (their `identity`/`context` flow from
230
+ the builder); to name and reuse one, type it as `Middleware` (or
231
+ `MiddlewareFunction` when it must read upstream context). A gating
232
+ middleware's `deny()` runs before the handler, so the handler never runs **and the
233
+ renderer is never created** — the reason lands on the main screen and persists,
234
+ exactly what a rejection wants. (The renderer is the app's resource: middleware
235
+ see a `MiddlewareSession` without it; only the handler's `Session` has it.) See
236
+ [`examples/middleware.ts`](./examples/middleware.ts).
237
+
238
+ ### Built-in: `logging`
239
+
240
+ `@opentui/ssh` ships one ready-made middleware. `logging()` is a setup/teardown
241
+ link that emits a `connect` event on entry and a `disconnect` (with duration) on
242
+ teardown. It is **pure observability** — it never reports errors, so `onError`
243
+ stays the single error sink; a throwing handler is logged as a normal disconnect
244
+ _and_ still flows to `onError`.
245
+
246
+ ```ts
247
+ import { createServer, logging } from "@opentui/ssh"
248
+
249
+ createServer({ auth: { publicKey: "any" } })
250
+ .use(logging()) // one line per event to console.log…
251
+ .use(logging({ log: (e) => metrics.record(e) })) // …or a structured sink
252
+ .serve((s) => mountApp(s.renderer))
253
+ ```
254
+
255
+ The `log` sink receives a `LogEvent` (`type`, `identity`, `remoteAddress`, `term`,
256
+ `cols`/`rows`, and `durationMs` on disconnect). Omit it for a one-line default.
257
+
258
+ ## Host key
259
+
260
+ ```ts
261
+ hostKey: {
262
+ path: "./host_key"
263
+ } // load if present; else generate ed25519 & persist (0600)
264
+ hostKey: {
265
+ pem: "..."
266
+ } // provide PEM directly
267
+ // omit entirely → ephemeral key, regenerated each start (fine for dev)
268
+ ```
269
+
270
+ The first run with a `path` generates and saves an ed25519 key; `listen()` prints
271
+ its fingerprint so clients can verify it. When multiple PEMs are provided, every
272
+ fingerprint is returned and printed in the same order.
273
+
274
+ ## Lifecycle, errors & shutdown
275
+
276
+ Renderer-backed shell sessions are bounded by default to one per SSH connection
277
+ and 100 across the server. Excess shell requests are rejected without closing the
278
+ SSH connection or reporting an error. Adjust both positive-integer limits when an
279
+ application intentionally needs more concurrency:
280
+
281
+ ```ts
282
+ const server = createServer({
283
+ limits: {
284
+ session: {
285
+ perConnection: 2,
286
+ global: 200,
287
+ },
288
+ },
289
+ }).serve(handler)
290
+ ```
291
+
292
+ Capacity remains reserved until the shell transport finishes teardown. These
293
+ limits bound application renderers; they do not replace authentication, network
294
+ access controls, connection-rate limiting, or process resource limits.
295
+
296
+ There is no lifecycle event bus and no pluggable logger — the work splits by
297
+ **verb**, with no overlap:
298
+
299
+ - **react** to a session → middleware + `session.onClose` (deny, enrich, tear down)
300
+ - **observe** the connection lifecycle → the `logging` middleware (connect/disconnect/duration)
301
+ - **report** an error → `onError`, the single error sink
302
+
303
+ ```ts
304
+ let live = 0 // server-wide aggregate is just a counter
305
+
306
+ const server = createServer({
307
+ auth: "open",
308
+ idleTimeout: "10m", // reap a session after no client input ("30s", "500ms", or ms)
309
+ maxTimeout: "1h", // absolute session lifetime, regardless of activity
310
+ startupBanner: true, // set false to silence listen()'s summary
311
+ onError: (err) => console.error(err), // the one error sink; this is the default
312
+ }).serve((session) => {
313
+ live++
314
+ session.onClose(() => {
315
+ live-- // your own per-session bookkeeping (the renderer is torn down for you)
316
+ })
317
+ })
318
+ ```
319
+
320
+ - **the handler + `session.onClose`** are the per-session lifecycle: set up on
321
+ entry, tear down on disconnect. Anything reusable/cross-cutting is a middleware.
322
+ - **`onError(err)`** is the runtime error sink. Contained application and transport errors land here —
323
+ a throwing handler/middleware, a throwing `onResize`/`onClose`, a throwing auth
324
+ predicate, connection- and server-level `ssh2` errors. Defaults to
325
+ `console.error`. It is _reporting_, not reacting — to react to a session, use
326
+ middleware / `onClose`; to observe lifecycle, use the `logging` middleware. (A
327
+ bind failure rejects `listen()` rather than coming here; logging sink failures
328
+ are isolated and ignored so observability cannot affect a session.)
329
+ - **`idleTimeout`** reaps a session after that long with **no client input**
330
+ (re-armed on every keystroke). Per-session: only the idle session is dropped;
331
+ active sessions and the listener are untouched. Durations must be between `1ms`
332
+ and `24h`.
333
+ - **`maxTimeout`** reaps a session after that absolute lifetime, even when the
334
+ client keeps sending input. Durations must be between `1ms` and `24h`.
335
+ - **`listen()`** binds, prints the startup banner (URL, host-key fingerprints,
336
+ auth methods, allowlist fingerprints) to stdout unless `startupBanner: false`,
337
+ and returns `{ host, port, fingerprints }`.
338
+ - **`close()`** stops accepting, destroys live renderers, and closes the listener
339
+ — a graceful, global shutdown.
340
+
341
+ ```
342
+ @opentui/ssh ▸ ssh://localhost:2222
343
+ host key SHA256:nThbg6kX…0bGQ (ssh-ed25519, generated ./host_key)
344
+ auth publickey, password
345
+ authorized 2 keys · SHA256:abc… SHA256:def…
346
+ ```
347
+
348
+ ## License
349
+
350
+ MIT