yaport 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.
@@ -0,0 +1,967 @@
1
+ import express from "express";
2
+ import { spawn } from "node:child_process";
3
+ import { statSync } from "node:fs";
4
+ import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { ZodError, z } from "zod";
9
+ import { createHash, randomUUID } from "node:crypto";
10
+ //#region src/shared/sshTimeouts.ts
11
+ var CONNECT_TIMEOUT_SECONDS_OPTIONS = [
12
+ 8,
13
+ 15,
14
+ 30,
15
+ 60
16
+ ];
17
+ var COMMAND_TIMEOUT_SECONDS_OPTIONS = [
18
+ 12,
19
+ 30,
20
+ 45,
21
+ 90
22
+ ];
23
+ function defaultCommandTimeoutSeconds(connectTimeoutSeconds) {
24
+ return connectTimeoutSeconds >= 60 ? 90 : 45;
25
+ }
26
+ //#endregion
27
+ //#region src/server/machineStore.ts
28
+ var passwordSchema = z.preprocess((value) => value === "" ? void 0 : value, z.string().min(1).max(1024).refine((value) => !/[\r\n]/.test(value), "Password must be a single line.").optional());
29
+ var connectionEndpointSchema = z.object({
30
+ host: z.string().trim().min(1).max(255).regex(/^[A-Za-z0-9_.:-]+$/),
31
+ port: z.coerce.number().int().min(1).max(65535).optional(),
32
+ user: z.string().trim().min(1).max(64).regex(/^[A-Za-z0-9._-]+$/).optional(),
33
+ password: passwordSchema
34
+ });
35
+ var connectTimeoutSchema = timeoutChoiceSchema(CONNECT_TIMEOUT_SECONDS_OPTIONS, "连接超时");
36
+ var commandTimeoutSchema = timeoutChoiceSchema(COMMAND_TIMEOUT_SECONDS_OPTIONS, "命令超时");
37
+ var machineInputSchema = z.object({
38
+ name: z.string().trim().min(1).max(80),
39
+ host: connectionEndpointSchema.shape.host,
40
+ password: connectionEndpointSchema.shape.password,
41
+ port: connectionEndpointSchema.shape.port,
42
+ user: connectionEndpointSchema.shape.user,
43
+ connectTimeoutSeconds: connectTimeoutSchema.optional(),
44
+ commandTimeoutSeconds: commandTimeoutSchema.optional(),
45
+ jumpHosts: z.array(connectionEndpointSchema).max(2).optional()
46
+ }).transform((input) => {
47
+ const connectTimeoutSeconds = input.connectTimeoutSeconds ?? 15;
48
+ return {
49
+ ...input,
50
+ connectTimeoutSeconds,
51
+ commandTimeoutSeconds: input.commandTimeoutSeconds ?? defaultCommandTimeoutSeconds(connectTimeoutSeconds)
52
+ };
53
+ }).superRefine((input, context) => {
54
+ if (input.commandTimeoutSeconds <= input.connectTimeoutSeconds) context.addIssue({
55
+ code: "custom",
56
+ path: ["commandTimeoutSeconds"],
57
+ message: "命令超时必须大于连接超时。"
58
+ });
59
+ });
60
+ var emptyMachineFile = { machines: [] };
61
+ function timeoutChoiceSchema(options, label) {
62
+ return z.coerce.number().int().refine((value) => options.includes(value), { message: `${label}必须是 ${options.join(" / ")} 秒之一。` });
63
+ }
64
+ var JsonMachineStore = class {
65
+ filePath;
66
+ constructor(filePath) {
67
+ this.filePath = filePath;
68
+ }
69
+ async list() {
70
+ return (await this.read()).machines;
71
+ }
72
+ async get(id) {
73
+ return (await this.list()).find((machine) => machine.id === id);
74
+ }
75
+ async add(input) {
76
+ const file = await this.read();
77
+ const machine = {
78
+ id: randomUUID(),
79
+ ...input,
80
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
81
+ };
82
+ await this.write({ machines: [...file.machines, machine] });
83
+ return machine;
84
+ }
85
+ async read() {
86
+ try {
87
+ const raw = await readFile(this.filePath, "utf8");
88
+ const parsed = JSON.parse(raw);
89
+ return { machines: Array.isArray(parsed.machines) ? parsed.machines : [] };
90
+ } catch (error) {
91
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return emptyMachineFile;
92
+ throw error;
93
+ }
94
+ }
95
+ async write(file) {
96
+ await mkdir(dirname(this.filePath), {
97
+ recursive: true,
98
+ mode: 448
99
+ });
100
+ await writeFile(this.filePath, `${JSON.stringify(file, null, 2)}\n`, {
101
+ encoding: "utf8",
102
+ mode: 384
103
+ });
104
+ await chmod(this.filePath, 384);
105
+ }
106
+ };
107
+ //#endregion
108
+ //#region src/server/nginxExternalRoutes.ts
109
+ function parseNginxExternalRoutes(config) {
110
+ return extractServerBlocks(stripComments(config)).flatMap((block) => {
111
+ const validNames = block.serverNames.filter(isDisplayableServerName);
112
+ return block.listens.flatMap((listen) => validNames.map((serverName) => ({
113
+ port: listen.port,
114
+ serverName,
115
+ source: "nginx",
116
+ url: routeUrl(serverName, listen)
117
+ })));
118
+ });
119
+ }
120
+ function attachExternalRoutes(ports, routes) {
121
+ const routesByPort = /* @__PURE__ */ new Map();
122
+ for (const route of routes) routesByPort.set(route.port, [...routesByPort.get(route.port) ?? [], route]);
123
+ return ports.map((port) => {
124
+ const externalRoutes = routesByPort.get(port.port);
125
+ if (!externalRoutes?.length) return port;
126
+ return {
127
+ ...port,
128
+ externalRoutes
129
+ };
130
+ });
131
+ }
132
+ function stripComments(config) {
133
+ return config.split("\n").map((line) => line.replace(/(^|[^\\])#.*/, "$1")).join("\n");
134
+ }
135
+ function extractServerBlocks(config) {
136
+ const blocks = [];
137
+ const serverBlockPattern = /server\s*\{/g;
138
+ let match;
139
+ while (match = serverBlockPattern.exec(config)) {
140
+ const blockStart = match.index + match[0].length;
141
+ const blockEnd = findMatchingBrace(config, blockStart - 1);
142
+ if (blockEnd === -1) continue;
143
+ blocks.push(parseServerBlock(config.slice(blockStart, blockEnd)));
144
+ serverBlockPattern.lastIndex = blockEnd + 1;
145
+ }
146
+ return blocks;
147
+ }
148
+ function findMatchingBrace(config, openBraceIndex) {
149
+ let depth = 0;
150
+ for (let index = openBraceIndex; index < config.length; index += 1) {
151
+ if (config[index] === "{") depth += 1;
152
+ if (config[index] === "}") {
153
+ depth -= 1;
154
+ if (depth === 0) return index;
155
+ }
156
+ }
157
+ return -1;
158
+ }
159
+ function parseServerBlock(block) {
160
+ const directives = block.split(";").map((directive) => directive.trim()).filter(Boolean);
161
+ return {
162
+ listens: directives.filter((directive) => directive.startsWith("listen ")).flatMap(parseListenDirective),
163
+ serverNames: directives.filter((directive) => directive.startsWith("server_name ")).flatMap(parseServerNames)
164
+ };
165
+ }
166
+ function parseListenDirective(directive) {
167
+ const tokens = directive.split(/\s+/).slice(1);
168
+ const endpoint = tokens[0];
169
+ if (!endpoint) return [];
170
+ const port = parseListenPort(endpoint);
171
+ if (!port) return [];
172
+ return [{
173
+ port,
174
+ ssl: tokens.includes("ssl")
175
+ }];
176
+ }
177
+ function parseListenPort(endpoint) {
178
+ const normalized = endpoint.startsWith("[") ? endpoint.replace(/^\[[^\]]+\]:/, "") : endpoint;
179
+ const portValue = normalized.includes(":") ? normalized.slice(normalized.lastIndexOf(":") + 1) : normalized;
180
+ const port = Number(portValue);
181
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return;
182
+ return port;
183
+ }
184
+ function parseServerNames(directive) {
185
+ return directive.split(/\s+/).slice(1);
186
+ }
187
+ function isDisplayableServerName(serverName) {
188
+ return Boolean(serverName) && serverName !== "_" && !serverName.includes("$") && /^[A-Za-z0-9.-]+$/.test(serverName);
189
+ }
190
+ function routeUrl(serverName, listen) {
191
+ if (listen.port === 443) return `https://${serverName}`;
192
+ return `http://${serverName}:${listen.port}`;
193
+ }
194
+ //#endregion
195
+ //#region src/server/portInventory.ts
196
+ function parseSsListeningPorts(output) {
197
+ return output.split("\n").map((line) => line.trim()).filter(Boolean).flatMap((line) => parseSsLine(line));
198
+ }
199
+ function parseSsLine(line) {
200
+ const columns = line.split(/\s+/);
201
+ const protocol = normalizeProtocol(columns[0]);
202
+ const state = columns[1];
203
+ const endpoint = parseEndpoint(columns[4]);
204
+ if (!protocol || !state || !endpoint) return [];
205
+ const rawProcess = columns.slice(6).join(" ") || void 0;
206
+ const process = rawProcess ? parseProcess(rawProcess) : {};
207
+ return [{
208
+ protocol,
209
+ state,
210
+ address: endpoint.address,
211
+ port: endpoint.port,
212
+ ...process,
213
+ rawProcess
214
+ }];
215
+ }
216
+ function normalizeProtocol(value) {
217
+ if (!value) return;
218
+ if (value.startsWith("tcp")) return "tcp";
219
+ if (value.startsWith("udp")) return "udp";
220
+ }
221
+ function parseEndpoint(value) {
222
+ if (!value) return;
223
+ if (value.startsWith("[")) {
224
+ const closeBracketIndex = value.lastIndexOf("]:");
225
+ if (closeBracketIndex === -1) return;
226
+ return endpointFromParts(value.slice(1, closeBracketIndex), value.slice(closeBracketIndex + 2));
227
+ }
228
+ const portSeparatorIndex = value.lastIndexOf(":");
229
+ if (portSeparatorIndex === -1) return;
230
+ return endpointFromParts(value.slice(0, portSeparatorIndex), value.slice(portSeparatorIndex + 1));
231
+ }
232
+ function endpointFromParts(address, portValue) {
233
+ const port = Number(portValue);
234
+ if (!Number.isInteger(port) || port < 0 || port > 65535) return;
235
+ return {
236
+ address,
237
+ port
238
+ };
239
+ }
240
+ function parseProcess(value) {
241
+ const match = value.match(/\("([^"]+)",pid=(\d+)/);
242
+ if (!match) return {};
243
+ return {
244
+ processName: match[1],
245
+ pid: Number(match[2])
246
+ };
247
+ }
248
+ //#endregion
249
+ //#region src/server/sshPortInventory.ts
250
+ var SshCommandError = class extends Error {
251
+ stderr;
252
+ constructor(message, stderr) {
253
+ super(message);
254
+ this.stderr = stderr;
255
+ this.name = "SshCommandError";
256
+ }
257
+ };
258
+ var CONTROL_SOCKET_DIRECTORY = "/tmp/yaport-ssh";
259
+ var NGINX_EXTERNAL_ROUTE_CACHE_MS = 300 * 1e3;
260
+ var nginxExternalRouteCache = /* @__PURE__ */ new Map();
261
+ async function collectPortInventory(machine, runner = runSsh, options = {}) {
262
+ const sshOptions = buildSshOptions(machine);
263
+ const sshArgsOptions = await buildSshArgsOptions(machine, options);
264
+ const { stdout } = await runner(buildSshArgs(machine, [
265
+ "ss",
266
+ "-H",
267
+ "-lntup"
268
+ ], sshArgsOptions), sshOptions);
269
+ const ports = parseSsListeningPorts(stdout);
270
+ const [portsWithCommandsResult, externalRoutesResult] = await Promise.allSettled([attachProcessCommands(machine, ports, runner, sshOptions, sshArgsOptions), readCachedNginxExternalRoutes(machine, runner, sshOptions, sshArgsOptions)]);
271
+ return attachExternalRoutes(portsWithCommandsResult.status === "fulfilled" ? portsWithCommandsResult.value : ports, externalRoutesResult.status === "fulfilled" ? externalRoutesResult.value : []);
272
+ }
273
+ async function attachProcessCommands(machine, ports, runner, sshOptions, sshArgsOptions) {
274
+ const pids = [...new Set(ports.map((port) => port.pid).filter((pid) => typeof pid === "number"))];
275
+ if (pids.length === 0) return ports;
276
+ try {
277
+ const { stdout } = await runner(buildSshArgs(machine, [
278
+ "ps",
279
+ "-ww",
280
+ "-p",
281
+ pids.join(","),
282
+ "-o",
283
+ "pid=,args="
284
+ ], sshArgsOptions), sshOptions);
285
+ const commands = parsePsCommands(stdout);
286
+ return ports.map((port) => {
287
+ const processCommand = port.pid ? commands.get(port.pid) : void 0;
288
+ return processCommand ? {
289
+ ...port,
290
+ processCommand
291
+ } : port;
292
+ });
293
+ } catch {
294
+ return ports;
295
+ }
296
+ }
297
+ async function readCachedNginxExternalRoutes(machine, runner, sshOptions, sshArgsOptions) {
298
+ const cacheKey = machineConnectionCacheKey(machine);
299
+ const cached = nginxExternalRouteCache.get(cacheKey);
300
+ if (cached && cached.expiresAt > Date.now()) return cached.routes;
301
+ try {
302
+ const routes = parseNginxExternalRoutes((await runner(buildSshArgs(machine, ["nginx", "-T"], sshArgsOptions), sshOptions)).stdout);
303
+ nginxExternalRouteCache.set(cacheKey, {
304
+ expiresAt: Date.now() + NGINX_EXTERNAL_ROUTE_CACHE_MS,
305
+ routes
306
+ });
307
+ return routes;
308
+ } catch {
309
+ nginxExternalRouteCache.set(cacheKey, {
310
+ expiresAt: Date.now() + NGINX_EXTERNAL_ROUTE_CACHE_MS,
311
+ routes: []
312
+ });
313
+ return [];
314
+ }
315
+ }
316
+ function buildSshOptions(machine) {
317
+ const entries = buildAskPassEntries(machine);
318
+ const connectTimeoutSeconds = machine.connectTimeoutSeconds ?? 15;
319
+ return {
320
+ timeoutMs: (machine.commandTimeoutSeconds ?? defaultCommandTimeoutSeconds(connectTimeoutSeconds)) * 1e3,
321
+ ...entries.length ? { askPass: { entries } } : {}
322
+ };
323
+ }
324
+ function parsePsCommands(output) {
325
+ const commands = /* @__PURE__ */ new Map();
326
+ for (const line of output.split("\n")) {
327
+ const match = line.trim().match(/^(\d+)\s+(.+)$/);
328
+ if (!match) continue;
329
+ commands.set(Number(match[1]), match[2]);
330
+ }
331
+ return commands;
332
+ }
333
+ async function buildSshArgsOptions(machine, options) {
334
+ if (!options.reuseConnection) return {};
335
+ await mkdir(CONTROL_SOCKET_DIRECTORY, {
336
+ recursive: true,
337
+ mode: 448
338
+ });
339
+ return { controlPath: join(CONTROL_SOCKET_DIRECTORY, machineConnectionHash(machine).slice(0, 40)) };
340
+ }
341
+ function buildSshArgs(machine, command, options = {}) {
342
+ const hasPasswords = buildAskPassEntries(machine).length > 0;
343
+ const args = ["-o", hasPasswords ? "BatchMode=no" : "BatchMode=yes"];
344
+ if (hasPasswords) args.push("-o", "PasswordAuthentication=yes", "-o", "KbdInteractiveAuthentication=yes", "-o", "NumberOfPasswordPrompts=1");
345
+ args.push("-o", `ConnectTimeout=${machine.connectTimeoutSeconds ?? 15}`);
346
+ if (options.controlPath) args.push("-o", "ControlMaster=auto", "-o", "ControlPersist=5m", "-o", `ControlPath=${options.controlPath}`);
347
+ if (machine.jumpHosts?.length) args.push("-J", machine.jumpHosts.map(formatJumpHost).join(","));
348
+ if (machine.port) args.push("-p", String(machine.port));
349
+ return [
350
+ ...args,
351
+ formatSshTarget(machine),
352
+ ...command
353
+ ];
354
+ }
355
+ function machineConnectionCacheKey(machine) {
356
+ return `${machine.id}:${machineConnectionHash(machine)}`;
357
+ }
358
+ function machineConnectionHash(machine) {
359
+ return createHash("sha256").update(JSON.stringify(machineConnectionIdentity(machine))).digest("hex");
360
+ }
361
+ function machineConnectionIdentity(machine) {
362
+ return {
363
+ host: machine.host,
364
+ port: machine.port,
365
+ user: machine.user,
366
+ jumpHosts: machine.jumpHosts?.map((jumpHost) => ({
367
+ host: jumpHost.host,
368
+ port: jumpHost.port,
369
+ user: jumpHost.user
370
+ }))
371
+ };
372
+ }
373
+ function buildAskPassEntries(machine) {
374
+ return [...machine.jumpHosts ?? [], machine].flatMap((endpoint) => endpoint.password ? [{
375
+ password: endpoint.password,
376
+ promptKeys: buildPromptKeys(endpoint)
377
+ }] : []);
378
+ }
379
+ function buildPromptKeys(endpoint) {
380
+ return [...endpoint.user ? [`${endpoint.user}@${endpoint.host}`] : [], endpoint.host];
381
+ }
382
+ function formatJumpHost(jumpHost) {
383
+ const target = formatSshTarget(jumpHost);
384
+ return jumpHost.port ? `${target}:${jumpHost.port}` : target;
385
+ }
386
+ function formatSshTarget(target) {
387
+ return target.user ? `${target.user}@${target.host}` : target.host;
388
+ }
389
+ async function runSsh(args, options) {
390
+ const askPass = options.askPass ? await createAskPass(options.askPass) : void 0;
391
+ const controller = new AbortController();
392
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
393
+ try {
394
+ return await new Promise((resolve, reject) => {
395
+ const child = spawn("ssh", args, {
396
+ env: {
397
+ ...process.env,
398
+ ...askPass?.env ?? {}
399
+ },
400
+ stdio: [
401
+ "ignore",
402
+ "pipe",
403
+ "pipe"
404
+ ],
405
+ signal: controller.signal
406
+ });
407
+ let stdout = "";
408
+ let stderr = "";
409
+ child.stdout.setEncoding("utf8");
410
+ child.stderr.setEncoding("utf8");
411
+ child.stdout.on("data", (chunk) => {
412
+ stdout += chunk;
413
+ });
414
+ child.stderr.on("data", (chunk) => {
415
+ stderr += chunk;
416
+ });
417
+ child.on("error", (error) => {
418
+ clearTimeout(timeout);
419
+ reject(new SshCommandError(error.message, stderr));
420
+ });
421
+ child.on("close", (code) => {
422
+ clearTimeout(timeout);
423
+ if (controller.signal.aborted) {
424
+ reject(new SshCommandError("SSH command timed out.", stderr));
425
+ return;
426
+ }
427
+ if (code !== 0) {
428
+ reject(new SshCommandError(`SSH command failed with exit code ${code}.`, stderr));
429
+ return;
430
+ }
431
+ resolve({
432
+ stdout,
433
+ stderr
434
+ });
435
+ });
436
+ });
437
+ } finally {
438
+ await askPass?.cleanup();
439
+ }
440
+ }
441
+ async function createAskPass(config) {
442
+ const directory = await mkdtemp(join(tmpdir(), "yaport-askpass-"));
443
+ const scriptPath = join(directory, "askpass.cjs");
444
+ await writeFile(scriptPath, buildAskPassScript(config), {
445
+ encoding: "utf8",
446
+ mode: 448
447
+ });
448
+ return {
449
+ cleanup: async () => {
450
+ await rm(directory, {
451
+ force: true,
452
+ recursive: true
453
+ });
454
+ },
455
+ env: {
456
+ DISPLAY: process.env.DISPLAY || "yaport",
457
+ SSH_ASKPASS: scriptPath,
458
+ SSH_ASKPASS_REQUIRE: "force"
459
+ }
460
+ };
461
+ }
462
+ function buildAskPassScript(config) {
463
+ return `#!/usr/bin/env node
464
+ const entries = ${JSON.stringify(config.entries)};
465
+ const prompt = process.argv.slice(2).join(" ").toLowerCase();
466
+ const rankedEntries = entries
467
+ .map((entry) => ({
468
+ ...entry,
469
+ promptKeys: [...entry.promptKeys].sort((left, right) => right.length - left.length)
470
+ }))
471
+ .sort((left, right) => (right.promptKeys[0]?.length ?? 0) - (left.promptKeys[0]?.length ?? 0));
472
+
473
+ for (const entry of rankedEntries) {
474
+ if (entry.promptKeys.some((key) => key && prompt.includes(String(key).toLowerCase()))) {
475
+ process.stdout.write(entry.password);
476
+ process.exit(0);
477
+ }
478
+ }
479
+
480
+ if (rankedEntries.length === 1) {
481
+ process.stdout.write(rankedEntries[0].password);
482
+ process.exit(0);
483
+ }
484
+
485
+ process.exit(1);
486
+ `;
487
+ }
488
+ //#endregion
489
+ //#region src/server/app.ts
490
+ function createApp(dependencies) {
491
+ const app = express();
492
+ app.use(express.json());
493
+ app.get("/api/health", (_request, response) => {
494
+ response.json({ ok: true });
495
+ });
496
+ app.get("/api/machines", asyncHandler(async (_request, response) => {
497
+ const machines = await dependencies.machineStore.list();
498
+ response.json({ machines: machines.map(stripMachineSecrets$1) });
499
+ }));
500
+ app.post("/api/machines", asyncHandler(async (request, response) => {
501
+ const input = machineInputSchema.parse(request.body);
502
+ const machine = await dependencies.machineStore.add(input);
503
+ response.status(201).json({ machine: stripMachineSecrets$1(machine) });
504
+ }));
505
+ app.get("/api/machines/:id/ports", asyncHandler(async (request, response) => {
506
+ const machineId = String(request.params.id);
507
+ const machine = await dependencies.machineStore.get(machineId);
508
+ if (!machine) {
509
+ response.status(404).json({ error: "Remote machine not found." });
510
+ return;
511
+ }
512
+ const ports = await dependencies.collectPortInventory(machine, { reuseConnection: request.get("X-Yaport-Reuse-Connection") === "1" });
513
+ response.json({
514
+ machineId: machine.id,
515
+ collectedAt: (/* @__PURE__ */ new Date()).toISOString(),
516
+ ports
517
+ });
518
+ }));
519
+ app.use((error, _request, response, _next) => {
520
+ if (error instanceof ZodError) {
521
+ response.status(400).json({
522
+ error: "Invalid remote machine.",
523
+ issues: error.issues.map((issue) => ({
524
+ path: issue.path.join("."),
525
+ message: issue.message
526
+ }))
527
+ });
528
+ return;
529
+ }
530
+ if (error instanceof SshCommandError) {
531
+ response.status(502).json({
532
+ error: "SSH query failed.",
533
+ message: formatSshErrorMessage(error),
534
+ stderr: sanitizeSshStderr(error.stderr)
535
+ });
536
+ return;
537
+ }
538
+ response.status(500).json({ error: "Unexpected server error." });
539
+ });
540
+ return app;
541
+ }
542
+ function asyncHandler(handler) {
543
+ return (request, response, next) => {
544
+ handler(request, response).catch(next);
545
+ };
546
+ }
547
+ function stripMachineSecrets$1(machine) {
548
+ const { password: _password, jumpHosts, ...publicMachine } = machine;
549
+ return {
550
+ ...publicMachine,
551
+ ...jumpHosts ? { jumpHosts: jumpHosts.map(stripJumpHostSecrets$1) } : {}
552
+ };
553
+ }
554
+ function stripJumpHostSecrets$1(jumpHost) {
555
+ const { password: _password, ...publicJumpHost } = jumpHost;
556
+ return publicJumpHost;
557
+ }
558
+ function formatSshErrorMessage(error) {
559
+ const stderr = sanitizeSshStderr(error.stderr);
560
+ const reason = sshFailureReason(stderr.toLowerCase(), error.message);
561
+ if (!stderr) return reason;
562
+ return `${reason}\nOpenSSH 输出:${stderr}`;
563
+ }
564
+ function sshFailureReason(normalizedStderr, fallbackMessage) {
565
+ if (normalizedStderr.includes("timed out during banner exchange")) return "SSH 连接建立超时。通常是跳板链路或目标机器握手太慢,可以把连接超时调到 30 或 60 秒,命令超时调到 90 秒。";
566
+ if (normalizedStderr.includes("connection timed out")) return "SSH 连接超时。请检查目标机器、跳板机链路和连接超时设置。";
567
+ if (normalizedStderr.includes("permission denied")) return "SSH 认证失败。请检查用户名、密码、SSH key 或跳板机账号。";
568
+ if (normalizedStderr.includes("could not resolve hostname") || normalizedStderr.includes("name or service not known")) return "SSH 主机解析失败。请检查 Host、OpenSSH alias 或 DNS。";
569
+ if (normalizedStderr.includes("no route to host")) return "SSH 网络不可达。请检查本机到目标机器或跳板机的网络路径。";
570
+ if (normalizedStderr.includes("connection refused")) return "SSH 连接被拒绝。请检查目标机器 SSH 服务是否启动、端口是否正确。";
571
+ if (normalizedStderr.includes("host key verification failed")) return "SSH 主机校验失败。请在本机终端先执行一次 ssh 并确认 host key。";
572
+ if (normalizedStderr.includes("too many authentication failures")) return "SSH 认证尝试次数过多。请检查本机 ssh-agent 里的 key,或改用明确的 OpenSSH alias 配置。";
573
+ return `SSH 查询失败:${fallbackMessage}`;
574
+ }
575
+ function sanitizeSshStderr(stderr) {
576
+ return stderr.replace(/\u001b\[[0-9;]*m/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").map((line) => line.trim()).filter(Boolean).join("\n").slice(0, 1200);
577
+ }
578
+ //#endregion
579
+ //#region src/server/runtime.ts
580
+ var DEFAULT_HOST = "127.0.0.1";
581
+ var DEFAULT_PORT = 5173;
582
+ function defaultDataFile(cwd = process.cwd(), home = homedir()) {
583
+ const cwdConfigDirectory = resolve(cwd, ".yaport");
584
+ const homeConfigDirectory = resolve(home, ".yaport");
585
+ if (directoryExists(cwdConfigDirectory)) return join(cwdConfigDirectory, "machines.json");
586
+ if (directoryExists(homeConfigDirectory)) return join(homeConfigDirectory, "machines.json");
587
+ return join(cwdConfigDirectory, "machines.json");
588
+ }
589
+ function directoryExists(path) {
590
+ try {
591
+ return statSync(path).isDirectory();
592
+ } catch {
593
+ return false;
594
+ }
595
+ }
596
+ function browserOpenCommand(url, platform = process.platform) {
597
+ if (platform === "darwin") return {
598
+ command: "open",
599
+ args: [url]
600
+ };
601
+ if (platform === "win32") return {
602
+ command: "cmd",
603
+ args: [
604
+ "/c",
605
+ "start",
606
+ "",
607
+ url
608
+ ]
609
+ };
610
+ return {
611
+ command: "xdg-open",
612
+ args: [url]
613
+ };
614
+ }
615
+ function listenUrl(host, port) {
616
+ return `http://${formatUrlHost(host)}:${port}`;
617
+ }
618
+ function localBrowserUrl(host, port) {
619
+ if (host === "0.0.0.0" || host === "::") return listenUrl("127.0.0.1", port);
620
+ return listenUrl(host, port);
621
+ }
622
+ function openBrowser(url) {
623
+ const { command, args } = browserOpenCommand(url);
624
+ const child = spawn(command, args, {
625
+ detached: true,
626
+ stdio: "ignore"
627
+ });
628
+ child.on("error", () => {});
629
+ child.unref();
630
+ }
631
+ async function createYaportHttpApp(mode, dataFile) {
632
+ const app = createApp({
633
+ machineStore: new JsonMachineStore(dataFile),
634
+ collectPortInventory: (machine, options) => collectPortInventory(machine, void 0, options)
635
+ });
636
+ if (mode === "production") {
637
+ const clientDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "../client");
638
+ app.use(express.static(clientDirectory));
639
+ app.use(async (request, response, next) => {
640
+ if (request.path.startsWith("/api/")) {
641
+ next();
642
+ return;
643
+ }
644
+ response.type("html").send(await readFile(join(clientDirectory, "index.html"), "utf8"));
645
+ });
646
+ return app;
647
+ }
648
+ const devServer = await (await import("vite")).createServer({
649
+ server: { middlewareMode: true },
650
+ appType: "spa"
651
+ });
652
+ app.use(devServer.middlewares);
653
+ return app;
654
+ }
655
+ async function startYaportServer(options) {
656
+ const host = options.host ?? "127.0.0.1";
657
+ const url = listenUrl(host, options.port);
658
+ const browserUrl = localBrowserUrl(host, options.port);
659
+ (await createYaportHttpApp(options.mode, options.dataFile)).listen(options.port, host, () => {
660
+ console.log(`丫口 listening on ${url}`);
661
+ console.log(`Remote machines are stored at ${options.dataFile}`);
662
+ if (options.open) {
663
+ openBrowser(browserUrl);
664
+ console.log(`Opened ${browserUrl}`);
665
+ }
666
+ });
667
+ }
668
+ function formatUrlHost(host) {
669
+ if (host.includes(":") && !host.startsWith("[")) return `[${host}]`;
670
+ return host;
671
+ }
672
+ //#endregion
673
+ //#region src/server/cli.ts
674
+ var usage = `Usage: yaport [serve options]
675
+ Usage: yaport serve [options]
676
+ Usage: yaport add-machine --name <name> --host <host> [connection options]
677
+
678
+ Start the 丫口 local web UI.
679
+
680
+ Commands:
681
+ serve Start the local web UI. This is the default command.
682
+ add-machine Add a remote machine to the local 丫口 data file.
683
+
684
+ Serve options:
685
+ --host <host> Host to bind. Default: ${DEFAULT_HOST}
686
+ --port <port> Port to listen on. Default: ${DEFAULT_PORT}
687
+ --data-file <path> Remote machine config file. Default lookup: ./.yaport, then ~/.yaport.
688
+ --no-open Do not open the web UI after the server starts.
689
+ -h, --help Print this help message
690
+
691
+ add-machine required options:
692
+ --name <name> Display name in the UI.
693
+ --host <host> SSH host, IP, or OpenSSH alias. For "ssh erya", use --host erya.
694
+
695
+ add-machine connection options:
696
+ --user <user> SSH user. Omit for OpenSSH aliases that already define User.
697
+ --port <port> SSH port. Omit for OpenSSH aliases that already define Port.
698
+ --password <value> SSH password for the target machine. Prefer --password-env.
699
+ --password-env <env> Read the target machine SSH password from an environment variable.
700
+ --connect-timeout <seconds>
701
+ SSH connection timeout. Choices: ${CONNECT_TIMEOUT_SECONDS_OPTIONS.join(", ")}. Default: 15.
702
+ --command-timeout <seconds>
703
+ Local command timeout. Choices: ${COMMAND_TIMEOUT_SECONDS_OPTIONS.join(", ")}. Default: 45.
704
+
705
+ add-machine jump host options:
706
+ --jump1-host <host> First jump host. Order matches OpenSSH ProxyJump.
707
+ --jump1-user <user> First jump host SSH user.
708
+ --jump1-port <port> First jump host SSH port.
709
+ --jump1-password <value> First jump host SSH password. Prefer --jump1-password-env.
710
+ --jump1-password-env <env> Read first jump host password from an environment variable.
711
+ --jump2-host <host> Second jump host.
712
+ --jump2-user <user> Second jump host SSH user.
713
+ --jump2-port <port> Second jump host SSH port.
714
+ --jump2-password <value> Second jump host SSH password. Prefer --jump2-password-env.
715
+ --jump2-password-env <env> Read second jump host password from an environment variable.
716
+
717
+ add-machine output options:
718
+ --json Print deterministic JSON. Passwords are never printed.
719
+ --data-file <path> Remote machine config file. Default lookup: ./.yaport, then ~/.yaport.
720
+
721
+ Examples:
722
+ yaport add-machine --name Erya --host erya --json
723
+ TARGET_PASSWORD='[redacted-target-password]' yaport add-machine --name Prod --host 10.0.0.5 --user root --password-env TARGET_PASSWORD --json
724
+ JUMP_PASSWORD='[redacted-jump-password]' TARGET_PASSWORD='[redacted-target-password]' yaport add-machine --name Prod --host prod.internal --user app --password-env TARGET_PASSWORD --connect-timeout 30 --command-timeout 90 --jump1-host jump.internal --jump1-user ops --jump1-password-env JUMP_PASSWORD --json
725
+
726
+ Agent notes:
727
+ - Prefer --json for machine-readable output.
728
+ - Prefer --password-env and --jumpN-password-env to avoid putting passwords in process arguments.
729
+ - Omit --user and --port when --host is an OpenSSH alias such as "erya".
730
+ - The command is non-interactive. Missing required flags fail fast with a non-zero exit code.
731
+ `;
732
+ function parseCliOptions(argv, env = process.env) {
733
+ if (argv[0] === "add-machine") return parseAddMachineOptions(argv.slice(1), env);
734
+ if (argv[0] === "serve") return parseServeOptions(argv.slice(1), env);
735
+ return parseServeOptions(argv, env);
736
+ }
737
+ function parseServeOptions(argv, env) {
738
+ const options = {
739
+ command: "serve",
740
+ help: false,
741
+ host: env.HOST ?? "127.0.0.1",
742
+ open: true,
743
+ port: Number(env.PORT ?? 5173),
744
+ dataFile: env.YAPORT_DATA_FILE ?? defaultDataFile()
745
+ };
746
+ for (let index = 0; index < argv.length; index += 1) {
747
+ const arg = argv[index];
748
+ if (arg === "-h" || arg === "--help") {
749
+ options.help = true;
750
+ continue;
751
+ }
752
+ if (arg === "--host") {
753
+ options.host = readValue(argv, index, arg);
754
+ index += 1;
755
+ continue;
756
+ }
757
+ if (arg === "--port") {
758
+ options.port = Number(readValue(argv, index, arg));
759
+ index += 1;
760
+ continue;
761
+ }
762
+ if (arg === "--data-file") {
763
+ options.dataFile = readValue(argv, index, arg);
764
+ index += 1;
765
+ continue;
766
+ }
767
+ if (arg === "--no-open") {
768
+ options.open = false;
769
+ continue;
770
+ }
771
+ throw new Error(`Unknown option: ${arg}`);
772
+ }
773
+ if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error("Port must be an integer between 1 and 65535.");
774
+ return options;
775
+ }
776
+ function parseAddMachineOptions(argv, env) {
777
+ const machine = {};
778
+ const jumps = [{}, {}];
779
+ const options = {
780
+ command: "add-machine",
781
+ dataFile: env.YAPORT_DATA_FILE ?? defaultDataFile(),
782
+ help: false,
783
+ json: false
784
+ };
785
+ for (let index = 0; index < argv.length; index += 1) {
786
+ const arg = argv[index];
787
+ if (arg === "-h" || arg === "--help") {
788
+ options.help = true;
789
+ continue;
790
+ }
791
+ if (arg === "--json") {
792
+ options.json = true;
793
+ continue;
794
+ }
795
+ if (arg === "--data-file") {
796
+ options.dataFile = readValue(argv, index, arg);
797
+ index += 1;
798
+ continue;
799
+ }
800
+ if (arg === "--name") {
801
+ machine.name = readValue(argv, index, arg);
802
+ index += 1;
803
+ continue;
804
+ }
805
+ if (arg === "--host") {
806
+ machine.host = readValue(argv, index, arg);
807
+ index += 1;
808
+ continue;
809
+ }
810
+ if (arg === "--user") {
811
+ machine.user = readValue(argv, index, arg);
812
+ index += 1;
813
+ continue;
814
+ }
815
+ if (arg === "--port") {
816
+ machine.port = parsePortValue(readValue(argv, index, arg), arg);
817
+ index += 1;
818
+ continue;
819
+ }
820
+ if (arg === "--password") {
821
+ machine.password = readValue(argv, index, arg);
822
+ index += 1;
823
+ continue;
824
+ }
825
+ if (arg === "--password-env") {
826
+ machine.passwordEnv = readValue(argv, index, arg);
827
+ index += 1;
828
+ continue;
829
+ }
830
+ if (arg === "--connect-timeout") {
831
+ machine.connectTimeoutSeconds = parseTimeoutValue(readValue(argv, index, arg), arg, CONNECT_TIMEOUT_SECONDS_OPTIONS);
832
+ index += 1;
833
+ continue;
834
+ }
835
+ if (arg === "--command-timeout") {
836
+ machine.commandTimeoutSeconds = parseTimeoutValue(readValue(argv, index, arg), arg, COMMAND_TIMEOUT_SECONDS_OPTIONS);
837
+ index += 1;
838
+ continue;
839
+ }
840
+ const jumpMatch = arg.match(/^--jump([12])-(host|password|password-env|port|user)$/);
841
+ if (jumpMatch) {
842
+ const jump = jumps[Number(jumpMatch[1]) - 1];
843
+ const field = jumpMatch[2];
844
+ const value = readValue(argv, index, arg);
845
+ if (field === "host") jump.host = value;
846
+ else if (field === "password") jump.password = value;
847
+ else if (field === "password-env") jump.passwordEnv = value;
848
+ else if (field === "port") jump.port = parsePortValue(value, arg);
849
+ else jump.user = value;
850
+ index += 1;
851
+ continue;
852
+ }
853
+ throw new Error(`Unknown option: ${arg}`);
854
+ }
855
+ if (options.help) return options;
856
+ const input = machineInputSchema.parse({
857
+ name: requiredValue(machine.name, "--name"),
858
+ host: requiredValue(machine.host, "--host"),
859
+ ...machine.connectTimeoutSeconds ? { connectTimeoutSeconds: machine.connectTimeoutSeconds } : {},
860
+ ...machine.commandTimeoutSeconds ? { commandTimeoutSeconds: machine.commandTimeoutSeconds } : {},
861
+ ...connectionFields(machine, "target machine", env),
862
+ jumpHosts: compactJumpHosts(jumps, env)
863
+ });
864
+ return {
865
+ ...options,
866
+ input
867
+ };
868
+ }
869
+ function readValue(argv, optionIndex, optionName) {
870
+ const value = argv[optionIndex + 1];
871
+ if (!value || value.startsWith("-")) throw new Error(`${optionName} requires a value.`);
872
+ return value;
873
+ }
874
+ function parsePortValue(value, optionName) {
875
+ const port = Number(value);
876
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${optionName} must be an integer between 1 and 65535.`);
877
+ return port;
878
+ }
879
+ function parseTimeoutValue(value, optionName, choices) {
880
+ const timeout = Number(value);
881
+ if (!Number.isInteger(timeout) || !choices.includes(timeout)) throw new Error(`${optionName} must be one of: ${choices.join(", ")}.`);
882
+ return timeout;
883
+ }
884
+ function requiredValue(value, optionName) {
885
+ if (!value) throw new Error(`${optionName} is required.`);
886
+ return value;
887
+ }
888
+ function compactJumpHosts(jumps, env) {
889
+ const compacted = jumps.flatMap((jump, index) => {
890
+ if (!Boolean(jump.host || jump.password || jump.passwordEnv || jump.port || jump.user)) return [];
891
+ if (!jump.host) throw new Error(`--jump${index + 1}-host is required when any jump${index + 1} option is used.`);
892
+ return [{
893
+ host: jump.host,
894
+ ...connectionFields(jump, `jump${index + 1}`, env)
895
+ }];
896
+ });
897
+ return compacted.length ? compacted : void 0;
898
+ }
899
+ function connectionFields(endpoint, label, env) {
900
+ if (endpoint.password && endpoint.passwordEnv) throw new Error(`${label} cannot use both password and password-env.`);
901
+ const password = endpoint.passwordEnv ? readPasswordFromEnv(endpoint.passwordEnv, env) : endpoint.password;
902
+ return {
903
+ ...password ? { password } : {},
904
+ ...endpoint.port ? { port: endpoint.port } : {},
905
+ ...endpoint.user ? { user: endpoint.user } : {}
906
+ };
907
+ }
908
+ function readPasswordFromEnv(name, env) {
909
+ const password = env[name];
910
+ if (!password) throw new Error(`Environment variable ${name} is not set or is empty.`);
911
+ return password;
912
+ }
913
+ async function main() {
914
+ let options;
915
+ try {
916
+ options = parseCliOptions(process.argv.slice(2));
917
+ } catch (error) {
918
+ console.error(error instanceof Error ? error.message : "Invalid CLI options.");
919
+ console.error("");
920
+ console.error(usage);
921
+ process.exitCode = 1;
922
+ return;
923
+ }
924
+ if (options.help) {
925
+ console.log(usage);
926
+ return;
927
+ }
928
+ if (options.command === "add-machine") {
929
+ await addMachine(options);
930
+ return;
931
+ }
932
+ await startYaportServer({
933
+ host: options.host,
934
+ port: options.port,
935
+ dataFile: options.dataFile,
936
+ mode: "production",
937
+ open: options.open
938
+ });
939
+ }
940
+ async function addMachine(options) {
941
+ if (!options.input) throw new Error("Missing add-machine input.");
942
+ const publicMachine = stripMachineSecrets(await new JsonMachineStore(options.dataFile).add(options.input));
943
+ if (options.json) {
944
+ console.log(JSON.stringify({
945
+ dataFile: options.dataFile,
946
+ machine: publicMachine
947
+ }, null, 2));
948
+ return;
949
+ }
950
+ console.log(`Added remote machine ${publicMachine.name} (${publicMachine.id})`);
951
+ console.log(`Host: ${publicMachine.host}`);
952
+ console.log(`Remote machines are stored at ${options.dataFile}`);
953
+ }
954
+ function stripMachineSecrets(machine) {
955
+ const { password: _password, jumpHosts, ...publicMachine } = machine;
956
+ return {
957
+ ...publicMachine,
958
+ ...jumpHosts ? { jumpHosts: jumpHosts.map(stripJumpHostSecrets) } : {}
959
+ };
960
+ }
961
+ function stripJumpHostSecrets(jumpHost) {
962
+ const { password: _password, ...publicJumpHost } = jumpHost;
963
+ return publicJumpHost;
964
+ }
965
+ if (import.meta.url === `file://${process.argv[1]}`) main();
966
+ //#endregion
967
+ export { main, parseCliOptions };