@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 +21 -0
- package/README.md +350 -0
- package/index.js +988 -0
- package/index.js.map +20 -0
- package/package.json +31 -0
- package/src/auth.d.ts +97 -0
- package/src/banner.d.ts +14 -0
- package/src/bridge.d.ts +50 -0
- package/src/connection.d.ts +27 -0
- package/src/errors.d.ts +35 -0
- package/src/index.d.ts +4 -0
- package/src/keys.d.ts +18 -0
- package/src/logging.d.ts +40 -0
- package/src/run-session.d.ts +25 -0
- package/src/runtime.d.ts +38 -0
- package/src/safe.d.ts +23 -0
- package/src/server.d.ts +32 -0
- package/src/types.d.ts +294 -0
package/src/safe.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolation guard for user callbacks. Each runs synchronously inside an ssh2
|
|
3
|
+
* event handler, where an uncaught throw or rejection would reach ssh2's emitter
|
|
4
|
+
* and could drop the connection or the process, and would starve sibling
|
|
5
|
+
* callbacks in the same dispatch.
|
|
6
|
+
*
|
|
7
|
+
* `safe(fn)` runs a callback and routes any throw/rejection to the sink; its
|
|
8
|
+
* returned promise always resolves, so callers can await without their own guard.
|
|
9
|
+
* `safe.report(err)` sinks an error with no callback (connection/server errors).
|
|
10
|
+
* A throwing sink is contained too.
|
|
11
|
+
*/
|
|
12
|
+
export interface SafeInvoke {
|
|
13
|
+
(fn: () => unknown): Promise<void>;
|
|
14
|
+
/** Report an error directly to the sink, without ever letting the sink throw escape. */
|
|
15
|
+
report(err: unknown): void;
|
|
16
|
+
}
|
|
17
|
+
export declare function createSafeInvoke(onError: (err: unknown) => void): SafeInvoke;
|
|
18
|
+
/**
|
|
19
|
+
* Best-effort teardown: run `fn` and swallow any throw. Distinct from `safe`:
|
|
20
|
+
* this ignores the error rather than routing it to `onError`, for cleanup paths
|
|
21
|
+
* (renderer/channel/socket destroy) where a failure is not worth reporting.
|
|
22
|
+
*/
|
|
23
|
+
export declare function ignoreErrors(fn: () => void): void;
|
package/src/server.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AuthConfig, AuthMethods, IdentityFor, ServerBuilder, ServerConfig } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Create a server builder. Configure auth/host-key/etc. in the config object,
|
|
4
|
+
* then chain `.use(mw)` for each cross-cutting concern and seal with
|
|
5
|
+
* `serve(handler)`. `serve` takes the handler so the builder can flow the typed
|
|
6
|
+
* `context` the chain accumulates (via `next({ ... })`) into it; the builder has
|
|
7
|
+
* no `listen`, so a missing handler is a compile error.
|
|
8
|
+
*
|
|
9
|
+
* Inline middleware need no type arguments: `identity`/`context` flow from the
|
|
10
|
+
* builder, the contribution is inferred from `next({ ... })`. Author a reusable one
|
|
11
|
+
* by typing it as {@link Middleware} (or {@link MiddlewareFunction} when it reads
|
|
12
|
+
* upstream context). `.use(...)` order is execution order, first is OUTERMOST.
|
|
13
|
+
* `session.context` is the accumulation of every link's contribution.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* const server = createServer({ auth: { publicKey: "any" } })
|
|
17
|
+
* .use(async (s, next) => {
|
|
18
|
+
* const user = await lookup(s.identity.fingerprint)
|
|
19
|
+
* if (!user) s.deny("unknown key")
|
|
20
|
+
* return next({ user })
|
|
21
|
+
* })
|
|
22
|
+
* .serve((s) => mountApp(s.renderer, s.context.user))
|
|
23
|
+
* await server.listen()
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export declare function createServer(config?: Omit<ServerConfig, "auth"> & {
|
|
27
|
+
auth?: "open";
|
|
28
|
+
}): ServerBuilder<IdentityFor<"open">>;
|
|
29
|
+
export declare function createServer<A extends AuthMethods>(config: Omit<ServerConfig, "auth"> & {
|
|
30
|
+
auth: A;
|
|
31
|
+
}): ServerBuilder<IdentityFor<A>>;
|
|
32
|
+
export declare function createServer<A extends AuthConfig>(config: ServerConfig<A>): ServerBuilder<IdentityFor<A | "open">>;
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import type { CliRenderer } from "@opentui/core";
|
|
2
|
+
/** A client public key as surfaced by ssh2 during authentication. */
|
|
3
|
+
export type PublicKey = {
|
|
4
|
+
algorithm: string;
|
|
5
|
+
blob: Buffer;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Discriminated union on `method`; only `publickey` carries `fingerprint`/`publicKey`.
|
|
9
|
+
* {@link IdentityFor} narrows it to exactly the configured methods.
|
|
10
|
+
*
|
|
11
|
+
* Security note: `username` is client-supplied. For `publickey`, key ownership is
|
|
12
|
+
* verified but the name is not; authorize by `fingerprint` and pair names to keys
|
|
13
|
+
* in `auth.publicKey.allow` if you need that binding. For password and
|
|
14
|
+
* keyboard-interactive auth, the name is only as trustworthy as your predicate.
|
|
15
|
+
*/
|
|
16
|
+
export type Identity = {
|
|
17
|
+
method: "none";
|
|
18
|
+
username: string;
|
|
19
|
+
} | {
|
|
20
|
+
method: "password";
|
|
21
|
+
username: string;
|
|
22
|
+
} | {
|
|
23
|
+
method: "keyboard-interactive";
|
|
24
|
+
username: string;
|
|
25
|
+
} | {
|
|
26
|
+
method: "publickey";
|
|
27
|
+
/** Client-supplied label; not bound to the key. */
|
|
28
|
+
username: string;
|
|
29
|
+
/** The verified principal: SHA256 fingerprint of the authenticated key. */
|
|
30
|
+
fingerprint: string;
|
|
31
|
+
publicKey: PublicKey;
|
|
32
|
+
};
|
|
33
|
+
export interface RemoteAddress {
|
|
34
|
+
readonly address: string;
|
|
35
|
+
readonly port?: number;
|
|
36
|
+
}
|
|
37
|
+
declare const HANDOFF: unique symbol;
|
|
38
|
+
/**
|
|
39
|
+
* Opaque token returned by `next()`. Middleware return it to prove control was
|
|
40
|
+
* handed onward; `Add` is the context contributed by `next(add)`.
|
|
41
|
+
*/
|
|
42
|
+
export interface Handoff<Add extends object = object> {
|
|
43
|
+
readonly [HANDOFF]: Add;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Hand off to the rest of the chain. Call bare to continue, or pass an object to
|
|
47
|
+
* contribute typed context to downstream middleware and the handler. The promise
|
|
48
|
+
* resolves when the session ends, making `finally` blocks natural teardown points.
|
|
49
|
+
*/
|
|
50
|
+
export interface Next {
|
|
51
|
+
(): Promise<Handoff>;
|
|
52
|
+
<Add extends object>(add: Add): Promise<Handoff<Add>>;
|
|
53
|
+
}
|
|
54
|
+
/** Fields every session exposes, whether held by a middleware or the handler. */
|
|
55
|
+
export interface SessionCommon<Id extends Identity = Identity> {
|
|
56
|
+
/** Who connected, and how — narrowed to the configured auth methods. */
|
|
57
|
+
readonly identity: Id;
|
|
58
|
+
/** Terminal type reported by the client (e.g. "xterm-256color"). */
|
|
59
|
+
readonly term: string;
|
|
60
|
+
/** Current terminal size; updates on resize. */
|
|
61
|
+
readonly cols: number;
|
|
62
|
+
readonly rows: number;
|
|
63
|
+
/** Whether the client requested a PTY; false for bare shell channels. */
|
|
64
|
+
readonly hasPty: boolean;
|
|
65
|
+
/** Client socket address for logging, rate limiting, and policy. */
|
|
66
|
+
readonly remoteAddress: RemoteAddress;
|
|
67
|
+
/** Fired when the client resizes; renderer is already resized for you. */
|
|
68
|
+
onResize(cb: (cols: number, rows: number) => void): () => void;
|
|
69
|
+
/**
|
|
70
|
+
* Fired when the client disconnects. The renderer is already destroyed; use this
|
|
71
|
+
* for app-owned teardown such as framework roots, timers, or counters.
|
|
72
|
+
*/
|
|
73
|
+
onClose(cb: () => void): () => void;
|
|
74
|
+
/**
|
|
75
|
+
* Write raw bytes to the client terminal, bypassing frame diffing — the escape
|
|
76
|
+
* hatch for control the renderer doesn't model (OSC 52, title, bell). No-op once closed.
|
|
77
|
+
*/
|
|
78
|
+
write(data: Buffer | string): void;
|
|
79
|
+
/** Force-close this session. */
|
|
80
|
+
end(): void;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The session shape middleware receives. The renderer is created only after the
|
|
84
|
+
* chain authorizes, so gating middleware can deny before the alternate screen is
|
|
85
|
+
* entered. Contribute context with `next({ ... })`; gate with `deny()`.
|
|
86
|
+
*/
|
|
87
|
+
export interface MiddlewareSession<Id extends Identity = Identity, Ctx extends object = object> extends SessionCommon<Id> {
|
|
88
|
+
/** Typed contributions from earlier middleware; `{}` at the chain head. */
|
|
89
|
+
readonly context: Ctx;
|
|
90
|
+
/**
|
|
91
|
+
* Deny this session before the handler runs. A reason is written to the main
|
|
92
|
+
* screen, the session closes, and the middleware chain unwinds as intended.
|
|
93
|
+
*/
|
|
94
|
+
deny(reason?: string): never;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A reusable middleware: annotate a function with this type to author one outside
|
|
98
|
+
* a `.use(...)` call. `Add` is the context it contributes via `next({ ... })`; it
|
|
99
|
+
* sees the wide `Identity` and an untyped upstream `context` (a standalone link
|
|
100
|
+
* can't know what precedes it — use {@link MiddlewareFunction} to require a `Ctx`).
|
|
101
|
+
*/
|
|
102
|
+
export type Middleware<Id extends Identity = Identity, Add extends object = object> = (session: MiddlewareSession<Id>, next: Next) => Handoff<Add> | Promise<Handoff<Add>>;
|
|
103
|
+
/**
|
|
104
|
+
* The call signature `.use(...)` accepts. `Ctx` is what earlier links contributed;
|
|
105
|
+
* `Add` is inferred from this link's `next({ ... })` call.
|
|
106
|
+
*/
|
|
107
|
+
export type MiddlewareFunction<Id extends Identity = Identity, Ctx extends object = object, Add extends object = object> = (session: MiddlewareSession<Id, Ctx>, next: Next) => Handoff<Add> | Promise<Handoff<Add>>;
|
|
108
|
+
/**
|
|
109
|
+
* The per-session app handler. Receives a {@link Session} with the live renderer
|
|
110
|
+
* and combined middleware context. The renderer is destroyed on disconnect;
|
|
111
|
+
* `session.onClose` is for app-owned teardown.
|
|
112
|
+
*/
|
|
113
|
+
export type SessionHandler<Id extends Identity = Identity, Ctx extends object = {}> = (session: Session<Id, Ctx>) => void | Promise<void>;
|
|
114
|
+
/**
|
|
115
|
+
* The session shape the handler receives: common fields plus the attached renderer
|
|
116
|
+
* and typed context.
|
|
117
|
+
*/
|
|
118
|
+
export interface Session<Id extends Identity = Identity, Ctx extends object = {}> extends SessionCommon<Id> {
|
|
119
|
+
/** The sum of every middleware's contribution; `{}` with no middleware. Each session gets its own bag. */
|
|
120
|
+
readonly context: Ctx;
|
|
121
|
+
/**
|
|
122
|
+
* Pre-wired renderer: stdin/stdout = SSH channel, dims = client PTY. Use `write`
|
|
123
|
+
* for raw output the renderer doesn't model.
|
|
124
|
+
*/
|
|
125
|
+
readonly renderer: CliRenderer;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Public-key admission policy. A key is admitted when it matches `authorizedKeys`,
|
|
129
|
+
* when `allow` returns true, or both.
|
|
130
|
+
*/
|
|
131
|
+
export interface PublicKeyPolicy {
|
|
132
|
+
/** Static allowlist: a path to an authorized_keys file, or an array of public key strings. */
|
|
133
|
+
authorizedKeys?: string | string[];
|
|
134
|
+
/**
|
|
135
|
+
* Dynamic decision for checks a static file cannot express, such as DB lookup,
|
|
136
|
+
* revocation, or username-to-key pairing. Runs only after the signature verifies,
|
|
137
|
+
* so `fingerprint` and `publicKey` refer to a proven key.
|
|
138
|
+
*/
|
|
139
|
+
allow?: (ctx: {
|
|
140
|
+
username: string;
|
|
141
|
+
fingerprint: string;
|
|
142
|
+
publicKey: PublicKey;
|
|
143
|
+
}) => boolean | Promise<boolean>;
|
|
144
|
+
}
|
|
145
|
+
/** Promise-based keyboard-interactive prompt, bridged onto ssh2's callback flow. */
|
|
146
|
+
export type KeyboardPrompt = (questions: {
|
|
147
|
+
prompt: string;
|
|
148
|
+
echo: boolean;
|
|
149
|
+
}[]) => Promise<string[]>;
|
|
150
|
+
/** The credential methods a client can present. Not re-exported from the package index. */
|
|
151
|
+
export interface CredentialMethods {
|
|
152
|
+
/**
|
|
153
|
+
* Public-key auth. `"any"` accepts & identifies any key; a `PublicKeyPolicy`
|
|
154
|
+
* configures an allowlist, an `allow` predicate, or both. `session.identity` is
|
|
155
|
+
* the publickey variant either way.
|
|
156
|
+
*/
|
|
157
|
+
publicKey?: "any" | PublicKeyPolicy;
|
|
158
|
+
/** Password check. */
|
|
159
|
+
password?: (ctx: {
|
|
160
|
+
username: string;
|
|
161
|
+
password: string;
|
|
162
|
+
}) => boolean | Promise<boolean>;
|
|
163
|
+
/** Keyboard-interactive flow. */
|
|
164
|
+
keyboardInteractive?: (ctx: {
|
|
165
|
+
username: string;
|
|
166
|
+
prompt: KeyboardPrompt;
|
|
167
|
+
}) => boolean | Promise<boolean>;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The credential methods a client can present. Each is optional and they merge
|
|
171
|
+
* (the server advertises every one you set; the client picks). An empty set is
|
|
172
|
+
* rejected at startup; use `auth: "open"` for deliberate no-auth.
|
|
173
|
+
*
|
|
174
|
+
* `none` is typed `never` so a no-auth server and a credentialed one cannot be
|
|
175
|
+
* mixed: `{ none: true, publicKey }` is a compile error, not a silent open server.
|
|
176
|
+
*/
|
|
177
|
+
export interface AuthMethods extends CredentialMethods {
|
|
178
|
+
/** Not a credential — use `auth: "open"` for no authentication. */
|
|
179
|
+
none?: never;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* How clients authenticate: either `"open"` or a configured set of auth methods.
|
|
183
|
+
*/
|
|
184
|
+
export type AuthConfig = "open" | AuthMethods;
|
|
185
|
+
/** Maps each configured credential key to its Identity variant. */
|
|
186
|
+
type CredentialVariant = {
|
|
187
|
+
password: Extract<Identity, {
|
|
188
|
+
method: "password";
|
|
189
|
+
}>;
|
|
190
|
+
keyboardInteractive: Extract<Identity, {
|
|
191
|
+
method: "keyboard-interactive";
|
|
192
|
+
}>;
|
|
193
|
+
publicKey: Extract<Identity, {
|
|
194
|
+
method: "publickey";
|
|
195
|
+
}>;
|
|
196
|
+
};
|
|
197
|
+
/** The credential keys actually configured in `A` (value is not `undefined`). */
|
|
198
|
+
type ConfiguredCredentialKeys<A extends AuthMethods> = {
|
|
199
|
+
[K in keyof A & keyof CredentialVariant]: A[K] extends undefined ? never : K;
|
|
200
|
+
}[keyof A & keyof CredentialVariant];
|
|
201
|
+
/**
|
|
202
|
+
* Narrows `Identity` to exactly the methods `A` configures: `"open"` → the `none`
|
|
203
|
+
* variant; an `AuthMethods` set → the union of its configured methods. You can only
|
|
204
|
+
* read a field you required.
|
|
205
|
+
*/
|
|
206
|
+
export type IdentityFor<A extends AuthConfig = "open"> = A extends "open" ? Extract<Identity, {
|
|
207
|
+
method: "none";
|
|
208
|
+
}> : A extends AuthMethods ? CredentialVariant[ConfiguredCredentialKeys<A> & keyof CredentialVariant] : never;
|
|
209
|
+
/** Static server configuration, excluding the middleware chain and handler. */
|
|
210
|
+
export interface ServerConfig<A extends AuthConfig = "open"> {
|
|
211
|
+
/**
|
|
212
|
+
* How clients authenticate. Defaults to `"open"` (no auth) for localhost. Set a
|
|
213
|
+
* `AuthMethods` set instead: `{ publicKey: "any" }`, `{ publicKey: { authorizedKeys } }`, `{ password }`, …
|
|
214
|
+
* `session.identity` narrows to exactly the methods configured here. Listening
|
|
215
|
+
* on a host other than `localhost`, `127.0.0.1`, or `::1` while `"open"` warns
|
|
216
|
+
* (never throws); empty credentials throw.
|
|
217
|
+
*/
|
|
218
|
+
auth?: A;
|
|
219
|
+
/** Host key source. If `path` is given and missing, it is generated & saved. */
|
|
220
|
+
hostKey?: {
|
|
221
|
+
path: string;
|
|
222
|
+
} | {
|
|
223
|
+
pem: string | Buffer | (string | Buffer)[];
|
|
224
|
+
};
|
|
225
|
+
/** Disconnect after this much inactivity, e.g. "10m" or ms. Optional. */
|
|
226
|
+
idleTimeout?: string | number;
|
|
227
|
+
/** Disconnect after this absolute session lifetime, e.g. "1h" or ms. Optional. */
|
|
228
|
+
maxTimeout?: string | number;
|
|
229
|
+
/** Resource limits for renderer-backed SSH shell sessions. */
|
|
230
|
+
limits?: {
|
|
231
|
+
session?: {
|
|
232
|
+
/** Maximum live shell sessions on one SSH connection. Default 1. */
|
|
233
|
+
perConnection?: number;
|
|
234
|
+
/** Maximum live shell sessions across this server. Default 100. */
|
|
235
|
+
global?: number;
|
|
236
|
+
};
|
|
237
|
+
};
|
|
238
|
+
/** Startup summary printed on listen(). Default true; set false to silence. */
|
|
239
|
+
startupBanner?: boolean;
|
|
240
|
+
/**
|
|
241
|
+
* The single runtime error sink — the *report* path. Contained application and
|
|
242
|
+
* transport errors land here: a throwing handler/middleware, a throwing
|
|
243
|
+
* `onResize`/`onClose`, a throwing auth predicate, and per-connection /
|
|
244
|
+
* server-level ssh2 errors. Logging sink failures are isolated and ignored so
|
|
245
|
+
* observability cannot affect a session.
|
|
246
|
+
* Defaults to `console.error`. A bind failure during `listen()` rejects the
|
|
247
|
+
* `listen()` promise instead of coming here.
|
|
248
|
+
*
|
|
249
|
+
* This is reporting, not reacting: to *react* to a session (deny, enrich, tear
|
|
250
|
+
* down, render an error screen) use middleware or `onClose`; to *observe* the
|
|
251
|
+
* connection lifecycle use the `logging` middleware.
|
|
252
|
+
*/
|
|
253
|
+
onError?: (err: unknown) => void;
|
|
254
|
+
}
|
|
255
|
+
/** What `listen()` resolves to — useful for tests and programmatic callers. */
|
|
256
|
+
export interface ListenInfo {
|
|
257
|
+
host: string;
|
|
258
|
+
port: number;
|
|
259
|
+
/** SHA256 fingerprints for every configured host key, in configuration order. */
|
|
260
|
+
fingerprints: string[];
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The builder returned by `createServer`. `.use(mw)` adds a link and accumulates
|
|
264
|
+
* its contribution into `Ctx`, so each subsequent `.use` and the handler see a
|
|
265
|
+
* `context` typed as the sum of every upstream link. No `listen` here — you must
|
|
266
|
+
* `serve(handler)` first, so "forgot the handler" is a compile error. `.use(...)`
|
|
267
|
+
* order === execution order: the first middleware is the outermost link.
|
|
268
|
+
*/
|
|
269
|
+
export interface ServerBuilder<Id extends Identity = Identity, Ctx extends object = {}> {
|
|
270
|
+
/**
|
|
271
|
+
* Add a middleware link. Pass an inline arrow (`Id`/`Ctx` flow from the builder,
|
|
272
|
+
* contribution inferred from `next({ ... })`) or a reusable function typed as
|
|
273
|
+
* {@link Middleware} / {@link MiddlewareFunction}. Returns a builder whose `Ctx`
|
|
274
|
+
* is widened by this link's contribution.
|
|
275
|
+
*/
|
|
276
|
+
use<Add extends object>(mw: MiddlewareFunction<Id, Ctx, Add>): ServerBuilder<Id, Ctx & Add>;
|
|
277
|
+
/**
|
|
278
|
+
* Seal the chain with the handler and return the startable server. The handler's
|
|
279
|
+
* `session.context` is the accumulated `Ctx`.
|
|
280
|
+
*/
|
|
281
|
+
serve(handler: SessionHandler<Id, Ctx>): Server;
|
|
282
|
+
}
|
|
283
|
+
export interface Server {
|
|
284
|
+
/**
|
|
285
|
+
* Bind and start accepting (and, unless silenced, print the startup banner).
|
|
286
|
+
* Defaults to `2222` on `127.0.0.1` — pass `0` for an ephemeral port. Listening
|
|
287
|
+
* on a host other than `localhost`, `127.0.0.1`, or `::1` with no auth logs a
|
|
288
|
+
* warning (never throws).
|
|
289
|
+
*/
|
|
290
|
+
listen(port?: number, host?: string): Promise<ListenInfo>;
|
|
291
|
+
/** Stop accepting, destroy live renderers, close the listener. */
|
|
292
|
+
close(): Promise<void>;
|
|
293
|
+
}
|
|
294
|
+
export {};
|