@runneth/cli 0.0.0-sha.3142666bf4dc.production
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/README.md +144 -0
- package/dist/build-defaults.d.ts +1 -0
- package/dist/build-defaults.js +21 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1112 -0
- package/dist/copy.d.ts +25 -0
- package/dist/copy.js +492 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.js +547 -0
- package/dist/oauth.d.ts +80 -0
- package/dist/oauth.js +592 -0
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +25 -0
- package/dist/skills.d.ts +15 -0
- package/dist/skills.js +89 -0
- package/dist/ssh-stdio.d.ts +63 -0
- package/dist/ssh-stdio.js +608 -0
- package/dist/ssh.d.ts +129 -0
- package/dist/ssh.js +835 -0
- package/package.json +38 -0
- package/skills/runneth/SKILL.md +177 -0
package/dist/ssh.js
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { getOAuthAccessToken } from "./oauth.js";
|
|
8
|
+
import { resolveRunnethCliHome } from "./paths.js";
|
|
9
|
+
const DEFAULT_SSH_APP_PATH = "/runneth/ssh";
|
|
10
|
+
const KEY_COMMENT_PREFIX = "runneth-cli";
|
|
11
|
+
const SSH_TARGET_NAME_PATTERN = /^[A-Za-z0-9._-]{1,64}$/u;
|
|
12
|
+
const SSH_TARGET_STORE_VERSION = 1;
|
|
13
|
+
const SSH_TARGETS_FILE_NAME = "targets.json";
|
|
14
|
+
const SSH_TUNNEL_API_BASE = "/api/tunnels";
|
|
15
|
+
const SSH_PUBLIC_KEY_PATTERN = /^(ssh-ed25519) ([A-Za-z0-9+/]+={0,3})(?: ([^\r\n]{1,256}))?$/u;
|
|
16
|
+
const isRecord = (value) => {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
};
|
|
19
|
+
const isErrnoCode = (error, code) => {
|
|
20
|
+
return (typeof error === "object" &&
|
|
21
|
+
error !== null &&
|
|
22
|
+
"code" in error &&
|
|
23
|
+
error.code === code);
|
|
24
|
+
};
|
|
25
|
+
const normalizeResourceUrl = (value) => {
|
|
26
|
+
const url = new URL(value);
|
|
27
|
+
url.hash = "";
|
|
28
|
+
url.search = "";
|
|
29
|
+
if (url.pathname !== "/") {
|
|
30
|
+
url.pathname = url.pathname.replace(/\/+$/u, "");
|
|
31
|
+
}
|
|
32
|
+
return url.toString();
|
|
33
|
+
};
|
|
34
|
+
const normalizeHttpUrl = (value, label) => {
|
|
35
|
+
const url = new URL(value);
|
|
36
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
37
|
+
throw new Error(`${label} must use http or https`);
|
|
38
|
+
}
|
|
39
|
+
url.hash = "";
|
|
40
|
+
url.search = "";
|
|
41
|
+
if (url.pathname !== "/") {
|
|
42
|
+
url.pathname = url.pathname.replace(/\/+$/u, "");
|
|
43
|
+
}
|
|
44
|
+
return url.toString();
|
|
45
|
+
};
|
|
46
|
+
export const normalizeRunnethSshTargetName = (value) => {
|
|
47
|
+
const name = value.trim();
|
|
48
|
+
if (!SSH_TARGET_NAME_PATTERN.test(name)) {
|
|
49
|
+
throw new Error(`Invalid SSH target name: ${name}. Use letters, numbers, dot, underscore, and dash only.`);
|
|
50
|
+
}
|
|
51
|
+
return name;
|
|
52
|
+
};
|
|
53
|
+
export const resolveRunnethSshAppUrl = (input) => {
|
|
54
|
+
if (input.sshUrl !== undefined) {
|
|
55
|
+
return normalizeHttpUrl(input.sshUrl, "SSH app URL");
|
|
56
|
+
}
|
|
57
|
+
const resource = new URL(input.resourceUrl);
|
|
58
|
+
return normalizeHttpUrl(`${resource.origin}${DEFAULT_SSH_APP_PATH}`, "SSH app URL");
|
|
59
|
+
};
|
|
60
|
+
const buildSshAppApiUrl = (input) => {
|
|
61
|
+
const url = new URL(input.sshUrl);
|
|
62
|
+
url.pathname = `${url.pathname.replace(/\/+$/u, "")}${input.pathSuffix}`;
|
|
63
|
+
url.search = "";
|
|
64
|
+
url.hash = "";
|
|
65
|
+
return url.toString();
|
|
66
|
+
};
|
|
67
|
+
export const resolveRunnethSshTargetsPath = (input) => {
|
|
68
|
+
const homePath = input.homePath ?? resolveRunnethCliHome();
|
|
69
|
+
return path.join(homePath, "ssh", SSH_TARGETS_FILE_NAME);
|
|
70
|
+
};
|
|
71
|
+
export const resolveRunnethSshTargetPaths = (input) => {
|
|
72
|
+
const homePath = input.homePath ?? resolveRunnethCliHome();
|
|
73
|
+
const sshUrl = resolveRunnethSshAppUrl(input);
|
|
74
|
+
const digest = createHash("sha256")
|
|
75
|
+
.update(`${normalizeResourceUrl(input.resourceUrl)}\n${sshUrl}`)
|
|
76
|
+
.digest("hex")
|
|
77
|
+
.slice(0, 32);
|
|
78
|
+
const targetDirectory = path.join(homePath, "ssh", digest);
|
|
79
|
+
return {
|
|
80
|
+
configPath: path.join(targetDirectory, "ssh_config"),
|
|
81
|
+
hostAlias: `runneth-${digest.slice(0, 12)}`,
|
|
82
|
+
knownHostsPath: path.join(targetDirectory, "known_hosts"),
|
|
83
|
+
privateKeyPath: path.join(targetDirectory, "id_ed25519"),
|
|
84
|
+
publicKeyPath: path.join(targetDirectory, "id_ed25519.pub"),
|
|
85
|
+
targetDirectory,
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
export const resolveRunnethSshSharedKeyPaths = (input) => {
|
|
89
|
+
const keyDirectory = path.join(input.homePath ?? resolveRunnethCliHome(), "ssh");
|
|
90
|
+
return {
|
|
91
|
+
keyDirectory,
|
|
92
|
+
keyMode: "shared",
|
|
93
|
+
privateKeyPath: path.join(keyDirectory, "id_ed25519"),
|
|
94
|
+
publicKeyPath: path.join(keyDirectory, "id_ed25519.pub"),
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
const resolveRunnethSshUniqueKeyPaths = (paths) => {
|
|
98
|
+
return {
|
|
99
|
+
keyDirectory: paths.targetDirectory,
|
|
100
|
+
keyMode: "unique",
|
|
101
|
+
privateKeyPath: paths.privateKeyPath,
|
|
102
|
+
publicKeyPath: paths.publicKeyPath,
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
const resolveUserFilePath = (value) => {
|
|
106
|
+
const trimmed = value.trim();
|
|
107
|
+
if (trimmed.length === 0) {
|
|
108
|
+
throw new Error("Identity file path must be a non-empty string");
|
|
109
|
+
}
|
|
110
|
+
if (trimmed === "~") {
|
|
111
|
+
return os.homedir();
|
|
112
|
+
}
|
|
113
|
+
if (trimmed.startsWith("~/")) {
|
|
114
|
+
return path.join(os.homedir(), trimmed.slice(2));
|
|
115
|
+
}
|
|
116
|
+
return path.resolve(trimmed);
|
|
117
|
+
};
|
|
118
|
+
const resolveRunnethSshCustomKeyPaths = (input) => {
|
|
119
|
+
const privateKeyPath = resolveUserFilePath(input.identityFilePath);
|
|
120
|
+
return {
|
|
121
|
+
keyDirectory: path.dirname(privateKeyPath),
|
|
122
|
+
keyMode: "custom",
|
|
123
|
+
privateKeyPath,
|
|
124
|
+
publicKeyPath: `${privateKeyPath}.pub`,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
const resolveRunnethSshKeyPaths = (input) => {
|
|
128
|
+
if (input.identityFilePath !== undefined && input.keyMode !== undefined) {
|
|
129
|
+
throw new Error("Use either --identity-file or --unique-key, not both");
|
|
130
|
+
}
|
|
131
|
+
if (input.identityFilePath !== undefined) {
|
|
132
|
+
return resolveRunnethSshCustomKeyPaths({
|
|
133
|
+
identityFilePath: input.identityFilePath,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (input.keyMode === "unique") {
|
|
137
|
+
return resolveRunnethSshUniqueKeyPaths(input.targetPaths);
|
|
138
|
+
}
|
|
139
|
+
return resolveRunnethSshSharedKeyPaths({ homePath: input.homePath });
|
|
140
|
+
};
|
|
141
|
+
const pathExists = async (filePath) => {
|
|
142
|
+
try {
|
|
143
|
+
await access(filePath);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (isErrnoCode(error, "ENOENT")) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const runProcess = async (command, args) => {
|
|
154
|
+
await new Promise((resolve, reject) => {
|
|
155
|
+
const child = spawn(command, [...args], {
|
|
156
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
157
|
+
});
|
|
158
|
+
let stderr = "";
|
|
159
|
+
child.stderr.on("data", (chunk) => {
|
|
160
|
+
stderr += String(chunk);
|
|
161
|
+
});
|
|
162
|
+
child.once("error", reject);
|
|
163
|
+
child.once("close", (code, signal) => {
|
|
164
|
+
if (code === 0) {
|
|
165
|
+
resolve();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const exitDescription = signal === null ? `exit code ${String(code)}` : `signal ${signal}`;
|
|
169
|
+
reject(new Error(`${command} failed with ${exitDescription}${stderr.trim().length > 0 ? `: ${stderr.trim()}` : ""}`));
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
const resolveGeneratedSshKeyComment = (keyMode, targetPaths, targetName) => {
|
|
174
|
+
if (keyMode === "shared") {
|
|
175
|
+
return `${KEY_COMMENT_PREFIX}:shared`;
|
|
176
|
+
}
|
|
177
|
+
const targetSegment = targetName === undefined
|
|
178
|
+
? path.basename(targetPaths.targetDirectory)
|
|
179
|
+
: `${targetName}:${path.basename(targetPaths.targetDirectory)}`;
|
|
180
|
+
return `${KEY_COMMENT_PREFIX}:${targetSegment}:${targetPaths.hostAlias}`;
|
|
181
|
+
};
|
|
182
|
+
const parseSshPublicKey = (publicKey, publicKeyPath) => {
|
|
183
|
+
const match = SSH_PUBLIC_KEY_PATTERN.exec(publicKey);
|
|
184
|
+
if (match === null || match[1] !== "ssh-ed25519" || match[2] === undefined) {
|
|
185
|
+
throw new Error(`SSH public key must be ssh-ed25519: ${publicKeyPath}`);
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
keyType: "ssh-ed25519",
|
|
189
|
+
keyMaterial: match[2],
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
const formatSshPublicKey = (parts, comment) => {
|
|
193
|
+
return `${parts.keyType} ${parts.keyMaterial} ${comment}`;
|
|
194
|
+
};
|
|
195
|
+
const normalizePublicKeyComment = async (keyPaths, comment) => {
|
|
196
|
+
const publicKey = (await readFile(keyPaths.publicKeyPath, "utf8")).trim();
|
|
197
|
+
const normalizedPublicKey = formatSshPublicKey(parseSshPublicKey(publicKey, keyPaths.publicKeyPath), comment);
|
|
198
|
+
if (publicKey === normalizedPublicKey) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
await writeFile(keyPaths.publicKeyPath, `${normalizedPublicKey}\n`, {
|
|
202
|
+
encoding: "utf8",
|
|
203
|
+
mode: 0o644,
|
|
204
|
+
});
|
|
205
|
+
if (process.platform !== "win32") {
|
|
206
|
+
await chmod(keyPaths.publicKeyPath, 0o644);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const ensureGeneratedSshKeyPair = async (keyPaths, comment) => {
|
|
210
|
+
await mkdir(keyPaths.keyDirectory, { mode: 0o700, recursive: true });
|
|
211
|
+
if (process.platform !== "win32") {
|
|
212
|
+
await chmod(keyPaths.keyDirectory, 0o700);
|
|
213
|
+
}
|
|
214
|
+
const privateKeyExists = await pathExists(keyPaths.privateKeyPath);
|
|
215
|
+
const publicKeyExists = await pathExists(keyPaths.publicKeyPath);
|
|
216
|
+
if (privateKeyExists && publicKeyExists) {
|
|
217
|
+
await normalizePublicKeyComment(keyPaths, comment);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (privateKeyExists || publicKeyExists) {
|
|
221
|
+
throw new Error(`SSH keypair is incomplete. Expected both ${keyPaths.privateKeyPath} and ${keyPaths.publicKeyPath}`);
|
|
222
|
+
}
|
|
223
|
+
await runProcess("ssh-keygen", [
|
|
224
|
+
"-t",
|
|
225
|
+
"ed25519",
|
|
226
|
+
"-f",
|
|
227
|
+
keyPaths.privateKeyPath,
|
|
228
|
+
"-N",
|
|
229
|
+
"",
|
|
230
|
+
"-C",
|
|
231
|
+
comment,
|
|
232
|
+
]);
|
|
233
|
+
if (process.platform !== "win32") {
|
|
234
|
+
await chmod(keyPaths.privateKeyPath, 0o600);
|
|
235
|
+
await chmod(keyPaths.publicKeyPath, 0o644);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const ensureCustomSshKeyPair = async (keyPaths) => {
|
|
239
|
+
const privateKeyExists = await pathExists(keyPaths.privateKeyPath);
|
|
240
|
+
const publicKeyExists = await pathExists(keyPaths.publicKeyPath);
|
|
241
|
+
if (!privateKeyExists || !publicKeyExists) {
|
|
242
|
+
throw new Error(`Identity file requires both ${keyPaths.privateKeyPath} and ${keyPaths.publicKeyPath}`);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const ensureSshKeyPair = async (input) => {
|
|
246
|
+
if (input.keyPaths.keyMode === "custom") {
|
|
247
|
+
await ensureCustomSshKeyPair(input.keyPaths);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
await ensureGeneratedSshKeyPair(input.keyPaths, resolveGeneratedSshKeyComment(input.keyPaths.keyMode, input.targetPaths, input.targetName));
|
|
251
|
+
};
|
|
252
|
+
const readPublicKey = async (publicKeyPath) => {
|
|
253
|
+
const publicKey = (await readFile(publicKeyPath, "utf8")).trim();
|
|
254
|
+
parseSshPublicKey(publicKey, publicKeyPath);
|
|
255
|
+
return publicKey;
|
|
256
|
+
};
|
|
257
|
+
const parseStringField = (record, key) => {
|
|
258
|
+
const value = record[key];
|
|
259
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
260
|
+
throw new Error(`${key} must be a non-empty string`);
|
|
261
|
+
}
|
|
262
|
+
return value;
|
|
263
|
+
};
|
|
264
|
+
const parseOptionalStringField = (record, key) => {
|
|
265
|
+
const value = record[key];
|
|
266
|
+
if (value === undefined) {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
270
|
+
throw new Error(`${key} must be a non-empty string`);
|
|
271
|
+
}
|
|
272
|
+
return value;
|
|
273
|
+
};
|
|
274
|
+
const emptyRunnethSshTargetStore = () => {
|
|
275
|
+
return {
|
|
276
|
+
targets: [],
|
|
277
|
+
version: SSH_TARGET_STORE_VERSION,
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
const parseRunnethSshTarget = (payload) => {
|
|
281
|
+
if (!isRecord(payload)) {
|
|
282
|
+
throw new Error("SSH target entry must be a JSON object");
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
createdAt: parseStringField(payload, "createdAt"),
|
|
286
|
+
name: normalizeRunnethSshTargetName(parseStringField(payload, "name")),
|
|
287
|
+
resourceUrl: normalizeResourceUrl(parseStringField(payload, "resourceUrl")),
|
|
288
|
+
sshUrl: normalizeHttpUrl(parseStringField(payload, "sshUrl"), "SSH app URL"),
|
|
289
|
+
updatedAt: parseStringField(payload, "updatedAt"),
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
const parseRunnethSshTargetStore = (payload) => {
|
|
293
|
+
if (!isRecord(payload)) {
|
|
294
|
+
throw new Error("SSH target store must be a JSON object");
|
|
295
|
+
}
|
|
296
|
+
if (payload.version !== SSH_TARGET_STORE_VERSION) {
|
|
297
|
+
throw new Error("Unsupported SSH target store version");
|
|
298
|
+
}
|
|
299
|
+
if (!Array.isArray(payload.targets)) {
|
|
300
|
+
throw new Error("SSH target store targets must be an array");
|
|
301
|
+
}
|
|
302
|
+
const targets = payload.targets.map(parseRunnethSshTarget);
|
|
303
|
+
const names = new Set();
|
|
304
|
+
for (const target of targets) {
|
|
305
|
+
if (names.has(target.name)) {
|
|
306
|
+
throw new Error(`Duplicate SSH target name in store: ${target.name}`);
|
|
307
|
+
}
|
|
308
|
+
names.add(target.name);
|
|
309
|
+
}
|
|
310
|
+
const rawDefaultTarget = parseOptionalStringField(payload, "defaultTarget");
|
|
311
|
+
const defaultTarget = rawDefaultTarget === undefined
|
|
312
|
+
? undefined
|
|
313
|
+
: normalizeRunnethSshTargetName(rawDefaultTarget);
|
|
314
|
+
if (defaultTarget !== undefined && !names.has(defaultTarget)) {
|
|
315
|
+
throw new Error(`Default SSH target does not exist: ${defaultTarget}`);
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
...(defaultTarget === undefined ? {} : { defaultTarget }),
|
|
319
|
+
targets,
|
|
320
|
+
version: SSH_TARGET_STORE_VERSION,
|
|
321
|
+
};
|
|
322
|
+
};
|
|
323
|
+
export const readRunnethSshTargetStore = async (input) => {
|
|
324
|
+
const storePath = resolveRunnethSshTargetsPath(input);
|
|
325
|
+
try {
|
|
326
|
+
const payload = JSON.parse(await readFile(storePath, "utf8"));
|
|
327
|
+
return parseRunnethSshTargetStore(payload);
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
if (isErrnoCode(error, "ENOENT")) {
|
|
331
|
+
return emptyRunnethSshTargetStore();
|
|
332
|
+
}
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
const writeRunnethSshTargetStore = async (input) => {
|
|
337
|
+
const storePath = resolveRunnethSshTargetsPath(input);
|
|
338
|
+
await mkdir(path.dirname(storePath), { mode: 0o700, recursive: true });
|
|
339
|
+
if (process.platform !== "win32") {
|
|
340
|
+
await chmod(path.dirname(storePath), 0o700);
|
|
341
|
+
}
|
|
342
|
+
await writeFile(storePath, `${JSON.stringify(input.store, null, 2)}\n`, {
|
|
343
|
+
encoding: "utf8",
|
|
344
|
+
mode: 0o600,
|
|
345
|
+
});
|
|
346
|
+
if (process.platform !== "win32") {
|
|
347
|
+
await chmod(storePath, 0o600);
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
export const saveRunnethSshTarget = async (input) => {
|
|
351
|
+
const store = await readRunnethSshTargetStore(input);
|
|
352
|
+
const name = normalizeRunnethSshTargetName(input.name);
|
|
353
|
+
const now = new Date().toISOString();
|
|
354
|
+
const resourceUrl = normalizeResourceUrl(input.resourceUrl);
|
|
355
|
+
const sshUrl = resolveRunnethSshAppUrl({
|
|
356
|
+
resourceUrl,
|
|
357
|
+
...(input.sshUrl === undefined ? {} : { sshUrl: input.sshUrl }),
|
|
358
|
+
});
|
|
359
|
+
const existing = store.targets.find((target) => target.name === name);
|
|
360
|
+
const target = {
|
|
361
|
+
createdAt: existing?.createdAt ?? now,
|
|
362
|
+
name,
|
|
363
|
+
resourceUrl,
|
|
364
|
+
sshUrl,
|
|
365
|
+
updatedAt: now,
|
|
366
|
+
};
|
|
367
|
+
const targets = existing === undefined
|
|
368
|
+
? [...store.targets, target]
|
|
369
|
+
: store.targets.map((item) => (item.name === name ? target : item));
|
|
370
|
+
const defaultTarget = input.makeDefault === true || store.defaultTarget === undefined
|
|
371
|
+
? name
|
|
372
|
+
: store.defaultTarget;
|
|
373
|
+
const updatedStore = {
|
|
374
|
+
defaultTarget,
|
|
375
|
+
targets,
|
|
376
|
+
version: SSH_TARGET_STORE_VERSION,
|
|
377
|
+
};
|
|
378
|
+
await writeRunnethSshTargetStore({
|
|
379
|
+
homePath: input.homePath,
|
|
380
|
+
store: updatedStore,
|
|
381
|
+
});
|
|
382
|
+
return {
|
|
383
|
+
store: updatedStore,
|
|
384
|
+
target,
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
export const setDefaultRunnethSshTarget = async (input) => {
|
|
388
|
+
const store = await readRunnethSshTargetStore(input);
|
|
389
|
+
const name = normalizeRunnethSshTargetName(input.name);
|
|
390
|
+
if (!store.targets.some((target) => target.name === name)) {
|
|
391
|
+
throw new Error(`Unknown SSH target: ${name}`);
|
|
392
|
+
}
|
|
393
|
+
const updatedStore = {
|
|
394
|
+
defaultTarget: name,
|
|
395
|
+
targets: store.targets,
|
|
396
|
+
version: SSH_TARGET_STORE_VERSION,
|
|
397
|
+
};
|
|
398
|
+
await writeRunnethSshTargetStore({
|
|
399
|
+
homePath: input.homePath,
|
|
400
|
+
store: updatedStore,
|
|
401
|
+
});
|
|
402
|
+
return updatedStore;
|
|
403
|
+
};
|
|
404
|
+
export const removeRunnethSshTarget = async (input) => {
|
|
405
|
+
const store = await readRunnethSshTargetStore(input);
|
|
406
|
+
const name = normalizeRunnethSshTargetName(input.name);
|
|
407
|
+
if (!store.targets.some((target) => target.name === name)) {
|
|
408
|
+
throw new Error(`Unknown SSH target: ${name}`);
|
|
409
|
+
}
|
|
410
|
+
const defaultTarget = store.defaultTarget === name ? undefined : store.defaultTarget;
|
|
411
|
+
const updatedStore = {
|
|
412
|
+
...(defaultTarget === undefined ? {} : { defaultTarget }),
|
|
413
|
+
targets: store.targets.filter((target) => target.name !== name),
|
|
414
|
+
version: SSH_TARGET_STORE_VERSION,
|
|
415
|
+
};
|
|
416
|
+
await writeRunnethSshTargetStore({
|
|
417
|
+
homePath: input.homePath,
|
|
418
|
+
store: updatedStore,
|
|
419
|
+
});
|
|
420
|
+
return updatedStore;
|
|
421
|
+
};
|
|
422
|
+
export const resolveRunnethSshTarget = async (input) => {
|
|
423
|
+
if (input.targetName !== undefined) {
|
|
424
|
+
if (input.resourceUrl !== undefined || input.sshUrl !== undefined) {
|
|
425
|
+
throw new Error("Use either --target or explicit --resource/--ssh-url, not both");
|
|
426
|
+
}
|
|
427
|
+
const store = await readRunnethSshTargetStore(input);
|
|
428
|
+
const targetName = normalizeRunnethSshTargetName(input.targetName);
|
|
429
|
+
const target = store.targets.find((item) => item.name === targetName);
|
|
430
|
+
if (target === undefined) {
|
|
431
|
+
throw new Error(`Unknown SSH target: ${targetName}`);
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
resourceUrl: target.resourceUrl,
|
|
435
|
+
sshUrl: target.sshUrl,
|
|
436
|
+
targetName,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (input.resourceUrl !== undefined) {
|
|
440
|
+
const resourceUrl = normalizeResourceUrl(input.resourceUrl);
|
|
441
|
+
return {
|
|
442
|
+
resourceUrl,
|
|
443
|
+
sshUrl: resolveRunnethSshAppUrl({
|
|
444
|
+
resourceUrl,
|
|
445
|
+
...(input.sshUrl === undefined ? {} : { sshUrl: input.sshUrl }),
|
|
446
|
+
}),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
if (input.sshUrl !== undefined) {
|
|
450
|
+
throw new Error("runneth-cli ssh requires --resource when --ssh-url is set");
|
|
451
|
+
}
|
|
452
|
+
const store = await readRunnethSshTargetStore(input);
|
|
453
|
+
if (store.defaultTarget === undefined) {
|
|
454
|
+
throw new Error("No default SSH target configured. Run: runneth-cli ssh target add <name> --resource <url> --ssh-url <url> --default");
|
|
455
|
+
}
|
|
456
|
+
const target = store.targets.find((item) => item.name === store.defaultTarget);
|
|
457
|
+
if (target === undefined) {
|
|
458
|
+
throw new Error(`Default SSH target does not exist: ${store.defaultTarget}`);
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
resourceUrl: target.resourceUrl,
|
|
462
|
+
sshUrl: target.sshUrl,
|
|
463
|
+
targetName: target.name,
|
|
464
|
+
};
|
|
465
|
+
};
|
|
466
|
+
const parseSshAppMetadata = (payload) => {
|
|
467
|
+
if (!isRecord(payload)) {
|
|
468
|
+
throw new Error("SSH app response must be a JSON object");
|
|
469
|
+
}
|
|
470
|
+
if (payload.ok !== true) {
|
|
471
|
+
throw new Error("SSH app response did not report ok: true");
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
authorizedKeysPath: parseStringField(payload, "authorizedKeysPath"),
|
|
475
|
+
connectRoute: parseStringField(payload, "connectRoute"),
|
|
476
|
+
ok: true,
|
|
477
|
+
sshUser: parseStringField(payload, "sshUser"),
|
|
478
|
+
};
|
|
479
|
+
};
|
|
480
|
+
const parseSshInstallResponse = (payload) => {
|
|
481
|
+
const metadata = parseSshAppMetadata(payload);
|
|
482
|
+
if (!isRecord(payload) || typeof payload.installed !== "boolean") {
|
|
483
|
+
throw new Error("SSH key install response must include installed");
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
...metadata,
|
|
487
|
+
installed: payload.installed,
|
|
488
|
+
};
|
|
489
|
+
};
|
|
490
|
+
const fetchJson = async (url, init, label) => {
|
|
491
|
+
const response = await fetch(url, init);
|
|
492
|
+
if (!response.ok) {
|
|
493
|
+
const body = await response.text();
|
|
494
|
+
throw new Error(`${label} failed with HTTP ${String(response.status)}: ${body}`);
|
|
495
|
+
}
|
|
496
|
+
const payload = await response.json();
|
|
497
|
+
return payload;
|
|
498
|
+
};
|
|
499
|
+
const resolveBearerAuthorizationHeader = (token) => {
|
|
500
|
+
if (token.tokenType.toLowerCase() !== "bearer") {
|
|
501
|
+
throw new Error(`Unsupported OAuth token type for SSH: ${token.tokenType}`);
|
|
502
|
+
}
|
|
503
|
+
return `Bearer ${token.accessToken}`;
|
|
504
|
+
};
|
|
505
|
+
const installSshPublicKey = async (input) => {
|
|
506
|
+
return parseSshInstallResponse(await fetchJson(buildSshAppApiUrl({ pathSuffix: "/api/keys", sshUrl: input.sshUrl }), {
|
|
507
|
+
body: JSON.stringify({
|
|
508
|
+
publicKey: input.publicKey,
|
|
509
|
+
}),
|
|
510
|
+
headers: {
|
|
511
|
+
Accept: "application/json",
|
|
512
|
+
Authorization: input.authorizationHeader,
|
|
513
|
+
"Content-Type": "application/json",
|
|
514
|
+
},
|
|
515
|
+
method: "POST",
|
|
516
|
+
}, "SSH public key install request"));
|
|
517
|
+
};
|
|
518
|
+
const quoteSshConfigValue = (value) => {
|
|
519
|
+
return `"${value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"')}"`;
|
|
520
|
+
};
|
|
521
|
+
const writeSshConfig = async (input) => {
|
|
522
|
+
const proxyCommand = [
|
|
523
|
+
quoteSshConfigValue(process.execPath),
|
|
524
|
+
quoteSshConfigValue(input.cliPath),
|
|
525
|
+
"ssh",
|
|
526
|
+
"proxy",
|
|
527
|
+
...(input.targetName === undefined
|
|
528
|
+
? [
|
|
529
|
+
"--resource",
|
|
530
|
+
quoteSshConfigValue(input.resourceUrl),
|
|
531
|
+
"--ssh-url",
|
|
532
|
+
quoteSshConfigValue(input.sshUrl),
|
|
533
|
+
]
|
|
534
|
+
: ["--target", quoteSshConfigValue(input.targetName)]),
|
|
535
|
+
].join(" ");
|
|
536
|
+
await mkdir(input.paths.targetDirectory, { mode: 0o700, recursive: true });
|
|
537
|
+
if (process.platform !== "win32") {
|
|
538
|
+
await chmod(input.paths.targetDirectory, 0o700);
|
|
539
|
+
}
|
|
540
|
+
await writeFile(input.paths.configPath, [
|
|
541
|
+
`Host ${input.paths.hostAlias}`,
|
|
542
|
+
` HostName ${input.paths.hostAlias}`,
|
|
543
|
+
` User ${input.metadata.sshUser}`,
|
|
544
|
+
` IdentityFile ${quoteSshConfigValue(input.keyPaths.privateKeyPath)}`,
|
|
545
|
+
" IdentitiesOnly yes",
|
|
546
|
+
" StrictHostKeyChecking accept-new",
|
|
547
|
+
` UserKnownHostsFile ${quoteSshConfigValue(input.paths.knownHostsPath)}`,
|
|
548
|
+
` ProxyCommand ${proxyCommand}`,
|
|
549
|
+
"",
|
|
550
|
+
].join("\n"), {
|
|
551
|
+
encoding: "utf8",
|
|
552
|
+
mode: 0o600,
|
|
553
|
+
});
|
|
554
|
+
if (process.platform !== "win32") {
|
|
555
|
+
await chmod(input.paths.configPath, 0o600);
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
export const installRunnethSshAccess = async (options) => {
|
|
559
|
+
const sshUrl = resolveRunnethSshAppUrl(options);
|
|
560
|
+
const paths = resolveRunnethSshTargetPaths({
|
|
561
|
+
homePath: options.homePath,
|
|
562
|
+
resourceUrl: options.resourceUrl,
|
|
563
|
+
sshUrl,
|
|
564
|
+
});
|
|
565
|
+
const keyPaths = resolveRunnethSshKeyPaths({
|
|
566
|
+
homePath: options.homePath,
|
|
567
|
+
...(options.identityFilePath === undefined
|
|
568
|
+
? {}
|
|
569
|
+
: { identityFilePath: options.identityFilePath }),
|
|
570
|
+
...(options.keyMode === undefined ? {} : { keyMode: options.keyMode }),
|
|
571
|
+
targetPaths: paths,
|
|
572
|
+
});
|
|
573
|
+
await ensureSshKeyPair({
|
|
574
|
+
keyPaths,
|
|
575
|
+
targetName: options.targetName,
|
|
576
|
+
targetPaths: paths,
|
|
577
|
+
});
|
|
578
|
+
const authorizationHeader = resolveBearerAuthorizationHeader(options.oauthToken);
|
|
579
|
+
const publicKey = await readPublicKey(keyPaths.publicKeyPath);
|
|
580
|
+
const installResponse = await installSshPublicKey({
|
|
581
|
+
authorizationHeader,
|
|
582
|
+
publicKey,
|
|
583
|
+
sshUrl,
|
|
584
|
+
});
|
|
585
|
+
await writeSshConfig({
|
|
586
|
+
cliPath: options.cliPath,
|
|
587
|
+
keyPaths,
|
|
588
|
+
metadata: installResponse,
|
|
589
|
+
paths,
|
|
590
|
+
resourceUrl: options.resourceUrl,
|
|
591
|
+
sshUrl,
|
|
592
|
+
...(options.targetName === undefined
|
|
593
|
+
? {}
|
|
594
|
+
: { targetName: options.targetName }),
|
|
595
|
+
});
|
|
596
|
+
return {
|
|
597
|
+
authorizedKeysPath: installResponse.authorizedKeysPath,
|
|
598
|
+
configPath: paths.configPath,
|
|
599
|
+
connectRoute: installResponse.connectRoute,
|
|
600
|
+
hostAlias: paths.hostAlias,
|
|
601
|
+
installed: installResponse.installed,
|
|
602
|
+
keyMode: keyPaths.keyMode,
|
|
603
|
+
knownHostsPath: paths.knownHostsPath,
|
|
604
|
+
privateKeyPath: keyPaths.privateKeyPath,
|
|
605
|
+
publicKeyPath: keyPaths.publicKeyPath,
|
|
606
|
+
sshUrl,
|
|
607
|
+
sshUser: installResponse.sshUser,
|
|
608
|
+
...(options.targetName === undefined
|
|
609
|
+
? {}
|
|
610
|
+
: { targetName: options.targetName }),
|
|
611
|
+
};
|
|
612
|
+
};
|
|
613
|
+
const defaultSshProxyStreams = () => {
|
|
614
|
+
return {
|
|
615
|
+
stderr: process.stderr,
|
|
616
|
+
stdin: process.stdin,
|
|
617
|
+
stdout: process.stdout,
|
|
618
|
+
};
|
|
619
|
+
};
|
|
620
|
+
const buildSshTunnelApiUrl = (input) => {
|
|
621
|
+
return buildSshAppApiUrl({
|
|
622
|
+
pathSuffix: `${SSH_TUNNEL_API_BASE}${input.pathSuffix}`,
|
|
623
|
+
sshUrl: input.sshUrl,
|
|
624
|
+
});
|
|
625
|
+
};
|
|
626
|
+
const parseSshTunnelCreateResponse = (payload) => {
|
|
627
|
+
if (!isRecord(payload) || payload.ok !== true) {
|
|
628
|
+
throw new Error("SSH tunnel create response was invalid");
|
|
629
|
+
}
|
|
630
|
+
const tunnelId = parseStringField(payload, "tunnelId");
|
|
631
|
+
return {
|
|
632
|
+
ok: true,
|
|
633
|
+
tunnelId,
|
|
634
|
+
};
|
|
635
|
+
};
|
|
636
|
+
const parseSshTunnelOutputResponse = (payload) => {
|
|
637
|
+
if (!isRecord(payload) ||
|
|
638
|
+
payload.ok !== true ||
|
|
639
|
+
typeof payload.closed !== "boolean" ||
|
|
640
|
+
typeof payload.data !== "string") {
|
|
641
|
+
throw new Error("SSH tunnel output response was invalid");
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
closed: payload.closed,
|
|
645
|
+
data: Buffer.from(payload.data, "base64"),
|
|
646
|
+
ok: true,
|
|
647
|
+
};
|
|
648
|
+
};
|
|
649
|
+
const createSshHttpTunnel = async (input) => {
|
|
650
|
+
return parseSshTunnelCreateResponse(await fetchJson(buildSshTunnelApiUrl({
|
|
651
|
+
pathSuffix: "",
|
|
652
|
+
sshUrl: input.sshUrl,
|
|
653
|
+
}), {
|
|
654
|
+
headers: {
|
|
655
|
+
Accept: "application/json",
|
|
656
|
+
Authorization: input.authorizationHeader,
|
|
657
|
+
},
|
|
658
|
+
method: "POST",
|
|
659
|
+
}, "SSH tunnel create request"));
|
|
660
|
+
};
|
|
661
|
+
const postSshHttpTunnelInput = async (input) => {
|
|
662
|
+
const url = new URL(buildSshTunnelApiUrl({
|
|
663
|
+
pathSuffix: `/${input.tunnelId}/input`,
|
|
664
|
+
sshUrl: input.sshUrl,
|
|
665
|
+
}));
|
|
666
|
+
if (input.close) {
|
|
667
|
+
url.searchParams.set("close", "1");
|
|
668
|
+
}
|
|
669
|
+
await fetchJson(url.toString(), {
|
|
670
|
+
body: new Blob([new Uint8Array(input.payload)]),
|
|
671
|
+
headers: {
|
|
672
|
+
Accept: "application/json",
|
|
673
|
+
Authorization: input.authorizationHeader,
|
|
674
|
+
"Content-Type": "application/octet-stream",
|
|
675
|
+
},
|
|
676
|
+
method: "POST",
|
|
677
|
+
signal: input.signal,
|
|
678
|
+
}, "SSH tunnel input request");
|
|
679
|
+
};
|
|
680
|
+
const pumpSshHttpTunnelInput = async (input) => {
|
|
681
|
+
for await (const chunk of input.stdin) {
|
|
682
|
+
const payload = typeof chunk === "string"
|
|
683
|
+
? Buffer.from(chunk, "utf8")
|
|
684
|
+
: chunk instanceof Uint8Array
|
|
685
|
+
? Buffer.from(chunk)
|
|
686
|
+
: null;
|
|
687
|
+
if (payload === null) {
|
|
688
|
+
throw new Error("SSH tunnel stdin must emit bytes");
|
|
689
|
+
}
|
|
690
|
+
await postSshHttpTunnelInput({
|
|
691
|
+
authorizationHeader: input.authorizationHeader,
|
|
692
|
+
close: false,
|
|
693
|
+
payload,
|
|
694
|
+
signal: input.signal,
|
|
695
|
+
sshUrl: input.sshUrl,
|
|
696
|
+
tunnelId: input.tunnelId,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
await postSshHttpTunnelInput({
|
|
700
|
+
authorizationHeader: input.authorizationHeader,
|
|
701
|
+
close: true,
|
|
702
|
+
payload: Buffer.alloc(0),
|
|
703
|
+
signal: input.signal,
|
|
704
|
+
sshUrl: input.sshUrl,
|
|
705
|
+
tunnelId: input.tunnelId,
|
|
706
|
+
});
|
|
707
|
+
};
|
|
708
|
+
const readSshHttpTunnelOutput = async (input) => {
|
|
709
|
+
return parseSshTunnelOutputResponse(await fetchJson(buildSshTunnelApiUrl({
|
|
710
|
+
pathSuffix: `/${input.tunnelId}/output`,
|
|
711
|
+
sshUrl: input.sshUrl,
|
|
712
|
+
}), {
|
|
713
|
+
headers: {
|
|
714
|
+
Accept: "application/json",
|
|
715
|
+
Authorization: input.authorizationHeader,
|
|
716
|
+
},
|
|
717
|
+
method: "GET",
|
|
718
|
+
signal: input.signal,
|
|
719
|
+
}, "SSH tunnel output request"));
|
|
720
|
+
};
|
|
721
|
+
const deleteSshHttpTunnel = async (input) => {
|
|
722
|
+
await fetchJson(buildSshTunnelApiUrl({
|
|
723
|
+
pathSuffix: `/${input.tunnelId}`,
|
|
724
|
+
sshUrl: input.sshUrl,
|
|
725
|
+
}), {
|
|
726
|
+
headers: {
|
|
727
|
+
Accept: "application/json",
|
|
728
|
+
Authorization: input.authorizationHeader,
|
|
729
|
+
},
|
|
730
|
+
method: "DELETE",
|
|
731
|
+
}, "SSH tunnel delete request");
|
|
732
|
+
};
|
|
733
|
+
const writeStdout = async (stream, chunk) => {
|
|
734
|
+
if (stream.write(chunk)) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
await new Promise((resolve) => {
|
|
738
|
+
stream.once("drain", () => {
|
|
739
|
+
resolve();
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
};
|
|
743
|
+
const destroyReadableStream = (stream) => {
|
|
744
|
+
if (stream.destroyed) {
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
stream.destroy();
|
|
748
|
+
};
|
|
749
|
+
const openSshHttpTunnel = async (input) => {
|
|
750
|
+
const tunnel = await createSshHttpTunnel({
|
|
751
|
+
authorizationHeader: input.authorizationHeader,
|
|
752
|
+
sshUrl: input.sshUrl,
|
|
753
|
+
});
|
|
754
|
+
const abortController = new AbortController();
|
|
755
|
+
let inputError = null;
|
|
756
|
+
let outputClosed = false;
|
|
757
|
+
const inputPump = pumpSshHttpTunnelInput({
|
|
758
|
+
authorizationHeader: input.authorizationHeader,
|
|
759
|
+
signal: abortController.signal,
|
|
760
|
+
sshUrl: input.sshUrl,
|
|
761
|
+
stdin: input.streams.stdin,
|
|
762
|
+
tunnelId: tunnel.tunnelId,
|
|
763
|
+
}).catch((error) => {
|
|
764
|
+
inputError = error;
|
|
765
|
+
abortController.abort();
|
|
766
|
+
});
|
|
767
|
+
try {
|
|
768
|
+
while (!outputClosed) {
|
|
769
|
+
if (inputError !== null) {
|
|
770
|
+
throw inputError;
|
|
771
|
+
}
|
|
772
|
+
let output;
|
|
773
|
+
try {
|
|
774
|
+
output = await readSshHttpTunnelOutput({
|
|
775
|
+
authorizationHeader: input.authorizationHeader,
|
|
776
|
+
signal: abortController.signal,
|
|
777
|
+
sshUrl: input.sshUrl,
|
|
778
|
+
tunnelId: tunnel.tunnelId,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
catch (error) {
|
|
782
|
+
if (inputError !== null) {
|
|
783
|
+
throw inputError;
|
|
784
|
+
}
|
|
785
|
+
throw error;
|
|
786
|
+
}
|
|
787
|
+
if (output.data.length > 0) {
|
|
788
|
+
await writeStdout(input.streams.stdout, output.data);
|
|
789
|
+
}
|
|
790
|
+
if (output.closed) {
|
|
791
|
+
outputClosed = true;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
finally {
|
|
796
|
+
outputClosed = true;
|
|
797
|
+
abortController.abort();
|
|
798
|
+
destroyReadableStream(input.streams.stdin);
|
|
799
|
+
await inputPump;
|
|
800
|
+
await deleteSshHttpTunnel({
|
|
801
|
+
authorizationHeader: input.authorizationHeader,
|
|
802
|
+
sshUrl: input.sshUrl,
|
|
803
|
+
tunnelId: tunnel.tunnelId,
|
|
804
|
+
}).catch(() => { });
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
export const runRunnethSshProxy = async (options) => {
|
|
808
|
+
const sshUrl = resolveRunnethSshAppUrl(options);
|
|
809
|
+
const oauthToken = await getOAuthAccessToken({
|
|
810
|
+
homePath: options.homePath,
|
|
811
|
+
resourceUrl: options.resourceUrl,
|
|
812
|
+
});
|
|
813
|
+
const authorizationHeader = resolveBearerAuthorizationHeader(oauthToken);
|
|
814
|
+
await openSshHttpTunnel({
|
|
815
|
+
authorizationHeader,
|
|
816
|
+
sshUrl,
|
|
817
|
+
streams: options.streams ?? defaultSshProxyStreams(),
|
|
818
|
+
});
|
|
819
|
+
};
|
|
820
|
+
export const runOpenSsh = async (options) => {
|
|
821
|
+
return await new Promise((resolve, reject) => {
|
|
822
|
+
const child = spawn("ssh", [
|
|
823
|
+
"-F",
|
|
824
|
+
options.setup.configPath,
|
|
825
|
+
options.setup.hostAlias,
|
|
826
|
+
...options.extraArgs,
|
|
827
|
+
], {
|
|
828
|
+
stdio: "inherit",
|
|
829
|
+
});
|
|
830
|
+
child.once("error", reject);
|
|
831
|
+
child.once("close", (code) => {
|
|
832
|
+
resolve(code ?? 1);
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
};
|