multiclaws 0.4.42 → 0.4.43
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 +2 -0
- package/dist/gateway/handlers.d.ts +4 -4
- package/dist/gateway/handlers.js +239 -239
- package/dist/index.d.ts +8 -8
- package/dist/index.js +710 -710
- package/dist/infra/frp.d.ts +55 -55
- package/dist/infra/frp.js +398 -398
- package/dist/infra/gateway-client.d.ts +27 -27
- package/dist/infra/gateway-client.js +136 -136
- package/dist/infra/json-store.d.ts +4 -4
- package/dist/infra/json-store.js +57 -57
- package/dist/infra/logger.d.ts +14 -14
- package/dist/infra/logger.js +25 -25
- package/dist/infra/rate-limiter.d.ts +19 -19
- package/dist/infra/rate-limiter.js +69 -69
- package/dist/infra/tailscale.d.ts +19 -19
- package/dist/infra/tailscale.js +120 -120
- package/dist/infra/telemetry.d.ts +3 -3
- package/dist/infra/telemetry.js +17 -17
- package/dist/infra/version.d.ts +1 -1
- package/dist/infra/version.js +19 -19
- package/dist/service/a2a-adapter.d.ts +80 -80
- package/dist/service/a2a-adapter.js +505 -505
- package/dist/service/agent-profile.d.ts +17 -17
- package/dist/service/agent-profile.js +58 -58
- package/dist/service/agent-registry.d.ts +29 -29
- package/dist/service/agent-registry.js +131 -131
- package/dist/service/multiclaws-service.d.ts +150 -150
- package/dist/service/multiclaws-service.js +1137 -1137
- package/dist/service/session-store.d.ts +46 -46
- package/dist/service/session-store.js +143 -143
- package/dist/task/tracker.d.ts +46 -46
- package/dist/task/tracker.js +191 -191
- package/dist/team/team-store.d.ts +42 -42
- package/dist/team/team-store.js +195 -195
- package/dist/types/openclaw.d.ts +109 -109
- package/dist/types/openclaw.js +2 -2
- package/package.json +1 -1
package/dist/infra/frp.js
CHANGED
|
@@ -1,398 +1,398 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.FrpTunnelManager = void 0;
|
|
7
|
-
exports.detectFrpc = detectFrpc;
|
|
8
|
-
const node_child_process_1 = require("node:child_process");
|
|
9
|
-
const node_net_1 = __importDefault(require("node:net"));
|
|
10
|
-
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
11
|
-
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
-
const node_crypto_1 = require("node:crypto");
|
|
13
|
-
/* ------------------------------------------------------------------ */
|
|
14
|
-
/* Constants */
|
|
15
|
-
/* ------------------------------------------------------------------ */
|
|
16
|
-
const FRP_VERSION = "0.61.1";
|
|
17
|
-
const ADMIN_API_POLL_INTERVAL_MS = 1_000;
|
|
18
|
-
const ADMIN_API_POLL_MAX_RETRIES = 15;
|
|
19
|
-
const HEALTH_CHECK_INTERVAL_MS = 30_000;
|
|
20
|
-
const PROCESS_KILL_TIMEOUT_MS = 3_000;
|
|
21
|
-
/* ------------------------------------------------------------------ */
|
|
22
|
-
/* Helpers */
|
|
23
|
-
/* ------------------------------------------------------------------ */
|
|
24
|
-
function run(cmd, timeoutMs = 5_000) {
|
|
25
|
-
return (0, node_child_process_1.execSync)(cmd, { timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"] })
|
|
26
|
-
.toString()
|
|
27
|
-
.trim();
|
|
28
|
-
}
|
|
29
|
-
/** Check if frpc binary is available in system PATH */
|
|
30
|
-
function detectFrpc() {
|
|
31
|
-
try {
|
|
32
|
-
const cmd = process.platform === "win32" ? "where frpc" : "which frpc";
|
|
33
|
-
run(cmd);
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
/** Find an available port by briefly binding to port 0 */
|
|
41
|
-
async function findFreePort() {
|
|
42
|
-
return new Promise((resolve, reject) => {
|
|
43
|
-
const server = node_net_1.default.createServer();
|
|
44
|
-
server.listen(0, "127.0.0.1", () => {
|
|
45
|
-
const addr = server.address();
|
|
46
|
-
if (!addr || typeof addr === "string") {
|
|
47
|
-
server.close(() => reject(new Error("failed to get port")));
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
const port = addr.port;
|
|
51
|
-
server.close(() => resolve(port));
|
|
52
|
-
});
|
|
53
|
-
server.on("error", reject);
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
/** Parse remote port from frpc admin API response (format: ":12345" or "[::]:12345") */
|
|
57
|
-
function parseRemotePort(remoteAddr) {
|
|
58
|
-
const colonIdx = remoteAddr.lastIndexOf(":");
|
|
59
|
-
if (colonIdx === -1)
|
|
60
|
-
throw new Error(`unexpected remote_addr format: ${remoteAddr}`);
|
|
61
|
-
const portStr = remoteAddr.slice(colonIdx + 1);
|
|
62
|
-
const port = parseInt(portStr, 10);
|
|
63
|
-
if (isNaN(port) || port <= 0)
|
|
64
|
-
throw new Error(`invalid port in remote_addr: ${remoteAddr}`);
|
|
65
|
-
return port;
|
|
66
|
-
}
|
|
67
|
-
/** Get platform identifier for frp release download */
|
|
68
|
-
function getFrpPlatform() {
|
|
69
|
-
const platform = process.platform;
|
|
70
|
-
const arch = process.arch;
|
|
71
|
-
let frpOs;
|
|
72
|
-
if (platform === "linux")
|
|
73
|
-
frpOs = "linux";
|
|
74
|
-
else if (platform === "darwin")
|
|
75
|
-
frpOs = "darwin";
|
|
76
|
-
else if (platform === "win32")
|
|
77
|
-
frpOs = "windows";
|
|
78
|
-
else
|
|
79
|
-
throw new Error(`unsupported platform: ${platform}`);
|
|
80
|
-
let frpArch;
|
|
81
|
-
if (arch === "x64")
|
|
82
|
-
frpArch = "amd64";
|
|
83
|
-
else if (arch === "arm64")
|
|
84
|
-
frpArch = "arm64";
|
|
85
|
-
else if (arch === "ia32")
|
|
86
|
-
frpArch = "386";
|
|
87
|
-
else
|
|
88
|
-
throw new Error(`unsupported architecture: ${arch}`);
|
|
89
|
-
const ext = platform === "win32" ? "zip" : "tar.gz";
|
|
90
|
-
return { os: frpOs, arch: frpArch, ext };
|
|
91
|
-
}
|
|
92
|
-
/** Shuffle an array in place (Fisher-Yates) */
|
|
93
|
-
function shuffle(arr) {
|
|
94
|
-
for (let i = arr.length - 1; i > 0; i--) {
|
|
95
|
-
const j = Math.floor(Math.random() * (i + 1));
|
|
96
|
-
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
97
|
-
}
|
|
98
|
-
return arr;
|
|
99
|
-
}
|
|
100
|
-
/** Generate a range of numbers [start, end) */
|
|
101
|
-
function range(start, end) {
|
|
102
|
-
const result = [];
|
|
103
|
-
for (let i = start; i < end; i++)
|
|
104
|
-
result.push(i);
|
|
105
|
-
return result;
|
|
106
|
-
}
|
|
107
|
-
/* ------------------------------------------------------------------ */
|
|
108
|
-
/* FrpTunnelManager */
|
|
109
|
-
/* ------------------------------------------------------------------ */
|
|
110
|
-
class FrpTunnelManager {
|
|
111
|
-
config;
|
|
112
|
-
localPort;
|
|
113
|
-
stateDir;
|
|
114
|
-
logger;
|
|
115
|
-
frpcProcess = null;
|
|
116
|
-
healthCheckTimer = null;
|
|
117
|
-
_status = { status: "stopped" };
|
|
118
|
-
_publicUrl = null;
|
|
119
|
-
configPath = "";
|
|
120
|
-
adminPort = 0;
|
|
121
|
-
constructor(opts) {
|
|
122
|
-
this.config = opts.config;
|
|
123
|
-
this.localPort = opts.localPort;
|
|
124
|
-
this.stateDir = opts.stateDir;
|
|
125
|
-
this.logger = opts.logger ?? {
|
|
126
|
-
info: () => { },
|
|
127
|
-
warn: () => { },
|
|
128
|
-
error: () => { },
|
|
129
|
-
};
|
|
130
|
-
}
|
|
131
|
-
get status() {
|
|
132
|
-
return this._status;
|
|
133
|
-
}
|
|
134
|
-
get publicUrl() {
|
|
135
|
-
return this._publicUrl;
|
|
136
|
-
}
|
|
137
|
-
/* ── Start ─────────────────────────────────────────────────────── */
|
|
138
|
-
async start() {
|
|
139
|
-
this._status = { status: "starting" };
|
|
140
|
-
// 1. Ensure frpc binary exists
|
|
141
|
-
const frpcPath = await this.ensureFrpcBinary();
|
|
142
|
-
this.logger.info(`[frp] using frpc binary: ${frpcPath}`);
|
|
143
|
-
// 2. Find free port for admin API
|
|
144
|
-
this.adminPort = await findFreePort();
|
|
145
|
-
// 3. Try ports in random order from range
|
|
146
|
-
const ports = shuffle(range(this.config.portRangeStart, this.config.portRangeEnd + 1));
|
|
147
|
-
for (const port of ports) {
|
|
148
|
-
try {
|
|
149
|
-
const publicUrl = await this.tryStartWithPort(frpcPath, port);
|
|
150
|
-
this._publicUrl = publicUrl;
|
|
151
|
-
this._status = { status: "running", publicUrl, remotePort: port };
|
|
152
|
-
// Start health monitoring
|
|
153
|
-
this.startHealthCheck();
|
|
154
|
-
return publicUrl;
|
|
155
|
-
}
|
|
156
|
-
catch (err) {
|
|
157
|
-
this.logger.warn(`[frp] port ${port} unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
158
|
-
// Kill process if started, try next port
|
|
159
|
-
await this.killProcess();
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
this._status = {
|
|
163
|
-
status: "error",
|
|
164
|
-
reason: `all ports in range ${this.config.portRangeStart}-${this.config.portRangeEnd} exhausted`,
|
|
165
|
-
};
|
|
166
|
-
throw new Error(`FRP tunnel failed: all ports in range ${this.config.portRangeStart}-${this.config.portRangeEnd} are unavailable`);
|
|
167
|
-
}
|
|
168
|
-
/* ── Stop ──────────────────────────────────────────────────────── */
|
|
169
|
-
async stop() {
|
|
170
|
-
if (this.healthCheckTimer) {
|
|
171
|
-
clearInterval(this.healthCheckTimer);
|
|
172
|
-
this.healthCheckTimer = null;
|
|
173
|
-
}
|
|
174
|
-
await this.killProcess();
|
|
175
|
-
// Cleanup config file
|
|
176
|
-
if (this.configPath) {
|
|
177
|
-
try {
|
|
178
|
-
await promises_1.default.unlink(this.configPath);
|
|
179
|
-
}
|
|
180
|
-
catch {
|
|
181
|
-
// ignore if already removed
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
this._status = { status: "stopped" };
|
|
185
|
-
this._publicUrl = null;
|
|
186
|
-
}
|
|
187
|
-
/* ── Private: try a specific port ──────────────────────────────── */
|
|
188
|
-
async tryStartWithPort(frpcPath, remotePort) {
|
|
189
|
-
const proxyName = `multiclaws-${(0, node_crypto_1.randomBytes)(4).toString("hex")}`;
|
|
190
|
-
this.configPath = node_path_1.default.join(this.stateDir, "frpc.toml");
|
|
191
|
-
const configContent = [
|
|
192
|
-
`serverAddr = "${this.config.serverAddr}"`,
|
|
193
|
-
`serverPort = ${this.config.serverPort}`,
|
|
194
|
-
`auth.token = "${this.config.token}"`,
|
|
195
|
-
``,
|
|
196
|
-
`webServer.addr = "127.0.0.1"`,
|
|
197
|
-
`webServer.port = ${this.adminPort}`,
|
|
198
|
-
``,
|
|
199
|
-
`[[proxies]]`,
|
|
200
|
-
`name = "${proxyName}"`,
|
|
201
|
-
`type = "tcp"`,
|
|
202
|
-
`localIP = "127.0.0.1"`,
|
|
203
|
-
`localPort = ${this.localPort}`,
|
|
204
|
-
`remotePort = ${remotePort}`,
|
|
205
|
-
].join("\n");
|
|
206
|
-
// Ensure stateDir exists
|
|
207
|
-
await promises_1.default.mkdir(this.stateDir, { recursive: true });
|
|
208
|
-
await promises_1.default.writeFile(this.configPath, configContent, "utf8");
|
|
209
|
-
// Spawn frpc
|
|
210
|
-
this.frpcProcess = (0, node_child_process_1.spawn)(frpcPath, ["-c", this.configPath], {
|
|
211
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
212
|
-
});
|
|
213
|
-
// Capture stdout/stderr for logging
|
|
214
|
-
this.frpcProcess.stdout?.on("data", (data) => {
|
|
215
|
-
const line = data.toString().trim();
|
|
216
|
-
if (line)
|
|
217
|
-
this.logger.info(`[frpc] ${line}`);
|
|
218
|
-
});
|
|
219
|
-
this.frpcProcess.stderr?.on("data", (data) => {
|
|
220
|
-
const line = data.toString().trim();
|
|
221
|
-
if (line)
|
|
222
|
-
this.logger.warn(`[frpc:stderr] ${line}`);
|
|
223
|
-
});
|
|
224
|
-
this.frpcProcess.on("exit", (code, signal) => {
|
|
225
|
-
if (this._status.status === "running") {
|
|
226
|
-
this.logger.error(`[frp] frpc process exited unexpectedly (code=${code}, signal=${signal})`);
|
|
227
|
-
this._status = { status: "error", reason: `frpc exited (code=${code})` };
|
|
228
|
-
}
|
|
229
|
-
});
|
|
230
|
-
// Poll admin API to confirm proxy is running
|
|
231
|
-
await this.waitForProxy(proxyName);
|
|
232
|
-
return `http://${this.config.serverAddr}:${remotePort}`;
|
|
233
|
-
}
|
|
234
|
-
/* ── Private: poll admin API ──────────────────────────────────── */
|
|
235
|
-
async waitForProxy(proxyName) {
|
|
236
|
-
// frpc 0.61.x uses /api/status (returns { tcp: [...], udp: [...], ... })
|
|
237
|
-
// older versions used /api/proxy/tcp (returns flat array or { proxies: [...] })
|
|
238
|
-
const statusUrl = `http://127.0.0.1:${this.adminPort}/api/status`;
|
|
239
|
-
for (let attempt = 0; attempt < ADMIN_API_POLL_MAX_RETRIES; attempt++) {
|
|
240
|
-
await new Promise((r) => setTimeout(r, ADMIN_API_POLL_INTERVAL_MS));
|
|
241
|
-
// Check if process has already exited
|
|
242
|
-
if (!this.frpcProcess || this.frpcProcess.exitCode !== null) {
|
|
243
|
-
throw new Error("frpc process exited before proxy became ready");
|
|
244
|
-
}
|
|
245
|
-
try {
|
|
246
|
-
const res = await fetch(statusUrl, { signal: AbortSignal.timeout(3_000) });
|
|
247
|
-
if (!res.ok)
|
|
248
|
-
continue;
|
|
249
|
-
const data = (await res.json());
|
|
250
|
-
// /api/status groups proxies by type: { tcp: [...], udp: [...], ... }
|
|
251
|
-
const tcpProxies = data.tcp ?? [];
|
|
252
|
-
const proxy = tcpProxies.find((p) => p.name === proxyName);
|
|
253
|
-
if (!proxy)
|
|
254
|
-
continue;
|
|
255
|
-
if (proxy.status === "running") {
|
|
256
|
-
this.logger.info(`[frp] proxy "${proxyName}" is running (remote_addr: ${proxy.remote_addr})`);
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
if (proxy.err) {
|
|
260
|
-
throw new Error(`frp proxy error: ${proxy.err}`);
|
|
261
|
-
}
|
|
262
|
-
// status might be "new" or "wait_start" — keep polling
|
|
263
|
-
}
|
|
264
|
-
catch (err) {
|
|
265
|
-
if (err instanceof Error && err.message.startsWith("frp proxy error:")) {
|
|
266
|
-
throw err;
|
|
267
|
-
}
|
|
268
|
-
// fetch failed (admin API not yet ready) — keep polling
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
throw new Error("timeout waiting for frpc proxy to become running");
|
|
272
|
-
}
|
|
273
|
-
/* ── Private: health check ─────────────────────────────────────── */
|
|
274
|
-
startHealthCheck() {
|
|
275
|
-
this.healthCheckTimer = setInterval(async () => {
|
|
276
|
-
if (this._status.status !== "running")
|
|
277
|
-
return;
|
|
278
|
-
try {
|
|
279
|
-
const res = await fetch(`http://127.0.0.1:${this.adminPort}/api/status`, { signal: AbortSignal.timeout(5_000) });
|
|
280
|
-
if (!res.ok) {
|
|
281
|
-
this.logger.warn("[frp] health check: admin API returned non-OK");
|
|
282
|
-
this._status = { status: "error", reason: "admin API returned non-OK status" };
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
catch {
|
|
286
|
-
this.logger.warn("[frp] health check: failed to reach admin API");
|
|
287
|
-
this._status = { status: "error", reason: "admin API unreachable" };
|
|
288
|
-
}
|
|
289
|
-
}, HEALTH_CHECK_INTERVAL_MS);
|
|
290
|
-
// Don't prevent Node from exiting
|
|
291
|
-
if (this.healthCheckTimer.unref) {
|
|
292
|
-
this.healthCheckTimer.unref();
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
/* ── Private: kill process ─────────────────────────────────────── */
|
|
296
|
-
async killProcess() {
|
|
297
|
-
const proc = this.frpcProcess;
|
|
298
|
-
if (!proc)
|
|
299
|
-
return;
|
|
300
|
-
this.frpcProcess = null;
|
|
301
|
-
// Phase 1: graceful kill
|
|
302
|
-
proc.kill();
|
|
303
|
-
const exited = await Promise.race([
|
|
304
|
-
new Promise((resolve) => proc.on("exit", () => resolve(true))),
|
|
305
|
-
new Promise((resolve) => setTimeout(() => resolve(false), PROCESS_KILL_TIMEOUT_MS)),
|
|
306
|
-
]);
|
|
307
|
-
if (!exited) {
|
|
308
|
-
// Phase 2: force kill
|
|
309
|
-
try {
|
|
310
|
-
if (process.platform === "win32" && proc.pid) {
|
|
311
|
-
(0, node_child_process_1.execSync)(`taskkill /pid ${proc.pid} /f /t`, { stdio: "ignore" });
|
|
312
|
-
}
|
|
313
|
-
else {
|
|
314
|
-
proc.kill("SIGKILL");
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
catch {
|
|
318
|
-
// process may have already exited
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
/* ── Private: ensure frpc binary ───────────────────────────────── */
|
|
323
|
-
async ensureFrpcBinary() {
|
|
324
|
-
const ext = process.platform === "win32" ? ".exe" : "";
|
|
325
|
-
const localBinary = node_path_1.default.join(this.stateDir, `frpc${ext}`);
|
|
326
|
-
// 1. Check stateDir
|
|
327
|
-
try {
|
|
328
|
-
await promises_1.default.access(localBinary);
|
|
329
|
-
return localBinary;
|
|
330
|
-
}
|
|
331
|
-
catch {
|
|
332
|
-
// not found locally
|
|
333
|
-
}
|
|
334
|
-
// 2. Check system PATH
|
|
335
|
-
if (detectFrpc()) {
|
|
336
|
-
return "frpc";
|
|
337
|
-
}
|
|
338
|
-
// 3. Auto-download
|
|
339
|
-
this.logger.info(`[frp] frpc not found, downloading v${FRP_VERSION}...`);
|
|
340
|
-
return await this.downloadFrpc(localBinary);
|
|
341
|
-
}
|
|
342
|
-
async downloadFrpc(targetPath) {
|
|
343
|
-
const { os: frpOs, arch: frpArch, ext } = getFrpPlatform();
|
|
344
|
-
const archiveName = `frp_${FRP_VERSION}_${frpOs}_${frpArch}`;
|
|
345
|
-
const url = `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/${archiveName}.${ext}`;
|
|
346
|
-
const downloadDir = node_path_1.default.join(this.stateDir, "frpc-download");
|
|
347
|
-
await promises_1.default.mkdir(downloadDir, { recursive: true });
|
|
348
|
-
const archivePath = node_path_1.default.join(downloadDir, `${archiveName}.${ext}`);
|
|
349
|
-
// Download
|
|
350
|
-
this.logger.info(`[frp] downloading from ${url}`);
|
|
351
|
-
const res = await fetch(url);
|
|
352
|
-
if (!res.ok) {
|
|
353
|
-
throw new Error(`failed to download frpc: HTTP ${res.status} from ${url}`);
|
|
354
|
-
}
|
|
355
|
-
const buffer = Buffer.from(await res.arrayBuffer());
|
|
356
|
-
await promises_1.default.writeFile(archivePath, buffer);
|
|
357
|
-
// Extract
|
|
358
|
-
this.logger.info(`[frp] extracting ${archivePath}`);
|
|
359
|
-
const binaryName = process.platform === "win32" ? "frpc.exe" : "frpc";
|
|
360
|
-
try {
|
|
361
|
-
if (ext === "tar.gz") {
|
|
362
|
-
(0, node_child_process_1.execSync)(`tar -xzf "${archivePath}" -C "${downloadDir}"`, { stdio: "ignore" });
|
|
363
|
-
}
|
|
364
|
-
else {
|
|
365
|
-
// Windows: use spawnSync to bypass cmd.exe quote-mangling that breaks
|
|
366
|
-
// the embedded PowerShell double-quotes when execSync(string) is used.
|
|
367
|
-
const psCmd = `$ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${downloadDir.replace(/'/g, "''")}' -Force`;
|
|
368
|
-
const psResult = (0, node_child_process_1.spawnSync)("powershell", ["-NoProfile", "-Command", psCmd], {
|
|
369
|
-
stdio: "pipe",
|
|
370
|
-
});
|
|
371
|
-
if (psResult.status !== 0) {
|
|
372
|
-
const stderr = psResult.stderr?.toString().trim() ?? "";
|
|
373
|
-
throw new Error(`Expand-Archive failed (exit ${psResult.status}): ${stderr}`);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
// Move binary to target
|
|
377
|
-
const extractedBinary = node_path_1.default.join(downloadDir, archiveName, binaryName);
|
|
378
|
-
await promises_1.default.mkdir(node_path_1.default.dirname(targetPath), { recursive: true });
|
|
379
|
-
await promises_1.default.copyFile(extractedBinary, targetPath);
|
|
380
|
-
// Make executable on Unix
|
|
381
|
-
if (process.platform !== "win32") {
|
|
382
|
-
await promises_1.default.chmod(targetPath, 0o755);
|
|
383
|
-
}
|
|
384
|
-
this.logger.info(`[frp] frpc installed to ${targetPath}`);
|
|
385
|
-
}
|
|
386
|
-
finally {
|
|
387
|
-
// Cleanup download directory
|
|
388
|
-
try {
|
|
389
|
-
await promises_1.default.rm(downloadDir, { recursive: true, force: true });
|
|
390
|
-
}
|
|
391
|
-
catch {
|
|
392
|
-
// ignore cleanup errors
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
return targetPath;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
exports.FrpTunnelManager = FrpTunnelManager;
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.FrpTunnelManager = void 0;
|
|
7
|
+
exports.detectFrpc = detectFrpc;
|
|
8
|
+
const node_child_process_1 = require("node:child_process");
|
|
9
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
10
|
+
const promises_1 = __importDefault(require("node:fs/promises"));
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
const node_crypto_1 = require("node:crypto");
|
|
13
|
+
/* ------------------------------------------------------------------ */
|
|
14
|
+
/* Constants */
|
|
15
|
+
/* ------------------------------------------------------------------ */
|
|
16
|
+
const FRP_VERSION = "0.61.1";
|
|
17
|
+
const ADMIN_API_POLL_INTERVAL_MS = 1_000;
|
|
18
|
+
const ADMIN_API_POLL_MAX_RETRIES = 15;
|
|
19
|
+
const HEALTH_CHECK_INTERVAL_MS = 30_000;
|
|
20
|
+
const PROCESS_KILL_TIMEOUT_MS = 3_000;
|
|
21
|
+
/* ------------------------------------------------------------------ */
|
|
22
|
+
/* Helpers */
|
|
23
|
+
/* ------------------------------------------------------------------ */
|
|
24
|
+
function run(cmd, timeoutMs = 5_000) {
|
|
25
|
+
return (0, node_child_process_1.execSync)(cmd, { timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"] })
|
|
26
|
+
.toString()
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
/** Check if frpc binary is available in system PATH */
|
|
30
|
+
function detectFrpc() {
|
|
31
|
+
try {
|
|
32
|
+
const cmd = process.platform === "win32" ? "where frpc" : "which frpc";
|
|
33
|
+
run(cmd);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Find an available port by briefly binding to port 0 */
|
|
41
|
+
async function findFreePort() {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const server = node_net_1.default.createServer();
|
|
44
|
+
server.listen(0, "127.0.0.1", () => {
|
|
45
|
+
const addr = server.address();
|
|
46
|
+
if (!addr || typeof addr === "string") {
|
|
47
|
+
server.close(() => reject(new Error("failed to get port")));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const port = addr.port;
|
|
51
|
+
server.close(() => resolve(port));
|
|
52
|
+
});
|
|
53
|
+
server.on("error", reject);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** Parse remote port from frpc admin API response (format: ":12345" or "[::]:12345") */
|
|
57
|
+
function parseRemotePort(remoteAddr) {
|
|
58
|
+
const colonIdx = remoteAddr.lastIndexOf(":");
|
|
59
|
+
if (colonIdx === -1)
|
|
60
|
+
throw new Error(`unexpected remote_addr format: ${remoteAddr}`);
|
|
61
|
+
const portStr = remoteAddr.slice(colonIdx + 1);
|
|
62
|
+
const port = parseInt(portStr, 10);
|
|
63
|
+
if (isNaN(port) || port <= 0)
|
|
64
|
+
throw new Error(`invalid port in remote_addr: ${remoteAddr}`);
|
|
65
|
+
return port;
|
|
66
|
+
}
|
|
67
|
+
/** Get platform identifier for frp release download */
|
|
68
|
+
function getFrpPlatform() {
|
|
69
|
+
const platform = process.platform;
|
|
70
|
+
const arch = process.arch;
|
|
71
|
+
let frpOs;
|
|
72
|
+
if (platform === "linux")
|
|
73
|
+
frpOs = "linux";
|
|
74
|
+
else if (platform === "darwin")
|
|
75
|
+
frpOs = "darwin";
|
|
76
|
+
else if (platform === "win32")
|
|
77
|
+
frpOs = "windows";
|
|
78
|
+
else
|
|
79
|
+
throw new Error(`unsupported platform: ${platform}`);
|
|
80
|
+
let frpArch;
|
|
81
|
+
if (arch === "x64")
|
|
82
|
+
frpArch = "amd64";
|
|
83
|
+
else if (arch === "arm64")
|
|
84
|
+
frpArch = "arm64";
|
|
85
|
+
else if (arch === "ia32")
|
|
86
|
+
frpArch = "386";
|
|
87
|
+
else
|
|
88
|
+
throw new Error(`unsupported architecture: ${arch}`);
|
|
89
|
+
const ext = platform === "win32" ? "zip" : "tar.gz";
|
|
90
|
+
return { os: frpOs, arch: frpArch, ext };
|
|
91
|
+
}
|
|
92
|
+
/** Shuffle an array in place (Fisher-Yates) */
|
|
93
|
+
function shuffle(arr) {
|
|
94
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
95
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
96
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
97
|
+
}
|
|
98
|
+
return arr;
|
|
99
|
+
}
|
|
100
|
+
/** Generate a range of numbers [start, end) */
|
|
101
|
+
function range(start, end) {
|
|
102
|
+
const result = [];
|
|
103
|
+
for (let i = start; i < end; i++)
|
|
104
|
+
result.push(i);
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
/* ------------------------------------------------------------------ */
|
|
108
|
+
/* FrpTunnelManager */
|
|
109
|
+
/* ------------------------------------------------------------------ */
|
|
110
|
+
class FrpTunnelManager {
|
|
111
|
+
config;
|
|
112
|
+
localPort;
|
|
113
|
+
stateDir;
|
|
114
|
+
logger;
|
|
115
|
+
frpcProcess = null;
|
|
116
|
+
healthCheckTimer = null;
|
|
117
|
+
_status = { status: "stopped" };
|
|
118
|
+
_publicUrl = null;
|
|
119
|
+
configPath = "";
|
|
120
|
+
adminPort = 0;
|
|
121
|
+
constructor(opts) {
|
|
122
|
+
this.config = opts.config;
|
|
123
|
+
this.localPort = opts.localPort;
|
|
124
|
+
this.stateDir = opts.stateDir;
|
|
125
|
+
this.logger = opts.logger ?? {
|
|
126
|
+
info: () => { },
|
|
127
|
+
warn: () => { },
|
|
128
|
+
error: () => { },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
get status() {
|
|
132
|
+
return this._status;
|
|
133
|
+
}
|
|
134
|
+
get publicUrl() {
|
|
135
|
+
return this._publicUrl;
|
|
136
|
+
}
|
|
137
|
+
/* ── Start ─────────────────────────────────────────────────────── */
|
|
138
|
+
async start() {
|
|
139
|
+
this._status = { status: "starting" };
|
|
140
|
+
// 1. Ensure frpc binary exists
|
|
141
|
+
const frpcPath = await this.ensureFrpcBinary();
|
|
142
|
+
this.logger.info(`[frp] using frpc binary: ${frpcPath}`);
|
|
143
|
+
// 2. Find free port for admin API
|
|
144
|
+
this.adminPort = await findFreePort();
|
|
145
|
+
// 3. Try ports in random order from range
|
|
146
|
+
const ports = shuffle(range(this.config.portRangeStart, this.config.portRangeEnd + 1));
|
|
147
|
+
for (const port of ports) {
|
|
148
|
+
try {
|
|
149
|
+
const publicUrl = await this.tryStartWithPort(frpcPath, port);
|
|
150
|
+
this._publicUrl = publicUrl;
|
|
151
|
+
this._status = { status: "running", publicUrl, remotePort: port };
|
|
152
|
+
// Start health monitoring
|
|
153
|
+
this.startHealthCheck();
|
|
154
|
+
return publicUrl;
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
this.logger.warn(`[frp] port ${port} unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
158
|
+
// Kill process if started, try next port
|
|
159
|
+
await this.killProcess();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
this._status = {
|
|
163
|
+
status: "error",
|
|
164
|
+
reason: `all ports in range ${this.config.portRangeStart}-${this.config.portRangeEnd} exhausted`,
|
|
165
|
+
};
|
|
166
|
+
throw new Error(`FRP tunnel failed: all ports in range ${this.config.portRangeStart}-${this.config.portRangeEnd} are unavailable`);
|
|
167
|
+
}
|
|
168
|
+
/* ── Stop ──────────────────────────────────────────────────────── */
|
|
169
|
+
async stop() {
|
|
170
|
+
if (this.healthCheckTimer) {
|
|
171
|
+
clearInterval(this.healthCheckTimer);
|
|
172
|
+
this.healthCheckTimer = null;
|
|
173
|
+
}
|
|
174
|
+
await this.killProcess();
|
|
175
|
+
// Cleanup config file
|
|
176
|
+
if (this.configPath) {
|
|
177
|
+
try {
|
|
178
|
+
await promises_1.default.unlink(this.configPath);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// ignore if already removed
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
this._status = { status: "stopped" };
|
|
185
|
+
this._publicUrl = null;
|
|
186
|
+
}
|
|
187
|
+
/* ── Private: try a specific port ──────────────────────────────── */
|
|
188
|
+
async tryStartWithPort(frpcPath, remotePort) {
|
|
189
|
+
const proxyName = `multiclaws-${(0, node_crypto_1.randomBytes)(4).toString("hex")}`;
|
|
190
|
+
this.configPath = node_path_1.default.join(this.stateDir, "frpc.toml");
|
|
191
|
+
const configContent = [
|
|
192
|
+
`serverAddr = "${this.config.serverAddr}"`,
|
|
193
|
+
`serverPort = ${this.config.serverPort}`,
|
|
194
|
+
`auth.token = "${this.config.token}"`,
|
|
195
|
+
``,
|
|
196
|
+
`webServer.addr = "127.0.0.1"`,
|
|
197
|
+
`webServer.port = ${this.adminPort}`,
|
|
198
|
+
``,
|
|
199
|
+
`[[proxies]]`,
|
|
200
|
+
`name = "${proxyName}"`,
|
|
201
|
+
`type = "tcp"`,
|
|
202
|
+
`localIP = "127.0.0.1"`,
|
|
203
|
+
`localPort = ${this.localPort}`,
|
|
204
|
+
`remotePort = ${remotePort}`,
|
|
205
|
+
].join("\n");
|
|
206
|
+
// Ensure stateDir exists
|
|
207
|
+
await promises_1.default.mkdir(this.stateDir, { recursive: true });
|
|
208
|
+
await promises_1.default.writeFile(this.configPath, configContent, "utf8");
|
|
209
|
+
// Spawn frpc
|
|
210
|
+
this.frpcProcess = (0, node_child_process_1.spawn)(frpcPath, ["-c", this.configPath], {
|
|
211
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
212
|
+
});
|
|
213
|
+
// Capture stdout/stderr for logging
|
|
214
|
+
this.frpcProcess.stdout?.on("data", (data) => {
|
|
215
|
+
const line = data.toString().trim();
|
|
216
|
+
if (line)
|
|
217
|
+
this.logger.info(`[frpc] ${line}`);
|
|
218
|
+
});
|
|
219
|
+
this.frpcProcess.stderr?.on("data", (data) => {
|
|
220
|
+
const line = data.toString().trim();
|
|
221
|
+
if (line)
|
|
222
|
+
this.logger.warn(`[frpc:stderr] ${line}`);
|
|
223
|
+
});
|
|
224
|
+
this.frpcProcess.on("exit", (code, signal) => {
|
|
225
|
+
if (this._status.status === "running") {
|
|
226
|
+
this.logger.error(`[frp] frpc process exited unexpectedly (code=${code}, signal=${signal})`);
|
|
227
|
+
this._status = { status: "error", reason: `frpc exited (code=${code})` };
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
// Poll admin API to confirm proxy is running
|
|
231
|
+
await this.waitForProxy(proxyName);
|
|
232
|
+
return `http://${this.config.serverAddr}:${remotePort}`;
|
|
233
|
+
}
|
|
234
|
+
/* ── Private: poll admin API ──────────────────────────────────── */
|
|
235
|
+
async waitForProxy(proxyName) {
|
|
236
|
+
// frpc 0.61.x uses /api/status (returns { tcp: [...], udp: [...], ... })
|
|
237
|
+
// older versions used /api/proxy/tcp (returns flat array or { proxies: [...] })
|
|
238
|
+
const statusUrl = `http://127.0.0.1:${this.adminPort}/api/status`;
|
|
239
|
+
for (let attempt = 0; attempt < ADMIN_API_POLL_MAX_RETRIES; attempt++) {
|
|
240
|
+
await new Promise((r) => setTimeout(r, ADMIN_API_POLL_INTERVAL_MS));
|
|
241
|
+
// Check if process has already exited
|
|
242
|
+
if (!this.frpcProcess || this.frpcProcess.exitCode !== null) {
|
|
243
|
+
throw new Error("frpc process exited before proxy became ready");
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const res = await fetch(statusUrl, { signal: AbortSignal.timeout(3_000) });
|
|
247
|
+
if (!res.ok)
|
|
248
|
+
continue;
|
|
249
|
+
const data = (await res.json());
|
|
250
|
+
// /api/status groups proxies by type: { tcp: [...], udp: [...], ... }
|
|
251
|
+
const tcpProxies = data.tcp ?? [];
|
|
252
|
+
const proxy = tcpProxies.find((p) => p.name === proxyName);
|
|
253
|
+
if (!proxy)
|
|
254
|
+
continue;
|
|
255
|
+
if (proxy.status === "running") {
|
|
256
|
+
this.logger.info(`[frp] proxy "${proxyName}" is running (remote_addr: ${proxy.remote_addr})`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (proxy.err) {
|
|
260
|
+
throw new Error(`frp proxy error: ${proxy.err}`);
|
|
261
|
+
}
|
|
262
|
+
// status might be "new" or "wait_start" — keep polling
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
if (err instanceof Error && err.message.startsWith("frp proxy error:")) {
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
// fetch failed (admin API not yet ready) — keep polling
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
throw new Error("timeout waiting for frpc proxy to become running");
|
|
272
|
+
}
|
|
273
|
+
/* ── Private: health check ─────────────────────────────────────── */
|
|
274
|
+
startHealthCheck() {
|
|
275
|
+
this.healthCheckTimer = setInterval(async () => {
|
|
276
|
+
if (this._status.status !== "running")
|
|
277
|
+
return;
|
|
278
|
+
try {
|
|
279
|
+
const res = await fetch(`http://127.0.0.1:${this.adminPort}/api/status`, { signal: AbortSignal.timeout(5_000) });
|
|
280
|
+
if (!res.ok) {
|
|
281
|
+
this.logger.warn("[frp] health check: admin API returned non-OK");
|
|
282
|
+
this._status = { status: "error", reason: "admin API returned non-OK status" };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
this.logger.warn("[frp] health check: failed to reach admin API");
|
|
287
|
+
this._status = { status: "error", reason: "admin API unreachable" };
|
|
288
|
+
}
|
|
289
|
+
}, HEALTH_CHECK_INTERVAL_MS);
|
|
290
|
+
// Don't prevent Node from exiting
|
|
291
|
+
if (this.healthCheckTimer.unref) {
|
|
292
|
+
this.healthCheckTimer.unref();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/* ── Private: kill process ─────────────────────────────────────── */
|
|
296
|
+
async killProcess() {
|
|
297
|
+
const proc = this.frpcProcess;
|
|
298
|
+
if (!proc)
|
|
299
|
+
return;
|
|
300
|
+
this.frpcProcess = null;
|
|
301
|
+
// Phase 1: graceful kill
|
|
302
|
+
proc.kill();
|
|
303
|
+
const exited = await Promise.race([
|
|
304
|
+
new Promise((resolve) => proc.on("exit", () => resolve(true))),
|
|
305
|
+
new Promise((resolve) => setTimeout(() => resolve(false), PROCESS_KILL_TIMEOUT_MS)),
|
|
306
|
+
]);
|
|
307
|
+
if (!exited) {
|
|
308
|
+
// Phase 2: force kill
|
|
309
|
+
try {
|
|
310
|
+
if (process.platform === "win32" && proc.pid) {
|
|
311
|
+
(0, node_child_process_1.execSync)(`taskkill /pid ${proc.pid} /f /t`, { stdio: "ignore" });
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
proc.kill("SIGKILL");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// process may have already exited
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/* ── Private: ensure frpc binary ───────────────────────────────── */
|
|
323
|
+
async ensureFrpcBinary() {
|
|
324
|
+
const ext = process.platform === "win32" ? ".exe" : "";
|
|
325
|
+
const localBinary = node_path_1.default.join(this.stateDir, `frpc${ext}`);
|
|
326
|
+
// 1. Check stateDir
|
|
327
|
+
try {
|
|
328
|
+
await promises_1.default.access(localBinary);
|
|
329
|
+
return localBinary;
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
// not found locally
|
|
333
|
+
}
|
|
334
|
+
// 2. Check system PATH
|
|
335
|
+
if (detectFrpc()) {
|
|
336
|
+
return "frpc";
|
|
337
|
+
}
|
|
338
|
+
// 3. Auto-download
|
|
339
|
+
this.logger.info(`[frp] frpc not found, downloading v${FRP_VERSION}...`);
|
|
340
|
+
return await this.downloadFrpc(localBinary);
|
|
341
|
+
}
|
|
342
|
+
async downloadFrpc(targetPath) {
|
|
343
|
+
const { os: frpOs, arch: frpArch, ext } = getFrpPlatform();
|
|
344
|
+
const archiveName = `frp_${FRP_VERSION}_${frpOs}_${frpArch}`;
|
|
345
|
+
const url = `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/${archiveName}.${ext}`;
|
|
346
|
+
const downloadDir = node_path_1.default.join(this.stateDir, "frpc-download");
|
|
347
|
+
await promises_1.default.mkdir(downloadDir, { recursive: true });
|
|
348
|
+
const archivePath = node_path_1.default.join(downloadDir, `${archiveName}.${ext}`);
|
|
349
|
+
// Download
|
|
350
|
+
this.logger.info(`[frp] downloading from ${url}`);
|
|
351
|
+
const res = await fetch(url);
|
|
352
|
+
if (!res.ok) {
|
|
353
|
+
throw new Error(`failed to download frpc: HTTP ${res.status} from ${url}`);
|
|
354
|
+
}
|
|
355
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
356
|
+
await promises_1.default.writeFile(archivePath, buffer);
|
|
357
|
+
// Extract
|
|
358
|
+
this.logger.info(`[frp] extracting ${archivePath}`);
|
|
359
|
+
const binaryName = process.platform === "win32" ? "frpc.exe" : "frpc";
|
|
360
|
+
try {
|
|
361
|
+
if (ext === "tar.gz") {
|
|
362
|
+
(0, node_child_process_1.execSync)(`tar -xzf "${archivePath}" -C "${downloadDir}"`, { stdio: "ignore" });
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
// Windows: use spawnSync to bypass cmd.exe quote-mangling that breaks
|
|
366
|
+
// the embedded PowerShell double-quotes when execSync(string) is used.
|
|
367
|
+
const psCmd = `$ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${downloadDir.replace(/'/g, "''")}' -Force`;
|
|
368
|
+
const psResult = (0, node_child_process_1.spawnSync)("powershell", ["-NoProfile", "-Command", psCmd], {
|
|
369
|
+
stdio: "pipe",
|
|
370
|
+
});
|
|
371
|
+
if (psResult.status !== 0) {
|
|
372
|
+
const stderr = psResult.stderr?.toString().trim() ?? "";
|
|
373
|
+
throw new Error(`Expand-Archive failed (exit ${psResult.status}): ${stderr}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Move binary to target
|
|
377
|
+
const extractedBinary = node_path_1.default.join(downloadDir, archiveName, binaryName);
|
|
378
|
+
await promises_1.default.mkdir(node_path_1.default.dirname(targetPath), { recursive: true });
|
|
379
|
+
await promises_1.default.copyFile(extractedBinary, targetPath);
|
|
380
|
+
// Make executable on Unix
|
|
381
|
+
if (process.platform !== "win32") {
|
|
382
|
+
await promises_1.default.chmod(targetPath, 0o755);
|
|
383
|
+
}
|
|
384
|
+
this.logger.info(`[frp] frpc installed to ${targetPath}`);
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
// Cleanup download directory
|
|
388
|
+
try {
|
|
389
|
+
await promises_1.default.rm(downloadDir, { recursive: true, force: true });
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// ignore cleanup errors
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return targetPath;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
exports.FrpTunnelManager = FrpTunnelManager;
|