@cjhyy/code-shell-server 0.9.48 → 0.9.49
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/dist/desktop-web/http-api.d.ts +9 -0
- package/dist/desktop-web/http-api.js +17 -1
- package/dist/http-byte-range.d.ts +5 -0
- package/dist/http-byte-range.js +19 -0
- package/dist/hub/files.js +71 -9
- package/dist/index.panels.d.ts +1 -0
- package/dist/index.panels.js +1 -0
- package/dist/mobile-remote/access-passcode.d.ts +5 -0
- package/dist/mobile-remote/access-passcode.js +48 -9
- package/dist/mobile-remote/mobile-client-event-validator.js +30 -1
- package/dist/mobile-remote/remote-host-manager.d.ts +7 -1
- package/dist/mobile-remote/remote-host-manager.js +132 -56
- package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
- package/dist/mobile-remote/tunnel-manager.js +45 -20
- package/dist/panels/background-service-process.d.ts +39 -0
- package/dist/panels/background-service-process.js +457 -0
- package/dist/panels/background-service-store.d.ts +67 -0
- package/dist/panels/background-service-store.js +539 -0
- package/dist/panels/background-services.d.ts +193 -0
- package/dist/panels/background-services.js +1159 -0
- package/dist/panels/bridge-contract.d.ts +1 -1
- package/dist/panels/execution-gate.d.ts +7 -1
- package/dist/panels/execution-gate.js +26 -0
- package/dist/panels/management.js +49 -13
- package/dist/panels/process-files.js +28 -3
- package/dist/panels/resources/library.d.ts +1 -4
- package/dist/panels/resources/library.js +2 -18
- package/dist/panels/resources/references.js +21 -8
- package/dist/panels/runtime.js +1 -1
- package/dist/panels/tool-jobs.d.ts +2 -0
- package/dist/panels/tool-jobs.js +1 -1
- package/dist/sessions-service.d.ts +4 -0
- package/dist/sessions-service.js +17 -0
- package/package.json +6 -6
|
@@ -10,6 +10,16 @@ import { parseMobileClientEvent } from "./mobile-client-event-validator.js";
|
|
|
10
10
|
import { isRelayOrigin } from "../remote-relay/protocol.js";
|
|
11
11
|
/** Outbound bytes one paired socket may leave unread before it is retired. */
|
|
12
12
|
const MAX_SOCKET_BACKLOG_BYTES = 4 * 1024 * 1024;
|
|
13
|
+
/** The query suffix ("" or "?…") when the raw request target is exactly the
|
|
14
|
+
* root entry, else undefined. The redirect path is fixed, so only the query
|
|
15
|
+
* is carried over and it can never form an off-site Location. */
|
|
16
|
+
function rootEntryQuery(target) {
|
|
17
|
+
if (target === "/")
|
|
18
|
+
return "";
|
|
19
|
+
if (target?.startsWith("/?"))
|
|
20
|
+
return target.slice(1);
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
13
23
|
/**
|
|
14
24
|
* Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
|
|
15
25
|
* remote host. We bind to a concrete LAN address (NOT 0.0.0.0) per the design's
|
|
@@ -57,6 +67,8 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
57
67
|
publicBaseUrl;
|
|
58
68
|
/** Passcode gate, present only in tunnel mode. */
|
|
59
69
|
passcode;
|
|
70
|
+
socketAccessValidity = new WeakMap();
|
|
71
|
+
accessSweep;
|
|
60
72
|
/** Where the built mobile app lives (out/mobile). */
|
|
61
73
|
mobileRootDir;
|
|
62
74
|
/** Dev proxy target for /mobile (HMR); undefined in prod. */
|
|
@@ -117,8 +129,8 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
117
129
|
async startOnce(options) {
|
|
118
130
|
const relay = options.mode === "relay";
|
|
119
131
|
const publicAccess = relay || options.mode === "tunnel";
|
|
120
|
-
if (
|
|
121
|
-
throw new Error("Relay mode requires an initialized access passcode
|
|
132
|
+
if (publicAccess && !options.passcode?.isSet()) {
|
|
133
|
+
throw new Error(`${relay ? "Relay" : "Tunnel"} mode requires an initialized access passcode`);
|
|
122
134
|
}
|
|
123
135
|
if (relay)
|
|
124
136
|
this.publicBaseUrl = undefined;
|
|
@@ -136,60 +148,88 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
136
148
|
req.resume();
|
|
137
149
|
return;
|
|
138
150
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
if (req.url?.startsWith("/api/v1/") && this.opts.webApi) {
|
|
149
|
-
const baseUrl = this.publicBaseUrl ?? this.started?.url;
|
|
150
|
-
if (!baseUrl) {
|
|
151
|
-
res.writeHead(503);
|
|
152
|
-
res.end("remote host is starting");
|
|
151
|
+
const serve = () => {
|
|
152
|
+
// All ordinary routes retain the tunnel passcode gate. Only a Host-verified
|
|
153
|
+
// short-lived Panel asset capability may use independent authorization.
|
|
154
|
+
if (gate && !gate.gate(req, res))
|
|
155
|
+
return;
|
|
156
|
+
if (req.url === "/health") {
|
|
157
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
158
|
+
res.end(JSON.stringify({ ok: true }));
|
|
153
159
|
return;
|
|
154
160
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
res.
|
|
160
|
-
|
|
161
|
+
if (req.url?.startsWith("/api/v1/") && this.opts.webApi) {
|
|
162
|
+
const baseUrl = this.publicBaseUrl ?? this.started?.url;
|
|
163
|
+
if (!baseUrl) {
|
|
164
|
+
res.writeHead(503);
|
|
165
|
+
res.end("remote host is starting");
|
|
166
|
+
return;
|
|
161
167
|
}
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
void this.opts.webApi
|
|
169
|
+
.handle(req, res, { baseUrl, isAccessAuthorized: gate?.captureValidity() })
|
|
170
|
+
.then((handled) => {
|
|
171
|
+
if (!handled && !res.writableEnded) {
|
|
172
|
+
res.writeHead(404);
|
|
173
|
+
res.end("not found");
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
.catch(() => {
|
|
177
|
+
if (!res.headersSent)
|
|
178
|
+
res.writeHead(500);
|
|
179
|
+
if (!res.writableEnded)
|
|
180
|
+
res.end("remote request failed");
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const uploadPath = pathname.match(/^\/api\/mobile\/uploads\/([A-Za-z0-9_-]+)$/);
|
|
185
|
+
if (uploadPath && this.opts.uploads) {
|
|
186
|
+
void this.opts.uploads.acceptPut(uploadPath[1], req, res);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// The mobile app is built/served with vite base "/mobile/", so ALL of its
|
|
190
|
+
// assets — and in dev, vite's HMR/module URLs — live under the /mobile
|
|
191
|
+
// prefix. A single prefix route serves prod (static out/mobile) and dev
|
|
192
|
+
// (proxy to the mobile vite server) symmetrically. Tunnel mode never
|
|
193
|
+
// proxies dev: Vite's HMR socket and @fs module URLs don't survive a
|
|
194
|
+
// public trycloudflare hop, so a public tunnel always serves the build.
|
|
195
|
+
// Match the route family exactly ("/mobile", "/mobile/…", "/mobile?…",
|
|
196
|
+
// "/mobile#…") — NOT a sibling like "/mobilexyz", which falls to 404.
|
|
197
|
+
if (req.url && /^\/mobile(?:[/?#]|$)/.test(req.url)) {
|
|
198
|
+
serveMobile(req, res, {
|
|
199
|
+
rootDir: this.mobileRootDir,
|
|
200
|
+
devUrl: publicAccess ? undefined : this.mobileDevUrl,
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// The bare origin is what people type or share; send it to the app with
|
|
205
|
+
// the query (e.g. ?pairing=) intact. Match the raw target, not the
|
|
206
|
+
// parsed pathname: "//other.host/" parses to "/" but must stay a 404.
|
|
207
|
+
const rootQuery = rootEntryQuery(req.url);
|
|
208
|
+
if (rootQuery !== undefined && (req.method === "GET" || req.method === "HEAD")) {
|
|
209
|
+
res.writeHead(302, { location: `/mobile/${rootQuery}`, "cache-control": "no-store" });
|
|
210
|
+
res.end();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
res.writeHead(404);
|
|
214
|
+
res.end("not found");
|
|
215
|
+
};
|
|
216
|
+
const webApi = this.opts.webApi;
|
|
217
|
+
if (gate?.isSet() &&
|
|
218
|
+
["GET", "HEAD"].includes(req.method ?? "") &&
|
|
219
|
+
pathname.startsWith("/api/v1/panel-assets/") &&
|
|
220
|
+
webApi?.handleCapabilityAsset) {
|
|
221
|
+
void webApi.handleCapabilityAsset(req, res).then((handled) => {
|
|
222
|
+
if (!handled && !res.writableEnded)
|
|
223
|
+
serve();
|
|
224
|
+
}, () => {
|
|
164
225
|
if (!res.headersSent)
|
|
165
226
|
res.writeHead(500);
|
|
166
227
|
if (!res.writableEnded)
|
|
167
|
-
res.end("remote request failed");
|
|
168
|
-
});
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
const uploadPath = pathname.match(/^\/api\/mobile\/uploads\/([A-Za-z0-9_-]+)$/);
|
|
172
|
-
if (uploadPath && this.opts.uploads) {
|
|
173
|
-
void this.opts.uploads.acceptPut(uploadPath[1], req, res);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
// The mobile app is built/served with vite base "/mobile/", so ALL of its
|
|
177
|
-
// assets — and in dev, vite's HMR/module URLs — live under the /mobile
|
|
178
|
-
// prefix. A single prefix route serves prod (static out/mobile) and dev
|
|
179
|
-
// (proxy to the mobile vite server) symmetrically. Tunnel mode never
|
|
180
|
-
// proxies dev: Vite's HMR socket and @fs module URLs don't survive a
|
|
181
|
-
// public trycloudflare hop, so a public tunnel always serves the build.
|
|
182
|
-
// Match the route family exactly ("/mobile", "/mobile/…", "/mobile?…",
|
|
183
|
-
// "/mobile#…") — NOT a sibling like "/mobilexyz", which falls to 404.
|
|
184
|
-
if (req.url && /^\/mobile(?:[/?#]|$)/.test(req.url)) {
|
|
185
|
-
serveMobile(req, res, {
|
|
186
|
-
rootDir: this.mobileRootDir,
|
|
187
|
-
devUrl: publicAccess ? undefined : this.mobileDevUrl,
|
|
228
|
+
res.end("remote asset request failed");
|
|
188
229
|
});
|
|
189
230
|
return;
|
|
190
231
|
}
|
|
191
|
-
|
|
192
|
-
res.end("not found");
|
|
232
|
+
serve();
|
|
193
233
|
});
|
|
194
234
|
// In tunnel mode we must gate the WS handshake too. `noServer` lets us run
|
|
195
235
|
// the passcode check during the HTTP `upgrade` event before completing the
|
|
@@ -225,7 +265,9 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
225
265
|
socket.destroy();
|
|
226
266
|
return;
|
|
227
267
|
}
|
|
268
|
+
const isAccessAuthorized = gate.captureValidity();
|
|
228
269
|
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
270
|
+
this.socketAccessValidity.set(ws, isAccessAuthorized);
|
|
229
271
|
this.wss.emit("connection", ws, req);
|
|
230
272
|
});
|
|
231
273
|
});
|
|
@@ -241,17 +283,19 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
241
283
|
this.emit("host-error", error);
|
|
242
284
|
});
|
|
243
285
|
ws.on("message", (raw) => {
|
|
286
|
+
if (!this.hasSocketAccess(ws))
|
|
287
|
+
return;
|
|
244
288
|
let parsed;
|
|
245
289
|
try {
|
|
246
290
|
parsed = JSON.parse(String(raw));
|
|
247
291
|
}
|
|
248
292
|
catch {
|
|
249
|
-
|
|
293
|
+
this.sendBounded(ws, JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
|
250
294
|
return;
|
|
251
295
|
}
|
|
252
296
|
const event = parseMobileClientEvent(parsed);
|
|
253
297
|
if (!event) {
|
|
254
|
-
|
|
298
|
+
this.sendBounded(ws, JSON.stringify({ type: "error", message: "Invalid client event" }));
|
|
255
299
|
return;
|
|
256
300
|
}
|
|
257
301
|
// Pairing/auth are handled inline and bind device identity to this
|
|
@@ -262,7 +306,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
262
306
|
reply = this.handleClientEvent(event);
|
|
263
307
|
}
|
|
264
308
|
catch {
|
|
265
|
-
|
|
309
|
+
this.sendBounded(ws, JSON.stringify({ type: "error", message: "Client event failed" }));
|
|
266
310
|
return;
|
|
267
311
|
}
|
|
268
312
|
if (event.type === "auth.device" && reply?.type === "auth.ok") {
|
|
@@ -273,11 +317,11 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
273
317
|
this.bindSocketDevice(ws, reply.device.id);
|
|
274
318
|
}
|
|
275
319
|
if (reply) {
|
|
276
|
-
|
|
320
|
+
this.sendBounded(ws, JSON.stringify(reply));
|
|
277
321
|
return;
|
|
278
322
|
}
|
|
279
323
|
if (!this.authed.has(ws)) {
|
|
280
|
-
|
|
324
|
+
this.sendBounded(ws, JSON.stringify({ type: "auth.failed", message: "Not authenticated" }));
|
|
281
325
|
return;
|
|
282
326
|
}
|
|
283
327
|
// Authenticated, non-auth event → hand to the main dispatcher, which
|
|
@@ -286,12 +330,12 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
286
330
|
const dispatched = this.opts.onClientEvent({ ...event, deviceId: this.authed.get(ws), viewerId: this.viewers.get(ws) }, ws);
|
|
287
331
|
void Promise.resolve(dispatched).catch(() => {
|
|
288
332
|
if (ws.readyState === ws.OPEN) {
|
|
289
|
-
|
|
333
|
+
this.sendBounded(ws, JSON.stringify({ type: "error", message: "Client event failed" }));
|
|
290
334
|
}
|
|
291
335
|
});
|
|
292
336
|
}
|
|
293
337
|
catch {
|
|
294
|
-
|
|
338
|
+
this.sendBounded(ws, JSON.stringify({ type: "error", message: "Client event failed" }));
|
|
295
339
|
}
|
|
296
340
|
});
|
|
297
341
|
ws.on("close", () => {
|
|
@@ -346,6 +390,17 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
346
390
|
url: `http://${bindHost}:${port}`,
|
|
347
391
|
mode: options.mode ?? "lan",
|
|
348
392
|
};
|
|
393
|
+
if (gate) {
|
|
394
|
+
const sockets = this.wss;
|
|
395
|
+
// Idle public tabs must also relinquish viewer-owned state after rotation.
|
|
396
|
+
// Traffic is checked synchronously in both directions, without rehashing
|
|
397
|
+
// any plaintext or spending the passcode attempt budget.
|
|
398
|
+
this.accessSweep = setInterval(() => {
|
|
399
|
+
for (const client of sockets?.clients ?? [])
|
|
400
|
+
this.hasSocketAccess(client);
|
|
401
|
+
}, 1000);
|
|
402
|
+
this.accessSweep.unref();
|
|
403
|
+
}
|
|
349
404
|
return this.started;
|
|
350
405
|
}
|
|
351
406
|
/**
|
|
@@ -473,7 +528,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
473
528
|
/** Reply only to the currently authenticated tab; retired/revoked viewers receive nothing. */
|
|
474
529
|
hasAuthenticatedViewer(viewerId, deviceId) {
|
|
475
530
|
for (const client of this.wss?.clients ?? []) {
|
|
476
|
-
if (
|
|
531
|
+
if (this.hasSocketAccess(client) &&
|
|
477
532
|
this.authed.get(client) === deviceId &&
|
|
478
533
|
this.viewers.get(client) === viewerId)
|
|
479
534
|
return true;
|
|
@@ -481,7 +536,10 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
481
536
|
return false;
|
|
482
537
|
}
|
|
483
538
|
sendToViewer(viewerId, event) {
|
|
484
|
-
|
|
539
|
+
this.sendRawToViewer(viewerId, JSON.stringify(event));
|
|
540
|
+
}
|
|
541
|
+
/** Preserve the full worker envelope while retaining exact authenticated-tab routing. */
|
|
542
|
+
sendRawToViewer(viewerId, payload) {
|
|
485
543
|
for (const client of this.wss?.clients ?? []) {
|
|
486
544
|
if (client.readyState === client.OPEN &&
|
|
487
545
|
this.authed.has(client) &&
|
|
@@ -519,6 +577,8 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
519
577
|
* socket is still delivered. Returns false when retired.
|
|
520
578
|
*/
|
|
521
579
|
sendBounded(client, payload) {
|
|
580
|
+
if (!this.hasSocketAccess(client))
|
|
581
|
+
return false;
|
|
522
582
|
if (client.bufferedAmount > 0 &&
|
|
523
583
|
client.bufferedAmount + Buffer.byteLength(payload) > MAX_SOCKET_BACKLOG_BYTES) {
|
|
524
584
|
this.releaseSocket(client);
|
|
@@ -528,6 +588,20 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
528
588
|
client.send(payload);
|
|
529
589
|
return true;
|
|
530
590
|
}
|
|
591
|
+
hasSocketAccess(client) {
|
|
592
|
+
if (client.readyState !== client.OPEN)
|
|
593
|
+
return false;
|
|
594
|
+
if (this.socketAccessValidity.get(client)?.() !== false)
|
|
595
|
+
return true;
|
|
596
|
+
this.releaseSocket(client);
|
|
597
|
+
// Distinct from paired-device rejection (4401/4403): the browser keeps its
|
|
598
|
+
// identity and offers a fresh HTTP challenge instead of silently retrying.
|
|
599
|
+
client.close(4407, "Access passcode changed");
|
|
600
|
+
const deadline = setTimeout(() => client.terminate(), 1000);
|
|
601
|
+
deadline.unref();
|
|
602
|
+
client.once("close", () => clearTimeout(deadline));
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
531
605
|
releaseSocket(ws) {
|
|
532
606
|
const deviceId = this.authed.get(ws);
|
|
533
607
|
const viewerId = this.viewers.get(ws);
|
|
@@ -584,6 +658,8 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
584
658
|
async stopOnce() {
|
|
585
659
|
if (this.starting)
|
|
586
660
|
await this.starting.catch(() => undefined);
|
|
661
|
+
clearInterval(this.accessSweep);
|
|
662
|
+
this.accessSweep = undefined;
|
|
587
663
|
this.relayLifetime?.abort();
|
|
588
664
|
this.relayLifetime = undefined;
|
|
589
665
|
const server = this.server;
|
|
@@ -51,6 +51,7 @@ export declare class TunnelManager extends EventEmitter {
|
|
|
51
51
|
private readonly killGraceMs;
|
|
52
52
|
private readonly killHardTimeoutMs;
|
|
53
53
|
private child?;
|
|
54
|
+
private cancelPendingStart?;
|
|
54
55
|
/** In-flight teardown of a previous child. Any start() awaits this first so a
|
|
55
56
|
* new cloudflared never spawns while the old one still owns the metrics port. */
|
|
56
57
|
private pendingTeardown?;
|
|
@@ -49,6 +49,7 @@ export class TunnelManager extends EventEmitter {
|
|
|
49
49
|
killGraceMs;
|
|
50
50
|
killHardTimeoutMs;
|
|
51
51
|
child;
|
|
52
|
+
cancelPendingStart;
|
|
52
53
|
/** In-flight teardown of a previous child. Any start() awaits this first so a
|
|
53
54
|
* new cloudflared never spawns while the old one still owns the metrics port. */
|
|
54
55
|
pendingTeardown;
|
|
@@ -102,7 +103,7 @@ export class TunnelManager extends EventEmitter {
|
|
|
102
103
|
// Force http2 (TCP). cloudflared defaults to QUIC (UDP/7844), which is
|
|
103
104
|
// blocked on many networks (corp Wi-Fi, some carriers) — the connection
|
|
104
105
|
// object registers but never becomes ready, so the phone hits Cloudflare
|
|
105
|
-
// error 1033.
|
|
106
|
+
// error 1033. HTTP/2 still requires outbound TCP/7844 to the edge.
|
|
106
107
|
"--protocol",
|
|
107
108
|
"http2",
|
|
108
109
|
// Fixed local metrics endpoint so we can poll /ready for edge readiness.
|
|
@@ -114,37 +115,55 @@ export class TunnelManager extends EventEmitter {
|
|
|
114
115
|
this.child = child;
|
|
115
116
|
return new Promise((resolve, reject) => {
|
|
116
117
|
let settled = false;
|
|
117
|
-
const
|
|
118
|
+
const settle = () => {
|
|
118
119
|
if (settled)
|
|
119
|
-
return;
|
|
120
|
+
return false;
|
|
120
121
|
settled = true;
|
|
121
122
|
clearTimeout(timer);
|
|
123
|
+
if (this.cancelPendingStart === cancelStart)
|
|
124
|
+
this.cancelPendingStart = undefined;
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
const cancelStart = () => {
|
|
128
|
+
if (settle())
|
|
129
|
+
reject(new Error("隧道启动已取消。"));
|
|
130
|
+
};
|
|
131
|
+
const finishReject = (err) => {
|
|
132
|
+
if (!settle())
|
|
133
|
+
return;
|
|
122
134
|
// Route through the shared teardown so a retried start() awaits this
|
|
123
135
|
// child's real exit (releasing the metrics port) before re-spawning.
|
|
124
|
-
|
|
136
|
+
if (this.child === child)
|
|
137
|
+
void this.beginTeardown();
|
|
125
138
|
reject(err);
|
|
126
139
|
};
|
|
127
140
|
const timer = setTimeout(() => {
|
|
128
141
|
finishReject(new Error("隧道启动失败:15s 内未取得公网地址(超时)"));
|
|
129
142
|
}, this.timeoutMs);
|
|
130
|
-
|
|
143
|
+
this.cancelPendingStart = cancelStart;
|
|
144
|
+
const scanTails = { stdout: "", stderr: "" };
|
|
145
|
+
const scan = (stream, buf) => {
|
|
131
146
|
if (settled)
|
|
132
147
|
return;
|
|
133
|
-
const
|
|
134
|
-
|
|
148
|
+
const text = scanTails[stream] + String(buf);
|
|
149
|
+
const match = TRYCLOUDFLARE_RE.exec(text);
|
|
150
|
+
if (!match) {
|
|
151
|
+
// A pipe chunk can end anywhere in the URL. Keep a bounded suffix
|
|
152
|
+
// per stream so stdout and stderr cannot combine into a false URL.
|
|
153
|
+
scanTails[stream] = text.slice(-256);
|
|
135
154
|
return;
|
|
155
|
+
}
|
|
136
156
|
// URL captured, but the edge connection is NOT necessarily ready yet —
|
|
137
157
|
// resolving here was the 1033 bug (handing back a dead QR). Stop the
|
|
138
158
|
// URL scanners and wait for /ready before declaring connected.
|
|
139
|
-
child.stdout?.off("data",
|
|
140
|
-
child.stderr?.off("data",
|
|
159
|
+
child.stdout?.off("data", scanStdout);
|
|
160
|
+
child.stderr?.off("data", scanStderr);
|
|
141
161
|
clearTimeout(timer);
|
|
142
162
|
const url = match[0];
|
|
143
|
-
this.waitForReady()
|
|
163
|
+
this.waitForReady(child)
|
|
144
164
|
.then(() => {
|
|
145
|
-
if (
|
|
165
|
+
if (this.child !== child || !settle())
|
|
146
166
|
return;
|
|
147
|
-
settled = true;
|
|
148
167
|
this.connected = true;
|
|
149
168
|
this.currentUrl = url;
|
|
150
169
|
this.emit("status", "connected", url);
|
|
@@ -153,8 +172,10 @@ export class TunnelManager extends EventEmitter {
|
|
|
153
172
|
})
|
|
154
173
|
.catch((err) => finishReject(err));
|
|
155
174
|
};
|
|
156
|
-
|
|
157
|
-
|
|
175
|
+
const scanStdout = (buf) => scan("stdout", buf);
|
|
176
|
+
const scanStderr = (buf) => scan("stderr", buf);
|
|
177
|
+
child.stdout?.on("data", scanStdout);
|
|
178
|
+
child.stderr?.on("data", scanStderr);
|
|
158
179
|
child.on("error", (err) => {
|
|
159
180
|
// A superseded child (torn down by a restart / stop-during-start) must
|
|
160
181
|
// NOT touch shared state — killAndWait owns its lifecycle. Match the exit
|
|
@@ -163,8 +184,7 @@ export class TunnelManager extends EventEmitter {
|
|
|
163
184
|
if (this.child !== child)
|
|
164
185
|
return;
|
|
165
186
|
if (!settled) {
|
|
166
|
-
|
|
167
|
-
clearTimeout(timer);
|
|
187
|
+
settle();
|
|
168
188
|
this.child = undefined;
|
|
169
189
|
this.connected = false;
|
|
170
190
|
this.currentUrl = undefined;
|
|
@@ -193,8 +213,7 @@ export class TunnelManager extends EventEmitter {
|
|
|
193
213
|
this.emit("status", "disconnected", { code, signal });
|
|
194
214
|
}
|
|
195
215
|
else if (!settled) {
|
|
196
|
-
|
|
197
|
-
clearTimeout(timer);
|
|
216
|
+
settle();
|
|
198
217
|
reject(new Error(`cloudflared 退出(code=${code ?? "?"} signal=${signal ?? "?"})`));
|
|
199
218
|
}
|
|
200
219
|
});
|
|
@@ -205,10 +224,15 @@ export class TunnelManager extends EventEmitter {
|
|
|
205
224
|
* `readyTimeoutMs`. This is what distinguishes a working tunnel from one that
|
|
206
225
|
* registered a connection but can't actually serve traffic (error 1033).
|
|
207
226
|
*/
|
|
208
|
-
async waitForReady() {
|
|
227
|
+
async waitForReady(child) {
|
|
209
228
|
const deadline = Date.now() + this.readyTimeoutMs;
|
|
210
229
|
for (;;) {
|
|
211
|
-
if (this.child
|
|
230
|
+
if (this.child !== child || this.stopping)
|
|
231
|
+
throw new Error("隧道启动已取消。");
|
|
232
|
+
const ready = await this.checkReady(this.metricsPort);
|
|
233
|
+
if (this.child !== child || this.stopping)
|
|
234
|
+
throw new Error("隧道启动已取消。");
|
|
235
|
+
if (ready)
|
|
212
236
|
return;
|
|
213
237
|
if (Date.now() >= deadline) {
|
|
214
238
|
throw new Error("隧道注册失败:公网边缘连接未就绪(可能网络限制,扫码会报 1033)");
|
|
@@ -285,6 +309,7 @@ export class TunnelManager extends EventEmitter {
|
|
|
285
309
|
*/
|
|
286
310
|
beginTeardown() {
|
|
287
311
|
this.stopping = true;
|
|
312
|
+
this.cancelPendingStart?.();
|
|
288
313
|
this.connected = false;
|
|
289
314
|
this.currentUrl = undefined;
|
|
290
315
|
this.stopHealthMonitor();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
import { groupStatus, type ServiceProcessIdentity, type ServiceIdentityInspector } from "./background-service-store.js";
|
|
3
|
+
export declare function safeServiceNodeVersion(version: string): boolean;
|
|
4
|
+
export declare function probeServiceRuntime(executable: string): Promise<string[]>;
|
|
5
|
+
export interface ServiceProcess {
|
|
6
|
+
identity: ServiceProcessIdentity;
|
|
7
|
+
send(message: unknown): void;
|
|
8
|
+
prepare(): Promise<ServiceProcessIdentity>;
|
|
9
|
+
activate(hello: unknown): void;
|
|
10
|
+
terminate(timeoutMs: number): Promise<boolean>;
|
|
11
|
+
/** Guardian stdio closed; use terminate() to confirm the entire group is dead. */
|
|
12
|
+
exited: Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
/** This private guardian protocol has exactly two messages and one total byte
|
|
15
|
+
* budget, not an ever-growing line buffer. Reject before any identity syscall. */
|
|
16
|
+
export declare function readServiceCustody(stream: Readable, guardianPid: number, identities: ServiceIdentityInspector): {
|
|
17
|
+
custody: Promise<ServiceProcessIdentity>;
|
|
18
|
+
member: Promise<ServiceProcessIdentity>;
|
|
19
|
+
close: (error?: Error) => void;
|
|
20
|
+
readonly memberIdentity: ServiceProcessIdentity | undefined;
|
|
21
|
+
readonly retainedBytes: number;
|
|
22
|
+
};
|
|
23
|
+
/** A guardian's close event only confirms its stdio closed. A signalled native
|
|
24
|
+
* member may still be executing; only the OS group observation confirms death. */
|
|
25
|
+
export declare function waitForServiceGroupExit(pid: number, timeoutMs: number, observe?: () => ReturnType<typeof groupStatus>, clock?: () => number, yieldTurn?: (delayMs: number) => Promise<void>): Promise<boolean>;
|
|
26
|
+
export declare function spawnServiceProcess(input: {
|
|
27
|
+
executable: string;
|
|
28
|
+
entry: string;
|
|
29
|
+
sha256: string;
|
|
30
|
+
packageRoot: string;
|
|
31
|
+
cwd: string;
|
|
32
|
+
maxLineBytes: number;
|
|
33
|
+
onLine(message: unknown): void;
|
|
34
|
+
onExit(error?: string): void;
|
|
35
|
+
}, adapters?: {
|
|
36
|
+
/** Explicit OS/runtime boundaries for owned-process fault tests; never guest supplied. */
|
|
37
|
+
identities?: ServiceIdentityInspector;
|
|
38
|
+
probeRuntime?: typeof probeServiceRuntime;
|
|
39
|
+
}): Promise<ServiceProcess>;
|