@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
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SFTP transfer client over the **host OpenSSH** `sftp` binary.
|
|
3
|
+
*
|
|
4
|
+
* Why shell out instead of an in-process SSH library: the host `sftp` (OpenSSH)
|
|
5
|
+
* already inherits `~/.ssh/config`, the ssh-agent, `known_hosts`, and the full
|
|
6
|
+
* algorithm negotiation RouterOS needs (rsa-sha2-256/512, ed25519,
|
|
7
|
+
* `strong-crypto`) — for free, with zero new dependencies. RouterOS's SSH server
|
|
8
|
+
* has **no pseudo-tty** (and a single-line `ssh host "cmd"` exec — used by
|
|
9
|
+
* `execute / ssh` — is not a file channel), so the reliable SSH file path is the
|
|
10
|
+
* **SFTP subsystem**; this client speaks exactly that. See
|
|
11
|
+
* `commands/transfer/README.md` (SFTP vs SCP) and `commands/terminal/README.md`
|
|
12
|
+
* (RouterOS SSH surface).
|
|
13
|
+
*
|
|
14
|
+
* Each high-level method runs one `sftp -b -` batch invocation (commands fed on
|
|
15
|
+
* stdin) so errors attribute cleanly to the operation. The runner is injectable
|
|
16
|
+
* so unit tests pin behavior without spawning a real `sftp`. **Backup plan:** if
|
|
17
|
+
* the batch I/O / `ls -l` parsing proves brittle on a real device, the swap to
|
|
18
|
+
* the `ssh2` npm library happens *behind this same interface* — nothing above
|
|
19
|
+
* `SftpClient` changes.
|
|
20
|
+
*
|
|
21
|
+
* Grounding note: the OpenSSH `ls -l` long-name format is server-influenced, so
|
|
22
|
+
* the size column is parsed **best-effort** (existence + name + type are the
|
|
23
|
+
* load-bearing signals; SFTP `put`/`get` carry any size in one call and do not
|
|
24
|
+
* need it). The exact RouterOS long-name shape is pinned by a CHR integration
|
|
25
|
+
* finding, not assumed.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { CentrsError } from "../errors.ts";
|
|
29
|
+
import { sshCommonOptions, sshUserHost } from "./ssh.ts";
|
|
30
|
+
|
|
31
|
+
/** A directory entry as parsed from an `sftp` `ls -l` long-name line. */
|
|
32
|
+
export interface SftpFileEntry {
|
|
33
|
+
name: string;
|
|
34
|
+
type: "file" | "directory" | "other";
|
|
35
|
+
/** Best-effort byte size from the long-name listing; may be `undefined`. */
|
|
36
|
+
size?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Raw result of one `sftp -b -` batch run. */
|
|
40
|
+
export interface SftpBatchResult {
|
|
41
|
+
exitCode: number;
|
|
42
|
+
stdout: string;
|
|
43
|
+
stderr: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Injectable batch runner (the default spawns the host `sftp`). */
|
|
47
|
+
export type SftpBatchRunner = (
|
|
48
|
+
argv: readonly string[],
|
|
49
|
+
batch: string,
|
|
50
|
+
timeoutMs: number,
|
|
51
|
+
) => Promise<SftpBatchResult>;
|
|
52
|
+
|
|
53
|
+
export interface SftpClientConfig {
|
|
54
|
+
host: string;
|
|
55
|
+
port: number;
|
|
56
|
+
username?: string;
|
|
57
|
+
/** Private-key path (`-i`); when unset, the agent / `~/.ssh/config` is used. */
|
|
58
|
+
sshKey?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Opt out of strict host-key checking. Default (false) is `accept-new`
|
|
61
|
+
* (trust-on-first-use into the user's `known_hosts`); `true` disables checking
|
|
62
|
+
* and uses an ephemeral hosts file.
|
|
63
|
+
*/
|
|
64
|
+
insecure?: boolean;
|
|
65
|
+
/** Per-operation timeout (also the connect timeout, in whole seconds). */
|
|
66
|
+
timeoutMs: number;
|
|
67
|
+
/** Override the `known_hosts` file (tests / ephemeral runs). */
|
|
68
|
+
knownHostsFile?: string;
|
|
69
|
+
/** Override the `sftp` binary path (tests). */
|
|
70
|
+
binary?: string;
|
|
71
|
+
/** Injected batch runner (tests); defaults to {@link spawnSftpBatch}. */
|
|
72
|
+
runner?: SftpBatchRunner;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class SftpClient {
|
|
76
|
+
private readonly run: SftpBatchRunner;
|
|
77
|
+
|
|
78
|
+
constructor(private readonly config: SftpClientConfig) {
|
|
79
|
+
this.run = config.runner ?? spawnSftpBatch;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Existence + best-effort size for one remote path; `undefined` if absent. */
|
|
83
|
+
async stat(remote: string): Promise<SftpFileEntry | undefined> {
|
|
84
|
+
const base = basename(remote);
|
|
85
|
+
// `-ls` ignores the per-command error so a missing path does not abort the
|
|
86
|
+
// batch with a non-zero exit (which we could not tell from auth failures).
|
|
87
|
+
const result = await this.batch([`-ls -l ${quote(remote)}`]);
|
|
88
|
+
this.throwIfConnectFailed(result, "stat");
|
|
89
|
+
for (const entry of parseLsOutput(result.stdout)) {
|
|
90
|
+
if (entry.name === base || entry.name === remote) {
|
|
91
|
+
return { ...entry, name: remote };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** List a remote directory. */
|
|
98
|
+
async readdir(remote: string): Promise<SftpFileEntry[]> {
|
|
99
|
+
const target = remote.length > 0 ? remote : ".";
|
|
100
|
+
const result = await this.batch([`-ls -l ${quote(target)}`]);
|
|
101
|
+
this.throwIfConnectFailed(result, "readdir");
|
|
102
|
+
return parseLsOutput(result.stdout).filter(
|
|
103
|
+
(entry) => entry.name !== "." && entry.name !== "..",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Upload a local file to a remote path (SFTP `put`). */
|
|
108
|
+
async put(localPath: string, remote: string): Promise<void> {
|
|
109
|
+
const result = await this.batch([
|
|
110
|
+
`put ${quote(localPath)} ${quote(remote)}`,
|
|
111
|
+
]);
|
|
112
|
+
this.throwIfFailed(result, "put", remote);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Download a remote path to a local file (SFTP `get`). */
|
|
116
|
+
async get(remote: string, localPath: string): Promise<void> {
|
|
117
|
+
const result = await this.batch([
|
|
118
|
+
`get ${quote(remote)} ${quote(localPath)}`,
|
|
119
|
+
]);
|
|
120
|
+
this.throwIfFailed(result, "get", remote);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Create a remote directory. */
|
|
124
|
+
async mkdir(remote: string): Promise<void> {
|
|
125
|
+
const result = await this.batch([`mkdir ${quote(remote)}`]);
|
|
126
|
+
this.throwIfFailed(result, "mkdir", remote);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Remove a remote file. */
|
|
130
|
+
async remove(remote: string): Promise<void> {
|
|
131
|
+
const result = await this.batch([`rm ${quote(remote)}`]);
|
|
132
|
+
this.throwIfFailed(result, "remove", remote);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Rename / move a remote path. */
|
|
136
|
+
async rename(from: string, to: string): Promise<void> {
|
|
137
|
+
const result = await this.batch([`rename ${quote(from)} ${quote(to)}`]);
|
|
138
|
+
this.throwIfFailed(result, "rename", from);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private batch(commands: readonly string[]): Promise<SftpBatchResult> {
|
|
142
|
+
return this.run(
|
|
143
|
+
this.argv(),
|
|
144
|
+
`${commands.join("\n")}\n`,
|
|
145
|
+
this.config.timeoutMs,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Build the `sftp` argv (one place so tests can assert it). */
|
|
150
|
+
argv(): string[] {
|
|
151
|
+
// `sftp` takes the port as `-P` (vs `ssh -p`); the `-i`/`-o` trust + batch
|
|
152
|
+
// options are shared with the ssh execute client via `sshCommonOptions`.
|
|
153
|
+
return [
|
|
154
|
+
this.config.binary ?? "sftp",
|
|
155
|
+
"-b",
|
|
156
|
+
"-",
|
|
157
|
+
"-P",
|
|
158
|
+
String(this.config.port),
|
|
159
|
+
...sshCommonOptions(this.config),
|
|
160
|
+
sshUserHost(this.config),
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Connect/auth/host-key failures happen before the batch runs. */
|
|
165
|
+
private throwIfConnectFailed(result: SftpBatchResult, op: string): void {
|
|
166
|
+
if (result.exitCode !== 0) {
|
|
167
|
+
throw this.mapError(result, op);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private throwIfFailed(
|
|
172
|
+
result: SftpBatchResult,
|
|
173
|
+
op: string,
|
|
174
|
+
remote: string,
|
|
175
|
+
): void {
|
|
176
|
+
if (result.exitCode !== 0) {
|
|
177
|
+
throw this.mapError(result, op, remote);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private mapError(
|
|
182
|
+
result: SftpBatchResult,
|
|
183
|
+
op: string,
|
|
184
|
+
remote?: string,
|
|
185
|
+
): CentrsError {
|
|
186
|
+
const { host, port } = this.config;
|
|
187
|
+
const stderr = result.stderr.trim();
|
|
188
|
+
const lower = stderr.toLowerCase();
|
|
189
|
+
const context = {
|
|
190
|
+
via: "ssh",
|
|
191
|
+
host,
|
|
192
|
+
port,
|
|
193
|
+
op,
|
|
194
|
+
...(remote ? { remote } : {}),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (lower.includes("host key verification failed")) {
|
|
198
|
+
return new CentrsError({
|
|
199
|
+
code: "transport/host-key-mismatch",
|
|
200
|
+
summary: `The SSH host key for ${host} did not match a known key.`,
|
|
201
|
+
remediation:
|
|
202
|
+
"The device's host key changed (or is impersonated). Remove the stale `known_hosts` entry after verifying the device, or pass `--insecure` to skip the check.",
|
|
203
|
+
context,
|
|
204
|
+
causeData: stderr,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (
|
|
208
|
+
lower.includes("permission denied") ||
|
|
209
|
+
lower.includes("authentication failed") ||
|
|
210
|
+
lower.includes("no such identity") ||
|
|
211
|
+
lower.includes("too many authentication failures")
|
|
212
|
+
) {
|
|
213
|
+
return new CentrsError({
|
|
214
|
+
code: "auth/failed",
|
|
215
|
+
summary: `SSH authentication to ${host} failed.`,
|
|
216
|
+
remediation:
|
|
217
|
+
"Provide a key the device trusts via `--ssh-key` / the ssh-agent, and confirm the RouterOS user has `ftp`/`ssh` policy. RouterOS refuses password login once an SSH key is set for the user.",
|
|
218
|
+
context,
|
|
219
|
+
causeData: stderr,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (lower.includes("connection refused")) {
|
|
223
|
+
return new CentrsError({
|
|
224
|
+
code: "transport/connection-refused",
|
|
225
|
+
summary: `SSH connection to ${host}:${port} was refused.`,
|
|
226
|
+
remediation:
|
|
227
|
+
"Enable the RouterOS SSH service (`/ip service enable ssh`) and confirm the port / firewall.",
|
|
228
|
+
context,
|
|
229
|
+
causeData: stderr,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (
|
|
233
|
+
lower.includes("timed out") ||
|
|
234
|
+
lower.includes("operation timed out") ||
|
|
235
|
+
lower.includes("connection timed out")
|
|
236
|
+
) {
|
|
237
|
+
return new CentrsError({
|
|
238
|
+
code: "transport/timeout",
|
|
239
|
+
summary: `SSH connection to ${host}:${port} timed out.`,
|
|
240
|
+
remediation:
|
|
241
|
+
"Raise `--timeout`, or confirm the host and port are reachable.",
|
|
242
|
+
context,
|
|
243
|
+
causeData: stderr,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (
|
|
247
|
+
lower.includes("could not resolve") ||
|
|
248
|
+
lower.includes("name or service not known") ||
|
|
249
|
+
lower.includes("nodename nor servname")
|
|
250
|
+
) {
|
|
251
|
+
return new CentrsError({
|
|
252
|
+
code: "transport/dns",
|
|
253
|
+
summary: `Could not resolve ${host} for SSH.`,
|
|
254
|
+
remediation:
|
|
255
|
+
"Check the host spelling / DNS, or pass a literal address with `--host`.",
|
|
256
|
+
context,
|
|
257
|
+
causeData: stderr,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
if (lower.includes("no such file") || lower.includes("not found")) {
|
|
261
|
+
return new CentrsError({
|
|
262
|
+
code: "routeros/command-failed",
|
|
263
|
+
summary: `SFTP ${op} failed: ${remote ?? "path"} not found on ${host}.`,
|
|
264
|
+
remediation:
|
|
265
|
+
"Check the remote path (RouterOS file names have no leading slash); `transfer list` shows what is present.",
|
|
266
|
+
context,
|
|
267
|
+
causeData: stderr,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return new CentrsError({
|
|
271
|
+
code: "transport/network",
|
|
272
|
+
summary: `SFTP ${op} failed against ${host}:${port} (exit ${result.exitCode}).`,
|
|
273
|
+
remediation:
|
|
274
|
+
"Re-run with `--format json` for the structured cause, and confirm SSH/SFTP access to the device.",
|
|
275
|
+
context,
|
|
276
|
+
causeData: stderr || `exit ${result.exitCode}`,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Parse `sftp` `ls -l` output into entries. Tolerant of column variation: a
|
|
283
|
+
* strict OpenSSH long-name shape is tried first, then a loose fallback (perms +
|
|
284
|
+
* trailing name + largest integer as size). Lines that are not listings (the
|
|
285
|
+
* `sftp>` echo, blank lines) are skipped.
|
|
286
|
+
*/
|
|
287
|
+
export function parseLsOutput(stdout: string): SftpFileEntry[] {
|
|
288
|
+
const entries: SftpFileEntry[] = [];
|
|
289
|
+
for (const rawLine of stdout.split("\n")) {
|
|
290
|
+
const line = rawLine.replace(/\r$/, "");
|
|
291
|
+
const entry = parseLsLine(line);
|
|
292
|
+
if (entry) {
|
|
293
|
+
entries.push(entry);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return entries;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const STRICT_LS =
|
|
300
|
+
/^([-dlbcps])([-rwxsStT]{9})\s+\d+\s+\S+\s+\S+\s+(\d+)\s+\S+\s+\S+\s+\S+\s+(.+)$/;
|
|
301
|
+
|
|
302
|
+
function parseLsLine(line: string): SftpFileEntry | undefined {
|
|
303
|
+
const trimmed = line.trimEnd();
|
|
304
|
+
if (trimmed.trim().length === 0) {
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
// Skip the interactive echo lines some sftp builds emit in batch mode.
|
|
308
|
+
if (trimmed.startsWith("sftp>") || trimmed.startsWith("Connected to")) {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
const strict = STRICT_LS.exec(trimmed);
|
|
312
|
+
if (strict) {
|
|
313
|
+
const typeChar = strict[1] as string;
|
|
314
|
+
const size = Number.parseInt(strict[3] as string, 10);
|
|
315
|
+
return {
|
|
316
|
+
name: basename((strict[4] as string).trim()),
|
|
317
|
+
type: typeFromPermChar(typeChar),
|
|
318
|
+
...(Number.isNaN(size) ? {} : { size }),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
// Loose fallback: only treat lines that *look* like a long listing (start
|
|
322
|
+
// with a permission/type character followed by rwx flags). Name + type only —
|
|
323
|
+
// no size guess, because the size column position is server-format-dependent
|
|
324
|
+
// and a bad guess (e.g. grabbing a year out of the date) is worse than none.
|
|
325
|
+
if (!/^[-dlbcps][-rwxsStT]{9}\s/.test(trimmed)) {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
const tokens = trimmed.split(/\s+/);
|
|
329
|
+
const name = tokens[tokens.length - 1];
|
|
330
|
+
if (!name) {
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
name: basename(name),
|
|
335
|
+
type: typeFromPermChar(trimmed[0] as string),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function typeFromPermChar(char: string): SftpFileEntry["type"] {
|
|
340
|
+
if (char === "d") {
|
|
341
|
+
return "directory";
|
|
342
|
+
}
|
|
343
|
+
if (char === "-") {
|
|
344
|
+
return "file";
|
|
345
|
+
}
|
|
346
|
+
return "other";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Quote a path for an `sftp` batch command line. RouterOS file names never carry
|
|
351
|
+
* a double-quote or newline; rejecting them keeps the batch un-injectable.
|
|
352
|
+
*/
|
|
353
|
+
export function quote(path: string): string {
|
|
354
|
+
if (/["\n\r]/.test(path)) {
|
|
355
|
+
throw new CentrsError({
|
|
356
|
+
code: "input/invalid-path",
|
|
357
|
+
summary: `Path contains a character SFTP cannot quote: ${path}`,
|
|
358
|
+
remediation:
|
|
359
|
+
"Remove embedded quotes / newlines from the path; RouterOS file names do not use them.",
|
|
360
|
+
context: { path },
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return `"${path}"`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function basename(path: string): string {
|
|
367
|
+
const parts = path.split("/").filter(Boolean);
|
|
368
|
+
return parts[parts.length - 1] ?? path;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Default runner: spawn the host `sftp`, feed the batch on stdin, and capture
|
|
373
|
+
* stdout/stderr with a hard kill timeout. No shell — argv is passed directly, so
|
|
374
|
+
* hostnames / paths from the CDB cannot inject commands.
|
|
375
|
+
*/
|
|
376
|
+
function startSftp(argv: readonly string[]) {
|
|
377
|
+
try {
|
|
378
|
+
return Bun.spawn(argv as string[], {
|
|
379
|
+
stdin: "pipe",
|
|
380
|
+
stdout: "pipe",
|
|
381
|
+
stderr: "pipe",
|
|
382
|
+
});
|
|
383
|
+
} catch (cause) {
|
|
384
|
+
// `sftp` not on PATH (ENOENT) — Bun.spawn throws synchronously. Without this
|
|
385
|
+
// the failure surfaces as `internal/unhandled` (or a raw stack) instead of an
|
|
386
|
+
// actionable transport error.
|
|
387
|
+
throw new CentrsError({
|
|
388
|
+
code: "transport/local-tool-missing",
|
|
389
|
+
summary: `Cannot launch the host \`${argv[0]}\` client for the SFTP transfer.`,
|
|
390
|
+
remediation:
|
|
391
|
+
"Install an OpenSSH client so `sftp` is on PATH (macOS ships it; Debian/Ubuntu: `openssh-client`), or use `--via rest` / `--via native` for files ≤60 KB.",
|
|
392
|
+
context: { binary: argv[0] },
|
|
393
|
+
cause,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function spawnSftpBatch(
|
|
399
|
+
argv: readonly string[],
|
|
400
|
+
batch: string,
|
|
401
|
+
timeoutMs: number,
|
|
402
|
+
): Promise<SftpBatchResult> {
|
|
403
|
+
const proc = startSftp(argv);
|
|
404
|
+
proc.stdin.write(batch);
|
|
405
|
+
await proc.stdin.end();
|
|
406
|
+
|
|
407
|
+
let timedOut = false;
|
|
408
|
+
const timer = setTimeout(() => {
|
|
409
|
+
timedOut = true;
|
|
410
|
+
proc.kill();
|
|
411
|
+
}, timeoutMs);
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
const [stdout, stderr] = await Promise.all([
|
|
415
|
+
new Response(proc.stdout).text(),
|
|
416
|
+
new Response(proc.stderr).text(),
|
|
417
|
+
]);
|
|
418
|
+
const exitCode = await proc.exited;
|
|
419
|
+
return {
|
|
420
|
+
exitCode,
|
|
421
|
+
stdout,
|
|
422
|
+
stderr: timedOut ? `${stderr}\nConnection timed out`.trim() : stderr,
|
|
423
|
+
};
|
|
424
|
+
} finally {
|
|
425
|
+
clearTimeout(timer);
|
|
426
|
+
}
|
|
427
|
+
}
|