@tikoci/centrs 0.1.0
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/CHANGELOG.md +207 -0
- package/GLOSSARY.txt +420 -0
- package/README.md +196 -0
- package/SECURITY.md +30 -0
- package/commands/AGENTS.md +50 -0
- package/commands/btest/README.md +257 -0
- package/commands/btest/examples.md +190 -0
- package/commands/check/README.md +59 -0
- package/commands/devices/README.md +405 -0
- package/commands/devices/examples.md +488 -0
- package/commands/discover/README.md +170 -0
- package/commands/discover/examples.md +98 -0
- package/commands/execute/README.md +208 -0
- package/commands/execute/examples.md +353 -0
- package/commands/mcp/README.md +260 -0
- package/commands/mcp/examples.md +158 -0
- package/commands/retrieve/README.md +209 -0
- package/commands/retrieve/examples.md +306 -0
- package/commands/settings/README.md +69 -0
- package/commands/stream/README.md +70 -0
- package/commands/terminal/README.md +177 -0
- package/commands/terminal/examples.md +144 -0
- package/commands/transfer/README.md +315 -0
- package/commands/transfer/examples.md +368 -0
- package/docs/CONSTITUTION.md +417 -0
- package/docs/MATRIX.md +224 -0
- package/docs/errors/README.md +74 -0
- package/docs/errors/auth/failed.md +9 -0
- package/docs/errors/cdb/already-exists.md +9 -0
- package/docs/errors/cdb/created.md +9 -0
- package/docs/errors/cdb/decrypt-failed.md +9 -0
- package/docs/errors/cdb/empty-group.md +9 -0
- package/docs/errors/cdb/invalid-option.md +9 -0
- package/docs/errors/cdb/invalid-record.md +9 -0
- package/docs/errors/cdb/not-found-target.md +9 -0
- package/docs/errors/cdb/not-found.md +9 -0
- package/docs/errors/cdb/override-applied.md +9 -0
- package/docs/errors/cdb/parse-failed.md +9 -0
- package/docs/errors/cdb/password-not-needed.md +9 -0
- package/docs/errors/cdb/password-required.md +9 -0
- package/docs/errors/cdb/reserved-key.md +9 -0
- package/docs/errors/cdb/reserved-option.md +9 -0
- package/docs/errors/cdb/target-not-registered.md +9 -0
- package/docs/errors/cdb/unknown-field.md +9 -0
- package/docs/errors/cdb/unknown-option.md +9 -0
- package/docs/errors/cdb/write-not-permitted.md +9 -0
- package/docs/errors/discover/broadcast-unavailable.md +9 -0
- package/docs/errors/discover/failed.md +9 -0
- package/docs/errors/identity/ambiguous.md +9 -0
- package/docs/errors/identity/no-match.md +9 -0
- package/docs/errors/input/invalid-command.md +9 -0
- package/docs/errors/input/invalid-match.md +9 -0
- package/docs/errors/input/invalid-path.md +9 -0
- package/docs/errors/input/invalid-routeros-path.md +9 -0
- package/docs/errors/input/invalid-target.md +9 -0
- package/docs/errors/input/local-file-not-found.md +9 -0
- package/docs/errors/input/mac-address.md +9 -0
- package/docs/errors/input/max-results-exceeded.md +9 -0
- package/docs/errors/internal/devices-failed.md +9 -0
- package/docs/errors/internal/mcp-start.md +9 -0
- package/docs/errors/internal/mcp-tool.md +9 -0
- package/docs/errors/internal/native-api-length.md +9 -0
- package/docs/errors/internal/unhandled.md +9 -0
- package/docs/errors/internal/unreachable.md +9 -0
- package/docs/errors/mndp/encode-failed.md +9 -0
- package/docs/errors/mndp/listen-failed.md +9 -0
- package/docs/errors/mndp/malformed.md +9 -0
- package/docs/errors/routeros/api-fatal.md +9 -0
- package/docs/errors/routeros/api-protocol.md +9 -0
- package/docs/errors/routeros/api-trap.md +9 -0
- package/docs/errors/routeros/btest-protocol.md +12 -0
- package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
- package/docs/errors/routeros/command-failed.md +14 -0
- package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
- package/docs/errors/routeros/error.md +9 -0
- package/docs/errors/routeros/invalid-value.md +14 -0
- package/docs/errors/routeros/mac-telnet-error.md +9 -0
- package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
- package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
- package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
- package/docs/errors/routeros/protocol-not-implemented.md +9 -0
- package/docs/errors/routeros/request-failed.md +9 -0
- package/docs/errors/routeros/session-closed.md +13 -0
- package/docs/errors/routeros/unknown-attribute.md +13 -0
- package/docs/errors/routeros/unknown-path.md +15 -0
- package/docs/errors/routeros/unsupported-capability.md +9 -0
- package/docs/errors/settings/invalid-boolean.md +9 -0
- package/docs/errors/settings/invalid-format.md +9 -0
- package/docs/errors/settings/invalid-integer.md +9 -0
- package/docs/errors/settings/invalid-timeout.md +9 -0
- package/docs/errors/settings/invalid-via.md +9 -0
- package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
- package/docs/errors/target/mac-not-in-arp.md +14 -0
- package/docs/errors/target/mac-required.md +23 -0
- package/docs/errors/target/mac-unresolved.md +17 -0
- package/docs/errors/target/unresolved.md +9 -0
- package/docs/errors/transport/auth-failed.md +9 -0
- package/docs/errors/transport/auto-method.md +9 -0
- package/docs/errors/transport/capability-unsupported.md +22 -0
- package/docs/errors/transport/checksum-unavailable.md +9 -0
- package/docs/errors/transport/connection-closed.md +9 -0
- package/docs/errors/transport/connection-refused.md +9 -0
- package/docs/errors/transport/dns.md +9 -0
- package/docs/errors/transport/host-key-mismatch.md +9 -0
- package/docs/errors/transport/incomplete-transfer.md +9 -0
- package/docs/errors/transport/insecure-trust.md +9 -0
- package/docs/errors/transport/local-tool-missing.md +11 -0
- package/docs/errors/transport/network.md +9 -0
- package/docs/errors/transport/timeout.md +9 -0
- package/docs/errors/transport/tls-certificate.md +9 -0
- package/docs/errors/transport/unreachable.md +9 -0
- package/docs/errors/transport/unsupported-operation.md +9 -0
- package/docs/errors/usage/confirmation-required.md +9 -0
- package/docs/errors/usage/conflicting-flags.md +9 -0
- package/docs/errors/usage/invalid-concurrency.md +9 -0
- package/docs/errors/usage/missing-group.md +9 -0
- package/docs/errors/usage/not-implemented.md +9 -0
- package/docs/errors/usage/target-exists.md +9 -0
- package/docs/errors/usage/timeout-out-of-range.md +9 -0
- package/docs/errors/validation/not-implemented.md +9 -0
- package/docs/errors/validation/option.md +9 -0
- package/docs/errors/validation/syntax.md +9 -0
- package/docs/errors/validation/unknown-attribute.md +9 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +96 -0
- package/src/AGENTS.md +7 -0
- package/src/btest.ts +906 -0
- package/src/cli/btest.ts +455 -0
- package/src/cli/common.ts +53 -0
- package/src/cli/devices.ts +527 -0
- package/src/cli/discover.ts +186 -0
- package/src/cli/execute.ts +333 -0
- package/src/cli/mcp.ts +109 -0
- package/src/cli/retrieve.ts +383 -0
- package/src/cli/terminal.ts +303 -0
- package/src/cli/transfer.ts +471 -0
- package/src/cli.ts +115 -0
- package/src/core/envelope.ts +221 -0
- package/src/core/error-catalog.ts +469 -0
- package/src/core/routeros-errors.ts +268 -0
- package/src/data/AGENTS.md +11 -0
- package/src/data/dude-db.ts +0 -0
- package/src/data/mndp-cache.ts +132 -0
- package/src/data/mndp.ts +405 -0
- package/src/data/winbox-cdb-write.ts +200 -0
- package/src/data/winbox-cdb.ts +913 -0
- package/src/devices-schema.ts +107 -0
- package/src/devices.ts +1717 -0
- package/src/discover.ts +802 -0
- package/src/errors.ts +189 -0
- package/src/execute.ts +1350 -0
- package/src/index.ts +353 -0
- package/src/mcp/config.ts +66 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/safety.ts +119 -0
- package/src/mcp/server.ts +225 -0
- package/src/mcp/tools.ts +657 -0
- package/src/mcp.ts +33 -0
- package/src/protocols/AGENTS.md +14 -0
- package/src/protocols/adapter.ts +866 -0
- package/src/protocols/btest-session.ts +1493 -0
- package/src/protocols/btest.ts +447 -0
- package/src/protocols/ec-srp5.ts +466 -0
- package/src/protocols/index.ts +118 -0
- package/src/protocols/mac-telnet-console.ts +655 -0
- package/src/protocols/mac-telnet.ts +1299 -0
- package/src/protocols/mtwei.ts +47 -0
- package/src/protocols/native-api.ts +796 -0
- package/src/protocols/sftp.ts +427 -0
- package/src/protocols/ssh.ts +297 -0
- package/src/resolver/cdb.ts +580 -0
- package/src/resolver/comment-kv.ts +360 -0
- package/src/resolver/index.ts +74 -0
- package/src/resolver/mac.ts +227 -0
- package/src/resolver/settings.ts +228 -0
- package/src/resolver/target.ts +442 -0
- package/src/retrieve-fanout.ts +533 -0
- package/src/retrieve.ts +1215 -0
- package/src/terminal.ts +444 -0
- package/src/transfer.ts +1554 -0
- package/src/tui.ts +22 -0
- package/src/webproxy.ts +16 -0
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `terminal` — open an interactive RouterOS console (v1: mac-telnet only).
|
|
3
|
+
*
|
|
4
|
+
* Unlike `execute`, which runs one command and captures its output, `terminal`
|
|
5
|
+
* is a **raw passthrough**: after the shared `MacTelnetConsole` logs in and primes
|
|
6
|
+
* the prompt, input bytes are forwarded to the device and device output streams
|
|
7
|
+
* straight back out. The connection (resolver → route → UDP transport → console)
|
|
8
|
+
* is the exact path `execute / mac-telnet` proved; only the relay on top is new.
|
|
9
|
+
*
|
|
10
|
+
* Two input modes, chosen by whether stdin is a TTY (see {@link TerminalIo}):
|
|
11
|
+
* - interactive: raw-mode keystrokes, real terminal size, `SIGWINCH` resizes;
|
|
12
|
+
* - batch (piped stdin): forward bytes, then close on EOF after an output drain.
|
|
13
|
+
*
|
|
14
|
+
* The stream/exit contract: device bytes go to stdout, errors/summary to stderr,
|
|
15
|
+
* exit 0 on a clean close. A pre-stream failure (no terminal capability, auth,
|
|
16
|
+
* unresolved route) throws before any byte is emitted, so the CLI can render a
|
|
17
|
+
* clean error envelope. See `commands/terminal/README.md` / `examples.md`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
21
|
+
import {
|
|
22
|
+
createUdpMacTelnetTransport,
|
|
23
|
+
isBroadcastHost,
|
|
24
|
+
type MacTelnetTransport,
|
|
25
|
+
parseMac,
|
|
26
|
+
resolveMacTelnetRoute,
|
|
27
|
+
} from "./protocols/mac-telnet.ts";
|
|
28
|
+
import { MacTelnetConsole } from "./protocols/mac-telnet-console.ts";
|
|
29
|
+
import { sshCommonOptions, sshUserHost } from "./protocols/ssh.ts";
|
|
30
|
+
import {
|
|
31
|
+
type CdbResolution,
|
|
32
|
+
isIpTransport,
|
|
33
|
+
isMacAddress,
|
|
34
|
+
parseResolvePolicy,
|
|
35
|
+
type ResolvedAuth,
|
|
36
|
+
type ResolvedTarget,
|
|
37
|
+
resolveAuth,
|
|
38
|
+
resolveBooleanSetting,
|
|
39
|
+
resolveCdb,
|
|
40
|
+
resolveMacTarget,
|
|
41
|
+
resolveStringSetting,
|
|
42
|
+
resolveTarget,
|
|
43
|
+
} from "./resolver/index.ts";
|
|
44
|
+
|
|
45
|
+
/** Prime budget (login + license + the ~10s mac-telnet negotiation stall). */
|
|
46
|
+
const PRIME_TIMEOUT_MS = 30_000;
|
|
47
|
+
/** Per-session command/idle budget. */
|
|
48
|
+
const COMMAND_TIMEOUT_MS = 15_000;
|
|
49
|
+
/** Batch mode: close after this much output quiescence following stdin EOF. */
|
|
50
|
+
const DRAIN_IDLE_MS = 500;
|
|
51
|
+
|
|
52
|
+
export interface TerminalRequest {
|
|
53
|
+
/**
|
|
54
|
+
* Device handle: a MAC (resolves to the L2 console over mac-telnet), or a
|
|
55
|
+
* host / IP / CDB identity (the IP-level console over ssh).
|
|
56
|
+
*/
|
|
57
|
+
targetInput?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Transport. Defaults to `mac-telnet` for a MAC target and `ssh` for any other
|
|
60
|
+
* (host / IP / CDB identity); rest/native have no terminal capability.
|
|
61
|
+
*/
|
|
62
|
+
via?: string;
|
|
63
|
+
/** UDP delivery host override (defaults to the L2 broadcast route discovery). */
|
|
64
|
+
host?: string;
|
|
65
|
+
/** UDP delivery port override (defaults to 20561). */
|
|
66
|
+
port?: number;
|
|
67
|
+
/**
|
|
68
|
+
* MAC→IP resolution policy for the IP transport (`--via ssh`): `none`
|
|
69
|
+
* (default; CDB-first, then error) or `arp` (opt into host ARP). Ignored by
|
|
70
|
+
* the L2 default (`mac-telnet`), which addresses the MAC directly.
|
|
71
|
+
*/
|
|
72
|
+
resolve?: string;
|
|
73
|
+
username?: string;
|
|
74
|
+
password?: string;
|
|
75
|
+
/** Explicit in-packet source MAC (overrides egress-MAC resolution; mac-telnet). */
|
|
76
|
+
sourceMac?: string;
|
|
77
|
+
/** `--via ssh`: private-key path (path only; agent / ~/.ssh used if unset). */
|
|
78
|
+
sshKey?: string;
|
|
79
|
+
/** `--via ssh`: disable SSH host-key verification (accepts changed keys). */
|
|
80
|
+
insecure?: boolean;
|
|
81
|
+
cdbFile?: string;
|
|
82
|
+
cdbPassword?: string;
|
|
83
|
+
format?: string;
|
|
84
|
+
verbose?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The stdio/terminal seam `terminal` drives, injectable so the relay is testable
|
|
89
|
+
* without a real TTY. The CLI wires `process.*`; a test can pass scripted streams.
|
|
90
|
+
*/
|
|
91
|
+
export interface TerminalIo {
|
|
92
|
+
/** True when both stdin and stdout are TTYs (raw-mode interactive relay). */
|
|
93
|
+
isInteractive: boolean;
|
|
94
|
+
/** Current terminal size reported to the device. */
|
|
95
|
+
size(): { rows: number; cols: number };
|
|
96
|
+
/** Write raw device output bytes to the user's terminal. */
|
|
97
|
+
writeOutput(bytes: Uint8Array): void;
|
|
98
|
+
/** Start reading input; `onChunk` per chunk, `onEnd` once at EOF. */
|
|
99
|
+
readInput(onChunk: (bytes: Uint8Array) => void, onEnd: () => void): void;
|
|
100
|
+
/** Register a window-resize listener (interactive only). */
|
|
101
|
+
onResize?(listener: () => void): void;
|
|
102
|
+
/** Restore terminal state (raw mode off, listeners removed). */
|
|
103
|
+
cleanup(): void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface ResolvedTerminal {
|
|
107
|
+
via: string;
|
|
108
|
+
target: ResolvedTarget;
|
|
109
|
+
auth: ResolvedAuth;
|
|
110
|
+
sourceMac?: string;
|
|
111
|
+
insecure: boolean;
|
|
112
|
+
warnings: CdbResolution["warnings"];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Reject any transport without a terminal capability (mac-telnet + ssh). */
|
|
116
|
+
function gateTerminalVia(via: string): void {
|
|
117
|
+
if (via === "mac-telnet" || via === "ssh") {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (via === "rest-api" || via === "native-api") {
|
|
121
|
+
throw new CentrsError({
|
|
122
|
+
code: "transport/capability-unsupported",
|
|
123
|
+
summary: `${via} has no terminal capability.`,
|
|
124
|
+
remediation:
|
|
125
|
+
"terminal is a console transport: use `--via ssh` (host target) or `--via mac-telnet` (MAC target). For structured reads use `retrieve --via rest-api`/`native-api`.",
|
|
126
|
+
context: { via, command: "terminal" },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
throw new CentrsError({
|
|
130
|
+
code: "settings/invalid-via",
|
|
131
|
+
summary: `Unsupported terminal transport: ${via}.`,
|
|
132
|
+
remediation: "terminal supports `--via mac-telnet` or `--via ssh`.",
|
|
133
|
+
context: { via },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Resolve a terminal request to a mac-telnet route; reject other transports. */
|
|
138
|
+
export async function resolveTerminalRequest(
|
|
139
|
+
request: TerminalRequest,
|
|
140
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
141
|
+
): Promise<ResolvedTerminal> {
|
|
142
|
+
// Gate the transport BEFORE any CDB load: an explicit unsupported `--via`
|
|
143
|
+
// should fail fast and hermetically, without a device-registry lookup for a
|
|
144
|
+
// request we will reject anyway (an unmatched target would otherwise throw a
|
|
145
|
+
// `cdb/not-found-target` that masks the real "no terminal capability" reason).
|
|
146
|
+
const preliminaryVia = request.via ?? env["CENTRS_VIA"];
|
|
147
|
+
if (preliminaryVia !== undefined) {
|
|
148
|
+
gateTerminalVia(preliminaryVia);
|
|
149
|
+
}
|
|
150
|
+
const cdb = await resolveCdb(
|
|
151
|
+
{
|
|
152
|
+
targetInput: request.targetInput,
|
|
153
|
+
cdbFile: request.cdbFile,
|
|
154
|
+
cdbPassword: request.cdbPassword,
|
|
155
|
+
},
|
|
156
|
+
env,
|
|
157
|
+
);
|
|
158
|
+
// A MAC target defaults to the L2 console (mac-telnet); any other target
|
|
159
|
+
// (host / IP / CDB identity) defaults to ssh — the IP-level terminal transport.
|
|
160
|
+
const defaultVia = isMacAddress(request.targetInput ?? "")
|
|
161
|
+
? "mac-telnet"
|
|
162
|
+
: "ssh";
|
|
163
|
+
const via = resolveStringSetting(
|
|
164
|
+
request.via,
|
|
165
|
+
env,
|
|
166
|
+
"CENTRS_VIA",
|
|
167
|
+
defaultVia,
|
|
168
|
+
"via",
|
|
169
|
+
undefined,
|
|
170
|
+
cdb?.overrides.via,
|
|
171
|
+
);
|
|
172
|
+
const viaValue = via?.value ?? defaultVia;
|
|
173
|
+
// Re-gate on the fully resolved value to catch a CDB comment-kv `via=` override.
|
|
174
|
+
gateTerminalVia(viaValue);
|
|
175
|
+
// A MAC target over the IP transport (`--via ssh`) needs an IP. Resolve it
|
|
176
|
+
// CDB-first (the matched record's `target` already wins below); only when no
|
|
177
|
+
// record matches does this throw `target/mac-unresolved` — unless the caller
|
|
178
|
+
// opted into host ARP with `--resolve arp`. The L2 default (mac-telnet)
|
|
179
|
+
// addresses the MAC directly, so it never resolves (NO silent ARP).
|
|
180
|
+
const macResolution = isIpTransport(viaValue)
|
|
181
|
+
? await resolveMacTarget({
|
|
182
|
+
host: request.host,
|
|
183
|
+
targetInput: request.targetInput,
|
|
184
|
+
cdbTarget: cdb?.target,
|
|
185
|
+
env,
|
|
186
|
+
policy: parseResolvePolicy(request.resolve ?? env["CENTRS_RESOLVE"]),
|
|
187
|
+
operation: "terminal",
|
|
188
|
+
})
|
|
189
|
+
: undefined;
|
|
190
|
+
const target = resolveTarget(
|
|
191
|
+
{
|
|
192
|
+
targetInput: request.targetInput,
|
|
193
|
+
host: request.host,
|
|
194
|
+
port: request.port,
|
|
195
|
+
macResolution,
|
|
196
|
+
},
|
|
197
|
+
env,
|
|
198
|
+
viaValue === "ssh" ? "ssh" : "mac-telnet",
|
|
199
|
+
cdb,
|
|
200
|
+
);
|
|
201
|
+
const auth = resolveAuth(
|
|
202
|
+
{
|
|
203
|
+
username: request.username,
|
|
204
|
+
password: request.password,
|
|
205
|
+
sshKey: request.sshKey,
|
|
206
|
+
},
|
|
207
|
+
env,
|
|
208
|
+
cdb,
|
|
209
|
+
);
|
|
210
|
+
const insecure = resolveBooleanSetting(
|
|
211
|
+
request.insecure,
|
|
212
|
+
env,
|
|
213
|
+
"CENTRS_INSECURE",
|
|
214
|
+
false,
|
|
215
|
+
"insecure",
|
|
216
|
+
cdb?.overrides.insecure,
|
|
217
|
+
);
|
|
218
|
+
return {
|
|
219
|
+
via: viaValue,
|
|
220
|
+
target,
|
|
221
|
+
auth,
|
|
222
|
+
sourceMac: request.sourceMac,
|
|
223
|
+
insecure: insecure.value,
|
|
224
|
+
warnings: cdb?.warnings ?? [],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Build the UDP transport + console for a resolved mac-telnet terminal and open it. */
|
|
229
|
+
async function connectTerminalConsole(
|
|
230
|
+
resolved: ResolvedTerminal,
|
|
231
|
+
io: TerminalIo,
|
|
232
|
+
): Promise<{ console: MacTelnetConsole; transport: MacTelnetTransport }> {
|
|
233
|
+
if (!resolved.target.mac) {
|
|
234
|
+
throw new CentrsError({
|
|
235
|
+
code: "target/mac-required",
|
|
236
|
+
summary: "terminal over mac-telnet needs a MAC address target.",
|
|
237
|
+
remediation: "Pass the device MAC (aa:bb:cc:dd:ee:ff).",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
const destinationMac = parseMac(resolved.target.mac);
|
|
241
|
+
const { sourceMac, host } = await resolveMacTelnetRoute({
|
|
242
|
+
destinationMac,
|
|
243
|
+
host: resolved.target.host,
|
|
244
|
+
port: resolved.target.port,
|
|
245
|
+
timeoutMs: COMMAND_TIMEOUT_MS,
|
|
246
|
+
explicitSourceMac: resolved.sourceMac
|
|
247
|
+
? parseMac(resolved.sourceMac)
|
|
248
|
+
: undefined,
|
|
249
|
+
});
|
|
250
|
+
const transport = createUdpMacTelnetTransport({
|
|
251
|
+
host,
|
|
252
|
+
port: resolved.target.port,
|
|
253
|
+
broadcast: isBroadcastHost(host),
|
|
254
|
+
});
|
|
255
|
+
await transport.ready();
|
|
256
|
+
// Interactive: report the real terminal size so the device neither wraps nor
|
|
257
|
+
// paginates. Batch: keep the console's wide capture defaults (no TTY size).
|
|
258
|
+
const size = io.isInteractive ? io.size() : undefined;
|
|
259
|
+
const console = new MacTelnetConsole({
|
|
260
|
+
sink: transport,
|
|
261
|
+
sourceMac,
|
|
262
|
+
destinationMac,
|
|
263
|
+
username: resolved.auth.username ?? "",
|
|
264
|
+
password: resolved.auth.password,
|
|
265
|
+
primeTimeoutMs: PRIME_TIMEOUT_MS,
|
|
266
|
+
commandTimeoutMs: COMMAND_TIMEOUT_MS,
|
|
267
|
+
...(size ? { rows: size.rows, cols: size.cols } : {}),
|
|
268
|
+
});
|
|
269
|
+
transport.onMessage((bytes) => console.handlePacket(bytes));
|
|
270
|
+
await console.open();
|
|
271
|
+
return { console, transport };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Run an interactive terminal session and resolve with the exit code. mac-telnet
|
|
276
|
+
* drives the in-process console relay over {@link TerminalIo} (returns 0 on a
|
|
277
|
+
* clean close; throws before any output on a pre-stream failure). ssh delegates to
|
|
278
|
+
* the host `ssh` client with inherited stdio and returns its exit code. A
|
|
279
|
+
* non-terminal transport throws `transport/capability-unsupported` from the gate.
|
|
280
|
+
*/
|
|
281
|
+
export async function runTerminal(
|
|
282
|
+
request: TerminalRequest,
|
|
283
|
+
io: TerminalIo,
|
|
284
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
285
|
+
): Promise<number> {
|
|
286
|
+
const resolved = await resolveTerminalRequest(request, env);
|
|
287
|
+
if (resolved.via === "ssh") {
|
|
288
|
+
return runTerminalSsh(resolved);
|
|
289
|
+
}
|
|
290
|
+
const { console: cons, transport } = await connectTerminalConsole(
|
|
291
|
+
resolved,
|
|
292
|
+
io,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
await new Promise<void>((resolve, reject) => {
|
|
296
|
+
let settled = false;
|
|
297
|
+
let lastOutputAt = Date.now();
|
|
298
|
+
let sawOutput = false;
|
|
299
|
+
let drainTimer: ReturnType<typeof setInterval> | undefined;
|
|
300
|
+
let drainCap: ReturnType<typeof setTimeout> | undefined;
|
|
301
|
+
|
|
302
|
+
const finish = (error?: CentrsError): void => {
|
|
303
|
+
if (settled) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
settled = true;
|
|
307
|
+
if (drainTimer) clearInterval(drainTimer);
|
|
308
|
+
if (drainCap) clearTimeout(drainCap);
|
|
309
|
+
cons.close();
|
|
310
|
+
transport.close();
|
|
311
|
+
io.cleanup();
|
|
312
|
+
if (error) {
|
|
313
|
+
reject(error);
|
|
314
|
+
} else {
|
|
315
|
+
resolve();
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
cons.onClosed((error) => finish(error));
|
|
320
|
+
cons.attachInteractive(
|
|
321
|
+
(bytes) => {
|
|
322
|
+
lastOutputAt = Date.now();
|
|
323
|
+
sawOutput = true;
|
|
324
|
+
io.writeOutput(bytes);
|
|
325
|
+
},
|
|
326
|
+
{ answerProbe: !io.isInteractive },
|
|
327
|
+
);
|
|
328
|
+
io.onResize?.(() => {
|
|
329
|
+
const { rows, cols } = io.size();
|
|
330
|
+
cons.reportSize(rows, cols);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const onEnd = (): void => {
|
|
334
|
+
if (io.isInteractive) {
|
|
335
|
+
// A TTY EOF (the stream closing) ends the session.
|
|
336
|
+
finish();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
// Batch: stdin is exhausted, but the device may still be answering the
|
|
340
|
+
// last line — and stdin EOF typically beats the first response byte. Only
|
|
341
|
+
// close once we have seen the response *and* it has been quiet for
|
|
342
|
+
// DRAIN_IDLE_MS; `sawOutput` keeps the stale prime-prompt timestamp from
|
|
343
|
+
// closing us early. A hard cap covers a command that prints nothing.
|
|
344
|
+
drainTimer = setInterval(() => {
|
|
345
|
+
if (sawOutput && Date.now() - lastOutputAt >= DRAIN_IDLE_MS) {
|
|
346
|
+
finish();
|
|
347
|
+
}
|
|
348
|
+
}, 50);
|
|
349
|
+
drainTimer.unref?.();
|
|
350
|
+
drainCap = setTimeout(() => finish(), COMMAND_TIMEOUT_MS);
|
|
351
|
+
drainCap.unref?.();
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
io.readInput((bytes) => cons.write(bytes), onEnd);
|
|
355
|
+
});
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Build the `ssh` argv for an interactive RouterOS terminal: `ssh -p <port>
|
|
361
|
+
* <key/trust options> user@host` with **no** command (so it opens the console)
|
|
362
|
+
* and **no** `-t` (RouterOS grants no PTY; forcing one with `-tt` hangs, and a
|
|
363
|
+
* real TTY makes the host `ssh` request a PTY on its own). `interactive: true`
|
|
364
|
+
* drops `BatchMode=yes` (unlike the batch sftp/execute clients), so the host
|
|
365
|
+
* `ssh` may prompt on the inherited TTY for an encrypted key's passphrase or a
|
|
366
|
+
* password — centrs does not forward `--password`, as OpenSSH takes no password
|
|
367
|
+
* on the argv. Exported for tests.
|
|
368
|
+
*/
|
|
369
|
+
export function buildSshTerminalArgv(resolved: ResolvedTerminal): string[] {
|
|
370
|
+
const conn = {
|
|
371
|
+
host: resolved.target.host,
|
|
372
|
+
port: resolved.target.port,
|
|
373
|
+
username: resolved.auth.username,
|
|
374
|
+
sshKey: resolved.auth.sshKey,
|
|
375
|
+
insecure: resolved.insecure,
|
|
376
|
+
interactive: true,
|
|
377
|
+
timeoutMs: PRIME_TIMEOUT_MS,
|
|
378
|
+
};
|
|
379
|
+
return [
|
|
380
|
+
"ssh",
|
|
381
|
+
"-p",
|
|
382
|
+
String(resolved.target.port),
|
|
383
|
+
...sshCommonOptions(conn),
|
|
384
|
+
sshUserHost(conn),
|
|
385
|
+
];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Terminal over SSH: exec the host `ssh` with inherited stdio, so the OS relays
|
|
390
|
+
* the (already clean, no-PTY) RouterOS console — no screen emulation needed. The
|
|
391
|
+
* interactive TTY, raw mode, and signals are the inherited terminal's; centrs's
|
|
392
|
+
* value is resolving the target/key/trust and building the argv. Returns ssh's
|
|
393
|
+
* exit code (a no-PTY console closed by EOF can exit non-zero — that is the
|
|
394
|
+
* device/ssh's result, not a centrs failure).
|
|
395
|
+
*/
|
|
396
|
+
async function runTerminalSsh(resolved: ResolvedTerminal): Promise<number> {
|
|
397
|
+
const argv = buildSshTerminalArgv(resolved);
|
|
398
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
399
|
+
try {
|
|
400
|
+
proc = Bun.spawn(argv, {
|
|
401
|
+
stdin: "inherit",
|
|
402
|
+
stdout: "inherit",
|
|
403
|
+
stderr: "inherit",
|
|
404
|
+
});
|
|
405
|
+
} catch (cause) {
|
|
406
|
+
throw new CentrsError({
|
|
407
|
+
code: "transport/local-tool-missing",
|
|
408
|
+
summary: "Cannot launch the host `ssh` client for the terminal.",
|
|
409
|
+
remediation:
|
|
410
|
+
"Install an OpenSSH client so `ssh` is on PATH (macOS ships it; Debian/Ubuntu: `openssh-client`).",
|
|
411
|
+
context: { binary: "ssh", via: "ssh" },
|
|
412
|
+
cause,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return await proc.exited;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export interface TerminalErrorEnvelope {
|
|
419
|
+
ok: false;
|
|
420
|
+
error: ReturnType<typeof serializeCentrsError>;
|
|
421
|
+
warnings: [];
|
|
422
|
+
tips: [];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Wrap a pre-stream failure as the standard error envelope (rendered to stderr). */
|
|
426
|
+
export function buildTerminalErrorEnvelope(
|
|
427
|
+
error: unknown,
|
|
428
|
+
): TerminalErrorEnvelope {
|
|
429
|
+
const centrs =
|
|
430
|
+
error instanceof CentrsError
|
|
431
|
+
? error
|
|
432
|
+
: new CentrsError({
|
|
433
|
+
code: "internal/unhandled",
|
|
434
|
+
summary: error instanceof Error ? error.message : String(error),
|
|
435
|
+
remediation:
|
|
436
|
+
"This is a centrs bug; re-run with --verbose and file an issue with the printed code.",
|
|
437
|
+
});
|
|
438
|
+
return {
|
|
439
|
+
ok: false,
|
|
440
|
+
error: serializeCentrsError(centrs),
|
|
441
|
+
warnings: [],
|
|
442
|
+
tips: [],
|
|
443
|
+
};
|
|
444
|
+
}
|