yaport 0.1.0 → 0.1.2
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 +100 -30
- package/dist/client/assets/index-BMJd0Oii.css +1 -0
- package/dist/client/assets/index-CgNFN5bT.js +9 -0
- package/dist/client/index.html +2 -2
- package/dist/server/cli.js +635 -56
- package/package.json +1 -1
- package/dist/client/assets/index-Dss-tOs5.js +0 -9
- package/dist/client/assets/index-RIEF8eK4.css +0 -1
package/dist/server/cli.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { homedir, tmpdir } from "node:os";
|
|
1
2
|
import express from "express";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
3
4
|
import { statSync } from "node:fs";
|
|
4
5
|
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
-
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { ZodError, z } from "zod";
|
|
9
9
|
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
+
import { isIP } from "node:net";
|
|
11
|
+
import { promisify } from "node:util";
|
|
10
12
|
//#region src/shared/sshTimeouts.ts
|
|
11
13
|
var CONNECT_TIMEOUT_SECONDS_OPTIONS = [
|
|
12
14
|
8,
|
|
@@ -35,7 +37,7 @@ var connectionEndpointSchema = z.object({
|
|
|
35
37
|
var connectTimeoutSchema = timeoutChoiceSchema(CONNECT_TIMEOUT_SECONDS_OPTIONS, "连接超时");
|
|
36
38
|
var commandTimeoutSchema = timeoutChoiceSchema(COMMAND_TIMEOUT_SECONDS_OPTIONS, "命令超时");
|
|
37
39
|
var machineInputSchema = z.object({
|
|
38
|
-
name: z.
|
|
40
|
+
name: z.preprocess((value) => typeof value === "string" && value.trim() === "" ? void 0 : value, z.string().trim().max(80).optional()),
|
|
39
41
|
host: connectionEndpointSchema.shape.host,
|
|
40
42
|
password: connectionEndpointSchema.shape.password,
|
|
41
43
|
port: connectionEndpointSchema.shape.port,
|
|
@@ -43,10 +45,20 @@ var machineInputSchema = z.object({
|
|
|
43
45
|
connectTimeoutSeconds: connectTimeoutSchema.optional(),
|
|
44
46
|
commandTimeoutSeconds: commandTimeoutSchema.optional(),
|
|
45
47
|
jumpHosts: z.array(connectionEndpointSchema).max(2).optional()
|
|
48
|
+
}).superRefine((input, context) => {
|
|
49
|
+
validateEndpointUser(input, context, ["user"]);
|
|
50
|
+
input.jumpHosts?.forEach((jumpHost, index) => {
|
|
51
|
+
validateEndpointUser(jumpHost, context, [
|
|
52
|
+
"jumpHosts",
|
|
53
|
+
index,
|
|
54
|
+
"user"
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
46
57
|
}).transform((input) => {
|
|
47
58
|
const connectTimeoutSeconds = input.connectTimeoutSeconds ?? 15;
|
|
48
59
|
return {
|
|
49
60
|
...input,
|
|
61
|
+
name: input.name ?? defaultMachineName(input),
|
|
50
62
|
connectTimeoutSeconds,
|
|
51
63
|
commandTimeoutSeconds: input.commandTimeoutSeconds ?? defaultCommandTimeoutSeconds(connectTimeoutSeconds)
|
|
52
64
|
};
|
|
@@ -61,6 +73,18 @@ var emptyMachineFile = { machines: [] };
|
|
|
61
73
|
function timeoutChoiceSchema(options, label) {
|
|
62
74
|
return z.coerce.number().int().refine((value) => options.includes(value), { message: `${label}必须是 ${options.join(" / ")} 秒之一。` });
|
|
63
75
|
}
|
|
76
|
+
function validateEndpointUser(endpoint, context, path) {
|
|
77
|
+
if (!endpoint.user && isIP(endpoint.host)) context.addIssue({
|
|
78
|
+
code: "custom",
|
|
79
|
+
path,
|
|
80
|
+
message: "IP 地址连接必须填写用户;OpenSSH alias 可以留空。"
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function defaultMachineName(endpoint) {
|
|
84
|
+
const userPrefix = endpoint.user ? `${endpoint.user}@` : "";
|
|
85
|
+
const portSuffix = endpoint.port ? `:${endpoint.port}` : "";
|
|
86
|
+
return `${userPrefix}${endpoint.host}${portSuffix}`;
|
|
87
|
+
}
|
|
64
88
|
var JsonMachineStore = class {
|
|
65
89
|
filePath;
|
|
66
90
|
constructor(filePath) {
|
|
@@ -82,6 +106,29 @@ var JsonMachineStore = class {
|
|
|
82
106
|
await this.write({ machines: [...file.machines, machine] });
|
|
83
107
|
return machine;
|
|
84
108
|
}
|
|
109
|
+
async delete(id) {
|
|
110
|
+
const file = await this.read();
|
|
111
|
+
const machines = file.machines.filter((machine) => machine.id !== id);
|
|
112
|
+
if (machines.length === file.machines.length) return false;
|
|
113
|
+
await this.write({ machines });
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
async update(id, input) {
|
|
117
|
+
const file = await this.read();
|
|
118
|
+
const machineIndex = file.machines.findIndex((machine) => machine.id === id);
|
|
119
|
+
if (machineIndex === -1) return;
|
|
120
|
+
const existingMachine = file.machines[machineIndex];
|
|
121
|
+
const machine = {
|
|
122
|
+
...existingMachine,
|
|
123
|
+
...preserveEndpointSecrets(input, existingMachine),
|
|
124
|
+
id: existingMachine.id,
|
|
125
|
+
createdAt: existingMachine.createdAt
|
|
126
|
+
};
|
|
127
|
+
const machines = [...file.machines];
|
|
128
|
+
machines[machineIndex] = machine;
|
|
129
|
+
await this.write({ machines });
|
|
130
|
+
return machine;
|
|
131
|
+
}
|
|
85
132
|
async read() {
|
|
86
133
|
try {
|
|
87
134
|
const raw = await readFile(this.filePath, "utf8");
|
|
@@ -104,22 +151,126 @@ var JsonMachineStore = class {
|
|
|
104
151
|
await chmod(this.filePath, 384);
|
|
105
152
|
}
|
|
106
153
|
};
|
|
154
|
+
function preserveEndpointSecrets(input, existingMachine) {
|
|
155
|
+
const nextMachine = { ...input };
|
|
156
|
+
if (!nextMachine.password && existingMachine.password && sameEndpoint(nextMachine, existingMachine)) nextMachine.password = existingMachine.password;
|
|
157
|
+
if (nextMachine.jumpHosts) nextMachine.jumpHosts = nextMachine.jumpHosts.map((jumpHost, index) => {
|
|
158
|
+
const existingJumpHost = existingMachine.jumpHosts?.[index];
|
|
159
|
+
if (!jumpHost.password && existingJumpHost?.password && sameEndpoint(jumpHost, existingJumpHost)) return {
|
|
160
|
+
...jumpHost,
|
|
161
|
+
password: existingJumpHost.password
|
|
162
|
+
};
|
|
163
|
+
return jumpHost;
|
|
164
|
+
});
|
|
165
|
+
return nextMachine;
|
|
166
|
+
}
|
|
167
|
+
function sameEndpoint(first, second) {
|
|
168
|
+
return first.host === second.host && first.port === second.port && first.user === second.user;
|
|
169
|
+
}
|
|
170
|
+
//#endregion
|
|
171
|
+
//#region src/server/caddyExternalRoutes.ts
|
|
172
|
+
function parseCaddyExternalRoutes(caddyfile) {
|
|
173
|
+
return extractSiteAddresses(stripComments$1(caddyfile)).flatMap((address) => ({
|
|
174
|
+
port: address.port,
|
|
175
|
+
serverName: address.serverName,
|
|
176
|
+
source: "caddy",
|
|
177
|
+
url: caddyRouteUrl(address)
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
function extractSiteAddresses(caddyfile) {
|
|
181
|
+
const addresses = [];
|
|
182
|
+
for (const header of extractTopLevelBlockHeaders(caddyfile)) for (const rawAddress of header.split(",")) {
|
|
183
|
+
const address = parseCaddyAddress(rawAddress.trim());
|
|
184
|
+
if (address) addresses.push(address);
|
|
185
|
+
}
|
|
186
|
+
return addresses;
|
|
187
|
+
}
|
|
188
|
+
function extractTopLevelBlockHeaders(caddyfile) {
|
|
189
|
+
const headers = [];
|
|
190
|
+
let depth = 0;
|
|
191
|
+
let lineStart = 0;
|
|
192
|
+
for (let index = 0; index < caddyfile.length; index += 1) {
|
|
193
|
+
const character = caddyfile[index];
|
|
194
|
+
if (character === "{") {
|
|
195
|
+
if (depth === 0) {
|
|
196
|
+
const header = caddyfile.slice(lineStart, index).trim();
|
|
197
|
+
if (header) headers.push(header);
|
|
198
|
+
}
|
|
199
|
+
depth += 1;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (character === "}") {
|
|
203
|
+
depth = Math.max(0, depth - 1);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (character === "\n" && depth === 0) lineStart = index + 1;
|
|
207
|
+
}
|
|
208
|
+
return headers;
|
|
209
|
+
}
|
|
210
|
+
function parseCaddyAddress(rawAddress) {
|
|
211
|
+
const candidate = rawAddress.trim();
|
|
212
|
+
if (candidate.startsWith("http://") || candidate.startsWith("https://")) return parseUrlAddress(candidate);
|
|
213
|
+
const address = candidate.replace(/\/.*$/, "").trim();
|
|
214
|
+
if (!address || address === "_" || address.includes("$") || address.startsWith("*.") || address.startsWith(":")) return;
|
|
215
|
+
const portMatch = address.match(/^(.+):(\d+)$/);
|
|
216
|
+
const serverName = portMatch ? portMatch[1] : address;
|
|
217
|
+
const port = portMatch ? Number(portMatch[2]) : 443;
|
|
218
|
+
if (!isDisplayableServerName$1(serverName) || !isValidPort(port)) return;
|
|
219
|
+
return {
|
|
220
|
+
port,
|
|
221
|
+
scheme: port === 443 ? "https" : "http",
|
|
222
|
+
serverName
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function parseUrlAddress(address) {
|
|
226
|
+
try {
|
|
227
|
+
const url = new URL(address);
|
|
228
|
+
const scheme = url.protocol === "https:" ? "https" : "http";
|
|
229
|
+
const serverName = url.hostname;
|
|
230
|
+
const port = url.port ? Number(url.port) : scheme === "https" ? 443 : 80;
|
|
231
|
+
if (!isDisplayableServerName$1(serverName) || !isValidPort(port)) return;
|
|
232
|
+
return {
|
|
233
|
+
port,
|
|
234
|
+
scheme,
|
|
235
|
+
serverName
|
|
236
|
+
};
|
|
237
|
+
} catch {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function stripComments$1(config) {
|
|
242
|
+
return config.split("\n").map((line) => line.replace(/(^|[^\\])#.*/, "$1")).join("\n");
|
|
243
|
+
}
|
|
244
|
+
function isDisplayableServerName$1(serverName) {
|
|
245
|
+
return Boolean(serverName) && !serverName.includes("*") && /^[A-Za-z0-9.-]+$/.test(serverName);
|
|
246
|
+
}
|
|
247
|
+
function isValidPort(port) {
|
|
248
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535;
|
|
249
|
+
}
|
|
250
|
+
function caddyRouteUrl(address) {
|
|
251
|
+
if (address.scheme === "https" && address.port === 443) return `https://${address.serverName}`;
|
|
252
|
+
return `${address.scheme}://${address.serverName}:${address.port}`;
|
|
253
|
+
}
|
|
107
254
|
//#endregion
|
|
108
255
|
//#region src/server/nginxExternalRoutes.ts
|
|
109
|
-
function parseNginxExternalRoutes(config) {
|
|
256
|
+
function parseNginxExternalRoutes(config, source = "nginx") {
|
|
110
257
|
return extractServerBlocks(stripComments(config)).flatMap((block) => {
|
|
111
258
|
const validNames = block.serverNames.filter(isDisplayableServerName);
|
|
112
259
|
return block.listens.flatMap((listen) => validNames.map((serverName) => ({
|
|
113
260
|
port: listen.port,
|
|
114
261
|
serverName,
|
|
115
|
-
source
|
|
262
|
+
source,
|
|
116
263
|
url: routeUrl(serverName, listen)
|
|
117
264
|
})));
|
|
118
265
|
});
|
|
119
266
|
}
|
|
120
267
|
function attachExternalRoutes(ports, routes) {
|
|
121
268
|
const routesByPort = /* @__PURE__ */ new Map();
|
|
122
|
-
for (const route of routes)
|
|
269
|
+
for (const route of routes) {
|
|
270
|
+
const currentRoutes = routesByPort.get(route.port) ?? [];
|
|
271
|
+
if (currentRoutes.some((currentRoute) => sameExternalRoute(currentRoute, route))) continue;
|
|
272
|
+
routesByPort.set(route.port, [...currentRoutes, route]);
|
|
273
|
+
}
|
|
123
274
|
return ports.map((port) => {
|
|
124
275
|
const externalRoutes = routesByPort.get(port.port);
|
|
125
276
|
if (!externalRoutes?.length) return port;
|
|
@@ -129,6 +280,9 @@ function attachExternalRoutes(ports, routes) {
|
|
|
129
280
|
};
|
|
130
281
|
});
|
|
131
282
|
}
|
|
283
|
+
function sameExternalRoute(left, right) {
|
|
284
|
+
return left.port === right.port && left.serverName === right.serverName && left.source === right.source && left.url === right.url;
|
|
285
|
+
}
|
|
132
286
|
function stripComments(config) {
|
|
133
287
|
return config.split("\n").map((line) => line.replace(/(^|[^\\])#.*/, "$1")).join("\n");
|
|
134
288
|
}
|
|
@@ -246,6 +400,33 @@ function parseProcess(value) {
|
|
|
246
400
|
};
|
|
247
401
|
}
|
|
248
402
|
//#endregion
|
|
403
|
+
//#region src/server/sshConnection.ts
|
|
404
|
+
var SSH_CONTROL_SOCKET_DIRECTORY = "/tmp/yaport-ssh";
|
|
405
|
+
async function ensureSshControlSocketDirectory() {
|
|
406
|
+
await mkdir(SSH_CONTROL_SOCKET_DIRECTORY, {
|
|
407
|
+
recursive: true,
|
|
408
|
+
mode: 448
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
function buildSshControlPath(machine) {
|
|
412
|
+
return join(SSH_CONTROL_SOCKET_DIRECTORY, machineConnectionHash(machine).slice(0, 40));
|
|
413
|
+
}
|
|
414
|
+
function machineConnectionHash(machine) {
|
|
415
|
+
return createHash("sha256").update(JSON.stringify(machineConnectionIdentity(machine))).digest("hex");
|
|
416
|
+
}
|
|
417
|
+
function machineConnectionIdentity(machine) {
|
|
418
|
+
return {
|
|
419
|
+
host: machine.host,
|
|
420
|
+
port: machine.port,
|
|
421
|
+
user: machine.user,
|
|
422
|
+
jumpHosts: machine.jumpHosts?.map((jumpHost) => ({
|
|
423
|
+
host: jumpHost.host,
|
|
424
|
+
port: jumpHost.port,
|
|
425
|
+
user: jumpHost.user
|
|
426
|
+
}))
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
//#endregion
|
|
249
430
|
//#region src/server/sshPortInventory.ts
|
|
250
431
|
var SshCommandError = class extends Error {
|
|
251
432
|
stderr;
|
|
@@ -255,9 +436,8 @@ var SshCommandError = class extends Error {
|
|
|
255
436
|
this.name = "SshCommandError";
|
|
256
437
|
}
|
|
257
438
|
};
|
|
258
|
-
var
|
|
259
|
-
var
|
|
260
|
-
var nginxExternalRouteCache = /* @__PURE__ */ new Map();
|
|
439
|
+
var PROXY_EXTERNAL_ROUTE_CACHE_MS = 300 * 1e3;
|
|
440
|
+
var proxyExternalRouteCache = /* @__PURE__ */ new Map();
|
|
261
441
|
async function collectPortInventory(machine, runner = runSsh, options = {}) {
|
|
262
442
|
const sshOptions = buildSshOptions(machine);
|
|
263
443
|
const sshArgsOptions = await buildSshArgsOptions(machine, options);
|
|
@@ -267,7 +447,7 @@ async function collectPortInventory(machine, runner = runSsh, options = {}) {
|
|
|
267
447
|
"-lntup"
|
|
268
448
|
], sshArgsOptions), sshOptions);
|
|
269
449
|
const ports = parseSsListeningPorts(stdout);
|
|
270
|
-
const [portsWithCommandsResult, externalRoutesResult] = await Promise.allSettled([attachProcessCommands(machine, ports, runner, sshOptions, sshArgsOptions),
|
|
450
|
+
const [portsWithCommandsResult, externalRoutesResult] = await Promise.allSettled([attachProcessCommands(machine, ports, runner, sshOptions, sshArgsOptions), readCachedProxyExternalRoutes(machine, ports, runner, sshOptions, sshArgsOptions)]);
|
|
271
451
|
return attachExternalRoutes(portsWithCommandsResult.status === "fulfilled" ? portsWithCommandsResult.value : ports, externalRoutesResult.status === "fulfilled" ? externalRoutesResult.value : []);
|
|
272
452
|
}
|
|
273
453
|
async function attachProcessCommands(machine, ports, runner, sshOptions, sshArgsOptions) {
|
|
@@ -294,24 +474,33 @@ async function attachProcessCommands(machine, ports, runner, sshOptions, sshArgs
|
|
|
294
474
|
return ports;
|
|
295
475
|
}
|
|
296
476
|
}
|
|
297
|
-
async function
|
|
477
|
+
async function readCachedProxyExternalRoutes(machine, ports, runner, sshOptions, sshArgsOptions) {
|
|
478
|
+
const proxySources = proxySourcesForPorts(ports);
|
|
479
|
+
if (proxySources.length === 0) return [];
|
|
298
480
|
const cacheKey = machineConnectionCacheKey(machine);
|
|
299
|
-
const cached =
|
|
481
|
+
const cached = proxyExternalRouteCache.get(cacheKey);
|
|
300
482
|
if (cached && cached.expiresAt > Date.now()) return cached.routes;
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
483
|
+
const routes = (await Promise.allSettled(proxySources.map((source) => readProxyExternalRoutes(machine, source, runner, sshOptions, sshArgsOptions)))).flatMap((result) => result.status === "fulfilled" ? result.value : []);
|
|
484
|
+
proxyExternalRouteCache.set(cacheKey, {
|
|
485
|
+
expiresAt: Date.now() + PROXY_EXTERNAL_ROUTE_CACHE_MS,
|
|
486
|
+
routes
|
|
487
|
+
});
|
|
488
|
+
return routes;
|
|
489
|
+
}
|
|
490
|
+
function proxySourcesForPorts(ports) {
|
|
491
|
+
const sources = /* @__PURE__ */ new Set();
|
|
492
|
+
for (const port of ports) {
|
|
493
|
+
const processName = port.processName?.toLowerCase() ?? "";
|
|
494
|
+
if (processName.includes("nginx")) sources.add("nginx");
|
|
495
|
+
if (processName.includes("openresty")) sources.add("openresty");
|
|
496
|
+
if (processName.includes("tengine")) sources.add("tengine");
|
|
497
|
+
if (processName.includes("caddy")) sources.add("caddy");
|
|
314
498
|
}
|
|
499
|
+
return [...sources];
|
|
500
|
+
}
|
|
501
|
+
async function readProxyExternalRoutes(machine, source, runner, sshOptions, sshArgsOptions) {
|
|
502
|
+
if (source === "caddy") return parseCaddyExternalRoutes((await runner(buildSshArgs(machine, ["cat", "/etc/caddy/Caddyfile"], sshArgsOptions), sshOptions)).stdout);
|
|
503
|
+
return parseNginxExternalRoutes((await runner(buildSshArgs(machine, [source, "-T"], sshArgsOptions), sshOptions)).stdout, source);
|
|
315
504
|
}
|
|
316
505
|
function buildSshOptions(machine) {
|
|
317
506
|
const entries = buildAskPassEntries(machine);
|
|
@@ -332,11 +521,8 @@ function parsePsCommands(output) {
|
|
|
332
521
|
}
|
|
333
522
|
async function buildSshArgsOptions(machine, options) {
|
|
334
523
|
if (!options.reuseConnection) return {};
|
|
335
|
-
await
|
|
336
|
-
|
|
337
|
-
mode: 448
|
|
338
|
-
});
|
|
339
|
-
return { controlPath: join(CONTROL_SOCKET_DIRECTORY, machineConnectionHash(machine).slice(0, 40)) };
|
|
524
|
+
await ensureSshControlSocketDirectory();
|
|
525
|
+
return { controlPath: buildSshControlPath(machine) };
|
|
340
526
|
}
|
|
341
527
|
function buildSshArgs(machine, command, options = {}) {
|
|
342
528
|
const hasPasswords = buildAskPassEntries(machine).length > 0;
|
|
@@ -344,32 +530,17 @@ function buildSshArgs(machine, command, options = {}) {
|
|
|
344
530
|
if (hasPasswords) args.push("-o", "PasswordAuthentication=yes", "-o", "KbdInteractiveAuthentication=yes", "-o", "NumberOfPasswordPrompts=1");
|
|
345
531
|
args.push("-o", `ConnectTimeout=${machine.connectTimeoutSeconds ?? 15}`);
|
|
346
532
|
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(","));
|
|
533
|
+
if (machine.jumpHosts?.length) args.push("-J", machine.jumpHosts.map(formatJumpHost$1).join(","));
|
|
348
534
|
if (machine.port) args.push("-p", String(machine.port));
|
|
349
535
|
return [
|
|
350
536
|
...args,
|
|
351
|
-
formatSshTarget(machine),
|
|
537
|
+
formatSshTarget$1(machine),
|
|
352
538
|
...command
|
|
353
539
|
];
|
|
354
540
|
}
|
|
355
541
|
function machineConnectionCacheKey(machine) {
|
|
356
542
|
return `${machine.id}:${machineConnectionHash(machine)}`;
|
|
357
543
|
}
|
|
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
544
|
function buildAskPassEntries(machine) {
|
|
374
545
|
return [...machine.jumpHosts ?? [], machine].flatMap((endpoint) => endpoint.password ? [{
|
|
375
546
|
password: endpoint.password,
|
|
@@ -379,11 +550,11 @@ function buildAskPassEntries(machine) {
|
|
|
379
550
|
function buildPromptKeys(endpoint) {
|
|
380
551
|
return [...endpoint.user ? [`${endpoint.user}@${endpoint.host}`] : [], endpoint.host];
|
|
381
552
|
}
|
|
382
|
-
function formatJumpHost(jumpHost) {
|
|
383
|
-
const target = formatSshTarget(jumpHost);
|
|
553
|
+
function formatJumpHost$1(jumpHost) {
|
|
554
|
+
const target = formatSshTarget$1(jumpHost);
|
|
384
555
|
return jumpHost.port ? `${target}:${jumpHost.port}` : target;
|
|
385
556
|
}
|
|
386
|
-
function formatSshTarget(target) {
|
|
557
|
+
function formatSshTarget$1(target) {
|
|
387
558
|
return target.user ? `${target.user}@${target.host}` : target.host;
|
|
388
559
|
}
|
|
389
560
|
async function runSsh(args, options) {
|
|
@@ -486,6 +657,61 @@ process.exit(1);
|
|
|
486
657
|
`;
|
|
487
658
|
}
|
|
488
659
|
//#endregion
|
|
660
|
+
//#region src/server/sshTerminal.ts
|
|
661
|
+
var UnsupportedTerminalError = class extends Error {
|
|
662
|
+
constructor(platform) {
|
|
663
|
+
super(`当前系统暂不支持自动打开 Terminal:${platform}`);
|
|
664
|
+
this.name = "UnsupportedTerminalError";
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
function buildInteractiveSshArgs(machine, options = {}) {
|
|
668
|
+
const args = [];
|
|
669
|
+
if (options.controlPath) args.push("-o", "ControlMaster=auto", "-o", "ControlPersist=5m", "-o", `ControlPath=${options.controlPath}`);
|
|
670
|
+
if (machine.jumpHosts?.length) args.push("-J", machine.jumpHosts.map(formatJumpHost).join(","));
|
|
671
|
+
if (machine.port) args.push("-p", String(machine.port));
|
|
672
|
+
args.push(formatSshTarget(machine));
|
|
673
|
+
return args;
|
|
674
|
+
}
|
|
675
|
+
async function openSshTerminal(machine, options = {}) {
|
|
676
|
+
const platform = options.platform ?? process.platform;
|
|
677
|
+
if (platform !== "darwin") throw new UnsupportedTerminalError(platform);
|
|
678
|
+
const spawnCommand = options.spawnCommand ?? spawn;
|
|
679
|
+
await ensureSshControlSocketDirectory();
|
|
680
|
+
await runCommand(spawnCommand, "osascript", [
|
|
681
|
+
"-e",
|
|
682
|
+
"tell application \"Terminal\" to activate",
|
|
683
|
+
"-e",
|
|
684
|
+
`tell application "Terminal" to do script ${appleScriptString(["ssh", ...buildInteractiveSshArgs(machine, { controlPath: buildSshControlPath(machine) })].map(shellQuote).join(" "))}`
|
|
685
|
+
]);
|
|
686
|
+
}
|
|
687
|
+
function runCommand(spawnCommand, command, args) {
|
|
688
|
+
return new Promise((resolve, reject) => {
|
|
689
|
+
const child = spawnCommand(command, args, { stdio: "ignore" });
|
|
690
|
+
child.on("error", reject);
|
|
691
|
+
child.on("close", (code) => {
|
|
692
|
+
if (code === 0) {
|
|
693
|
+
resolve();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
reject(/* @__PURE__ */ new Error(`打开 Terminal 失败,退出码 ${code ?? "unknown"}。`));
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
function formatJumpHost(jumpHost) {
|
|
701
|
+
const target = formatSshTarget(jumpHost);
|
|
702
|
+
return jumpHost.port ? `${target}:${jumpHost.port}` : target;
|
|
703
|
+
}
|
|
704
|
+
function formatSshTarget(target) {
|
|
705
|
+
return target.user ? `${target.user}@${target.host}` : target.host;
|
|
706
|
+
}
|
|
707
|
+
function shellQuote(value) {
|
|
708
|
+
if (/^[A-Za-z0-9_./:@=-]+$/.test(value)) return value;
|
|
709
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
710
|
+
}
|
|
711
|
+
function appleScriptString(value) {
|
|
712
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
713
|
+
}
|
|
714
|
+
//#endregion
|
|
489
715
|
//#region src/server/app.ts
|
|
490
716
|
function createApp(dependencies) {
|
|
491
717
|
const app = express();
|
|
@@ -502,6 +728,36 @@ function createApp(dependencies) {
|
|
|
502
728
|
const machine = await dependencies.machineStore.add(input);
|
|
503
729
|
response.status(201).json({ machine: stripMachineSecrets$1(machine) });
|
|
504
730
|
}));
|
|
731
|
+
app.put("/api/machines/:id", asyncHandler(async (request, response) => {
|
|
732
|
+
const input = machineInputSchema.parse(request.body);
|
|
733
|
+
const machine = await dependencies.machineStore.update(String(request.params.id), input);
|
|
734
|
+
if (!machine) {
|
|
735
|
+
response.status(404).json({ error: "Remote machine not found." });
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
response.json({ machine: stripMachineSecrets$1(machine) });
|
|
739
|
+
}));
|
|
740
|
+
app.delete("/api/machines/:id", asyncHandler(async (request, response) => {
|
|
741
|
+
if (!await dependencies.machineStore.delete(String(request.params.id))) {
|
|
742
|
+
response.status(404).json({ error: "Remote machine not found." });
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
response.status(204).send();
|
|
746
|
+
}));
|
|
747
|
+
app.post("/api/machines/:id/terminal", asyncHandler(async (request, response) => {
|
|
748
|
+
const machineId = String(request.params.id);
|
|
749
|
+
const machine = await dependencies.machineStore.get(machineId);
|
|
750
|
+
if (!machine) {
|
|
751
|
+
response.status(404).json({ error: "Remote machine not found." });
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (!dependencies.openSshTerminal) {
|
|
755
|
+
response.status(501).json({ error: "Terminal opening is not configured." });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
await dependencies.openSshTerminal(machine);
|
|
759
|
+
response.json({ ok: true });
|
|
760
|
+
}));
|
|
505
761
|
app.get("/api/machines/:id/ports", asyncHandler(async (request, response) => {
|
|
506
762
|
const machineId = String(request.params.id);
|
|
507
763
|
const machine = await dependencies.machineStore.get(machineId);
|
|
@@ -535,6 +791,13 @@ function createApp(dependencies) {
|
|
|
535
791
|
});
|
|
536
792
|
return;
|
|
537
793
|
}
|
|
794
|
+
if (error instanceof UnsupportedTerminalError) {
|
|
795
|
+
response.status(501).json({
|
|
796
|
+
error: "Unsupported terminal.",
|
|
797
|
+
message: error.message
|
|
798
|
+
});
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
538
801
|
response.status(500).json({ error: "Unexpected server error." });
|
|
539
802
|
});
|
|
540
803
|
return app;
|
|
@@ -631,7 +894,8 @@ function openBrowser(url) {
|
|
|
631
894
|
async function createYaportHttpApp(mode, dataFile) {
|
|
632
895
|
const app = createApp({
|
|
633
896
|
machineStore: new JsonMachineStore(dataFile),
|
|
634
|
-
collectPortInventory: (machine, options) => collectPortInventory(machine, void 0, options)
|
|
897
|
+
collectPortInventory: (machine, options) => collectPortInventory(machine, void 0, options),
|
|
898
|
+
openSshTerminal
|
|
635
899
|
});
|
|
636
900
|
if (mode === "production") {
|
|
637
901
|
const clientDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "../client");
|
|
@@ -670,16 +934,235 @@ function formatUrlHost(host) {
|
|
|
670
934
|
return host;
|
|
671
935
|
}
|
|
672
936
|
//#endregion
|
|
937
|
+
//#region src/server/serviceManager.ts
|
|
938
|
+
var execFileAsync = promisify(execFile);
|
|
939
|
+
var SERVICE_LABEL = "com.boenfu.yaport";
|
|
940
|
+
var SYSTEMD_SERVICE_NAME = "yaport.service";
|
|
941
|
+
var UnsupportedServicePlatformError = class extends Error {
|
|
942
|
+
constructor(platform) {
|
|
943
|
+
super(`System service is only supported on macOS and Linux. Current platform: ${platform}.`);
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
async function installYaportService(options) {
|
|
947
|
+
const definition = buildYaportServiceDefinition(options);
|
|
948
|
+
const runCommand = options.runCommand ?? defaultRunCommand;
|
|
949
|
+
await mkdir(dirname(definition.filePath), { recursive: true });
|
|
950
|
+
await mkdir(join(options.home, ".yaport"), { recursive: true });
|
|
951
|
+
await writeFile(definition.filePath, definition.contents, "utf8");
|
|
952
|
+
for (const command of definition.installCommands) await runCommand(command.command, command.args, { allowFailure: command.allowFailure });
|
|
953
|
+
return definition;
|
|
954
|
+
}
|
|
955
|
+
async function uninstallYaportService(options) {
|
|
956
|
+
const definition = buildYaportServiceDefinition(options);
|
|
957
|
+
const runCommand = options.runCommand ?? defaultRunCommand;
|
|
958
|
+
for (const command of definition.uninstallCommands) await runCommand(command.command, command.args, { allowFailure: command.allowFailure });
|
|
959
|
+
await rm(definition.filePath, { force: true });
|
|
960
|
+
return definition;
|
|
961
|
+
}
|
|
962
|
+
async function printYaportServiceStatus(options) {
|
|
963
|
+
const runCommand = options.runCommand ?? defaultRunCommand;
|
|
964
|
+
if (options.platform === "darwin") {
|
|
965
|
+
printCommandResult(await runCommand("launchctl", ["print", launchdServiceTarget(options.uid)], { allowFailure: true }));
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (options.platform === "linux") {
|
|
969
|
+
printCommandResult(await runCommand("systemctl", [
|
|
970
|
+
"--user",
|
|
971
|
+
"status",
|
|
972
|
+
SYSTEMD_SERVICE_NAME
|
|
973
|
+
], { allowFailure: true }));
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
throw new UnsupportedServicePlatformError(String(options.platform));
|
|
977
|
+
}
|
|
978
|
+
function buildYaportServiceDefinition(options) {
|
|
979
|
+
if (options.platform === "darwin") return buildLaunchAgentDefinition(options);
|
|
980
|
+
if (options.platform === "linux") return buildSystemdDefinition(options);
|
|
981
|
+
throw new UnsupportedServicePlatformError(String(options.platform));
|
|
982
|
+
}
|
|
983
|
+
function buildLaunchAgentDefinition(options) {
|
|
984
|
+
const filePath = join(options.home, "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
|
|
985
|
+
const programArguments = serviceProgramArguments(options);
|
|
986
|
+
const logDirectory = join(options.home, ".yaport");
|
|
987
|
+
const target = `gui/${options.uid ?? process.getuid?.() ?? 0}`;
|
|
988
|
+
return {
|
|
989
|
+
filePath,
|
|
990
|
+
contents: `<?xml version="1.0" encoding="UTF-8"?>
|
|
991
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
992
|
+
<plist version="1.0">
|
|
993
|
+
<dict>
|
|
994
|
+
<key>Label</key>
|
|
995
|
+
<string>${SERVICE_LABEL}</string>
|
|
996
|
+
<key>ProgramArguments</key>
|
|
997
|
+
<array>
|
|
998
|
+
${programArguments.map((argument) => ` <string>${escapeXml(argument)}</string>`).join("\n")}
|
|
999
|
+
</array>
|
|
1000
|
+
<key>WorkingDirectory</key>
|
|
1001
|
+
<string>${escapeXml(options.home)}</string>
|
|
1002
|
+
<key>RunAtLoad</key>
|
|
1003
|
+
<true/>
|
|
1004
|
+
<key>KeepAlive</key>
|
|
1005
|
+
<true/>
|
|
1006
|
+
<key>StandardOutPath</key>
|
|
1007
|
+
<string>${escapeXml(join(logDirectory, "service.log"))}</string>
|
|
1008
|
+
<key>StandardErrorPath</key>
|
|
1009
|
+
<string>${escapeXml(join(logDirectory, "service.error.log"))}</string>
|
|
1010
|
+
</dict>
|
|
1011
|
+
</plist>
|
|
1012
|
+
`,
|
|
1013
|
+
installCommands: [
|
|
1014
|
+
{
|
|
1015
|
+
command: "launchctl",
|
|
1016
|
+
args: [
|
|
1017
|
+
"bootout",
|
|
1018
|
+
target,
|
|
1019
|
+
filePath
|
|
1020
|
+
],
|
|
1021
|
+
allowFailure: true
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
command: "launchctl",
|
|
1025
|
+
args: [
|
|
1026
|
+
"bootstrap",
|
|
1027
|
+
target,
|
|
1028
|
+
filePath
|
|
1029
|
+
]
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
command: "launchctl",
|
|
1033
|
+
args: ["enable", `${target}/${SERVICE_LABEL}`]
|
|
1034
|
+
},
|
|
1035
|
+
{
|
|
1036
|
+
command: "launchctl",
|
|
1037
|
+
args: [
|
|
1038
|
+
"kickstart",
|
|
1039
|
+
"-k",
|
|
1040
|
+
`${target}/${SERVICE_LABEL}`
|
|
1041
|
+
]
|
|
1042
|
+
}
|
|
1043
|
+
],
|
|
1044
|
+
uninstallCommands: [{
|
|
1045
|
+
command: "launchctl",
|
|
1046
|
+
args: [
|
|
1047
|
+
"bootout",
|
|
1048
|
+
target,
|
|
1049
|
+
filePath
|
|
1050
|
+
],
|
|
1051
|
+
allowFailure: true
|
|
1052
|
+
}, {
|
|
1053
|
+
command: "launchctl",
|
|
1054
|
+
args: ["disable", `${target}/${SERVICE_LABEL}`],
|
|
1055
|
+
allowFailure: true
|
|
1056
|
+
}]
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
function buildSystemdDefinition(options) {
|
|
1060
|
+
const filePath = join(options.home, ".config/systemd/user", SYSTEMD_SERVICE_NAME);
|
|
1061
|
+
const execStart = serviceProgramArguments(options).map(systemdQuote).join(" ");
|
|
1062
|
+
return {
|
|
1063
|
+
filePath,
|
|
1064
|
+
contents: `[Unit]
|
|
1065
|
+
Description=Yaport local web UI
|
|
1066
|
+
After=network-online.target
|
|
1067
|
+
|
|
1068
|
+
[Service]
|
|
1069
|
+
Type=simple
|
|
1070
|
+
WorkingDirectory=${systemdQuote(options.home)}
|
|
1071
|
+
ExecStart=${execStart}
|
|
1072
|
+
Restart=on-failure
|
|
1073
|
+
Environment=NODE_ENV=production
|
|
1074
|
+
|
|
1075
|
+
[Install]
|
|
1076
|
+
WantedBy=default.target
|
|
1077
|
+
`,
|
|
1078
|
+
installCommands: [{
|
|
1079
|
+
command: "systemctl",
|
|
1080
|
+
args: ["--user", "daemon-reload"]
|
|
1081
|
+
}, {
|
|
1082
|
+
command: "systemctl",
|
|
1083
|
+
args: [
|
|
1084
|
+
"--user",
|
|
1085
|
+
"enable",
|
|
1086
|
+
"--now",
|
|
1087
|
+
SYSTEMD_SERVICE_NAME
|
|
1088
|
+
]
|
|
1089
|
+
}],
|
|
1090
|
+
uninstallCommands: [{
|
|
1091
|
+
command: "systemctl",
|
|
1092
|
+
args: [
|
|
1093
|
+
"--user",
|
|
1094
|
+
"disable",
|
|
1095
|
+
"--now",
|
|
1096
|
+
SYSTEMD_SERVICE_NAME
|
|
1097
|
+
],
|
|
1098
|
+
allowFailure: true
|
|
1099
|
+
}, {
|
|
1100
|
+
command: "systemctl",
|
|
1101
|
+
args: ["--user", "daemon-reload"]
|
|
1102
|
+
}]
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
function serviceProgramArguments(options) {
|
|
1106
|
+
return [
|
|
1107
|
+
options.nodePath,
|
|
1108
|
+
options.cliPath,
|
|
1109
|
+
"serve",
|
|
1110
|
+
"--host",
|
|
1111
|
+
options.host,
|
|
1112
|
+
"--port",
|
|
1113
|
+
String(options.port),
|
|
1114
|
+
"--data-file",
|
|
1115
|
+
options.dataFile,
|
|
1116
|
+
"--no-open"
|
|
1117
|
+
];
|
|
1118
|
+
}
|
|
1119
|
+
function launchdServiceTarget(uid) {
|
|
1120
|
+
return `gui/${uid ?? process.getuid?.() ?? 0}/${SERVICE_LABEL}`;
|
|
1121
|
+
}
|
|
1122
|
+
async function defaultRunCommand(command, args, options = {}) {
|
|
1123
|
+
try {
|
|
1124
|
+
return await execFileAsync(command, args, { encoding: "utf8" });
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
if (options.allowFailure) return {
|
|
1127
|
+
stderr: errorWithOutput(error).stderr,
|
|
1128
|
+
stdout: errorWithOutput(error).stdout
|
|
1129
|
+
};
|
|
1130
|
+
throw error;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
function printCommandResult(result) {
|
|
1134
|
+
if (result?.stdout) console.log(result.stdout.trimEnd());
|
|
1135
|
+
if (result?.stderr) console.error(result.stderr.trimEnd());
|
|
1136
|
+
}
|
|
1137
|
+
function errorWithOutput(error) {
|
|
1138
|
+
if (error && typeof error === "object") {
|
|
1139
|
+
const output = error;
|
|
1140
|
+
return {
|
|
1141
|
+
stderr: typeof output.stderr === "string" ? output.stderr : "",
|
|
1142
|
+
stdout: typeof output.stdout === "string" ? output.stdout : ""
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
return {};
|
|
1146
|
+
}
|
|
1147
|
+
function escapeXml(value) {
|
|
1148
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1149
|
+
}
|
|
1150
|
+
function systemdQuote(value) {
|
|
1151
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/%/g, "%%")}"`;
|
|
1152
|
+
}
|
|
1153
|
+
//#endregion
|
|
673
1154
|
//#region src/server/cli.ts
|
|
674
1155
|
var usage = `Usage: yaport [serve options]
|
|
675
1156
|
Usage: yaport serve [options]
|
|
676
|
-
Usage: yaport add-machine --
|
|
1157
|
+
Usage: yaport add-machine --host <host> [connection options]
|
|
1158
|
+
Usage: yaport service <install|uninstall|status> [options]
|
|
677
1159
|
|
|
678
1160
|
Start the 丫口 local web UI.
|
|
679
1161
|
|
|
680
1162
|
Commands:
|
|
681
1163
|
serve Start the local web UI. This is the default command.
|
|
682
1164
|
add-machine Add a remote machine to the local 丫口 data file.
|
|
1165
|
+
service Install, uninstall, or inspect the user-level autostart service.
|
|
683
1166
|
|
|
684
1167
|
Serve options:
|
|
685
1168
|
--host <host> Host to bind. Default: ${DEFAULT_HOST}
|
|
@@ -689,11 +1172,12 @@ Serve options:
|
|
|
689
1172
|
-h, --help Print this help message
|
|
690
1173
|
|
|
691
1174
|
add-machine required options:
|
|
692
|
-
--name <name> Display name in the UI.
|
|
693
1175
|
--host <host> SSH host, IP, or OpenSSH alias. For "ssh erya", use --host erya.
|
|
694
1176
|
|
|
695
1177
|
add-machine connection options:
|
|
1178
|
+
--name <name> Display name in the UI. Defaults to user@host:port when omitted.
|
|
696
1179
|
--user <user> SSH user. Omit for OpenSSH aliases that already define User.
|
|
1180
|
+
Required when --host is an IP address.
|
|
697
1181
|
--port <port> SSH port. Omit for OpenSSH aliases that already define Port.
|
|
698
1182
|
--password <value> SSH password for the target machine. Prefer --password-env.
|
|
699
1183
|
--password-env <env> Read the target machine SSH password from an environment variable.
|
|
@@ -718,20 +1202,33 @@ add-machine output options:
|
|
|
718
1202
|
--json Print deterministic JSON. Passwords are never printed.
|
|
719
1203
|
--data-file <path> Remote machine config file. Default lookup: ./.yaport, then ~/.yaport.
|
|
720
1204
|
|
|
1205
|
+
service options:
|
|
1206
|
+
install Create and start a user-level service. macOS uses LaunchAgent; Linux uses systemd --user.
|
|
1207
|
+
uninstall Stop and remove the user-level service.
|
|
1208
|
+
status Print service status using launchctl or systemctl --user.
|
|
1209
|
+
--host <host> Host used by the installed service. Default: ${DEFAULT_HOST}
|
|
1210
|
+
--port <port> Port used by the installed service. Default: ${DEFAULT_PORT}
|
|
1211
|
+
--data-file <path> Machine config file used by the installed service. Default lookup: ./.yaport, then ~/.yaport.
|
|
1212
|
+
--json Print deterministic JSON for install/uninstall.
|
|
1213
|
+
|
|
721
1214
|
Examples:
|
|
722
|
-
yaport add-machine --
|
|
1215
|
+
yaport add-machine --host erya --json
|
|
723
1216
|
TARGET_PASSWORD='[redacted-target-password]' yaport add-machine --name Prod --host 10.0.0.5 --user root --password-env TARGET_PASSWORD --json
|
|
724
1217
|
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
|
|
1218
|
+
yaport service install --port 5173 --json
|
|
1219
|
+
yaport service status
|
|
1220
|
+
yaport service uninstall
|
|
725
1221
|
|
|
726
1222
|
Agent notes:
|
|
727
1223
|
- Prefer --json for machine-readable output.
|
|
728
1224
|
- 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".
|
|
1225
|
+
- Omit --user and --port when --host is an OpenSSH alias such as "erya"; IP hosts must include --user.
|
|
730
1226
|
- The command is non-interactive. Missing required flags fail fast with a non-zero exit code.
|
|
731
1227
|
`;
|
|
732
1228
|
function parseCliOptions(argv, env = process.env) {
|
|
733
1229
|
if (argv[0] === "add-machine") return parseAddMachineOptions(argv.slice(1), env);
|
|
734
1230
|
if (argv[0] === "serve") return parseServeOptions(argv.slice(1), env);
|
|
1231
|
+
if (argv[0] === "service") return parseServiceOptions(argv.slice(1), env);
|
|
735
1232
|
return parseServeOptions(argv, env);
|
|
736
1233
|
}
|
|
737
1234
|
function parseServeOptions(argv, env) {
|
|
@@ -854,7 +1351,7 @@ function parseAddMachineOptions(argv, env) {
|
|
|
854
1351
|
}
|
|
855
1352
|
if (options.help) return options;
|
|
856
1353
|
const input = machineInputSchema.parse({
|
|
857
|
-
name:
|
|
1354
|
+
...machine.name !== void 0 ? { name: machine.name } : {},
|
|
858
1355
|
host: requiredValue(machine.host, "--host"),
|
|
859
1356
|
...machine.connectTimeoutSeconds ? { connectTimeoutSeconds: machine.connectTimeoutSeconds } : {},
|
|
860
1357
|
...machine.commandTimeoutSeconds ? { commandTimeoutSeconds: machine.commandTimeoutSeconds } : {},
|
|
@@ -866,6 +1363,50 @@ function parseAddMachineOptions(argv, env) {
|
|
|
866
1363
|
input
|
|
867
1364
|
};
|
|
868
1365
|
}
|
|
1366
|
+
function parseServiceOptions(argv, env) {
|
|
1367
|
+
const action = argv[0];
|
|
1368
|
+
const options = {
|
|
1369
|
+
command: "service",
|
|
1370
|
+
action: action === "uninstall" || action === "status" ? action : "install",
|
|
1371
|
+
dataFile: env.YAPORT_DATA_FILE ?? defaultDataFile(),
|
|
1372
|
+
help: false,
|
|
1373
|
+
host: env.HOST ?? "127.0.0.1",
|
|
1374
|
+
json: false,
|
|
1375
|
+
port: Number(env.PORT ?? 5173)
|
|
1376
|
+
};
|
|
1377
|
+
const optionStartIndex = action === "install" || action === "uninstall" || action === "status" ? 1 : 0;
|
|
1378
|
+
if (!action || action === "-h" || action === "--help") options.help = true;
|
|
1379
|
+
else if (action !== "install" && action !== "uninstall" && action !== "status") throw new Error("service action must be install, uninstall, or status.");
|
|
1380
|
+
for (let index = optionStartIndex; index < argv.length; index += 1) {
|
|
1381
|
+
const arg = argv[index];
|
|
1382
|
+
if (arg === "-h" || arg === "--help") {
|
|
1383
|
+
options.help = true;
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
if (arg === "--json") {
|
|
1387
|
+
options.json = true;
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
if (arg === "--host") {
|
|
1391
|
+
options.host = readValue(argv, index, arg);
|
|
1392
|
+
index += 1;
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
if (arg === "--port") {
|
|
1396
|
+
options.port = Number(readValue(argv, index, arg));
|
|
1397
|
+
index += 1;
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
if (arg === "--data-file") {
|
|
1401
|
+
options.dataFile = readValue(argv, index, arg);
|
|
1402
|
+
index += 1;
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1406
|
+
}
|
|
1407
|
+
if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error("Port must be an integer between 1 and 65535.");
|
|
1408
|
+
return options;
|
|
1409
|
+
}
|
|
869
1410
|
function readValue(argv, optionIndex, optionName) {
|
|
870
1411
|
const value = argv[optionIndex + 1];
|
|
871
1412
|
if (!value || value.startsWith("-")) throw new Error(`${optionName} requires a value.`);
|
|
@@ -929,6 +1470,10 @@ async function main() {
|
|
|
929
1470
|
await addMachine(options);
|
|
930
1471
|
return;
|
|
931
1472
|
}
|
|
1473
|
+
if (options.command === "service") {
|
|
1474
|
+
await runServiceCommand(options);
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
932
1477
|
await startYaportServer({
|
|
933
1478
|
host: options.host,
|
|
934
1479
|
port: options.port,
|
|
@@ -937,6 +1482,40 @@ async function main() {
|
|
|
937
1482
|
open: options.open
|
|
938
1483
|
});
|
|
939
1484
|
}
|
|
1485
|
+
async function runServiceCommand(options) {
|
|
1486
|
+
const serviceOptions = {
|
|
1487
|
+
cliPath: process.argv[1],
|
|
1488
|
+
dataFile: options.dataFile,
|
|
1489
|
+
home: homedir(),
|
|
1490
|
+
host: options.host,
|
|
1491
|
+
nodePath: process.execPath,
|
|
1492
|
+
platform: process.platform,
|
|
1493
|
+
port: options.port,
|
|
1494
|
+
uid: process.getuid?.()
|
|
1495
|
+
};
|
|
1496
|
+
if (options.action === "install") {
|
|
1497
|
+
printServiceResult(options, "installed", (await installYaportService(serviceOptions)).filePath);
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
if (options.action === "uninstall") {
|
|
1501
|
+
printServiceResult(options, "uninstalled", (await uninstallYaportService(serviceOptions)).filePath);
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
await printYaportServiceStatus(serviceOptions);
|
|
1505
|
+
}
|
|
1506
|
+
function printServiceResult(options, status, filePath) {
|
|
1507
|
+
if (options.json) {
|
|
1508
|
+
console.log(JSON.stringify({
|
|
1509
|
+
action: options.action,
|
|
1510
|
+
filePath,
|
|
1511
|
+
service: "yaport",
|
|
1512
|
+
status
|
|
1513
|
+
}, null, 2));
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
console.log(`Yaport service ${status}.`);
|
|
1517
|
+
console.log(`Service file: ${filePath}`);
|
|
1518
|
+
}
|
|
940
1519
|
async function addMachine(options) {
|
|
941
1520
|
if (!options.input) throw new Error("Missing add-machine input.");
|
|
942
1521
|
const publicMachine = stripMachineSecrets(await new JsonMachineStore(options.dataFile).add(options.input));
|