@zhin.js/gateway 0.0.1-bootstrap.0 → 1.1.1
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 +61 -0
- package/lib/config/ai-config-loader.d.ts +22 -0
- package/lib/config/ai-config-loader.js +96 -0
- package/lib/config/configuration-chat-policy.d.ts +57 -0
- package/lib/config/configuration-chat-policy.js +423 -0
- package/lib/config/configuration-patch.d.ts +16 -0
- package/lib/config/configuration-patch.js +105 -0
- package/lib/config/configuration-path.d.ts +6 -0
- package/lib/config/configuration-path.js +40 -0
- package/lib/config/configuration-projection.d.ts +6 -0
- package/lib/config/configuration-projection.js +28 -0
- package/lib/config/configuration-redaction.d.ts +24 -0
- package/lib/config/configuration-redaction.js +128 -0
- package/lib/config/configuration.d.ts +81 -0
- package/lib/config/configuration.js +686 -0
- package/lib/config/console-schema.d.ts +11 -0
- package/lib/config/console-schema.js +222 -0
- package/lib/config/environment-files.d.ts +2 -0
- package/lib/config/environment-files.js +21 -0
- package/lib/config/plugin-package-map.d.ts +2 -0
- package/lib/config/plugin-package-map.js +14 -0
- package/lib/config/plugin-schema-catalog.d.ts +22 -0
- package/lib/config/plugin-schema-catalog.js +262 -0
- package/lib/config.d.ts +12 -0
- package/lib/config.js +12 -0
- package/lib/database/config.d.ts +22 -0
- package/lib/database/config.js +159 -0
- package/lib/database/index.d.ts +2 -0
- package/lib/database/index.js +2 -0
- package/lib/database/project-database.d.ts +31 -0
- package/lib/database/project-database.js +130 -0
- package/lib/database.d.ts +2 -0
- package/lib/database.js +2 -0
- package/lib/http/console-rpc.d.ts +19 -0
- package/lib/http/console-rpc.js +51 -0
- package/lib/http/gateway-console.d.ts +63 -0
- package/lib/http/gateway-console.js +309 -0
- package/lib/http/gateway-events.d.ts +35 -0
- package/lib/http/gateway-events.js +172 -0
- package/lib/http/gateway-http-server.d.ts +95 -0
- package/lib/http/gateway-http-server.js +518 -0
- package/lib/http/local-principal.d.ts +2 -0
- package/lib/http/local-principal.js +16 -0
- package/lib/http/run-gateway.d.ts +43 -0
- package/lib/http/run-gateway.js +268 -0
- package/lib/http/start-gateway.d.ts +114 -0
- package/lib/http/start-gateway.js +220 -0
- package/lib/http-response.d.ts +6 -0
- package/lib/http-response.js +25 -0
- package/lib/http.d.ts +6 -0
- package/lib/http.js +6 -0
- package/lib/iam/crypto.d.ts +12 -0
- package/lib/iam/crypto.js +58 -0
- package/lib/iam/index.d.ts +8 -0
- package/lib/iam/index.js +8 -0
- package/lib/iam/install.d.ts +36 -0
- package/lib/iam/install.js +74 -0
- package/lib/iam/local-admin.d.ts +27 -0
- package/lib/iam/local-admin.js +102 -0
- package/lib/iam/rate-limit.d.ts +15 -0
- package/lib/iam/rate-limit.js +52 -0
- package/lib/iam/routes.d.ts +23 -0
- package/lib/iam/routes.js +157 -0
- package/lib/iam/rpc.d.ts +14 -0
- package/lib/iam/rpc.js +100 -0
- package/lib/iam/service.d.ts +203 -0
- package/lib/iam/service.js +784 -0
- package/lib/iam/setup-service.d.ts +83 -0
- package/lib/iam/setup-service.js +206 -0
- package/lib/iam/tables.d.ts +20 -0
- package/lib/iam/tables.js +82 -0
- package/lib/iam.d.ts +2 -0
- package/lib/iam.js +2 -0
- package/lib/index.d.ts +22 -0
- package/lib/index.js +22 -0
- package/lib/local/client.d.ts +71 -0
- package/lib/local/client.js +296 -0
- package/lib/local/handler.d.ts +30 -0
- package/lib/local/handler.js +86 -0
- package/lib/local/local-socket.d.ts +55 -0
- package/lib/local/local-socket.js +232 -0
- package/lib/local/paths.d.ts +34 -0
- package/lib/local/paths.js +58 -0
- package/lib/local/protocol.d.ts +140 -0
- package/lib/local/protocol.js +90 -0
- package/lib/local/state-file.d.ts +8 -0
- package/lib/local/state-file.js +45 -0
- package/lib/local.d.ts +10 -0
- package/lib/local.js +10 -0
- package/lib/onboarding/allowlist.d.ts +54 -0
- package/lib/onboarding/allowlist.js +106 -0
- package/lib/onboarding/errors.d.ts +7 -0
- package/lib/onboarding/errors.js +17 -0
- package/lib/onboarding/index.d.ts +6 -0
- package/lib/onboarding/index.js +6 -0
- package/lib/onboarding/install.d.ts +99 -0
- package/lib/onboarding/install.js +132 -0
- package/lib/onboarding/package-manager.d.ts +47 -0
- package/lib/onboarding/package-manager.js +179 -0
- package/lib/onboarding/service.d.ts +53 -0
- package/lib/onboarding/service.js +144 -0
- package/lib/onboarding/state-store.d.ts +26 -0
- package/lib/onboarding/state-store.js +97 -0
- package/lib/onboarding.d.ts +2 -0
- package/lib/onboarding.js +2 -0
- package/lib/packages/authorization.d.ts +23 -0
- package/lib/packages/authorization.js +109 -0
- package/lib/packages/errors.d.ts +11 -0
- package/lib/packages/errors.js +26 -0
- package/lib/packages/index.d.ts +5 -0
- package/lib/packages/index.js +5 -0
- package/lib/packages/rpc.d.ts +30 -0
- package/lib/packages/rpc.js +155 -0
- package/lib/packages/service.d.ts +121 -0
- package/lib/packages/service.js +441 -0
- package/lib/packages/snapshot.d.ts +14 -0
- package/lib/packages/snapshot.js +73 -0
- package/lib/packages.d.ts +2 -0
- package/lib/packages.js +2 -0
- package/lib/plugin-management/activity.d.ts +6 -0
- package/lib/plugin-management/activity.js +14 -0
- package/lib/plugin-management/config-explanation.d.ts +6 -0
- package/lib/plugin-management/config-explanation.js +113 -0
- package/lib/plugin-management/use-cases.d.ts +15 -0
- package/lib/plugin-management/use-cases.js +120 -0
- package/lib/plugin-management/workflow.d.ts +92 -0
- package/lib/plugin-management/workflow.js +772 -0
- package/lib/plugin-management.d.ts +5 -0
- package/lib/plugin-management.js +5 -0
- package/lib/project/config-file.d.ts +6 -0
- package/lib/project/config-file.js +58 -0
- package/lib/project/environment.d.ts +13 -0
- package/lib/project/environment.js +52 -0
- package/lib/project/instance-declarations.d.ts +55 -0
- package/lib/project/instance-declarations.js +542 -0
- package/lib/project/legacy-config.d.ts +25 -0
- package/lib/project/legacy-config.js +70 -0
- package/lib/project/node-requirements.d.ts +7 -0
- package/lib/project/node-requirements.js +20 -0
- package/lib/project/tsx-module-loader.d.ts +7 -0
- package/lib/project/tsx-module-loader.js +29 -0
- package/lib/project.d.ts +7 -0
- package/lib/project.js +7 -0
- package/lib/server.d.ts +11 -0
- package/lib/server.js +11 -0
- package/lib/supervisor/instance-logs.d.ts +39 -0
- package/lib/supervisor/instance-logs.js +152 -0
- package/lib/supervisor/instance-supervisor.d.ts +140 -0
- package/lib/supervisor/instance-supervisor.js +408 -0
- package/lib/supervisor/run-instance.d.ts +35 -0
- package/lib/supervisor/run-instance.js +161 -0
- package/lib/supervisor/terminal-state.d.ts +30 -0
- package/lib/supervisor/terminal-state.js +57 -0
- package/lib/supervisor.d.ts +4 -0
- package/lib/supervisor.js +4 -0
- package/lib/terminal/backends.d.ts +38 -0
- package/lib/terminal/backends.js +193 -0
- package/lib/terminal/config.d.ts +63 -0
- package/lib/terminal/config.js +237 -0
- package/lib/terminal/host.d.ts +26 -0
- package/lib/terminal/host.js +76 -0
- package/lib/terminal/index.d.ts +60 -0
- package/lib/terminal/index.js +135 -0
- package/lib/terminal/modules.d.ts +87 -0
- package/lib/terminal/modules.js +44 -0
- package/lib/terminal/service.d.ts +101 -0
- package/lib/terminal/service.js +455 -0
- package/lib/terminal/tickets.d.ts +46 -0
- package/lib/terminal/tickets.js +76 -0
- package/lib/terminal.d.ts +3 -0
- package/lib/terminal.js +3 -0
- package/lib/usage/aggregate.d.ts +87 -0
- package/lib/usage/aggregate.js +212 -0
- package/lib/usage.d.ts +2 -0
- package/lib/usage.js +2 -0
- package/package.json +117 -4
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { globalPermissions, isUserPrincipal, requestMeta } from '@zhin.js/host-http';
|
|
2
|
+
import { installConsoleTerminal } from './index.js';
|
|
3
|
+
const TERMINAL_PERMISSION = 'ops.terminal';
|
|
4
|
+
/**
|
|
5
|
+
* Wires the Console terminal (ADR 0051) to Console access control (ADR 0049): only user
|
|
6
|
+
* sessions and user API tokens holding a global `ops.terminal` may use it (the bootstrap
|
|
7
|
+
* token never), terminal events land in the IAM audit log, and revoking a user's
|
|
8
|
+
* credentials ends their terminal sessions.
|
|
9
|
+
*/
|
|
10
|
+
export function installHostConsoleTerminal(http, options) {
|
|
11
|
+
// The Console principal behind each terminal principal, for authorization decisions.
|
|
12
|
+
const grantsOf = new WeakMap();
|
|
13
|
+
const handle = installConsoleTerminal(http, {
|
|
14
|
+
config: options.config,
|
|
15
|
+
projectRoot: options.projectRoot,
|
|
16
|
+
...(options.base ? { base: options.base } : {}),
|
|
17
|
+
resolvePrincipal: async (token, context) => {
|
|
18
|
+
if (!token)
|
|
19
|
+
return null;
|
|
20
|
+
const principal = await http.tokenRegistry.authenticate(token, requestMeta(context, context.remoteAddress)) ?? undefined;
|
|
21
|
+
// Bootstrap (setup token or open Host) is not a person: terminal access needs a user.
|
|
22
|
+
if (!isUserPrincipal(principal))
|
|
23
|
+
return null;
|
|
24
|
+
const resolved = Object.freeze({ id: `user:${principal.userId}`, kind: principal.kind });
|
|
25
|
+
grantsOf.set(resolved, principal);
|
|
26
|
+
return resolved;
|
|
27
|
+
},
|
|
28
|
+
authorize: async (principal) => {
|
|
29
|
+
const resolved = grantsOf.get(principal);
|
|
30
|
+
// Global grant only (API tokens are already narrowed to their permission list).
|
|
31
|
+
if (!resolved || !globalPermissions(resolved).includes(TERMINAL_PERMISSION))
|
|
32
|
+
return false;
|
|
33
|
+
// The user may have been disabled or deleted since the credential was resolved.
|
|
34
|
+
const user = await options.iam()?.getUser(resolved.userId);
|
|
35
|
+
return user !== null && user !== undefined && !user.disabled;
|
|
36
|
+
},
|
|
37
|
+
audit: (event) => {
|
|
38
|
+
options.iam()?.audit({
|
|
39
|
+
actor: event.actor,
|
|
40
|
+
action: event.action,
|
|
41
|
+
...(event.target ? { target: `terminal:${event.target}` } : {}),
|
|
42
|
+
outcome: event.outcome,
|
|
43
|
+
detail: event.detail,
|
|
44
|
+
...(event.ip ? { ip: event.ip } : {}),
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
...(options.logger ? { logger: options.logger } : {}),
|
|
48
|
+
...(options.modules ? { modules: options.modules } : {}),
|
|
49
|
+
...(options.env ? { env: options.env } : {}),
|
|
50
|
+
});
|
|
51
|
+
if (!handle.enabled)
|
|
52
|
+
return handle;
|
|
53
|
+
let stopRevocations;
|
|
54
|
+
const subscribe = () => {
|
|
55
|
+
if (stopRevocations)
|
|
56
|
+
return;
|
|
57
|
+
const iam = options.iam();
|
|
58
|
+
if (!iam)
|
|
59
|
+
return;
|
|
60
|
+
stopRevocations = iam.onCredentialsRevoked(({ userId }) => {
|
|
61
|
+
handle.terminatePrincipal(`user:${userId}`, 'revoked');
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
subscribe();
|
|
65
|
+
return Object.freeze({
|
|
66
|
+
enabled: handle.enabled,
|
|
67
|
+
config: handle.config,
|
|
68
|
+
terminatePrincipal: handle.terminatePrincipal,
|
|
69
|
+
sessions: handle.sessions,
|
|
70
|
+
close: () => {
|
|
71
|
+
stopRevocations?.();
|
|
72
|
+
stopRevocations = undefined;
|
|
73
|
+
handle.close();
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type ConsolePrincipal } from '@zhin.js/host-http';
|
|
2
|
+
import { type TerminalConfig } from './config.js';
|
|
3
|
+
import type { TerminalModuleLoaders } from './modules.js';
|
|
4
|
+
import { type TerminalAuditEvent, type TerminalEndReason, type TerminalLogger, type TerminalPrincipal, type TerminalConnection, type TerminalSessionInfo } from './service.js';
|
|
5
|
+
export * from './config.js';
|
|
6
|
+
export * from './tickets.js';
|
|
7
|
+
export * from './service.js';
|
|
8
|
+
export type { TerminalModuleLoaders, PtyModule, SshModule } from './modules.js';
|
|
9
|
+
/** Structural subset of `HttpHost` (`@zhin.js/host-http`). */
|
|
10
|
+
export interface TerminalHttpHost {
|
|
11
|
+
route(method: string, path: string, handler: (request: Request, context: {
|
|
12
|
+
readonly url: URL;
|
|
13
|
+
readonly principal?: ConsolePrincipal;
|
|
14
|
+
readonly remoteAddress?: string;
|
|
15
|
+
}) => Response | Promise<Response>, meta?: {
|
|
16
|
+
summary?: string;
|
|
17
|
+
tags?: string[];
|
|
18
|
+
}): () => void;
|
|
19
|
+
ws(path: string, options?: {
|
|
20
|
+
readonly auth?: 'host' | 'protocol';
|
|
21
|
+
}): {
|
|
22
|
+
onConnection(listener: (connection: TerminalConnection & {
|
|
23
|
+
readonly headers: Headers;
|
|
24
|
+
}) => void): () => void;
|
|
25
|
+
close(): void;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export interface TerminalPrincipalContext {
|
|
29
|
+
readonly headers: Headers;
|
|
30
|
+
readonly remoteAddress?: string;
|
|
31
|
+
/** Principal the Host already authenticated (HTTP routes); absent on WebSocket upgrades. */
|
|
32
|
+
readonly principal?: ConsolePrincipal;
|
|
33
|
+
}
|
|
34
|
+
export interface InstallConsoleTerminalOptions {
|
|
35
|
+
/** Raw `console.terminal` block, or an already parsed config. */
|
|
36
|
+
readonly config: unknown;
|
|
37
|
+
readonly projectRoot: string;
|
|
38
|
+
/** API base the Console uses (default `/api`). */
|
|
39
|
+
readonly base?: string;
|
|
40
|
+
/** Must return true only for principals holding `ops.terminal`. Errors deny. */
|
|
41
|
+
readonly authorize: (principal: TerminalPrincipal) => boolean | Promise<boolean>;
|
|
42
|
+
/** Maps a bearer credential to a principal; `null` → 401. */
|
|
43
|
+
readonly resolvePrincipal: (bearerToken: string, context: TerminalPrincipalContext) => TerminalPrincipal | null | Promise<TerminalPrincipal | null>;
|
|
44
|
+
readonly audit?: (event: TerminalAuditEvent) => void;
|
|
45
|
+
readonly logger?: TerminalLogger;
|
|
46
|
+
/** Test seam for `node-pty` / `ssh2`. */
|
|
47
|
+
readonly modules?: TerminalModuleLoaders;
|
|
48
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
49
|
+
readonly readFile?: (path: string) => Promise<Buffer>;
|
|
50
|
+
}
|
|
51
|
+
export interface ConsoleTerminalHandle {
|
|
52
|
+
readonly enabled: boolean;
|
|
53
|
+
readonly config: TerminalConfig;
|
|
54
|
+
/** Ends every session and pending ticket of a principal (credential revoked, user disabled…). */
|
|
55
|
+
terminatePrincipal(principalId: string, reason?: TerminalEndReason): number;
|
|
56
|
+
sessions(): TerminalSessionInfo[];
|
|
57
|
+
/** Unregisters routes and ends all sessions (`exit` reason `disabled`). Idempotent. */
|
|
58
|
+
close(): void;
|
|
59
|
+
}
|
|
60
|
+
export declare function installConsoleTerminal(host: TerminalHttpHost, options: InstallConsoleTerminalOptions): ConsoleTerminalHandle;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { jsonResponse, readJsonBody } from '@zhin.js/host-http';
|
|
2
|
+
import { parseTerminalConfig } from './config.js';
|
|
3
|
+
import { TerminalRequestError, TerminalService, } from './service.js';
|
|
4
|
+
export * from './config.js';
|
|
5
|
+
export * from './tickets.js';
|
|
6
|
+
export * from './service.js';
|
|
7
|
+
export function installConsoleTerminal(host, options) {
|
|
8
|
+
// Parsing is idempotent, so an already parsed TerminalConfig is accepted too.
|
|
9
|
+
const config = parseTerminalConfig(options.config, {
|
|
10
|
+
projectRoot: options.projectRoot,
|
|
11
|
+
...(options.env ? { env: options.env } : {}),
|
|
12
|
+
});
|
|
13
|
+
if (!config.enabled) {
|
|
14
|
+
// ADR 0051: disabled → no routes, optional modules never loaded.
|
|
15
|
+
return Object.freeze({
|
|
16
|
+
enabled: false,
|
|
17
|
+
config,
|
|
18
|
+
terminatePrincipal: () => 0,
|
|
19
|
+
sessions: () => [],
|
|
20
|
+
close: () => undefined,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const base = normalizeBase(options.base ?? '/api');
|
|
24
|
+
const service = new TerminalService({
|
|
25
|
+
config,
|
|
26
|
+
projectRoot: options.projectRoot,
|
|
27
|
+
authorize: options.authorize,
|
|
28
|
+
...(options.audit ? { audit: options.audit } : {}),
|
|
29
|
+
...(options.logger ? { logger: options.logger } : {}),
|
|
30
|
+
...(options.modules ? { modules: options.modules } : {}),
|
|
31
|
+
...(options.env ? { env: options.env } : {}),
|
|
32
|
+
...(options.readFile ? { readFile: options.readFile } : {}),
|
|
33
|
+
});
|
|
34
|
+
const authenticate = async (request, context) => {
|
|
35
|
+
const token = bearerToken(request.headers, context.url);
|
|
36
|
+
let principal = null;
|
|
37
|
+
try {
|
|
38
|
+
principal = await options.resolvePrincipal(token, {
|
|
39
|
+
headers: request.headers,
|
|
40
|
+
...(context.remoteAddress ? { remoteAddress: context.remoteAddress } : {}),
|
|
41
|
+
...(context.principal ? { principal: context.principal } : {}),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
principal = null;
|
|
46
|
+
}
|
|
47
|
+
return service.requireAuthorized(principal, context.remoteAddress);
|
|
48
|
+
};
|
|
49
|
+
const fail = (error) => {
|
|
50
|
+
if (error instanceof TerminalRequestError) {
|
|
51
|
+
return jsonResponse(error.status, { success: false, code: error.code, error: error.message });
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
};
|
|
55
|
+
const unregister = [];
|
|
56
|
+
unregister.push(host.route('GET', `${base}/console/terminal/targets`, async (request, context) => {
|
|
57
|
+
try {
|
|
58
|
+
await authenticate(request, context);
|
|
59
|
+
return jsonResponse(200, { success: true, data: await service.listTargets() });
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return fail(error);
|
|
63
|
+
}
|
|
64
|
+
}, { summary: 'List Console terminal targets', tags: ['console', 'terminal'] }));
|
|
65
|
+
unregister.push(host.route('POST', `${base}/console/terminal/tickets`, async (request, context) => {
|
|
66
|
+
try {
|
|
67
|
+
const principal = await authenticate(request, context);
|
|
68
|
+
const body = await readJsonBody(request, { limit: 4096 });
|
|
69
|
+
return jsonResponse(200, { success: true, data: await service.issueTicket(principal, body, context.remoteAddress) });
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return fail(error);
|
|
73
|
+
}
|
|
74
|
+
}, { summary: 'Issue a one-time Console terminal WebSocket ticket', tags: ['console', 'terminal'] }));
|
|
75
|
+
// Browsers cannot set Authorization on WebSocket upgrades: the ticket is the credential.
|
|
76
|
+
const ws = host.ws(`${base}/console/terminal`, { auth: 'protocol' });
|
|
77
|
+
const stopWs = ws.onConnection((connection) => {
|
|
78
|
+
void (async () => {
|
|
79
|
+
let presented;
|
|
80
|
+
const header = connection.headers.get('authorization');
|
|
81
|
+
if (typeof header === 'string' && header.startsWith('Bearer ')) {
|
|
82
|
+
try {
|
|
83
|
+
presented = await options.resolvePrincipal(header.slice(7).trim(), {
|
|
84
|
+
headers: connection.headers,
|
|
85
|
+
...(connection.remoteAddress ? { remoteAddress: connection.remoteAddress } : {}),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
presented = null;
|
|
90
|
+
}
|
|
91
|
+
// A bearer that resolves to nobody must not fall back to ticket-only auth.
|
|
92
|
+
if (!presented)
|
|
93
|
+
presented = { id: '\u0000invalid' };
|
|
94
|
+
}
|
|
95
|
+
await service.handleConnection(connection, presented);
|
|
96
|
+
})().catch((error) => {
|
|
97
|
+
options.logger?.warn?.(`terminal connection failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
98
|
+
try {
|
|
99
|
+
connection.close(1011, 'internal-error');
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* ignore */
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
unregister.push(stopWs, () => ws.close());
|
|
107
|
+
let closed = false;
|
|
108
|
+
return Object.freeze({
|
|
109
|
+
enabled: true,
|
|
110
|
+
config,
|
|
111
|
+
terminatePrincipal: (principalId, reason) => service.terminatePrincipal(principalId, reason),
|
|
112
|
+
sessions: () => service.sessions(),
|
|
113
|
+
close: () => {
|
|
114
|
+
if (closed)
|
|
115
|
+
return;
|
|
116
|
+
closed = true;
|
|
117
|
+
for (const stop of unregister)
|
|
118
|
+
stop();
|
|
119
|
+
service.closeAll('disabled', true);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function bearerToken(headers, url) {
|
|
124
|
+
const header = headers.get('authorization');
|
|
125
|
+
if (typeof header === 'string' && header.startsWith('Bearer '))
|
|
126
|
+
return header.slice(7).trim();
|
|
127
|
+
return url.searchParams.get('token')?.trim() ?? '';
|
|
128
|
+
}
|
|
129
|
+
function normalizeBase(value) {
|
|
130
|
+
const withSlash = value.startsWith('/') ? value : `/${value}`;
|
|
131
|
+
let end = withSlash.length;
|
|
132
|
+
while (end > 1 && withSlash[end - 1] === '/')
|
|
133
|
+
end -= 1;
|
|
134
|
+
return withSlash.slice(0, end);
|
|
135
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal structural views of `node-pty` and `ssh2`. Both are optional
|
|
3
|
+
* dependencies, loaded with `import()` only when a terminal is actually used.
|
|
4
|
+
*/
|
|
5
|
+
export interface PtyDisposable {
|
|
6
|
+
dispose(): void;
|
|
7
|
+
}
|
|
8
|
+
export interface PtyProcess {
|
|
9
|
+
readonly pid: number;
|
|
10
|
+
onData(listener: (data: string) => void): PtyDisposable | void;
|
|
11
|
+
onExit(listener: (event: {
|
|
12
|
+
exitCode: number;
|
|
13
|
+
signal?: number;
|
|
14
|
+
}) => void): PtyDisposable | void;
|
|
15
|
+
write(data: string | Buffer): void;
|
|
16
|
+
resize(cols: number, rows: number): void;
|
|
17
|
+
kill(signal?: string): void;
|
|
18
|
+
pause?(): void;
|
|
19
|
+
resume?(): void;
|
|
20
|
+
}
|
|
21
|
+
export interface PtySpawnOptions {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly cols: number;
|
|
24
|
+
readonly rows: number;
|
|
25
|
+
readonly cwd: string;
|
|
26
|
+
readonly env: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
export interface PtyModule {
|
|
29
|
+
spawn(file: string, args: string[], options: PtySpawnOptions): PtyProcess;
|
|
30
|
+
}
|
|
31
|
+
export interface SshChannel {
|
|
32
|
+
on(event: 'data', listener: (data: Buffer) => void): unknown;
|
|
33
|
+
on(event: 'exit', listener: (code: number | null, signal?: string) => void): unknown;
|
|
34
|
+
on(event: 'close', listener: () => void): unknown;
|
|
35
|
+
readonly stderr?: {
|
|
36
|
+
on(event: 'data', listener: (data: Buffer) => void): unknown;
|
|
37
|
+
};
|
|
38
|
+
write(data: Buffer | string): unknown;
|
|
39
|
+
setWindow(rows: number, cols: number, height: number, width: number): unknown;
|
|
40
|
+
close?(): unknown;
|
|
41
|
+
pause?(): unknown;
|
|
42
|
+
resume?(): unknown;
|
|
43
|
+
}
|
|
44
|
+
export interface SshConnectConfig {
|
|
45
|
+
readonly host: string;
|
|
46
|
+
readonly port: number;
|
|
47
|
+
readonly username: string;
|
|
48
|
+
readonly privateKey?: Buffer | string;
|
|
49
|
+
readonly passphrase?: string;
|
|
50
|
+
readonly password?: string;
|
|
51
|
+
readonly readyTimeout?: number;
|
|
52
|
+
readonly keepaliveInterval?: number;
|
|
53
|
+
readonly hostVerifier?: (key: Buffer) => boolean;
|
|
54
|
+
}
|
|
55
|
+
export interface SshClient {
|
|
56
|
+
on(event: 'ready', listener: () => void): unknown;
|
|
57
|
+
on(event: 'error', listener: (error: Error) => void): unknown;
|
|
58
|
+
on(event: 'close', listener: () => void): unknown;
|
|
59
|
+
connect(config: SshConnectConfig): unknown;
|
|
60
|
+
shell(window: {
|
|
61
|
+
term: string;
|
|
62
|
+
cols: number;
|
|
63
|
+
rows: number;
|
|
64
|
+
}, callback: (error: Error | undefined, channel: SshChannel) => void): unknown;
|
|
65
|
+
end(): unknown;
|
|
66
|
+
}
|
|
67
|
+
export interface SshModule {
|
|
68
|
+
readonly Client: new () => SshClient;
|
|
69
|
+
}
|
|
70
|
+
export interface TerminalModuleLoaders {
|
|
71
|
+
readonly loadPty?: () => Promise<PtyModule>;
|
|
72
|
+
readonly loadSsh?: () => Promise<SshModule>;
|
|
73
|
+
}
|
|
74
|
+
export type ModuleLoadResult<T> = {
|
|
75
|
+
readonly ok: true;
|
|
76
|
+
readonly module: T;
|
|
77
|
+
} | {
|
|
78
|
+
readonly ok: false;
|
|
79
|
+
readonly reason: string;
|
|
80
|
+
};
|
|
81
|
+
export declare function importNodePty(): Promise<PtyModule>;
|
|
82
|
+
export declare function importSsh2(): Promise<SshModule>;
|
|
83
|
+
/** Caches one load attempt per module; a failure is reported as an install hint. */
|
|
84
|
+
export declare function createModuleCache(loaders?: TerminalModuleLoaders): {
|
|
85
|
+
pty(): Promise<ModuleLoadResult<PtyModule>>;
|
|
86
|
+
ssh(): Promise<ModuleLoadResult<SshModule>>;
|
|
87
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal structural views of `node-pty` and `ssh2`. Both are optional
|
|
3
|
+
* dependencies, loaded with `import()` only when a terminal is actually used.
|
|
4
|
+
*/
|
|
5
|
+
// Non-literal specifiers keep TypeScript and bundlers from requiring the packages.
|
|
6
|
+
const PTY_SPECIFIER = 'node-pty';
|
|
7
|
+
const SSH_SPECIFIER = 'ssh2';
|
|
8
|
+
export async function importNodePty() {
|
|
9
|
+
const mod = await import(/* @vite-ignore */ PTY_SPECIFIER);
|
|
10
|
+
const spawn = typeof mod.spawn === 'function' ? mod.spawn : mod.default?.spawn;
|
|
11
|
+
if (typeof spawn !== 'function')
|
|
12
|
+
throw new Error('node-pty does not export spawn()');
|
|
13
|
+
return { spawn: spawn };
|
|
14
|
+
}
|
|
15
|
+
export async function importSsh2() {
|
|
16
|
+
const mod = await import(/* @vite-ignore */ SSH_SPECIFIER);
|
|
17
|
+
const Client = typeof mod.Client === 'function' ? mod.Client : mod.default?.Client;
|
|
18
|
+
if (typeof Client !== 'function')
|
|
19
|
+
throw new Error('ssh2 does not export Client');
|
|
20
|
+
return { Client: Client };
|
|
21
|
+
}
|
|
22
|
+
/** Caches one load attempt per module; a failure is reported as an install hint. */
|
|
23
|
+
export function createModuleCache(loaders = {}) {
|
|
24
|
+
let pty;
|
|
25
|
+
let ssh;
|
|
26
|
+
return {
|
|
27
|
+
pty() {
|
|
28
|
+
pty ??= settle(loaders.loadPty ?? importNodePty, 'node-pty');
|
|
29
|
+
return pty;
|
|
30
|
+
},
|
|
31
|
+
ssh() {
|
|
32
|
+
ssh ??= settle(loaders.loadSsh ?? importSsh2, 'ssh2');
|
|
33
|
+
return ssh;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function settle(load, name) {
|
|
38
|
+
try {
|
|
39
|
+
return { ok: true, module: await load() };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { ok: false, reason: `${name} 不可用(可选依赖未安装或原生模块构建失败):在项目中执行 pnpm add ${name} 后重启` };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type TerminalConfig } from './config.js';
|
|
2
|
+
import { type TerminalModuleLoaders } from './modules.js';
|
|
3
|
+
import { type IssuedTerminalTicket } from './tickets.js';
|
|
4
|
+
/** Authenticated caller. `id` binds tickets and is the revocation key (e.g. `user:<id>`). */
|
|
5
|
+
export interface TerminalPrincipal {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
/** Informational (e.g. `user`, `api-token`); authorization is decided by `authorize()` only. */
|
|
8
|
+
readonly kind?: string;
|
|
9
|
+
}
|
|
10
|
+
export type TerminalAuditAction = 'terminal.session.start' | 'terminal.session.end' | 'terminal.ticket.issue' | 'terminal.denied';
|
|
11
|
+
/** Shaped after `console_audit_log` (ADR 0049). Never carries terminal io or credentials. */
|
|
12
|
+
export interface TerminalAuditEvent {
|
|
13
|
+
readonly action: TerminalAuditAction;
|
|
14
|
+
/** Principal id, or `anonymous` when unauthenticated. */
|
|
15
|
+
readonly actor: string;
|
|
16
|
+
readonly target: string;
|
|
17
|
+
readonly outcome: 'ok' | 'denied' | 'error';
|
|
18
|
+
readonly ip?: string;
|
|
19
|
+
readonly detail: Readonly<Record<string, string | number | boolean | null>>;
|
|
20
|
+
}
|
|
21
|
+
export interface TerminalLogger {
|
|
22
|
+
info?(message: string): void;
|
|
23
|
+
warn?(message: string): void;
|
|
24
|
+
debug?(message: string): void;
|
|
25
|
+
}
|
|
26
|
+
export type TerminalEndReason = 'disconnect' | 'idle-timeout' | 'process-exit' | 'revoked' | 'disabled' | 'error';
|
|
27
|
+
export interface TerminalTarget {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly kind: 'local' | 'ssh';
|
|
30
|
+
readonly label: string;
|
|
31
|
+
readonly available: boolean;
|
|
32
|
+
readonly reason?: string;
|
|
33
|
+
/** SSH target configured with `insecureAcceptAnyHostKey`: the host key is not verified. */
|
|
34
|
+
readonly insecureHostKey?: true;
|
|
35
|
+
}
|
|
36
|
+
export interface TerminalSessionInfo {
|
|
37
|
+
readonly sessionId: string;
|
|
38
|
+
readonly principalId: string;
|
|
39
|
+
readonly target: string;
|
|
40
|
+
readonly kind: 'local' | 'ssh';
|
|
41
|
+
readonly startedAt: number;
|
|
42
|
+
}
|
|
43
|
+
/** Structural subset of a `WsConnection` (`@zhin.js/host-http`). */
|
|
44
|
+
export interface TerminalSocket {
|
|
45
|
+
/** Text frames as strings, binary frames as bytes. */
|
|
46
|
+
send(data: string | Uint8Array): void;
|
|
47
|
+
close(code?: number, reason?: string): void;
|
|
48
|
+
onMessage(listener: (data: string | Uint8Array) => void): () => void;
|
|
49
|
+
onClose(listener: (code: number, reason: string) => void): () => void;
|
|
50
|
+
readonly bufferedAmount: number;
|
|
51
|
+
}
|
|
52
|
+
/** The accepted upgrade a terminal session runs on. */
|
|
53
|
+
export interface TerminalConnection extends TerminalSocket {
|
|
54
|
+
readonly url: URL;
|
|
55
|
+
readonly remoteAddress?: string;
|
|
56
|
+
}
|
|
57
|
+
export interface TerminalServiceOptions {
|
|
58
|
+
readonly config: TerminalConfig;
|
|
59
|
+
readonly projectRoot: string;
|
|
60
|
+
readonly authorize: (principal: TerminalPrincipal) => boolean | Promise<boolean>;
|
|
61
|
+
readonly audit?: (event: TerminalAuditEvent) => void;
|
|
62
|
+
readonly logger?: TerminalLogger;
|
|
63
|
+
readonly modules?: TerminalModuleLoaders;
|
|
64
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
65
|
+
readonly now?: () => number;
|
|
66
|
+
/** Override key file reads (tests). */
|
|
67
|
+
readonly readFile?: (path: string) => Promise<Buffer>;
|
|
68
|
+
}
|
|
69
|
+
export declare class TerminalRequestError extends Error {
|
|
70
|
+
readonly status: number;
|
|
71
|
+
readonly code: string;
|
|
72
|
+
constructor(status: number, code: string, message: string);
|
|
73
|
+
}
|
|
74
|
+
export declare const TERMINAL_CLOSE_CODES: Readonly<{
|
|
75
|
+
normal: 1000;
|
|
76
|
+
startFailed: 1011;
|
|
77
|
+
badRequest: 4400;
|
|
78
|
+
invalidTicket: 4401;
|
|
79
|
+
forbidden: 4403;
|
|
80
|
+
tooManySessions: 4429;
|
|
81
|
+
}>;
|
|
82
|
+
/** Terminal tickets, targets and live sessions (ADR 0051). Transport-agnostic. */
|
|
83
|
+
export declare class TerminalService {
|
|
84
|
+
#private;
|
|
85
|
+
constructor(options: TerminalServiceOptions);
|
|
86
|
+
get config(): TerminalConfig;
|
|
87
|
+
/** Throws `TerminalRequestError(403)` unless the principal may use the terminal. */
|
|
88
|
+
requireAuthorized(principal: TerminalPrincipal | null, ip?: string): Promise<TerminalPrincipal>;
|
|
89
|
+
listTargets(): Promise<TerminalTarget[]>;
|
|
90
|
+
issueTicket(principal: TerminalPrincipal, body: unknown, ip?: string): Promise<IssuedTerminalTicket>;
|
|
91
|
+
/**
|
|
92
|
+
* Handles one accepted WebSocket. `presentedPrincipal` is set when the upgrade
|
|
93
|
+
* also carried a bearer credential; it must then match the ticket's principal.
|
|
94
|
+
*/
|
|
95
|
+
handleConnection(socket: TerminalConnection, presentedPrincipal?: TerminalPrincipal | null): Promise<void>;
|
|
96
|
+
/** Ends every session (and pending ticket) of a principal. Returns the number of sessions ended. */
|
|
97
|
+
terminatePrincipal(principalId: string, reason?: TerminalEndReason): number;
|
|
98
|
+
/** Ends all sessions; with `final`, refuses further tickets and connections. */
|
|
99
|
+
closeAll(reason?: TerminalEndReason, final?: boolean): void;
|
|
100
|
+
sessions(): TerminalSessionInfo[];
|
|
101
|
+
}
|