@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/transfer.ts
ADDED
|
@@ -0,0 +1,1554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `transfer` — copy files to and from a RouterOS device, and manage device
|
|
3
|
+
* files, over the REST / native-API `/file` menu.
|
|
4
|
+
*
|
|
5
|
+
* Design and grounding live in `commands/transfer/README.md`; the executable
|
|
6
|
+
* spec is `commands/transfer/examples.md`. The load-bearing facts this module
|
|
7
|
+
* encodes:
|
|
8
|
+
*
|
|
9
|
+
* - RouterOS file I/O is **asymmetric**: `/file/set contents` writes are capped
|
|
10
|
+
* at 60 KB, but reads scale via chunked `/file/read` (≤32 KB/chunk). So the
|
|
11
|
+
* default method is size- and direction-aware (see {@link selectTransferMethod}).
|
|
12
|
+
* - Every `/file` operation is command-shaped, so it rides the shared
|
|
13
|
+
* {@link ProtocolAdapter} `execute`/`list` seam over both REST and native-api.
|
|
14
|
+
* Each file is resolved to its `.id` by a `print` probe, which doubles as the
|
|
15
|
+
* validate-before-write existence guard.
|
|
16
|
+
* - `sftp`/`scp`/`fetch`/`ftp` are designed but not built in this pass; an
|
|
17
|
+
* explicit `--via` to one of them returns a defined not-implemented / gated
|
|
18
|
+
* error (examples P1–P4).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import type {
|
|
25
|
+
CentrsEnvelope,
|
|
26
|
+
CentrsErrorEnvelope,
|
|
27
|
+
CentrsSuccessEnvelope,
|
|
28
|
+
SettingSource as CoreSettingSource,
|
|
29
|
+
EnvelopeValidationMeta,
|
|
30
|
+
Warning,
|
|
31
|
+
} from "./core/envelope.ts";
|
|
32
|
+
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
33
|
+
import {
|
|
34
|
+
createProtocolAdapter,
|
|
35
|
+
type ProtocolAdapter,
|
|
36
|
+
type ProtocolExecuteResult,
|
|
37
|
+
} from "./protocols/adapter.ts";
|
|
38
|
+
import type { RouterOsProtocol } from "./protocols/index.ts";
|
|
39
|
+
import { SftpClient } from "./protocols/sftp.ts";
|
|
40
|
+
import {
|
|
41
|
+
type CdbResolution,
|
|
42
|
+
parseDuration,
|
|
43
|
+
type ResolvedAuth,
|
|
44
|
+
type ResolvedSetting,
|
|
45
|
+
type ResolvedTarget,
|
|
46
|
+
resolveAuth,
|
|
47
|
+
resolveBooleanSetting,
|
|
48
|
+
resolveCdb,
|
|
49
|
+
resolveStringSetting,
|
|
50
|
+
resolveTarget,
|
|
51
|
+
toCoreSource,
|
|
52
|
+
} from "./resolver/index.ts";
|
|
53
|
+
import { toYaml } from "./retrieve.ts";
|
|
54
|
+
|
|
55
|
+
export const transferOutputFormats = ["text", "json", "yaml"] as const;
|
|
56
|
+
export type TransferOutputFormat = (typeof transferOutputFormats)[number];
|
|
57
|
+
|
|
58
|
+
/** Direction / management sub-verbs. `list` is sugar over a `/file/print` read. */
|
|
59
|
+
export const transferVerbs = [
|
|
60
|
+
"upload",
|
|
61
|
+
"download",
|
|
62
|
+
"list",
|
|
63
|
+
"remove",
|
|
64
|
+
"mkdir",
|
|
65
|
+
"copy",
|
|
66
|
+
] as const;
|
|
67
|
+
export type TransferVerb = (typeof transferVerbs)[number];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `--via` method names. These are transfer *methods*, finer than the grid
|
|
71
|
+
* transport: `rest`/`native` ride rest-api/native-api; `sftp`/`scp` ride ssh;
|
|
72
|
+
* `fetch` rides rest-api/native-api plus an inbound HTTP server; `ftp` is gated.
|
|
73
|
+
*/
|
|
74
|
+
export const transferMethods = [
|
|
75
|
+
"sftp",
|
|
76
|
+
"scp",
|
|
77
|
+
"rest",
|
|
78
|
+
"native",
|
|
79
|
+
"fetch",
|
|
80
|
+
"ftp",
|
|
81
|
+
] as const;
|
|
82
|
+
export type TransferMethod = (typeof transferMethods)[number];
|
|
83
|
+
|
|
84
|
+
/** RouterOS `/file` contents write cap (`/file/set contents`). */
|
|
85
|
+
export const REST_FILE_WRITE_CAP_BYTES = 60_000;
|
|
86
|
+
/** Maximum `/file/read` chunk RouterOS accepts (`chunk-size` upper bound). */
|
|
87
|
+
export const FILE_READ_CHUNK_BYTES = 32_768;
|
|
88
|
+
|
|
89
|
+
export interface TransferRequest {
|
|
90
|
+
verb: TransferVerb;
|
|
91
|
+
targetInput?: string;
|
|
92
|
+
/** Local path (or `-` for stdin/stdout). Present for upload/download. */
|
|
93
|
+
local?: string;
|
|
94
|
+
/** RouterOS file path. Present for upload/download/remove/mkdir and copy source. */
|
|
95
|
+
remote?: string;
|
|
96
|
+
/** Copy destination (on-device). */
|
|
97
|
+
remoteDest?: string;
|
|
98
|
+
/** `list` path prefix filter (optional). */
|
|
99
|
+
path?: string;
|
|
100
|
+
via?: string;
|
|
101
|
+
host?: string;
|
|
102
|
+
port?: number;
|
|
103
|
+
username?: string;
|
|
104
|
+
password?: string;
|
|
105
|
+
/** SSH private-key path for sftp/scp (`--ssh-key`). Path only. */
|
|
106
|
+
sshKey?: string;
|
|
107
|
+
/** Accept self-signed TLS / new SSH host keys (`--insecure`). Default false. */
|
|
108
|
+
insecure?: boolean;
|
|
109
|
+
timeout?: string | number;
|
|
110
|
+
format?: string;
|
|
111
|
+
validate?: boolean;
|
|
112
|
+
verbose?: boolean;
|
|
113
|
+
/** Replace an existing destination instead of refusing it. */
|
|
114
|
+
force?: boolean;
|
|
115
|
+
/** Integrity check: `size` (default), `checksum`, or `off`. */
|
|
116
|
+
verify?: string;
|
|
117
|
+
/** `list` filter: row type. */
|
|
118
|
+
type?: string;
|
|
119
|
+
/** `list` filter: name glob. */
|
|
120
|
+
name?: string;
|
|
121
|
+
cdbFile?: string;
|
|
122
|
+
cdbPassword?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type TransferOp = TransferVerb;
|
|
126
|
+
|
|
127
|
+
export interface TransferData {
|
|
128
|
+
op: TransferOp;
|
|
129
|
+
remote: string | null;
|
|
130
|
+
local: string | null;
|
|
131
|
+
bytes: number | null;
|
|
132
|
+
verified: "size" | "checksum" | "off" | null;
|
|
133
|
+
/** Concrete method used (`rest`, `native`, …). */
|
|
134
|
+
method: TransferMethod;
|
|
135
|
+
durationMs?: number;
|
|
136
|
+
/** Only on `list`: the `/file` rows. */
|
|
137
|
+
rows?: readonly Record<string, unknown>[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface TransferOperationMeta {
|
|
141
|
+
op: TransferOp;
|
|
142
|
+
method: TransferMethod;
|
|
143
|
+
request: {
|
|
144
|
+
verb: TransferVerb;
|
|
145
|
+
remote: string | null;
|
|
146
|
+
local: string | null;
|
|
147
|
+
force: boolean;
|
|
148
|
+
verify: "size" | "checksum" | "off";
|
|
149
|
+
validate: boolean;
|
|
150
|
+
format: TransferOutputFormat;
|
|
151
|
+
};
|
|
152
|
+
auth: { username?: string; passwordProvided: boolean };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type TransferEnvelope = CentrsEnvelope<
|
|
156
|
+
TransferData | readonly Record<string, unknown>[],
|
|
157
|
+
TransferOperationMeta
|
|
158
|
+
>;
|
|
159
|
+
export type TransferSuccessEnvelope = CentrsSuccessEnvelope<
|
|
160
|
+
TransferData | readonly Record<string, unknown>[],
|
|
161
|
+
TransferOperationMeta
|
|
162
|
+
>;
|
|
163
|
+
export type TransferErrorEnvelope = CentrsErrorEnvelope<TransferOperationMeta>;
|
|
164
|
+
|
|
165
|
+
interface ResolvedTransferRequest {
|
|
166
|
+
verb: TransferVerb;
|
|
167
|
+
method: TransferMethod;
|
|
168
|
+
protocol: RouterOsProtocol;
|
|
169
|
+
target: ResolvedTarget;
|
|
170
|
+
auth: ResolvedAuth;
|
|
171
|
+
timeoutMs: ResolvedSetting<number>;
|
|
172
|
+
format: ResolvedSetting<TransferOutputFormat>;
|
|
173
|
+
validate: ResolvedSetting<boolean>;
|
|
174
|
+
insecure: ResolvedSetting<boolean>;
|
|
175
|
+
via: ResolvedSetting<string>;
|
|
176
|
+
local?: string;
|
|
177
|
+
remote?: string;
|
|
178
|
+
remoteDest?: string;
|
|
179
|
+
listPath?: string;
|
|
180
|
+
force: boolean;
|
|
181
|
+
verify: "size" | "checksum" | "off";
|
|
182
|
+
type?: string;
|
|
183
|
+
name?: string;
|
|
184
|
+
verbose: boolean;
|
|
185
|
+
warnings: readonly Warning[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Public entry ────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
export async function transfer(
|
|
191
|
+
request: TransferRequest,
|
|
192
|
+
env: Record<string, string | undefined> = Bun.env,
|
|
193
|
+
): Promise<TransferSuccessEnvelope> {
|
|
194
|
+
const resolved = await resolveTransferRequest(request, env);
|
|
195
|
+
const backend = createFileBackend(resolved);
|
|
196
|
+
try {
|
|
197
|
+
return await runResolvedTransfer(resolved, backend);
|
|
198
|
+
} finally {
|
|
199
|
+
await backend.close();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build the file backend for the resolved method. `rest`/`native` wrap the
|
|
205
|
+
* shared {@link ProtocolAdapter} `/file` command seam; `sftp` drives the host
|
|
206
|
+
* OpenSSH `sftp` subsystem (the SSH file path — RouterOS has no pseudo-tty, and
|
|
207
|
+
* its SFTP subsystem is the real file protocol, unlike a one-shot `ssh host "cmd"`
|
|
208
|
+
* exec). Both satisfy {@link FileBackend}, so the verb runners are
|
|
209
|
+
* backend-agnostic.
|
|
210
|
+
*/
|
|
211
|
+
export function createFileBackend(
|
|
212
|
+
resolved: ResolvedTransferRequest,
|
|
213
|
+
): FileBackend {
|
|
214
|
+
if (resolved.method === "sftp") {
|
|
215
|
+
return new SftpFileBackend(
|
|
216
|
+
new SftpClient({
|
|
217
|
+
host: resolved.target.host,
|
|
218
|
+
port: resolved.target.port,
|
|
219
|
+
username: resolved.auth.username,
|
|
220
|
+
sshKey: resolved.auth.sshKey,
|
|
221
|
+
insecure: resolved.insecure.value,
|
|
222
|
+
timeoutMs: resolved.timeoutMs.value,
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const adapter = createProtocolAdapter({
|
|
227
|
+
protocol: resolved.protocol,
|
|
228
|
+
host: resolved.target.host,
|
|
229
|
+
port: resolved.target.port,
|
|
230
|
+
tls: resolved.target.tls,
|
|
231
|
+
baseUrl: resolved.target.baseUrl,
|
|
232
|
+
username: resolved.auth.username,
|
|
233
|
+
password: resolved.auth.password,
|
|
234
|
+
timeoutMs: resolved.timeoutMs.value,
|
|
235
|
+
insecure: resolved.insecure.value,
|
|
236
|
+
});
|
|
237
|
+
return new AdapterFileBackend(adapter);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The verb dispatch shared by `transfer()` and (future) fanout. Takes an
|
|
242
|
+
* already-built {@link FileBackend} so tests can drive it with a mocked
|
|
243
|
+
* transport.
|
|
244
|
+
*/
|
|
245
|
+
export async function runResolvedTransfer(
|
|
246
|
+
resolved: ResolvedTransferRequest,
|
|
247
|
+
backend: FileBackend,
|
|
248
|
+
): Promise<TransferSuccessEnvelope> {
|
|
249
|
+
const startedAt = Date.now();
|
|
250
|
+
const warnings: Warning[] = [...resolved.warnings];
|
|
251
|
+
|
|
252
|
+
const result = await dispatchTransferVerb(resolved, backend, warnings);
|
|
253
|
+
const durationMs = Date.now() - startedAt;
|
|
254
|
+
|
|
255
|
+
if (resolved.verb === "list") {
|
|
256
|
+
return buildSuccessEnvelope(
|
|
257
|
+
resolved,
|
|
258
|
+
result.rows ?? [],
|
|
259
|
+
{ ...result.data, durationMs },
|
|
260
|
+
warnings,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return buildSuccessEnvelope(
|
|
265
|
+
resolved,
|
|
266
|
+
{ ...result.data, durationMs },
|
|
267
|
+
{ ...result.data, durationMs },
|
|
268
|
+
warnings,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface VerbResult {
|
|
273
|
+
data: TransferData;
|
|
274
|
+
rows?: readonly Record<string, unknown>[];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function dispatchTransferVerb(
|
|
278
|
+
resolved: ResolvedTransferRequest,
|
|
279
|
+
backend: FileBackend,
|
|
280
|
+
warnings: Warning[],
|
|
281
|
+
): Promise<VerbResult> {
|
|
282
|
+
switch (resolved.verb) {
|
|
283
|
+
case "list":
|
|
284
|
+
return runList(resolved, backend);
|
|
285
|
+
case "upload":
|
|
286
|
+
return runUpload(resolved, backend, warnings);
|
|
287
|
+
case "download":
|
|
288
|
+
return runDownload(resolved, backend, warnings);
|
|
289
|
+
case "remove":
|
|
290
|
+
return runRemove(resolved, backend);
|
|
291
|
+
case "mkdir":
|
|
292
|
+
return runMkdir(resolved, backend);
|
|
293
|
+
case "copy":
|
|
294
|
+
return runCopy(resolved, backend);
|
|
295
|
+
default:
|
|
296
|
+
return exhaustiveVerb(resolved.verb);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Verb implementations (backend-agnostic over FileBackend) ─────────────────
|
|
301
|
+
|
|
302
|
+
async function runList(
|
|
303
|
+
resolved: ResolvedTransferRequest,
|
|
304
|
+
backend: FileBackend,
|
|
305
|
+
): Promise<VerbResult> {
|
|
306
|
+
const rows = await backend.listFiles(resolved.listPath);
|
|
307
|
+
const filtered = rows.filter((row) => matchesListFilters(row, resolved));
|
|
308
|
+
return {
|
|
309
|
+
rows: filtered,
|
|
310
|
+
data: baseData(resolved, { op: "list", remote: null, local: null }),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function runUpload(
|
|
315
|
+
resolved: ResolvedTransferRequest,
|
|
316
|
+
backend: FileBackend,
|
|
317
|
+
warnings: Warning[],
|
|
318
|
+
): Promise<VerbResult> {
|
|
319
|
+
const remote = requireRemote(resolved);
|
|
320
|
+
const bytes = readLocalSource(resolved);
|
|
321
|
+
|
|
322
|
+
// REST/native cannot write past the 60 KB `/file/set contents` cap; fail
|
|
323
|
+
// before any network call. sftp has no such cap (it streams the file).
|
|
324
|
+
if (
|
|
325
|
+
isRestFamily(resolved.method) &&
|
|
326
|
+
bytes.byteLength > REST_FILE_WRITE_CAP_BYTES
|
|
327
|
+
) {
|
|
328
|
+
throw restWriteCapError(remote, bytes.byteLength);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const existing = await backend.findFile(remote);
|
|
332
|
+
if (existing && !resolved.force) {
|
|
333
|
+
throw targetExistsError(remote);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await backend.writeFile(remote, bytes, existing);
|
|
337
|
+
|
|
338
|
+
const verified = await verifyWrittenSize(
|
|
339
|
+
resolved,
|
|
340
|
+
backend,
|
|
341
|
+
remote,
|
|
342
|
+
bytes.byteLength,
|
|
343
|
+
warnings,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
data: baseData(resolved, {
|
|
348
|
+
op: "upload",
|
|
349
|
+
remote,
|
|
350
|
+
local: localLabel(resolved),
|
|
351
|
+
bytes: bytes.byteLength,
|
|
352
|
+
verified,
|
|
353
|
+
}),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function runDownload(
|
|
358
|
+
resolved: ResolvedTransferRequest,
|
|
359
|
+
backend: FileBackend,
|
|
360
|
+
warnings: Warning[],
|
|
361
|
+
): Promise<VerbResult> {
|
|
362
|
+
const remote = requireRemote(resolved);
|
|
363
|
+
const row = await backend.findFile(remote);
|
|
364
|
+
if (!row) {
|
|
365
|
+
throw new CentrsError({
|
|
366
|
+
code: "routeros/command-failed",
|
|
367
|
+
summary: `RouterOS has no file named ${remote}.`,
|
|
368
|
+
remediation:
|
|
369
|
+
"Check the remote path (RouterOS file names have no leading slash); `transfer list` shows what is present.",
|
|
370
|
+
context: { remote },
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const bytes = await backend.readFile(row);
|
|
375
|
+
// Verify the byte count against the device-reported size *before* writing the
|
|
376
|
+
// local sink, so a short read never clobbers a local file or emits partial
|
|
377
|
+
// bytes to stdout.
|
|
378
|
+
const verified = verifyReadSize(
|
|
379
|
+
resolved,
|
|
380
|
+
row.size,
|
|
381
|
+
bytes.byteLength,
|
|
382
|
+
remote,
|
|
383
|
+
warnings,
|
|
384
|
+
);
|
|
385
|
+
writeLocalSink(resolved, bytes);
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
data: baseData(resolved, {
|
|
389
|
+
op: "download",
|
|
390
|
+
remote,
|
|
391
|
+
local: localLabel(resolved),
|
|
392
|
+
bytes: bytes.byteLength,
|
|
393
|
+
verified,
|
|
394
|
+
}),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function runRemove(
|
|
399
|
+
resolved: ResolvedTransferRequest,
|
|
400
|
+
backend: FileBackend,
|
|
401
|
+
): Promise<VerbResult> {
|
|
402
|
+
const remote = requireRemote(resolved);
|
|
403
|
+
const row = await backend.findFile(remote);
|
|
404
|
+
if (!row) {
|
|
405
|
+
throw new CentrsError({
|
|
406
|
+
code: "routeros/command-failed",
|
|
407
|
+
summary: `RouterOS has no file named ${remote}.`,
|
|
408
|
+
remediation: "Nothing to remove; `transfer list` shows what is present.",
|
|
409
|
+
context: { remote },
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
await backend.removeFile(row);
|
|
413
|
+
return {
|
|
414
|
+
data: baseData(resolved, { op: "remove", remote, local: null }),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function runMkdir(
|
|
419
|
+
resolved: ResolvedTransferRequest,
|
|
420
|
+
backend: FileBackend,
|
|
421
|
+
): Promise<VerbResult> {
|
|
422
|
+
const remote = requireRemote(resolved);
|
|
423
|
+
const existing = await backend.findFile(remote);
|
|
424
|
+
if (existing && !resolved.force) {
|
|
425
|
+
throw targetExistsError(remote);
|
|
426
|
+
}
|
|
427
|
+
await backend.makeDir(remote);
|
|
428
|
+
return {
|
|
429
|
+
data: baseData(resolved, { op: "mkdir", remote, local: null }),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function runCopy(
|
|
434
|
+
resolved: ResolvedTransferRequest,
|
|
435
|
+
backend: FileBackend,
|
|
436
|
+
): Promise<VerbResult> {
|
|
437
|
+
const remote = requireRemote(resolved);
|
|
438
|
+
const dest = resolved.remoteDest
|
|
439
|
+
? normalizeRemotePath(resolved.remoteDest)
|
|
440
|
+
: undefined;
|
|
441
|
+
if (!dest) {
|
|
442
|
+
throw new CentrsError({
|
|
443
|
+
code: "usage/conflicting-flags",
|
|
444
|
+
summary: "`transfer copy` requires a source and a destination path.",
|
|
445
|
+
remediation: "Pass both: `transfer <router> copy <src> <dst>`.",
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
const source = await backend.findFile(remote);
|
|
449
|
+
if (!source) {
|
|
450
|
+
throw new CentrsError({
|
|
451
|
+
code: "routeros/command-failed",
|
|
452
|
+
summary: `RouterOS has no file named ${remote}.`,
|
|
453
|
+
remediation: "Check the copy source path; `transfer list` shows files.",
|
|
454
|
+
context: { remote },
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
if (!resolved.force && (await backend.findFile(dest))) {
|
|
458
|
+
throw targetExistsError(dest);
|
|
459
|
+
}
|
|
460
|
+
await backend.copyFile(source, dest);
|
|
461
|
+
return {
|
|
462
|
+
data: baseData(resolved, { op: "copy", remote, local: null }),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ── FileBackend: the seam the verb runners drive ─────────────────────────────
|
|
467
|
+
|
|
468
|
+
interface FileRow {
|
|
469
|
+
id: string;
|
|
470
|
+
name: string;
|
|
471
|
+
type?: string;
|
|
472
|
+
size?: number;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* A backend-agnostic view of a device's file store. The verb runners drive this
|
|
477
|
+
* seam so the orchestration (existence guard, verify, envelope) lives in one
|
|
478
|
+
* place regardless of transport. `rest`/`native` map these to `/file` commands;
|
|
479
|
+
* `sftp` maps them to SFTP-subsystem operations.
|
|
480
|
+
*/
|
|
481
|
+
export interface FileBackend {
|
|
482
|
+
/** List files (optionally scoped to a directory prefix), `/file`-row shaped. */
|
|
483
|
+
listFiles(listPath?: string): Promise<Record<string, unknown>[]>;
|
|
484
|
+
/** Resolve a file by normalized name; `undefined` when absent. */
|
|
485
|
+
findFile(name: string): Promise<FileRow | undefined>;
|
|
486
|
+
/** Write `bytes` to `name`, reusing `existing` when present (overwrite). */
|
|
487
|
+
writeFile(
|
|
488
|
+
name: string,
|
|
489
|
+
bytes: Buffer,
|
|
490
|
+
existing: FileRow | undefined,
|
|
491
|
+
): Promise<void>;
|
|
492
|
+
/** Read a file's bytes. */
|
|
493
|
+
readFile(row: FileRow): Promise<Buffer>;
|
|
494
|
+
removeFile(row: FileRow): Promise<void>;
|
|
495
|
+
makeDir(name: string): Promise<void>;
|
|
496
|
+
copyFile(source: FileRow, dest: string): Promise<void>;
|
|
497
|
+
close(): Promise<void>;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function isRestFamily(method: TransferMethod): boolean {
|
|
501
|
+
return method === "rest" || method === "native";
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** REST / native-api `/file` command seam. The one place those wire shapes live. */
|
|
505
|
+
class AdapterFileBackend implements FileBackend {
|
|
506
|
+
constructor(private readonly adapter: ProtocolAdapter) {}
|
|
507
|
+
|
|
508
|
+
async listFiles(): Promise<Record<string, unknown>[]> {
|
|
509
|
+
return (await this.adapter.list("/file", {
|
|
510
|
+
proplist: [".id", "name", "type", "size", "last-modified"],
|
|
511
|
+
})) as Record<string, unknown>[];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async findFile(name: string): Promise<FileRow | undefined> {
|
|
515
|
+
const rows = (await this.adapter.list("/file", {
|
|
516
|
+
proplist: [".id", "name", "type", "size"],
|
|
517
|
+
})) as Record<string, unknown>[];
|
|
518
|
+
const match = rows.find((row) => readString(row, "name") === name);
|
|
519
|
+
if (!match) {
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
id: readString(match, ".id") ?? "",
|
|
524
|
+
name,
|
|
525
|
+
type: readString(match, "type"),
|
|
526
|
+
size: readNumber(match, "size"),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async writeFile(
|
|
531
|
+
name: string,
|
|
532
|
+
bytes: Buffer,
|
|
533
|
+
existing: FileRow | undefined,
|
|
534
|
+
): Promise<void> {
|
|
535
|
+
const text = bytes.toString("utf8");
|
|
536
|
+
let id = existing?.id;
|
|
537
|
+
if (!id) {
|
|
538
|
+
const added = await this.adapter.execute({
|
|
539
|
+
path: "/file",
|
|
540
|
+
command: "add",
|
|
541
|
+
attributes: { name, type: "file" },
|
|
542
|
+
});
|
|
543
|
+
id = extractNewId(added) ?? (await this.findFile(name))?.id;
|
|
544
|
+
}
|
|
545
|
+
if (!id) {
|
|
546
|
+
throw new CentrsError({
|
|
547
|
+
code: "routeros/command-failed",
|
|
548
|
+
summary: `RouterOS did not create the file ${name}.`,
|
|
549
|
+
remediation:
|
|
550
|
+
"Confirm the path is writable and the user has `ftp`/`write` policy.",
|
|
551
|
+
context: { remote: name },
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
await this.adapter.execute({
|
|
555
|
+
path: "/file",
|
|
556
|
+
command: "set",
|
|
557
|
+
attributes: { ".id": id, contents: text },
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async readFile(row: FileRow): Promise<Buffer> {
|
|
562
|
+
// Small file: `/file/get … contents`. Large: chunked `/file/read`.
|
|
563
|
+
if (row.size !== undefined && row.size <= REST_FILE_WRITE_CAP_BYTES) {
|
|
564
|
+
const result = await this.adapter.execute({
|
|
565
|
+
path: "/file",
|
|
566
|
+
command: "get",
|
|
567
|
+
attributes: { ".id": row.id, "value-name": "contents" },
|
|
568
|
+
});
|
|
569
|
+
return Buffer.from(extractContents(result), "utf8");
|
|
570
|
+
}
|
|
571
|
+
const parts: Buffer[] = [];
|
|
572
|
+
let offset = 0;
|
|
573
|
+
// Bound the loop defensively even if RouterOS never returns a short chunk.
|
|
574
|
+
const max = row.size ?? Number.MAX_SAFE_INTEGER;
|
|
575
|
+
while (offset < max) {
|
|
576
|
+
const result = await this.adapter.execute({
|
|
577
|
+
path: "/file",
|
|
578
|
+
command: "read",
|
|
579
|
+
attributes: {
|
|
580
|
+
file: row.name,
|
|
581
|
+
offset: String(offset),
|
|
582
|
+
"chunk-size": String(FILE_READ_CHUNK_BYTES),
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
const chunk = Buffer.from(extractReadData(result), "utf8");
|
|
586
|
+
if (chunk.byteLength === 0) {
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
parts.push(chunk);
|
|
590
|
+
offset += chunk.byteLength;
|
|
591
|
+
if (chunk.byteLength < FILE_READ_CHUNK_BYTES) {
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return Buffer.concat(parts);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async removeFile(row: FileRow): Promise<void> {
|
|
599
|
+
await this.adapter.execute({
|
|
600
|
+
path: "/file",
|
|
601
|
+
command: "remove",
|
|
602
|
+
attributes: { ".id": row.id },
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async makeDir(name: string): Promise<void> {
|
|
607
|
+
await this.adapter.execute({
|
|
608
|
+
path: "/file",
|
|
609
|
+
command: "add",
|
|
610
|
+
attributes: { name, type: "directory" },
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async copyFile(source: FileRow, dest: string): Promise<void> {
|
|
615
|
+
await this.adapter.execute({
|
|
616
|
+
path: "/file",
|
|
617
|
+
command: "copy",
|
|
618
|
+
attributes: { ".id": source.id, name: dest },
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async close(): Promise<void> {
|
|
623
|
+
await this.adapter.close();
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** SFTP-subsystem seam over the host OpenSSH `sftp` (see `protocols/sftp.ts`). */
|
|
628
|
+
class SftpFileBackend implements FileBackend {
|
|
629
|
+
constructor(private readonly client: SftpClient) {}
|
|
630
|
+
|
|
631
|
+
async listFiles(listPath?: string): Promise<Record<string, unknown>[]> {
|
|
632
|
+
const entries = await this.client.readdir(listPath ?? ".");
|
|
633
|
+
return entries.map((entry) => ({
|
|
634
|
+
// Prefix with the listed directory so the shared `--type`/`--name`/path
|
|
635
|
+
// filters see the same full-path shape the REST family returns.
|
|
636
|
+
name: listPath ? `${listPath}/${entry.name}` : entry.name,
|
|
637
|
+
type: entry.type,
|
|
638
|
+
...(entry.size === undefined ? {} : { size: entry.size }),
|
|
639
|
+
}));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async findFile(name: string): Promise<FileRow | undefined> {
|
|
643
|
+
const entry = await this.client.stat(name);
|
|
644
|
+
if (!entry) {
|
|
645
|
+
return undefined;
|
|
646
|
+
}
|
|
647
|
+
// Existence probe only — no size. SFTP `put`/`get` are protocol-guaranteed
|
|
648
|
+
// complete (a partial transfer errors), so the upload `--verify size` trusts
|
|
649
|
+
// that guarantee rather than re-reading a server-format-dependent `ls -l`
|
|
650
|
+
// size; and `get` streams any size without needing it.
|
|
651
|
+
return { id: "", name, type: entry.type };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async writeFile(name: string, bytes: Buffer): Promise<void> {
|
|
655
|
+
await withTempFile(bytes, (path) => this.client.put(path, name));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async readFile(row: FileRow): Promise<Buffer> {
|
|
659
|
+
return withTempFile(undefined, async (path) => {
|
|
660
|
+
await this.client.get(row.name, path);
|
|
661
|
+
return readFileSync(path);
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async removeFile(row: FileRow): Promise<void> {
|
|
666
|
+
await this.client.remove(row.name);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async makeDir(name: string): Promise<void> {
|
|
670
|
+
await this.client.mkdir(name);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async copyFile(): Promise<void> {
|
|
674
|
+
// RouterOS SFTP exposes no server-side copy; `copy` stays on rest/native.
|
|
675
|
+
throw new CentrsError({
|
|
676
|
+
code: "transport/unsupported-operation",
|
|
677
|
+
summary: "On-device `copy` is not available over sftp.",
|
|
678
|
+
remediation:
|
|
679
|
+
"Use `--via rest` / `--via native` for on-device copy; sftp moves bytes host↔device only.",
|
|
680
|
+
context: { via: "ssh", op: "copy" },
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async close(): Promise<void> {
|
|
685
|
+
// The sftp client is stateless across calls (one batch per op).
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Run `fn` with a private temp-file path. When `seed` is given the file is
|
|
691
|
+
* pre-filled (upload); otherwise `fn` writes it (download). The temp dir is
|
|
692
|
+
* always removed.
|
|
693
|
+
*/
|
|
694
|
+
async function withTempFile<T>(
|
|
695
|
+
seed: Buffer | undefined,
|
|
696
|
+
fn: (path: string) => Promise<T>,
|
|
697
|
+
): Promise<T> {
|
|
698
|
+
const dir = mkdtempSync(join(tmpdir(), "centrs-sftp-"));
|
|
699
|
+
const path = join(dir, "data");
|
|
700
|
+
try {
|
|
701
|
+
if (seed !== undefined) {
|
|
702
|
+
writeFileSync(path, seed);
|
|
703
|
+
}
|
|
704
|
+
return await fn(path);
|
|
705
|
+
} finally {
|
|
706
|
+
rmSync(dir, { recursive: true, force: true });
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function verifyWrittenSize(
|
|
711
|
+
resolved: ResolvedTransferRequest,
|
|
712
|
+
backend: FileBackend,
|
|
713
|
+
remote: string,
|
|
714
|
+
expected: number,
|
|
715
|
+
warnings: Warning[],
|
|
716
|
+
): Promise<"size" | "checksum" | "off"> {
|
|
717
|
+
if (resolved.verify === "off") {
|
|
718
|
+
return "off";
|
|
719
|
+
}
|
|
720
|
+
if (resolved.verify === "checksum") {
|
|
721
|
+
warnings.push({
|
|
722
|
+
code: "transport/checksum-unavailable",
|
|
723
|
+
message:
|
|
724
|
+
"RouterOS exposes no file digest over REST/native; verifying by size instead.",
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
const row = await backend.findFile(remote);
|
|
728
|
+
const actual = row?.size;
|
|
729
|
+
if (actual !== undefined && actual !== expected) {
|
|
730
|
+
throw new CentrsError({
|
|
731
|
+
code: "transport/incomplete-transfer",
|
|
732
|
+
summary: `Uploaded ${remote} settled at ${actual} bytes, expected ${expected}.`,
|
|
733
|
+
remediation:
|
|
734
|
+
"Re-run the upload; if it recurs, the device may be low on storage.",
|
|
735
|
+
context: { remote, expected, actual },
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
return "size";
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Verify a download by byte count. REST/native `/file` rows carry a size, so a
|
|
743
|
+
* short/truncated read fails with `transport/incomplete-transfer`. SFTP rows
|
|
744
|
+
* report no reliable size, so it trusts the `get` guarantee (a partial transfer
|
|
745
|
+
* errors) — mirroring {@link verifyWrittenSize}.
|
|
746
|
+
*/
|
|
747
|
+
function verifyReadSize(
|
|
748
|
+
resolved: ResolvedTransferRequest,
|
|
749
|
+
expected: number | undefined,
|
|
750
|
+
actual: number,
|
|
751
|
+
remote: string,
|
|
752
|
+
warnings: Warning[],
|
|
753
|
+
): "size" | "checksum" | "off" {
|
|
754
|
+
if (resolved.verify === "off") {
|
|
755
|
+
return "off";
|
|
756
|
+
}
|
|
757
|
+
if (resolved.verify === "checksum") {
|
|
758
|
+
warnings.push({
|
|
759
|
+
code: "transport/checksum-unavailable",
|
|
760
|
+
message:
|
|
761
|
+
"RouterOS exposes no file digest over REST/native; verifying by size instead.",
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
if (expected !== undefined && expected !== actual) {
|
|
765
|
+
throw new CentrsError({
|
|
766
|
+
code: "transport/incomplete-transfer",
|
|
767
|
+
summary: `Downloaded ${remote} is ${actual} bytes, expected ${expected}.`,
|
|
768
|
+
remediation:
|
|
769
|
+
"Re-run the download; if it recurs, the remote file or link may be unstable.",
|
|
770
|
+
context: { remote, expected, actual },
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
return "size";
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// ── Resolution ───────────────────────────────────────────────────────────────
|
|
777
|
+
|
|
778
|
+
export async function resolveTransferRequest(
|
|
779
|
+
request: TransferRequest,
|
|
780
|
+
env: Record<string, string | undefined>,
|
|
781
|
+
): Promise<ResolvedTransferRequest> {
|
|
782
|
+
validateTransferRequestShape(request);
|
|
783
|
+
|
|
784
|
+
const direction: TransferDirection =
|
|
785
|
+
request.verb === "upload" ? "upload" : "download";
|
|
786
|
+
const sizeHint = uploadSizeHint(request);
|
|
787
|
+
const { method, protocol, via } = selectTransferMethod(
|
|
788
|
+
request,
|
|
789
|
+
direction,
|
|
790
|
+
sizeHint,
|
|
791
|
+
env,
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
const cdbResolution = await resolveCdb(
|
|
795
|
+
{
|
|
796
|
+
targetInput: request.targetInput,
|
|
797
|
+
cdbFile: request.cdbFile,
|
|
798
|
+
cdbPassword: request.cdbPassword,
|
|
799
|
+
},
|
|
800
|
+
env,
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
const format = resolveTransferFormat(request, env);
|
|
804
|
+
const validate = resolveBooleanSetting(
|
|
805
|
+
request.validate,
|
|
806
|
+
env,
|
|
807
|
+
"CENTRS_VALIDATE",
|
|
808
|
+
true,
|
|
809
|
+
"validate",
|
|
810
|
+
cdbResolution?.overrides.validate,
|
|
811
|
+
);
|
|
812
|
+
const insecure = resolveBooleanSetting(
|
|
813
|
+
request.insecure,
|
|
814
|
+
env,
|
|
815
|
+
"CENTRS_INSECURE",
|
|
816
|
+
false,
|
|
817
|
+
"insecure",
|
|
818
|
+
cdbResolution?.overrides.insecure,
|
|
819
|
+
);
|
|
820
|
+
const timeoutMs = resolveTransferTimeout(
|
|
821
|
+
request.timeout,
|
|
822
|
+
env,
|
|
823
|
+
protocol,
|
|
824
|
+
cdbResolution,
|
|
825
|
+
);
|
|
826
|
+
const target = resolveTarget(
|
|
827
|
+
{
|
|
828
|
+
targetInput: request.targetInput,
|
|
829
|
+
host: request.host,
|
|
830
|
+
port: request.port,
|
|
831
|
+
},
|
|
832
|
+
env,
|
|
833
|
+
protocol,
|
|
834
|
+
cdbResolution,
|
|
835
|
+
);
|
|
836
|
+
const auth = resolveAuth(
|
|
837
|
+
{
|
|
838
|
+
username: request.username,
|
|
839
|
+
password: request.password,
|
|
840
|
+
sshKey: request.sshKey,
|
|
841
|
+
},
|
|
842
|
+
env,
|
|
843
|
+
cdbResolution,
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
const warnings: Warning[] = [
|
|
847
|
+
...((cdbResolution?.warnings ?? []) as readonly Warning[]),
|
|
848
|
+
];
|
|
849
|
+
// Report the auto hop: a large upload cannot ride the REST family's 60 KB
|
|
850
|
+
// `/file/set` cap, so auto-selection moved it to sftp.
|
|
851
|
+
if (via.value === "auto" && method === "sftp") {
|
|
852
|
+
warnings.push({
|
|
853
|
+
code: "transport/auto-method",
|
|
854
|
+
message:
|
|
855
|
+
"Large upload (>60 KB) auto-selected sftp; the REST family cannot write past the 60 KB `/file/set` cap.",
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
// A self-signed-accepting run is an anomaly worth surfacing on every transport.
|
|
859
|
+
if (insecure.value) {
|
|
860
|
+
warnings.push({
|
|
861
|
+
code: "transport/insecure-trust",
|
|
862
|
+
message:
|
|
863
|
+
"`--insecure` is set: TLS peer verification and strict SSH host-key checking are disabled.",
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return {
|
|
868
|
+
verb: request.verb,
|
|
869
|
+
method,
|
|
870
|
+
protocol,
|
|
871
|
+
target,
|
|
872
|
+
auth,
|
|
873
|
+
timeoutMs,
|
|
874
|
+
format,
|
|
875
|
+
validate,
|
|
876
|
+
insecure,
|
|
877
|
+
via,
|
|
878
|
+
local: request.local,
|
|
879
|
+
remote: resolveRemoteTarget(request),
|
|
880
|
+
remoteDest: request.remoteDest,
|
|
881
|
+
listPath: request.path ? normalizeRemotePath(request.path) : undefined,
|
|
882
|
+
force: request.force ?? false,
|
|
883
|
+
verify: parseVerifyMode(request.verify),
|
|
884
|
+
type: request.type,
|
|
885
|
+
name: request.name,
|
|
886
|
+
verbose: request.verbose ?? false,
|
|
887
|
+
warnings,
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
type TransferDirection = "upload" | "download";
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Size/direction-aware method selection plus explicit-`--via` gating.
|
|
895
|
+
* Pure (no I/O) so it is unit-testable. Throws `CentrsError` for the
|
|
896
|
+
* not-implemented / gated methods (examples P1–P4) and the REST write cap.
|
|
897
|
+
*/
|
|
898
|
+
export function selectTransferMethod(
|
|
899
|
+
request: TransferRequest,
|
|
900
|
+
direction: TransferDirection,
|
|
901
|
+
uploadBytes: number | undefined,
|
|
902
|
+
env: Record<string, string | undefined>,
|
|
903
|
+
): {
|
|
904
|
+
method: TransferMethod;
|
|
905
|
+
protocol: RouterOsProtocol;
|
|
906
|
+
via: ResolvedSetting<string>;
|
|
907
|
+
} {
|
|
908
|
+
const via = resolveStringSetting(
|
|
909
|
+
request.via,
|
|
910
|
+
env,
|
|
911
|
+
"CENTRS_TRANSFER_VIA",
|
|
912
|
+
"auto",
|
|
913
|
+
"via",
|
|
914
|
+
) as ResolvedSetting<string>;
|
|
915
|
+
const requested = via.value;
|
|
916
|
+
|
|
917
|
+
if (requested === "auto") {
|
|
918
|
+
// Auto: REST family carries reads and small writes; a large upload exceeds
|
|
919
|
+
// the 60 KB `/file/set` cap, so it auto-selects sftp (reported as an auto
|
|
920
|
+
// hop in meta.warnings by the caller).
|
|
921
|
+
if (
|
|
922
|
+
direction === "upload" &&
|
|
923
|
+
uploadBytes !== undefined &&
|
|
924
|
+
uploadBytes > REST_FILE_WRITE_CAP_BYTES
|
|
925
|
+
) {
|
|
926
|
+
return { method: "sftp", protocol: "ssh", via };
|
|
927
|
+
}
|
|
928
|
+
return { method: "rest", protocol: "rest-api", via };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (requested === "rest" || requested === "rest-api") {
|
|
932
|
+
return { method: "rest", protocol: "rest-api", via };
|
|
933
|
+
}
|
|
934
|
+
if (requested === "native" || requested === "native-api") {
|
|
935
|
+
return { method: "native", protocol: "native-api", via };
|
|
936
|
+
}
|
|
937
|
+
if (requested === "sftp") {
|
|
938
|
+
return { method: "sftp", protocol: "ssh", via };
|
|
939
|
+
}
|
|
940
|
+
if (requested === "scp" || requested === "fetch") {
|
|
941
|
+
throw notImplementedMethod(requested);
|
|
942
|
+
}
|
|
943
|
+
if (requested === "ftp") {
|
|
944
|
+
if ((env["ALLOW_UNSAFE_PROTOCOLS"] ?? "").split(",").includes("ftp")) {
|
|
945
|
+
throw notImplementedMethod("ftp");
|
|
946
|
+
}
|
|
947
|
+
throw new CentrsError({
|
|
948
|
+
code: "settings/unsafe-protocol-blocked",
|
|
949
|
+
summary: "`ftp` is cleartext and is gated behind an explicit opt-in.",
|
|
950
|
+
remediation:
|
|
951
|
+
"Set `ALLOW_UNSAFE_PROTOCOLS=ftp` to permit it, or use the secure default (sftp) or `--via rest`.",
|
|
952
|
+
context: { via: "ftp" },
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
throw new CentrsError({
|
|
957
|
+
code: "settings/invalid-via",
|
|
958
|
+
summary: `Unsupported transfer method: ${requested}`,
|
|
959
|
+
remediation: `Choose one of ${transferMethods.join(", ")} (or omit --via for the size-aware default).`,
|
|
960
|
+
context: { via: requested },
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function notImplementedMethod(method: string, why?: string): CentrsError {
|
|
965
|
+
return new CentrsError({
|
|
966
|
+
code: "usage/not-implemented",
|
|
967
|
+
summary: why
|
|
968
|
+
? `transfer ${why} — \`--via ${method}\` is not implemented yet.`
|
|
969
|
+
: `transfer \`--via ${method}\` is not implemented yet.`,
|
|
970
|
+
remediation:
|
|
971
|
+
"Use `--via sftp` for large transfers, or `--via rest` / `--via native` (≤60 KB writes and all reads); scp/fetch are not built yet.",
|
|
972
|
+
context: { via: method },
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Validate request-shape concerns that do not need a target or the network:
|
|
978
|
+
* required positionals per verb, conflicting verify flags. Mutating ops that
|
|
979
|
+
* refuse-overwrite are checked against the device later (existence probe).
|
|
980
|
+
*/
|
|
981
|
+
export function validateTransferRequestShape(request: TransferRequest): void {
|
|
982
|
+
if (!transferVerbs.includes(request.verb)) {
|
|
983
|
+
throw new CentrsError({
|
|
984
|
+
code: "input/invalid-command",
|
|
985
|
+
summary: `Unknown transfer verb: ${String(request.verb)}`,
|
|
986
|
+
remediation: `Use one of ${transferVerbs.join(", ")}.`,
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (request.verify !== undefined) {
|
|
991
|
+
parseVerifyMode(request.verify); // throws on a bad value
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// `upload` needs a local source; `download` may omit local (defaults to the
|
|
995
|
+
// remote basename). Both need a remote, except `download` infers nothing there.
|
|
996
|
+
if (request.verb === "upload" && !request.local) {
|
|
997
|
+
throw missingArg(
|
|
998
|
+
"upload",
|
|
999
|
+
"a local source path (or `-`)",
|
|
1000
|
+
"<local> [remote]",
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const needsRemote: readonly TransferVerb[] = [
|
|
1005
|
+
"upload",
|
|
1006
|
+
"download",
|
|
1007
|
+
"remove",
|
|
1008
|
+
"mkdir",
|
|
1009
|
+
];
|
|
1010
|
+
if (needsRemote.includes(request.verb) && !request.remote) {
|
|
1011
|
+
// upload/download can take the remote as an optional positional; the
|
|
1012
|
+
// run-time `requireRemote` enforces it once defaults are applied, but a
|
|
1013
|
+
// bare `remove`/`mkdir` with no path is a usage error here.
|
|
1014
|
+
if (request.verb === "remove" || request.verb === "mkdir") {
|
|
1015
|
+
throw missingArg(request.verb, "a remote path", "<remote>");
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (request.verb === "copy" && (!request.remote || !request.remoteDest)) {
|
|
1020
|
+
throw new CentrsError({
|
|
1021
|
+
code: "usage/conflicting-flags",
|
|
1022
|
+
summary: "`transfer copy` requires a source and a destination path.",
|
|
1023
|
+
remediation: "Pass both: `transfer <router> copy <src> <dst>`.",
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function missingArg(verb: string, what: string, shape: string): CentrsError {
|
|
1029
|
+
return new CentrsError({
|
|
1030
|
+
code: "usage/conflicting-flags",
|
|
1031
|
+
summary: `\`transfer ${verb}\` requires ${what}.`,
|
|
1032
|
+
remediation: `Pass it: \`transfer <router> ${verb} ${shape}\`.`,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Normalize a RouterOS file path: accept a leading `/` for ergonomics but strip
|
|
1038
|
+
* it (REST `/file` keys and SFTP paths want the bare name), and collapse any
|
|
1039
|
+
* duplicate slashes. `flash/x` and `/flash/x` resolve to the same target.
|
|
1040
|
+
*/
|
|
1041
|
+
export function normalizeRemotePath(path: string): string {
|
|
1042
|
+
return path.replace(/^\/+/, "").replace(/\/{2,}/g, "/");
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function parseVerifyMode(
|
|
1046
|
+
value: string | undefined,
|
|
1047
|
+
): "size" | "checksum" | "off" {
|
|
1048
|
+
if (value === undefined) {
|
|
1049
|
+
return "size";
|
|
1050
|
+
}
|
|
1051
|
+
if (value === "size" || value === "checksum" || value === "off") {
|
|
1052
|
+
return value;
|
|
1053
|
+
}
|
|
1054
|
+
throw new CentrsError({
|
|
1055
|
+
code: "usage/conflicting-flags",
|
|
1056
|
+
summary: `Unsupported --verify mode: ${value}`,
|
|
1057
|
+
remediation:
|
|
1058
|
+
"Use `--verify size` (default), `--verify checksum`, or `--no-verify`.",
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function resolveTransferFormat(
|
|
1063
|
+
request: TransferRequest,
|
|
1064
|
+
env: Record<string, string | undefined>,
|
|
1065
|
+
): ResolvedSetting<TransferOutputFormat> {
|
|
1066
|
+
return resolveStringSetting(
|
|
1067
|
+
request.format,
|
|
1068
|
+
env,
|
|
1069
|
+
"CENTRS_FORMAT",
|
|
1070
|
+
"text",
|
|
1071
|
+
"format",
|
|
1072
|
+
(value) => {
|
|
1073
|
+
if (transferOutputFormats.includes(value as TransferOutputFormat)) {
|
|
1074
|
+
return value as TransferOutputFormat;
|
|
1075
|
+
}
|
|
1076
|
+
throw new CentrsError({
|
|
1077
|
+
code: "settings/invalid-format",
|
|
1078
|
+
summary: `Unsupported output format: ${value}`,
|
|
1079
|
+
remediation: `Choose one of ${transferOutputFormats.join(", ")}.`,
|
|
1080
|
+
});
|
|
1081
|
+
},
|
|
1082
|
+
) as ResolvedSetting<TransferOutputFormat>;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function resolveTransferTimeout(
|
|
1086
|
+
timeout: TransferRequest["timeout"],
|
|
1087
|
+
env: Record<string, string | undefined>,
|
|
1088
|
+
protocol: RouterOsProtocol,
|
|
1089
|
+
cdb?: CdbResolution,
|
|
1090
|
+
): ResolvedSetting<number> {
|
|
1091
|
+
const resolved = resolveStringSetting(
|
|
1092
|
+
timeout === undefined ? undefined : String(timeout),
|
|
1093
|
+
env,
|
|
1094
|
+
"CENTRS_TIMEOUT",
|
|
1095
|
+
"30000",
|
|
1096
|
+
"timeout",
|
|
1097
|
+
(value) => {
|
|
1098
|
+
const parsed = parseDuration(value);
|
|
1099
|
+
if (parsed <= 0) {
|
|
1100
|
+
throw new CentrsError({
|
|
1101
|
+
code: "settings/invalid-timeout",
|
|
1102
|
+
summary: `Timeout must be greater than zero. Received: ${value}`,
|
|
1103
|
+
remediation: "Use a positive duration like `5s` or `30000`.",
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
return parsed;
|
|
1107
|
+
},
|
|
1108
|
+
cdb?.overrides.timeoutMs,
|
|
1109
|
+
) as ResolvedSetting<number>;
|
|
1110
|
+
|
|
1111
|
+
if (protocol === "rest-api" && resolved.value > 60_000) {
|
|
1112
|
+
throw new CentrsError({
|
|
1113
|
+
code: "usage/timeout-out-of-range",
|
|
1114
|
+
summary: `REST timeout ${resolved.value}ms exceeds the RouterOS REST ceiling.`,
|
|
1115
|
+
remediation: "Use `--timeout 60s` or less, or `--via native` for longer.",
|
|
1116
|
+
context: { via: protocol, timeoutMs: resolved.value, ceilingMs: 60_000 },
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
return resolved;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// ── Local I/O (files / stdin / stdout) ───────────────────────────────────────
|
|
1123
|
+
|
|
1124
|
+
function readLocalSource(resolved: ResolvedTransferRequest): Buffer {
|
|
1125
|
+
const local = resolved.local;
|
|
1126
|
+
if (local === undefined) {
|
|
1127
|
+
throw new CentrsError({
|
|
1128
|
+
code: "usage/conflicting-flags",
|
|
1129
|
+
summary: "upload requires a local source path (or `-`).",
|
|
1130
|
+
remediation: "Pass the local file or `-` for stdin.",
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
if (local === "-") {
|
|
1134
|
+
return readFileSync(0); // stdin
|
|
1135
|
+
}
|
|
1136
|
+
try {
|
|
1137
|
+
return readFileSync(local);
|
|
1138
|
+
} catch (error) {
|
|
1139
|
+
throw new CentrsError({
|
|
1140
|
+
code: "input/local-file-not-found",
|
|
1141
|
+
summary: `Cannot read local source ${local}.`,
|
|
1142
|
+
remediation: "Check the path and read permissions.",
|
|
1143
|
+
context: { local },
|
|
1144
|
+
cause: error,
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function writeLocalSink(
|
|
1150
|
+
resolved: ResolvedTransferRequest,
|
|
1151
|
+
bytes: Buffer,
|
|
1152
|
+
): void {
|
|
1153
|
+
const remote = resolved.remote ?? "";
|
|
1154
|
+
const local = resolved.local ?? basename(remote);
|
|
1155
|
+
if (local === "-") {
|
|
1156
|
+
process.stdout.write(bytes);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
writeFileSync(local, bytes);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function localLabel(resolved: ResolvedTransferRequest): string {
|
|
1163
|
+
if (resolved.verb === "download") {
|
|
1164
|
+
const remote = resolved.remote ?? "";
|
|
1165
|
+
return resolved.local ?? basename(remote);
|
|
1166
|
+
}
|
|
1167
|
+
return resolved.local ?? "-";
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// ── Envelope assembly ────────────────────────────────────────────────────────
|
|
1171
|
+
|
|
1172
|
+
function baseData(
|
|
1173
|
+
resolved: ResolvedTransferRequest,
|
|
1174
|
+
fields: Partial<TransferData> & { op: TransferOp },
|
|
1175
|
+
): TransferData {
|
|
1176
|
+
return {
|
|
1177
|
+
op: fields.op,
|
|
1178
|
+
remote: fields.remote ?? null,
|
|
1179
|
+
local: fields.local ?? null,
|
|
1180
|
+
bytes: fields.bytes ?? null,
|
|
1181
|
+
verified: fields.verified ?? null,
|
|
1182
|
+
method: resolved.method,
|
|
1183
|
+
...(fields.durationMs !== undefined
|
|
1184
|
+
? { durationMs: fields.durationMs }
|
|
1185
|
+
: {}),
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function buildSuccessEnvelope(
|
|
1190
|
+
resolved: ResolvedTransferRequest,
|
|
1191
|
+
data: TransferData | readonly Record<string, unknown>[],
|
|
1192
|
+
operationData: TransferData,
|
|
1193
|
+
warnings: readonly Warning[],
|
|
1194
|
+
): TransferSuccessEnvelope {
|
|
1195
|
+
return {
|
|
1196
|
+
ok: true,
|
|
1197
|
+
data,
|
|
1198
|
+
warnings,
|
|
1199
|
+
tips: [],
|
|
1200
|
+
meta: metaFromResolved(resolved, operationData, {
|
|
1201
|
+
enabled: resolved.validate.value,
|
|
1202
|
+
source: resolved.validate.value
|
|
1203
|
+
? "device /file existence probe"
|
|
1204
|
+
: "disabled",
|
|
1205
|
+
result: resolved.validate.value ? "passed" : "skipped",
|
|
1206
|
+
}),
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function metaFromResolved(
|
|
1211
|
+
resolved: ResolvedTransferRequest,
|
|
1212
|
+
operationData: TransferData,
|
|
1213
|
+
validation: EnvelopeValidationMeta,
|
|
1214
|
+
): TransferEnvelope["meta"] {
|
|
1215
|
+
const target = resolved.target;
|
|
1216
|
+
const targetSources: Record<string, CoreSettingSource> = {};
|
|
1217
|
+
for (const [field, source] of Object.entries(target.sources)) {
|
|
1218
|
+
targetSources[field] = toCoreSource(source);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
return {
|
|
1222
|
+
target: {
|
|
1223
|
+
input: target.input,
|
|
1224
|
+
host: target.host,
|
|
1225
|
+
port: target.port,
|
|
1226
|
+
baseUrl: target.baseUrl,
|
|
1227
|
+
identity: target.identity,
|
|
1228
|
+
recordIndex: target.recordIndex,
|
|
1229
|
+
source: toCoreSource(target.source),
|
|
1230
|
+
sources: targetSources,
|
|
1231
|
+
},
|
|
1232
|
+
via: resolved.protocol,
|
|
1233
|
+
settings: {
|
|
1234
|
+
via: toCoreSource(resolved.via.source),
|
|
1235
|
+
host: toCoreSource(target.hostSource),
|
|
1236
|
+
port: toCoreSource(target.portSource),
|
|
1237
|
+
timeoutMs: toCoreSource(resolved.timeoutMs.source),
|
|
1238
|
+
format: toCoreSource(resolved.format.source),
|
|
1239
|
+
validate: toCoreSource(resolved.validate.source),
|
|
1240
|
+
insecure: toCoreSource(resolved.insecure.source),
|
|
1241
|
+
username: resolved.auth.usernameSource
|
|
1242
|
+
? toCoreSource(resolved.auth.usernameSource)
|
|
1243
|
+
: undefined,
|
|
1244
|
+
password: resolved.auth.passwordSource
|
|
1245
|
+
? toCoreSource(resolved.auth.passwordSource)
|
|
1246
|
+
: undefined,
|
|
1247
|
+
sshKey: resolved.auth.sshKeySource
|
|
1248
|
+
? toCoreSource(resolved.auth.sshKeySource)
|
|
1249
|
+
: undefined,
|
|
1250
|
+
},
|
|
1251
|
+
validation,
|
|
1252
|
+
operation: {
|
|
1253
|
+
op: operationData.op,
|
|
1254
|
+
method: resolved.method,
|
|
1255
|
+
request: {
|
|
1256
|
+
verb: resolved.verb,
|
|
1257
|
+
remote: resolved.remote ?? null,
|
|
1258
|
+
local: resolved.local ?? null,
|
|
1259
|
+
force: resolved.force,
|
|
1260
|
+
verify: resolved.verify,
|
|
1261
|
+
validate: resolved.validate.value,
|
|
1262
|
+
format: resolved.format.value,
|
|
1263
|
+
},
|
|
1264
|
+
auth: {
|
|
1265
|
+
username: resolved.auth.username,
|
|
1266
|
+
passwordProvided: resolved.auth.passwordProvided,
|
|
1267
|
+
},
|
|
1268
|
+
},
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
export function buildTransferErrorEnvelope(
|
|
1273
|
+
request: TransferRequest,
|
|
1274
|
+
error: unknown,
|
|
1275
|
+
): TransferErrorEnvelope {
|
|
1276
|
+
const centrsError =
|
|
1277
|
+
error instanceof CentrsError
|
|
1278
|
+
? error
|
|
1279
|
+
: new CentrsError({
|
|
1280
|
+
code: "internal/unhandled",
|
|
1281
|
+
summary: "transfer failed with an unexpected internal error.",
|
|
1282
|
+
remediation:
|
|
1283
|
+
"Re-run with `--format json` to capture the structured error details.",
|
|
1284
|
+
cause: error,
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
return {
|
|
1288
|
+
ok: false,
|
|
1289
|
+
error: serializeCentrsError(centrsError),
|
|
1290
|
+
warnings: [],
|
|
1291
|
+
tips: [],
|
|
1292
|
+
meta: {
|
|
1293
|
+
target: { input: request.targetInput },
|
|
1294
|
+
via: null,
|
|
1295
|
+
settings: {},
|
|
1296
|
+
},
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// ── Rendering ────────────────────────────────────────────────────────────────
|
|
1301
|
+
|
|
1302
|
+
export function renderTransferEnvelope(
|
|
1303
|
+
envelope: TransferEnvelope,
|
|
1304
|
+
format: TransferOutputFormat,
|
|
1305
|
+
options: { verbose?: boolean } = {},
|
|
1306
|
+
): string {
|
|
1307
|
+
switch (format) {
|
|
1308
|
+
case "json":
|
|
1309
|
+
return JSON.stringify(envelope, null, 2);
|
|
1310
|
+
case "yaml":
|
|
1311
|
+
return toYaml(envelope);
|
|
1312
|
+
case "text":
|
|
1313
|
+
return envelope.ok
|
|
1314
|
+
? renderSuccessText(envelope, options)
|
|
1315
|
+
: renderErrorText(envelope);
|
|
1316
|
+
default:
|
|
1317
|
+
return exhaustiveFormat(format);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function renderSuccessText(
|
|
1322
|
+
envelope: TransferSuccessEnvelope,
|
|
1323
|
+
options: { verbose?: boolean },
|
|
1324
|
+
): string {
|
|
1325
|
+
const lines: string[] = [];
|
|
1326
|
+
const operation = envelope.meta.operation;
|
|
1327
|
+
|
|
1328
|
+
if (Array.isArray(envelope.data)) {
|
|
1329
|
+
// list
|
|
1330
|
+
const rows = envelope.data as readonly Record<string, unknown>[];
|
|
1331
|
+
if (rows.length === 0) {
|
|
1332
|
+
lines.push("(no files)");
|
|
1333
|
+
}
|
|
1334
|
+
for (const row of rows) {
|
|
1335
|
+
const name = String(row["name"] ?? "");
|
|
1336
|
+
const type = String(row["type"] ?? "");
|
|
1337
|
+
const size =
|
|
1338
|
+
row["size"] === undefined ? "" : humanBytes(Number(row["size"]));
|
|
1339
|
+
lines.push(`${name}\t${type}\t${size}`.trimEnd());
|
|
1340
|
+
}
|
|
1341
|
+
} else {
|
|
1342
|
+
const data = envelope.data as TransferData;
|
|
1343
|
+
const parts = [`${data.op} ok`, `method=${data.method}`];
|
|
1344
|
+
if (data.remote) parts.push(`remote=${data.remote}`);
|
|
1345
|
+
if (data.local) parts.push(`local=${data.local}`);
|
|
1346
|
+
if (data.bytes !== null) parts.push(`bytes=${data.bytes}`);
|
|
1347
|
+
if (data.verified && data.verified !== "off")
|
|
1348
|
+
parts.push(`verified=${data.verified}`);
|
|
1349
|
+
lines.push(parts.join(" "));
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
if (options.verbose && operation) {
|
|
1353
|
+
lines.push("");
|
|
1354
|
+
lines.push(`via: ${envelope.meta.via}`);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if (envelope.warnings.length > 0) {
|
|
1358
|
+
lines.push("");
|
|
1359
|
+
for (const warning of envelope.warnings) {
|
|
1360
|
+
lines.push(`warning [${warning.code}]: ${warning.message}`);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
return lines.join("\n");
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function renderErrorText(envelope: TransferErrorEnvelope): string {
|
|
1368
|
+
const { error } = envelope;
|
|
1369
|
+
const lines = [`[${error.code}] ${error.summary}`];
|
|
1370
|
+
if (error.remediation) {
|
|
1371
|
+
lines.push(`Fix: ${error.remediation}`);
|
|
1372
|
+
}
|
|
1373
|
+
return lines.join("\n");
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// ── Small helpers ────────────────────────────────────────────────────────────
|
|
1377
|
+
|
|
1378
|
+
function uploadSizeHint(request: TransferRequest): number | undefined {
|
|
1379
|
+
if (request.verb !== "upload" || !request.local || request.local === "-") {
|
|
1380
|
+
return undefined;
|
|
1381
|
+
}
|
|
1382
|
+
// Size from stat only — `readLocalSource` reads the bytes later, so reading
|
|
1383
|
+
// the whole file here just to count would double the I/O. A missing file
|
|
1384
|
+
// reports `0`; method selection treats that like an unknown hint (→ rest).
|
|
1385
|
+
const size = Bun.file(request.local).size;
|
|
1386
|
+
return size > 0 ? size : undefined;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function matchesListFilters(
|
|
1390
|
+
row: Record<string, unknown>,
|
|
1391
|
+
resolved: ResolvedTransferRequest,
|
|
1392
|
+
): boolean {
|
|
1393
|
+
const name = readString(row, "name") ?? "";
|
|
1394
|
+
if (resolved.listPath && !name.startsWith(resolved.listPath)) {
|
|
1395
|
+
return false;
|
|
1396
|
+
}
|
|
1397
|
+
if (resolved.type) {
|
|
1398
|
+
const type = readString(row, "type") ?? "";
|
|
1399
|
+
// RouterOS reports file types as e.g. ".txt file" / "directory" / "disk".
|
|
1400
|
+
const wanted = resolved.type;
|
|
1401
|
+
const isMatch =
|
|
1402
|
+
wanted === "file"
|
|
1403
|
+
? type.includes("file")
|
|
1404
|
+
: type === wanted || type.startsWith(wanted);
|
|
1405
|
+
if (!isMatch) {
|
|
1406
|
+
return false;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
if (resolved.name && !globMatch(resolved.name, name)) {
|
|
1410
|
+
return false;
|
|
1411
|
+
}
|
|
1412
|
+
return true;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function globMatch(glob: string, value: string): boolean {
|
|
1416
|
+
const pattern = glob
|
|
1417
|
+
.split("*")
|
|
1418
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
1419
|
+
.join(".*");
|
|
1420
|
+
return new RegExp(`^${pattern}$`).test(value);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* Resolve the effective remote path. `upload` marks `[remote]` optional in the
|
|
1425
|
+
* CLI/docs: when it is omitted and the source is a real file (not stdin), it
|
|
1426
|
+
* defaults to that file's basename. Stdin uploads (`local === "-"`) can't derive
|
|
1427
|
+
* a name and fall through to {@link requireRemote}'s actionable error.
|
|
1428
|
+
*/
|
|
1429
|
+
function resolveRemoteTarget(request: TransferRequest): string | undefined {
|
|
1430
|
+
if (request.remote) {
|
|
1431
|
+
return normalizeRemotePath(request.remote);
|
|
1432
|
+
}
|
|
1433
|
+
if (request.verb === "upload" && request.local && request.local !== "-") {
|
|
1434
|
+
return normalizeRemotePath(basename(request.local));
|
|
1435
|
+
}
|
|
1436
|
+
return undefined;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function requireRemote(resolved: ResolvedTransferRequest): string {
|
|
1440
|
+
if (!resolved.remote) {
|
|
1441
|
+
throw new CentrsError({
|
|
1442
|
+
code: "usage/conflicting-flags",
|
|
1443
|
+
summary: `\`transfer ${resolved.verb}\` requires a remote path.`,
|
|
1444
|
+
remediation: "Pass the RouterOS file path.",
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
return resolved.remote;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function targetExistsError(remote: string): CentrsError {
|
|
1451
|
+
return new CentrsError({
|
|
1452
|
+
code: "usage/target-exists",
|
|
1453
|
+
summary: `${remote} already exists on the device.`,
|
|
1454
|
+
remediation: "Pass `--force` to overwrite, or choose a different name.",
|
|
1455
|
+
context: { remote },
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function restWriteCapError(remote: string, bytes: number): CentrsError {
|
|
1460
|
+
return new CentrsError({
|
|
1461
|
+
code: "transport/unsupported-operation",
|
|
1462
|
+
summary: `Cannot write ${bytes} bytes to ${remote} over REST/native (60 KB cap).`,
|
|
1463
|
+
remediation:
|
|
1464
|
+
"Large uploads need sftp (lands with the SSH transport); the REST `/file` write cap is 60 KB.",
|
|
1465
|
+
context: { remote, bytes, capBytes: REST_FILE_WRITE_CAP_BYTES },
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function extractNewId(result: ProtocolExecuteResult): string | undefined {
|
|
1470
|
+
if (typeof result.ret === "string" && result.ret.length > 0) {
|
|
1471
|
+
return result.ret;
|
|
1472
|
+
}
|
|
1473
|
+
const first = result.records[0];
|
|
1474
|
+
return first
|
|
1475
|
+
? (readString(first, "ret") ?? readString(first, ".id"))
|
|
1476
|
+
: undefined;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function extractContents(result: ProtocolExecuteResult): string {
|
|
1480
|
+
if (typeof result.ret === "string") {
|
|
1481
|
+
return result.ret;
|
|
1482
|
+
}
|
|
1483
|
+
const first = result.records[0];
|
|
1484
|
+
return (
|
|
1485
|
+
(first && (readString(first, "contents") ?? readString(first, "ret"))) ?? ""
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function extractReadData(result: ProtocolExecuteResult): string {
|
|
1490
|
+
if (typeof result.ret === "string") {
|
|
1491
|
+
return result.ret;
|
|
1492
|
+
}
|
|
1493
|
+
const first = result.records[0];
|
|
1494
|
+
return (
|
|
1495
|
+
(first && (readString(first, "data") ?? readString(first, "ret"))) ?? ""
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function readString(
|
|
1500
|
+
record: Record<string, unknown>,
|
|
1501
|
+
key: string,
|
|
1502
|
+
): string | undefined {
|
|
1503
|
+
const value = record[key];
|
|
1504
|
+
return typeof value === "string" ? value : undefined;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function readNumber(
|
|
1508
|
+
record: Record<string, unknown>,
|
|
1509
|
+
key: string,
|
|
1510
|
+
): number | undefined {
|
|
1511
|
+
const value = record[key];
|
|
1512
|
+
if (typeof value === "number") {
|
|
1513
|
+
return value;
|
|
1514
|
+
}
|
|
1515
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
1516
|
+
const parsed = Number(value);
|
|
1517
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
1518
|
+
}
|
|
1519
|
+
return undefined;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function basename(path: string): string {
|
|
1523
|
+
// Split on both separators: this helper derives a name from RouterOS remote
|
|
1524
|
+
// paths (always `/`) *and* from a host-local path (which uses `\` on Windows).
|
|
1525
|
+
// A `\`-split is harmless for remote paths since RouterOS filenames never
|
|
1526
|
+
// contain a backslash. (Without this, an omitted upload remote on Windows
|
|
1527
|
+
// defaulted to the full `C:\…\file` path instead of `file`.)
|
|
1528
|
+
const parts = path.split(/[/\\]/).filter(Boolean);
|
|
1529
|
+
return parts[parts.length - 1] ?? path;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function humanBytes(bytes: number): string {
|
|
1533
|
+
if (!Number.isFinite(bytes)) {
|
|
1534
|
+
return "";
|
|
1535
|
+
}
|
|
1536
|
+
const units = ["B", "KiB", "MiB", "GiB"];
|
|
1537
|
+
let value = bytes;
|
|
1538
|
+
let unit = 0;
|
|
1539
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
1540
|
+
value /= 1024;
|
|
1541
|
+
unit += 1;
|
|
1542
|
+
}
|
|
1543
|
+
return unit === 0
|
|
1544
|
+
? `${value}${units[unit]}`
|
|
1545
|
+
: `${value.toFixed(1)}${units[unit]}`;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function exhaustiveVerb(verb: never): never {
|
|
1549
|
+
throw new Error(`Unhandled transfer verb: ${String(verb)}`);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function exhaustiveFormat(format: never): never {
|
|
1553
|
+
throw new Error(`Unhandled transfer output format: ${String(format)}`);
|
|
1554
|
+
}
|