made-bridge 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +45 -2
- package/builtin/plugins/README.md +22 -0
- package/builtin/themes/README.md +64 -0
- package/builtin/themes/sunset/theme.json +19 -0
- package/config.example.json +20 -0
- package/demo-project/README.md +5 -0
- package/demo-project/index.mjs +14 -0
- package/package.json +48 -4
- package/public/app.js +1028 -0
- package/public/assistants.html +67 -0
- package/public/bean-icon.png +0 -0
- package/public/i18n-messages.js +967 -0
- package/public/i18n-static.js +615 -0
- package/public/i18n.js +50 -0
- package/public/index.html +149 -0
- package/public/made-icon.png +0 -0
- package/public/plugins.html +64 -0
- package/public/plugins.js +335 -0
- package/public/provider-icon.js +77 -0
- package/public/styles.css +445 -0
- package/public/tasks.html +85 -0
- package/src/agent-icons.mjs +138 -0
- package/src/agents/codex.mjs +94 -0
- package/src/agents/cursor.mjs +31 -0
- package/src/agents/events.mjs +92 -0
- package/src/agents/index.mjs +7 -0
- package/src/agents/qoder.mjs +34 -0
- package/src/agents/shared.mjs +191 -0
- package/src/agents/workbuddy.mjs +121 -0
- package/src/board-link.mjs +266 -0
- package/src/config.mjs +196 -0
- package/src/doctor.mjs +146 -0
- package/src/i18n.mjs +257 -0
- package/src/installed.mjs +291 -0
- package/src/jobs.mjs +369 -0
- package/src/language-settings.mjs +38 -0
- package/src/model-settings.mjs +63 -0
- package/src/openai-key.mjs +47 -0
- package/src/oray.mjs +243 -0
- package/src/pairing.mjs +203 -0
- package/src/plugin-package.mjs +132 -0
- package/src/plugin-runtime.mjs +169 -0
- package/src/plugins/contracts.d.ts +184 -0
- package/src/plugins/enhance.mjs +156 -0
- package/src/plugins/registry.mjs +146 -0
- package/src/providers.mjs +75 -0
- package/src/server.mjs +939 -0
- package/src/sessions.mjs +134 -0
- package/src/speech/index.mjs +10 -0
- package/src/speech/openai.mjs +48 -0
- package/src/speech/whisper.mjs +59 -0
- package/src/speech-settings.mjs +209 -0
- package/src/transcribe.mjs +85 -0
- package/src/tunnel.mjs +531 -0
- package/src/usb-receiver.mjs +664 -0
- package/src/voice-settings.mjs +69 -0
package/src/tunnel.mjs
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { isIP, createServer } from 'node:net';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { OrayManager } from './oray.mjs';
|
|
9
|
+
|
|
10
|
+
const QUICK_TUNNEL_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com(?![a-z0-9.-])/i;
|
|
11
|
+
const NAMED_CONNECTED = /Registered tunnel connection/i;
|
|
12
|
+
const STARTUP_TIMEOUT_MS = 30_000;
|
|
13
|
+
const TAILSCALE_STARTUP_TIMEOUT_MS = 120_000;
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
const TAILSCALE_TARGET_PORT = 443;
|
|
16
|
+
|
|
17
|
+
async function freeLoopbackPort() {
|
|
18
|
+
return await new Promise((resolve, reject) => {
|
|
19
|
+
const server = createServer();
|
|
20
|
+
server.once('error', reject);
|
|
21
|
+
server.listen(0, '127.0.0.1', () => {
|
|
22
|
+
const port = server.address().port;
|
|
23
|
+
server.close((error) => error ? reject(error) : resolve(port));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validNgrokToken(value) {
|
|
29
|
+
if (typeof value !== 'string' || value.length < 20 || value.length > 8192 ||
|
|
30
|
+
!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('ngrok Authtoken 格式无效');
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function inspectNgrokEndpointStatus(raw, devicePort, configuredUrl = null) {
|
|
35
|
+
const entries = (typeof raw === 'string' ? JSON.parse(raw) : raw)?.endpoints;
|
|
36
|
+
if (!Array.isArray(entries)) return null;
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (!tailscaleTargetMatches(entry?.upstream?.url, devicePort) || typeof entry.url !== 'string') continue;
|
|
39
|
+
try {
|
|
40
|
+
const url = namedUrl(entry.url);
|
|
41
|
+
if (!configuredUrl || url === configuredUrl) return url;
|
|
42
|
+
} catch { /* Never advertise a non-HTTPS or malformed endpoint. */ }
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function inspectLegacyNgrokTunnelStatus(raw, devicePort, configuredUrl = null) {
|
|
48
|
+
const entries = (typeof raw === 'string' ? JSON.parse(raw) : raw)?.tunnels;
|
|
49
|
+
if (!Array.isArray(entries)) return null;
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const target = entry?.config?.addr;
|
|
52
|
+
const validTarget = target === `127.0.0.1:${devicePort}` || tailscaleTargetMatches(target, devicePort);
|
|
53
|
+
if (!validTarget || typeof entry.public_url !== 'string') continue;
|
|
54
|
+
try {
|
|
55
|
+
const url = namedUrl(entry.public_url);
|
|
56
|
+
if (!configuredUrl || url === configuredUrl) return url;
|
|
57
|
+
} catch { /* Do not advertise invalid or HTTP addresses. */ }
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function runTailscaleCommand(command, args) {
|
|
63
|
+
const { stdout } = await execFileAsync(command, args, { timeout: 7000, maxBuffer: 128 * 1024 });
|
|
64
|
+
return stdout;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function statusSections(config) {
|
|
68
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return [];
|
|
69
|
+
const foreground = config.Foreground && typeof config.Foreground === 'object' ?
|
|
70
|
+
Object.values(config.Foreground) : [];
|
|
71
|
+
return [config, ...foreground.filter((entry) => entry && typeof entry === 'object')];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function tailscaleHostPort(value) {
|
|
75
|
+
const match = /^([a-z0-9-]+(?:\.[a-z0-9-]+)*\.ts\.net):443$/i.exec(value);
|
|
76
|
+
return match ? match[1].toLowerCase() : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function tailscaleTargetMatches(value, devicePort) {
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(value);
|
|
82
|
+
return url.protocol === 'http:' && url.hostname === '127.0.0.1' &&
|
|
83
|
+
url.port === String(devicePort) && url.pathname === '/' && !url.search && !url.hash;
|
|
84
|
+
} catch { return false; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function inspectTailscaleFunnelStatus(raw, devicePort) {
|
|
88
|
+
const config = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
89
|
+
let url = null;
|
|
90
|
+
let portOccupied = false;
|
|
91
|
+
for (const section of statusSections(config)) {
|
|
92
|
+
if (section.TCP?.[String(TAILSCALE_TARGET_PORT)] ||
|
|
93
|
+
Object.keys(section.Web || {}).some((key) => key.endsWith(`:${TAILSCALE_TARGET_PORT}`))) {
|
|
94
|
+
portOccupied = true;
|
|
95
|
+
}
|
|
96
|
+
for (const [hostPort, allowed] of Object.entries(section.AllowFunnel || {})) {
|
|
97
|
+
if (!allowed || !hostPort.endsWith(`:${TAILSCALE_TARGET_PORT}`)) continue;
|
|
98
|
+
portOccupied = true;
|
|
99
|
+
const hostname = tailscaleHostPort(hostPort);
|
|
100
|
+
const proxy = section.Web?.[hostPort]?.Handlers?.['/']?.Proxy;
|
|
101
|
+
if (hostname && section.TCP?.[String(TAILSCALE_TARGET_PORT)]?.HTTPS === true &&
|
|
102
|
+
tailscaleTargetMatches(proxy, devicePort)) url = `https://${hostname}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { url, portOccupied };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writePrivate(file, value) {
|
|
109
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
110
|
+
const temporary = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
111
|
+
fs.writeFileSync(temporary, value, { mode: 0o600 });
|
|
112
|
+
fs.renameSync(temporary, file);
|
|
113
|
+
fs.chmodSync(file, 0o600);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function namedUrl(value) {
|
|
117
|
+
if (typeof value !== 'string' || value.length > 253) throw new Error('固定地址必须是 HTTPS 公网域名');
|
|
118
|
+
let parsed;
|
|
119
|
+
try { parsed = new URL(value.trim()); }
|
|
120
|
+
catch { throw new Error('固定地址必须是 HTTPS 公网域名'); }
|
|
121
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
122
|
+
const validHost = hostname.length <= 253 && hostname.split('.').length >= 2 &&
|
|
123
|
+
hostname.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
|
|
124
|
+
if (parsed.protocol !== 'https:' || !validHost || isIP(hostname) || parsed.username || parsed.password ||
|
|
125
|
+
parsed.port || parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
|
126
|
+
throw new Error('固定地址须为 HTTPS 域名,不含路径、端口或参数');
|
|
127
|
+
}
|
|
128
|
+
return `https://${hostname}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function validateToken(value) {
|
|
132
|
+
if (typeof value !== 'string' || value.length < 30 || value.length > 8192 || !/^[A-Za-z0-9+/_=.\-]+$/.test(value)) {
|
|
133
|
+
throw new Error('Cloudflare Tunnel 令牌格式无效');
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Only the authenticated device API is tunnelled. The desktop panel is a
|
|
139
|
+
// different server bound to loopback and is never passed to a tunnel provider.
|
|
140
|
+
export class TunnelManager {
|
|
141
|
+
constructor({ devicePort, stateDir = null, command = 'cloudflared', tailscaleCommand = 'tailscale',
|
|
142
|
+
ngrokCommand = 'ngrok', spawnProcess = spawn, runCommand = runTailscaleCommand,
|
|
143
|
+
fetchImpl = fetch, pickNgrokPort = freeLoopbackPort, startupTimeoutMs = null,
|
|
144
|
+
bridgeId = null, orayManager = null }) {
|
|
145
|
+
this.devicePort = devicePort;
|
|
146
|
+
this.command = command;
|
|
147
|
+
this.tailscaleCommand = tailscaleCommand;
|
|
148
|
+
this.ngrokCommand = ngrokCommand;
|
|
149
|
+
this.spawnProcess = spawnProcess;
|
|
150
|
+
this.runCommand = runCommand;
|
|
151
|
+
this.fetchImpl = fetchImpl;
|
|
152
|
+
this.pickNgrokPort = pickNgrokPort;
|
|
153
|
+
this.startupTimeoutMs = startupTimeoutMs ?? STARTUP_TIMEOUT_MS;
|
|
154
|
+
this.tailscaleStartupTimeoutMs = startupTimeoutMs ?? TAILSCALE_STARTUP_TIMEOUT_MS;
|
|
155
|
+
this.settingsFile = stateDir ? path.join(stateDir, 'tunnel-settings.json') : null;
|
|
156
|
+
this.tokenFile = stateDir ? path.join(stateDir, 'tunnel-token') : null;
|
|
157
|
+
this.ngrokTokenFile = stateDir ? path.join(stateDir, 'ngrok-token') : null;
|
|
158
|
+
this.ngrokRuntimeFile = stateDir ? path.join(stateDir, 'ngrok-runtime.yml') : null;
|
|
159
|
+
this.oray = orayManager || new OrayManager({ devicePort, stateDir, bridgeId, fetchImpl });
|
|
160
|
+
this.mode = 'quick';
|
|
161
|
+
this.configuredUrl = null;
|
|
162
|
+
this.ngrokConfiguredUrl = null;
|
|
163
|
+
if (this.settingsFile) {
|
|
164
|
+
try {
|
|
165
|
+
const saved = JSON.parse(fs.readFileSync(this.settingsFile, 'utf8'));
|
|
166
|
+
if (['named', 'tailscale', 'ngrok', 'oray'].includes(saved.mode)) this.mode = saved.mode;
|
|
167
|
+
if (saved.configuredUrl) this.configuredUrl = namedUrl(saved.configuredUrl);
|
|
168
|
+
if (saved.ngrokConfiguredUrl) this.ngrokConfiguredUrl = namedUrl(saved.ngrokConfiguredUrl);
|
|
169
|
+
} catch { /* Damaged settings never authorize a public tunnel. */ }
|
|
170
|
+
}
|
|
171
|
+
this.process = null;
|
|
172
|
+
this.status = 'off';
|
|
173
|
+
this.url = null;
|
|
174
|
+
this.error = '';
|
|
175
|
+
this.generation = 0;
|
|
176
|
+
this.timer = null;
|
|
177
|
+
this.pollTimer = null;
|
|
178
|
+
this.polling = false;
|
|
179
|
+
this.cleanupAvailable = false;
|
|
180
|
+
this.ngrokApiPort = null;
|
|
181
|
+
this.stopping = false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
get tokenConfigured() {
|
|
185
|
+
try { return Boolean(this.tokenFile && validateToken(fs.readFileSync(this.tokenFile, 'utf8').trim())); }
|
|
186
|
+
catch { return false; }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
get ngrokTokenConfigured() {
|
|
190
|
+
try { return Boolean(this.ngrokTokenFile && validNgrokToken(fs.readFileSync(this.ngrokTokenFile, 'utf8').trim())); }
|
|
191
|
+
catch { return false; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
summary() {
|
|
195
|
+
if (this.mode === 'oray') {
|
|
196
|
+
const oray = this.oray.summary();
|
|
197
|
+
return { ...oray, mode: this.mode, running: oray.mappingEnabled,
|
|
198
|
+
cleanupAvailable: oray.status === 'error' && Boolean(oray.domain),
|
|
199
|
+
configuredUrl: oray.domain ? `https://${oray.domain}` : null,
|
|
200
|
+
tokenConfigured: false, ngrokConfiguredUrl: this.ngrokConfiguredUrl,
|
|
201
|
+
ngrokTokenConfigured: this.ngrokTokenConfigured };
|
|
202
|
+
}
|
|
203
|
+
return { status: this.status, url: this.url, error: this.error, mode: this.mode,
|
|
204
|
+
running: Boolean(this.process) || this.stopping, cleanupAvailable: this.cleanupAvailable,
|
|
205
|
+
configuredUrl: this.configuredUrl, tokenConfigured: this.tokenConfigured,
|
|
206
|
+
ngrokConfiguredUrl: this.ngrokConfiguredUrl, ngrokTokenConfigured: this.ngrokTokenConfigured };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
configure({ mode, url, token, clearToken } = {}) {
|
|
210
|
+
if (this.process || this.stopping || this.cleanupAvailable || this.status === 'starting' ||
|
|
211
|
+
this.status === 'online' || this.oray.summary().mappingEnabled) {
|
|
212
|
+
throw new Error('请先关闭当前穿透,再修改设置');
|
|
213
|
+
}
|
|
214
|
+
if (!['quick', 'named', 'tailscale', 'ngrok', 'oray'].includes(mode)) throw new Error('请选择有效的公网接入方式');
|
|
215
|
+
if (clearToken !== undefined && typeof clearToken !== 'boolean') throw new Error('清除令牌参数无效');
|
|
216
|
+
if (clearToken && token) throw new Error('不能同时设置和清除令牌');
|
|
217
|
+
const configuredUrl = mode === 'named' && url ? namedUrl(url) : this.configuredUrl;
|
|
218
|
+
const ngrokConfiguredUrl = mode === 'ngrok' ? (url ? namedUrl(url) : null) : this.ngrokConfiguredUrl;
|
|
219
|
+
const newToken = token ? (mode === 'ngrok' ? validNgrokToken(token) :
|
|
220
|
+
mode === 'oray' ? token : validateToken(token)) : null;
|
|
221
|
+
if (mode === 'named' && (!configuredUrl || (!newToken && (clearToken || !this.tokenConfigured)))) {
|
|
222
|
+
throw new Error('固定地址模式需要 HTTPS 域名和 Cloudflare Tunnel 令牌');
|
|
223
|
+
}
|
|
224
|
+
if (mode === 'ngrok' && !newToken && (clearToken || !this.ngrokTokenConfigured)) {
|
|
225
|
+
throw new Error('ngrok 需要先填写 Authtoken');
|
|
226
|
+
}
|
|
227
|
+
if (mode === 'oray' && !url && !this.oray.summary().domain) {
|
|
228
|
+
throw new Error('请填写花生壳 HTTPS 映射域名');
|
|
229
|
+
}
|
|
230
|
+
if (mode === 'oray' && !newToken && (clearToken || !this.oray.apiKeyConfigured)) {
|
|
231
|
+
throw new Error('请填写花生壳 API Key');
|
|
232
|
+
}
|
|
233
|
+
if (!this.settingsFile || !this.tokenFile) throw new Error('桥接器未配置状态目录');
|
|
234
|
+
if (mode === 'oray') {
|
|
235
|
+
this.oray.configure({ domain: url || undefined, apiKey: token || undefined, clearApiKey: clearToken });
|
|
236
|
+
} else {
|
|
237
|
+
const selectedTokenFile = mode === 'ngrok' ? this.ngrokTokenFile : this.tokenFile;
|
|
238
|
+
if (newToken) writePrivate(selectedTokenFile, newToken);
|
|
239
|
+
else if (clearToken) fs.rmSync(selectedTokenFile, { force: true });
|
|
240
|
+
}
|
|
241
|
+
writePrivate(this.settingsFile, JSON.stringify({ mode, configuredUrl, ngrokConfiguredUrl }, null, 2));
|
|
242
|
+
this.mode = mode;
|
|
243
|
+
this.configuredUrl = configuredUrl;
|
|
244
|
+
this.ngrokConfiguredUrl = ngrokConfiguredUrl;
|
|
245
|
+
this.status = 'off';
|
|
246
|
+
this.url = null;
|
|
247
|
+
this.error = '';
|
|
248
|
+
this.cleanupAvailable = false;
|
|
249
|
+
return this.summary();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async tailscaleStatus() {
|
|
253
|
+
const raw = await this.runCommand(this.tailscaleCommand, ['funnel', 'status', '--json']);
|
|
254
|
+
return inspectTailscaleFunnelStatus(raw, this.devicePort);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async ngrokStatus() {
|
|
258
|
+
if (!this.ngrokApiPort) return null;
|
|
259
|
+
const response = await this.fetchImpl(`http://127.0.0.1:${this.ngrokApiPort}/api/endpoints`,
|
|
260
|
+
{ signal: AbortSignal.timeout(2500) });
|
|
261
|
+
if (response.status === 404) {
|
|
262
|
+
const legacy = await this.fetchImpl(`http://127.0.0.1:${this.ngrokApiPort}/api/tunnels`,
|
|
263
|
+
{ signal: AbortSignal.timeout(2500) });
|
|
264
|
+
if (!legacy.ok) throw new Error('ngrok local API unavailable');
|
|
265
|
+
return inspectLegacyNgrokTunnelStatus(await legacy.json(), this.devicePort, this.ngrokConfiguredUrl);
|
|
266
|
+
}
|
|
267
|
+
if (!response.ok) throw new Error('ngrok local API unavailable');
|
|
268
|
+
return inspectNgrokEndpointStatus(await response.json(), this.devicePort, this.ngrokConfiguredUrl);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async start() {
|
|
272
|
+
if (this.mode === 'oray') {
|
|
273
|
+
await this.oray.start();
|
|
274
|
+
return this.summary();
|
|
275
|
+
}
|
|
276
|
+
if (this.stopping) return this.summary();
|
|
277
|
+
if (this.process) return this.summary();
|
|
278
|
+
if (this.status === 'starting') return this.summary();
|
|
279
|
+
if (this.cleanupAvailable) return this.summary();
|
|
280
|
+
if (this.mode === 'named' && (!this.configuredUrl || !this.tokenConfigured)) {
|
|
281
|
+
this.status = 'error';
|
|
282
|
+
this.error = '请先保存固定域名和 Cloudflare Tunnel 令牌。';
|
|
283
|
+
return this.summary();
|
|
284
|
+
}
|
|
285
|
+
if (this.mode === 'ngrok' && !this.ngrokTokenConfigured) {
|
|
286
|
+
this.status = 'error';
|
|
287
|
+
this.error = '请先在电脑端保存 ngrok Authtoken。';
|
|
288
|
+
return this.summary();
|
|
289
|
+
}
|
|
290
|
+
this.generation += 1;
|
|
291
|
+
const generation = this.generation;
|
|
292
|
+
this.status = 'starting';
|
|
293
|
+
this.url = null;
|
|
294
|
+
this.error = '';
|
|
295
|
+
if (this.mode === 'tailscale') {
|
|
296
|
+
try {
|
|
297
|
+
const existing = await this.tailscaleStatus();
|
|
298
|
+
if (generation !== this.generation) return this.summary();
|
|
299
|
+
if (existing.portOccupied) {
|
|
300
|
+
this.cleanupAvailable = Boolean(existing.url);
|
|
301
|
+
this.fail(existing.url ?
|
|
302
|
+
'检测到指向本机码得接口的 Tailscale 公网路由。请点击“清理残留路由”后重试。' :
|
|
303
|
+
'Tailscale 的 443 端口已有服务;请检查 tailscale funnel status,勿覆盖原有 Serve/Funnel。');
|
|
304
|
+
return this.summary();
|
|
305
|
+
}
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (generation !== this.generation) return this.summary();
|
|
308
|
+
this.fail(error?.code === 'ENOENT' ? '未安装 tailscale,请先在电脑上安装并登录。' :
|
|
309
|
+
'无法读取 Tailscale 状态,请检查登录、运行状态与 Funnel 权限。');
|
|
310
|
+
return this.summary();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (this.mode === 'ngrok') {
|
|
314
|
+
try {
|
|
315
|
+
this.ngrokApiPort = await this.pickNgrokPort();
|
|
316
|
+
if (!Number.isInteger(this.ngrokApiPort) || this.ngrokApiPort < 1024 || this.ngrokApiPort > 65535) {
|
|
317
|
+
throw new Error('Invalid local API port');
|
|
318
|
+
}
|
|
319
|
+
if (generation !== this.generation) return this.summary();
|
|
320
|
+
const token = validNgrokToken(fs.readFileSync(this.ngrokTokenFile, 'utf8').trim());
|
|
321
|
+
writePrivate(this.ngrokRuntimeFile,
|
|
322
|
+
`version: 3\nagent:\n authtoken: ${JSON.stringify(token)}\n web_addr: 127.0.0.1:${this.ngrokApiPort}\n inspect_db_size: -1\n remote_management: false\n`);
|
|
323
|
+
} catch {
|
|
324
|
+
if (generation === this.generation) this.fail('无法准备 ngrok 本机配置,请检查状态目录。');
|
|
325
|
+
return this.summary();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// QUIC (UDP) is heavily degraded on some international links; force the
|
|
329
|
+
// TCP-based HTTP/2 transport so the managed connector stays reachable.
|
|
330
|
+
const args = this.mode === 'named' ? ['tunnel', 'run', '--protocol', 'http2', '--token-file', this.tokenFile] :
|
|
331
|
+
this.mode === 'tailscale' ? ['funnel', '--https=443', `http://127.0.0.1:${this.devicePort}`] :
|
|
332
|
+
this.mode === 'ngrok' ? ['http', `http://127.0.0.1:${this.devicePort}`, '--config', this.ngrokRuntimeFile,
|
|
333
|
+
'--inspect=false', ...(this.ngrokConfiguredUrl ? ['--url', this.ngrokConfiguredUrl] : [])] :
|
|
334
|
+
['tunnel', '--url', `http://127.0.0.1:${this.devicePort}`];
|
|
335
|
+
let child;
|
|
336
|
+
try {
|
|
337
|
+
child = this.spawnProcess(this.mode === 'tailscale' ? this.tailscaleCommand :
|
|
338
|
+
this.mode === 'ngrok' ? this.ngrokCommand : this.command,
|
|
339
|
+
args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
340
|
+
} catch (error) {
|
|
341
|
+
this.status = 'error';
|
|
342
|
+
this.error = error?.code === 'ENOENT' ? `未安装 ${this.mode === 'tailscale' ? 'tailscale' : this.mode === 'ngrok' ? 'ngrok' : 'cloudflared'},请先在电脑上安装。` : '无法启动内网穿透。';
|
|
343
|
+
this.removeNgrokRuntime();
|
|
344
|
+
return this.summary();
|
|
345
|
+
}
|
|
346
|
+
this.process = child;
|
|
347
|
+
let logTail = '';
|
|
348
|
+
const readLog = (chunk) => {
|
|
349
|
+
if (generation !== this.generation) return;
|
|
350
|
+
logTail = (logTail + chunk.toString('utf8')).slice(-4096);
|
|
351
|
+
if (this.mode === 'tailscale' || this.mode === 'ngrok') return;
|
|
352
|
+
const connected = this.mode === 'named' ? NAMED_CONNECTED.test(logTail) : QUICK_TUNNEL_URL.exec(logTail);
|
|
353
|
+
if (connected) {
|
|
354
|
+
this.url = this.mode === 'named' ? this.configuredUrl : connected[0];
|
|
355
|
+
this.status = 'online';
|
|
356
|
+
this.error = '';
|
|
357
|
+
if (this.timer) clearTimeout(this.timer);
|
|
358
|
+
this.timer = null;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
child.stdout?.on('data', readLog);
|
|
362
|
+
child.stderr?.on('data', readLog);
|
|
363
|
+
child.once('error', (error) => {
|
|
364
|
+
if (generation !== this.generation) return;
|
|
365
|
+
const message = error?.code === 'ENOENT' ?
|
|
366
|
+
`未安装 ${this.mode === 'tailscale' ? 'tailscale' : this.mode === 'ngrok' ? 'ngrok' : 'cloudflared'},请先在电脑上安装。` : '内网穿透启动失败。';
|
|
367
|
+
if (this.mode === 'tailscale' || this.mode === 'ngrok') void this.stopWithError(message);
|
|
368
|
+
else this.fail(message);
|
|
369
|
+
});
|
|
370
|
+
child.once('exit', () => {
|
|
371
|
+
if (generation !== this.generation) return;
|
|
372
|
+
const message = this.url ? '内网穿透连接已断开,请重新开启。' :
|
|
373
|
+
this.mode === 'tailscale' ? 'Tailscale Funnel 未能建立,请检查登录和 Funnel 权限。' :
|
|
374
|
+
this.mode === 'ngrok' ? 'ngrok 未能建立,请检查 Authtoken、域名与网络连接。' :
|
|
375
|
+
'未能建立内网穿透,请检查 cloudflared 与网络连接。';
|
|
376
|
+
if (this.mode === 'tailscale' || this.mode === 'ngrok') void this.stopWithError(message);
|
|
377
|
+
else this.fail(message);
|
|
378
|
+
});
|
|
379
|
+
if (this.mode === 'tailscale') {
|
|
380
|
+
this.pollTimer = setInterval(async () => {
|
|
381
|
+
if (generation !== this.generation || this.polling) return;
|
|
382
|
+
this.polling = true;
|
|
383
|
+
try {
|
|
384
|
+
const observed = await this.tailscaleStatus();
|
|
385
|
+
if (generation !== this.generation) return;
|
|
386
|
+
if (observed.url) {
|
|
387
|
+
this.url = observed.url;
|
|
388
|
+
this.status = 'online';
|
|
389
|
+
this.error = '';
|
|
390
|
+
if (this.timer) clearTimeout(this.timer);
|
|
391
|
+
this.timer = null;
|
|
392
|
+
} else if (this.status === 'online') {
|
|
393
|
+
this.status = 'error';
|
|
394
|
+
this.url = null;
|
|
395
|
+
this.error = 'Tailscale 公网路由已失效,请关闭后重新开启。';
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
if (generation === this.generation && this.status === 'online') {
|
|
399
|
+
this.status = 'error';
|
|
400
|
+
this.url = null;
|
|
401
|
+
this.error = '无法核实 Tailscale 公网路由,请检查客户端状态。';
|
|
402
|
+
}
|
|
403
|
+
} finally { this.polling = false; }
|
|
404
|
+
}, 1000);
|
|
405
|
+
this.pollTimer.unref?.();
|
|
406
|
+
}
|
|
407
|
+
if (this.mode === 'ngrok') {
|
|
408
|
+
this.pollTimer = setInterval(async () => {
|
|
409
|
+
if (generation !== this.generation || this.polling) return;
|
|
410
|
+
this.polling = true;
|
|
411
|
+
try {
|
|
412
|
+
const observed = await this.ngrokStatus();
|
|
413
|
+
if (generation !== this.generation) return;
|
|
414
|
+
if (observed) {
|
|
415
|
+
this.url = observed;
|
|
416
|
+
this.status = 'online';
|
|
417
|
+
this.error = '';
|
|
418
|
+
if (this.timer) clearTimeout(this.timer);
|
|
419
|
+
this.timer = null;
|
|
420
|
+
} else if (this.status === 'online') {
|
|
421
|
+
void this.stopWithError('ngrok 公网路由已失效或转发目标不符。');
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
if (generation === this.generation && this.status === 'online') {
|
|
425
|
+
void this.stopWithError('无法核实 ngrok 公网路由,已关闭穿透。');
|
|
426
|
+
}
|
|
427
|
+
} finally { this.polling = false; }
|
|
428
|
+
}, 1000);
|
|
429
|
+
this.pollTimer.unref?.();
|
|
430
|
+
}
|
|
431
|
+
this.timer = setTimeout(() => {
|
|
432
|
+
if (generation !== this.generation || this.url) return;
|
|
433
|
+
if (this.mode === 'tailscale' || this.mode === 'ngrok') {
|
|
434
|
+
void this.stopWithError(this.mode === 'ngrok' ? 'ngrok 启动超时,请检查 Authtoken、域名与网络连接。' :
|
|
435
|
+
'Tailscale Funnel 启动超时,请检查登录和 Funnel 权限。');
|
|
436
|
+
} else {
|
|
437
|
+
this.fail('内网穿透连接超时,请检查 cloudflared、令牌与网络连接。');
|
|
438
|
+
child.kill('SIGTERM');
|
|
439
|
+
}
|
|
440
|
+
}, this.mode === 'tailscale' ? this.tailscaleStartupTimeoutMs : this.startupTimeoutMs);
|
|
441
|
+
this.timer.unref?.();
|
|
442
|
+
return this.summary();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async refresh() {
|
|
446
|
+
if (this.mode === 'oray' && this.oray.apiKeyConfigured) await this.oray.refresh();
|
|
447
|
+
return this.summary();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
fail(message) {
|
|
451
|
+
if (this.timer) clearTimeout(this.timer);
|
|
452
|
+
this.timer = null;
|
|
453
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
454
|
+
this.pollTimer = null;
|
|
455
|
+
this.process = null;
|
|
456
|
+
this.removeNgrokRuntime();
|
|
457
|
+
this.status = 'error';
|
|
458
|
+
this.url = null;
|
|
459
|
+
this.error = message;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
removeNgrokRuntime() {
|
|
463
|
+
if (this.mode !== 'ngrok') return;
|
|
464
|
+
if (this.ngrokRuntimeFile) fs.rmSync(this.ngrokRuntimeFile, { force: true });
|
|
465
|
+
this.ngrokApiPort = null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async stopWithError(message) {
|
|
469
|
+
await this.stop();
|
|
470
|
+
if (this.status === 'off') {
|
|
471
|
+
this.status = 'error';
|
|
472
|
+
this.error = message;
|
|
473
|
+
}
|
|
474
|
+
return this.summary();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async stop() {
|
|
478
|
+
if (this.mode === 'oray') {
|
|
479
|
+
if (this.oray.summary().status !== 'off' || this.oray.summary().mappingEnabled) await this.oray.stop();
|
|
480
|
+
return this.summary();
|
|
481
|
+
}
|
|
482
|
+
if (this.stopping) return this.summary();
|
|
483
|
+
this.stopping = true;
|
|
484
|
+
this.generation += 1;
|
|
485
|
+
if (this.timer) clearTimeout(this.timer);
|
|
486
|
+
this.timer = null;
|
|
487
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
488
|
+
this.pollTimer = null;
|
|
489
|
+
const child = this.process;
|
|
490
|
+
const cleanupAvailable = this.cleanupAvailable;
|
|
491
|
+
this.process = null;
|
|
492
|
+
this.cleanupAvailable = false;
|
|
493
|
+
this.status = 'stopping';
|
|
494
|
+
this.url = null;
|
|
495
|
+
this.error = '';
|
|
496
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
497
|
+
await new Promise((resolve) => {
|
|
498
|
+
let finished = false;
|
|
499
|
+
const finish = () => {
|
|
500
|
+
if (finished) return;
|
|
501
|
+
finished = true;
|
|
502
|
+
clearTimeout(timeout);
|
|
503
|
+
resolve();
|
|
504
|
+
};
|
|
505
|
+
child.once('exit', finish);
|
|
506
|
+
child.once('error', finish);
|
|
507
|
+
const timeout = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* Already stopped. */ } finish(); }, 5000);
|
|
508
|
+
timeout.unref?.();
|
|
509
|
+
try { child.kill('SIGTERM'); } catch { finish(); }
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
this.removeNgrokRuntime();
|
|
513
|
+
if (this.mode === 'tailscale' && (child || cleanupAvailable)) {
|
|
514
|
+
try {
|
|
515
|
+
let observed = await this.tailscaleStatus();
|
|
516
|
+
if (observed.url) {
|
|
517
|
+
await this.runCommand(this.tailscaleCommand, ['funnel', '--https=443', 'off']);
|
|
518
|
+
observed = await this.tailscaleStatus();
|
|
519
|
+
}
|
|
520
|
+
if (observed.portOccupied) throw new Error('Tailscale port still occupied');
|
|
521
|
+
} catch {
|
|
522
|
+
this.status = 'error';
|
|
523
|
+
this.error = '无法确认 Tailscale 公网路由已关闭,请在电脑上运行 tailscale funnel status 检查。';
|
|
524
|
+
this.cleanupAvailable = true;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (this.status === 'stopping') this.status = 'off';
|
|
528
|
+
this.stopping = false;
|
|
529
|
+
return this.summary();
|
|
530
|
+
}
|
|
531
|
+
}
|