bothouse-dsh 0.1.47 → 0.1.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/client/client.js +3411 -398
- package/collab/bot-port.mjs +30 -0
- package/collab/cli-member.mjs +3 -0
- package/collab/clis.mjs +11 -4
- package/collab/cloudweb.mjs +106 -17
- package/collab/desk/index.html +271 -31
- package/collab/dsh-member.mjs +47 -7
- package/collab/gate-context.mjs +80 -6
- package/collab/hub.mjs +42 -1
- package/collab/later.mjs +21 -0
- package/collab/memory-sources.mjs +159 -0
- package/collab/page.html +461 -6
- package/collab/pairing.mjs +674 -0
- package/collab/panel.mjs +10 -1
- package/collab/plugin.mjs +160 -24
- package/collab/roster.mjs +27 -6
- package/collab/store.mjs +48 -1
- package/collab/tasks.mjs +192 -16
- package/collab/wiki-galaxy.mjs +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
// #131 / issue #981: two DSH computers pair with a short number, so one DSH drives the other's
|
|
2
|
+
// channels, team desk and bothouse panel without a terminal or a hand-copied token.
|
|
3
|
+
//
|
|
4
|
+
// The machine being driven ("host", PairHost) shows a one-time number and, only while it has a
|
|
5
|
+
// live number or a paired device, listens on its own Tailscale address. The driving machine
|
|
6
|
+
// ("controller", PairClient) lists the tailnet's online machines, sends the number once and gets
|
|
7
|
+
// back a device credential. From then on the controller's DSH forwards /collab/remote/<link>/* to
|
|
8
|
+
// the host's /collab/* with that credential, so the browser keeps talking to its own DSH and never
|
|
9
|
+
// holds the credential. Tailscale (WireGuard) already encrypts and authenticates every peer, so
|
|
10
|
+
// the number only has to prove "the person at both screens"; a PAKE (SPAKE2, magic-wormhole) is
|
|
11
|
+
// not needed on top. The host listener refuses addresses outside the tailnet ranges.
|
|
12
|
+
import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { createServer, request as httpRequest } from 'node:http';
|
|
16
|
+
import { hostname } from 'node:os';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
|
|
19
|
+
// 来源:用户 11-04 16:4x 认可的 C酱 方案,示例码「482 913」为 6 位数字、只能用一次。
|
|
20
|
+
export const CODE_DIGITS = 6;
|
|
21
|
+
// 来源:用户 11-04 16:58 定「数字码 5 分钟有效」。
|
|
22
|
+
export const CODE_TTL_MS = 5 * 60_000;
|
|
23
|
+
// 来源:用户 11-04 16:58 定「输错 5 次作废」。
|
|
24
|
+
export const MAX_ATTEMPTS = 5;
|
|
25
|
+
// 踢掉一台电脑后,宿主还留这么久让指挥端能问一句「我还在吗」:沿用上面用户定的 5 分钟码有效期
|
|
26
|
+
// (PairHost.kickGrace 用当次的 codeTtl,不另造数字)。过了这段,空转的监听照旧收掉;更晚才打开
|
|
27
|
+
// 列表的连接与「那台电脑关机」已无从区分。
|
|
28
|
+
// Temporary links: 用户 11-04 16:58 定——默认「直到我断开」(不设时长),或连接时填「自定义时长」,
|
|
29
|
+
// 到时自动断;不设固定上限。
|
|
30
|
+
// 沿用:DSH web 常用端口 3081(docs/INSTANCE_SETUP.md)的下一个;两端同一常量,发现才对得上。
|
|
31
|
+
export const PAIR_PORT = 3082;
|
|
32
|
+
export const MODES = ['temp', 'remember'];
|
|
33
|
+
// 「直到我断开」的临时连接靠指挥端页面心跳保活:刷新页面不断,心跳停了才断。
|
|
34
|
+
// 来源:用户 11-04 17:34「给几秒心跳宽限自动接回,只有关窗/点断开/踢掉才断」;每 3 秒一跳,
|
|
35
|
+
// 连丢三跳多一点(10 秒)算关窗,沿用 bridge/core/team.go:28 teamStaleAfter「三跳没来算掉线」的做法。
|
|
36
|
+
export const ALIVE_EVERY_MS = 3000;
|
|
37
|
+
export const UNTIL_GRACE_MS = 10_000;
|
|
38
|
+
export const REMOTE_PREFIX = '/collab/remote';
|
|
39
|
+
// Marks a request the host listener authenticated with a device credential (plugin.mjs owner()).
|
|
40
|
+
export const REMOTE_OWNER = Symbol('collab.remoteOwner');
|
|
41
|
+
|
|
42
|
+
const TAILSCALE_BINS = ['tailscale', '/opt/homebrew/bin/tailscale', '/usr/local/bin/tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale', 'C:\\Program Files\\Tailscale\\tailscale.exe'];
|
|
43
|
+
// 沿用 plugin.mjs MAX_BODY(256 KiB)。
|
|
44
|
+
const MAX_PAIR_BODY = 256 * 1024;
|
|
45
|
+
|
|
46
|
+
/** Error with a code the routes turn into an HTTP status and a plain-language message. */
|
|
47
|
+
export class PairError extends Error {
|
|
48
|
+
constructor(code, message) { super(message); this.code = code; }
|
|
49
|
+
}
|
|
50
|
+
// 来源:HTTP 状态码语义(RFC 9110 / RFC 6585 429)。
|
|
51
|
+
export const PAIR_STATUS = { 'no-code': 410, expired: 410, wrong: 403, locked: 429, mode: 400, unreachable: 502, refused: 403, missing: 404, tailnet: 503 };
|
|
52
|
+
|
|
53
|
+
const sha256 = s => createHash('sha256').update(String(s)).digest();
|
|
54
|
+
function sameHash(a, b) { return a.length === b.length && timingSafeEqual(a, b); }
|
|
55
|
+
|
|
56
|
+
/** Write JSON atomically with owner-only permissions (credentials live here). */
|
|
57
|
+
function saveJSON(file, value) {
|
|
58
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
59
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
60
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
61
|
+
renameSync(tmp, file);
|
|
62
|
+
try { chmodSync(file, 0o600); } catch { /* best effort */ }
|
|
63
|
+
}
|
|
64
|
+
function loadJSON(file) {
|
|
65
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return undefined; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True for Tailscale's IPv4 CGNAT range 100.64.0.0/10 and its IPv6 ULA fd7a:115c:a1e0::/48. */
|
|
69
|
+
export function isTailnetAddress(addr) {
|
|
70
|
+
const a = String(addr ?? '').replace(/^::ffff:/, '');
|
|
71
|
+
const v4 = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(a);
|
|
72
|
+
// 来源:Tailscale 文档,tailnet IPv4 取自 100.64.0.0/10(第二段 64–127)。
|
|
73
|
+
if (v4) return Number(v4[1]) === 100 && Number(v4[2]) >= 64 && Number(v4[2]) <= 127;
|
|
74
|
+
return a.toLowerCase().startsWith('fd7a:115c:a1e0:');
|
|
75
|
+
}
|
|
76
|
+
export function isLoopback(addr) {
|
|
77
|
+
const a = String(addr ?? '').replace(/^::ffff:/, '');
|
|
78
|
+
return a === '::1' || a.startsWith('127.');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A temporary link's length: null = until someone disconnects, else a whole number of
|
|
83
|
+
* milliseconds chosen when connecting. Remembered links never time out.
|
|
84
|
+
*/
|
|
85
|
+
export function tempDuration(mode, durationMs) {
|
|
86
|
+
if (mode !== 'temp' || durationMs === undefined || durationMs === null || durationMs === '') return null;
|
|
87
|
+
const ms = Number(durationMs);
|
|
88
|
+
if (!Number.isSafeInteger(ms) || ms <= 0) throw new PairError('mode', '自定义时长要填大于 0 的整数分钟或小时');
|
|
89
|
+
return ms;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Host side: the one live number, and the devices it let in.
|
|
94
|
+
* Remembered devices persist (credential hash only); temporary ones live in memory and end when
|
|
95
|
+
* either side disconnects, at the custom length chosen when connecting (if any), or when this
|
|
96
|
+
* DSH restarts.
|
|
97
|
+
*/
|
|
98
|
+
export class PairHost {
|
|
99
|
+
/**
|
|
100
|
+
* @param {{ file: string, now?: () => number, codeTtl?: number }} opts
|
|
101
|
+
*/
|
|
102
|
+
constructor({ file, now = Date.now, codeTtl = CODE_TTL_MS }) {
|
|
103
|
+
this.file = file;
|
|
104
|
+
this.now = now;
|
|
105
|
+
this.codeTtl = codeTtl;
|
|
106
|
+
this.code = undefined;
|
|
107
|
+
this.lastRevokeAt = undefined;
|
|
108
|
+
this.devices = new Map();
|
|
109
|
+
// Responses still open per device (event streams, proxied requests): cut when it is dropped,
|
|
110
|
+
// so kicking a device ends what it already holds, not only its next request.
|
|
111
|
+
this.open = new Map();
|
|
112
|
+
const saved = loadJSON(file);
|
|
113
|
+
for (const d of Array.isArray(saved?.devices) ? saved.devices : []) {
|
|
114
|
+
if (typeof d?.id === 'string' && typeof d.token_hash === 'string') this.devices.set(d.id, { ...d, mode: 'remember', expires_at: null });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A fresh number; any earlier one stops working. */
|
|
119
|
+
newCode() {
|
|
120
|
+
// 10^CODE_DIGITS numbers, leading zeros kept.
|
|
121
|
+
const code = String(randomInt(0, 10 ** CODE_DIGITS)).padStart(CODE_DIGITS, '0');
|
|
122
|
+
this.code = { hash: sha256(code), expires_at: this.now() + this.codeTtl, attempts: 0 };
|
|
123
|
+
return { code, expires_at: this.code.expires_at, ttl_ms: this.codeTtl, max_attempts: MAX_ATTEMPTS };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
cancelCode() { this.code = undefined; }
|
|
127
|
+
|
|
128
|
+
/** Whether a number is waiting to be typed in. */
|
|
129
|
+
codeLive() {
|
|
130
|
+
if (this.code !== undefined && this.code.expires_at <= this.now()) this.code = undefined;
|
|
131
|
+
return this.code !== undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check a typed number and, if right, let the device in.
|
|
136
|
+
* @param {{ code: string, mode: string, name: string, address?: string, duration_ms?: number|null }} claim
|
|
137
|
+
* @returns {{ token: string, device: object }}
|
|
138
|
+
*/
|
|
139
|
+
claim({ code, mode, name, address, duration_ms }) {
|
|
140
|
+
if (!MODES.includes(mode)) throw new PairError('mode', '连接方式只能是「临时」或「记住这台电脑」');
|
|
141
|
+
const duration = tempDuration(mode, duration_ms);
|
|
142
|
+
if (this.code === undefined) throw new PairError('no-code', '那台电脑现在没有在等配对,请在那台电脑上点「让别的电脑连我」');
|
|
143
|
+
if (this.code.expires_at <= this.now()) { this.code = undefined; throw new PairError('expired', '这串数字已过期,请在那台电脑上重新生成'); }
|
|
144
|
+
const typed = String(code ?? '').replace(/\D/g, '');
|
|
145
|
+
if (!sameHash(sha256(typed), this.code.hash)) {
|
|
146
|
+
this.code.attempts += 1;
|
|
147
|
+
const left = MAX_ATTEMPTS - this.code.attempts;
|
|
148
|
+
if (left <= 0) { this.code = undefined; throw new PairError('locked', '输错次数太多,这串数字已作废,请在那台电脑上重新生成'); }
|
|
149
|
+
throw new PairError('wrong', `数字不对,还能再试 ${left} 次`);
|
|
150
|
+
}
|
|
151
|
+
this.code = undefined;
|
|
152
|
+
const token = randomBytes(32).toString('base64url');
|
|
153
|
+
const now = this.now();
|
|
154
|
+
const device = {
|
|
155
|
+
id: randomUUID(), name: String(name ?? '').trim().slice(0, 80) || '另一台电脑', address: String(address ?? ''),
|
|
156
|
+
mode, token_hash: sha256(token).toString('hex'), created_at: now, last_seen: now,
|
|
157
|
+
expires_at: duration === null ? null : now + duration,
|
|
158
|
+
};
|
|
159
|
+
this.devices.set(device.id, device);
|
|
160
|
+
if (mode === 'remember') this.save();
|
|
161
|
+
return { token, device: this.view(device) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The device a credential belongs to, or undefined (unknown, revoked or expired). */
|
|
165
|
+
verify(token) {
|
|
166
|
+
if (typeof token !== 'string' || token === '') return undefined;
|
|
167
|
+
const hash = sha256(token);
|
|
168
|
+
for (const d of this.devices.values()) {
|
|
169
|
+
if (!sameHash(Buffer.from(d.token_hash, 'hex'), hash)) continue;
|
|
170
|
+
if (d.expires_at !== null && d.expires_at <= this.now()) { this.drop(d.id); return undefined; }
|
|
171
|
+
d.last_seen = this.now();
|
|
172
|
+
return d;
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
list() {
|
|
178
|
+
const now = this.now();
|
|
179
|
+
for (const d of [...this.devices.values()]) if (d.expires_at !== null && d.expires_at <= now) this.drop(d.id);
|
|
180
|
+
return [...this.devices.values()].sort((a, b) => b.created_at - a.created_at).map(d => this.view(d));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
revoke(id) {
|
|
184
|
+
const d = this.devices.get(id);
|
|
185
|
+
if (d === undefined) return false;
|
|
186
|
+
this.lastRevokeAt = this.now();
|
|
187
|
+
this.drop(id);
|
|
188
|
+
if (d.mode === 'remember') this.save();
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Remember a response a device has open, until it closes. */
|
|
193
|
+
hold(id, res) {
|
|
194
|
+
let set = this.open.get(id);
|
|
195
|
+
if (set === undefined) this.open.set(id, set = new Set());
|
|
196
|
+
set.add(res);
|
|
197
|
+
res.on('close', () => { set.delete(res); if (set.size === 0 && this.open.get(id) === set) this.open.delete(id); });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Forget a device and end every response it still has open. */
|
|
201
|
+
drop(id) {
|
|
202
|
+
this.devices.delete(id);
|
|
203
|
+
const set = this.open.get(id);
|
|
204
|
+
this.open.delete(id);
|
|
205
|
+
for (const res of set ?? []) res.destroy();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Whether the tailnet listener has anything to serve. */
|
|
209
|
+
active() { return this.codeLive() || this.list().length > 0; }
|
|
210
|
+
|
|
211
|
+
/** Just kicked something: stay up a little so this machine can answer "was I kicked?". */
|
|
212
|
+
// Same clock and length the number itself uses (a constructor may override codeTtl in tests).
|
|
213
|
+
kickGrace() { return this.lastRevokeAt !== undefined && this.now() - this.lastRevokeAt < this.codeTtl; }
|
|
214
|
+
|
|
215
|
+
view(d) { return { id: d.id, name: d.name, address: d.address, mode: d.mode, created_at: d.created_at, last_seen: d.last_seen, expires_at: d.expires_at }; }
|
|
216
|
+
|
|
217
|
+
save() {
|
|
218
|
+
saveJSON(this.file, { devices: [...this.devices.values()].filter(d => d.mode === 'remember')
|
|
219
|
+
.map(({ id, name, address, token_hash, created_at, last_seen }) => ({ id, name, address, token_hash, created_at, last_seen })) });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Run the tailscale CLI; resolves stdout or rejects. */
|
|
224
|
+
export function tailscale(args, { bins = TAILSCALE_BINS, timeout = 5000 } = {}) {
|
|
225
|
+
return new Promise((resolve, reject) => {
|
|
226
|
+
const tryAt = i => {
|
|
227
|
+
if (i >= bins.length) return reject(new PairError('tailnet', '这台电脑找不到 Tailscale,请先安装并登录 Tailscale'));
|
|
228
|
+
execFile(bins[i], args, { timeout, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
|
|
229
|
+
if (err?.code === 'ENOENT') return tryAt(i + 1);
|
|
230
|
+
if (err) return reject(new PairError('tailnet', 'Tailscale 没在运行或没登录,请先打开 Tailscale'));
|
|
231
|
+
resolve(stdout);
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
tryAt(0);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** This machine's tailnet IPv4 address. */
|
|
239
|
+
export async function tailnetAddress(run = tailscale) {
|
|
240
|
+
const ip = String(await run(['ip', '-4'])).trim().split(/\s+/)[0];
|
|
241
|
+
if (!isTailnetAddress(ip)) throw new PairError('tailnet', '这台电脑还没连上 Tailscale');
|
|
242
|
+
return ip;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Tailnet name of the machine at `ip` (tailscale whois), or undefined. */
|
|
246
|
+
export async function whoisName(ip, run = tailscale) {
|
|
247
|
+
try {
|
|
248
|
+
const w = JSON.parse(await run(['whois', '--json', ip]));
|
|
249
|
+
return w?.Node?.ComputedName || w?.Node?.Hostinfo?.Hostname || undefined;
|
|
250
|
+
} catch { return undefined; }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Other machines in the tailnet: [{ name, ip, online, os }]. */
|
|
254
|
+
export async function tailnetPeers(run = tailscale) {
|
|
255
|
+
const status = JSON.parse(await run(['status', '--json']));
|
|
256
|
+
return Object.values(status?.Peer ?? {}).map(p => ({
|
|
257
|
+
name: String(p.HostName || p.DNSName || '').trim() || '未命名电脑',
|
|
258
|
+
ip: (p.TailscaleIPs ?? []).find(isTailnetAddress) ?? p.TailscaleIPs?.[0],
|
|
259
|
+
online: p.Online === true, os: String(p.OS ?? ''),
|
|
260
|
+
})).filter(p => p.ip);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Small JSON request to another machine. */
|
|
264
|
+
export function requestJSON({ host, port, path, method = 'GET', body, headers = {}, timeout = 4000 }) {
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
267
|
+
const req = httpRequest({ host, port, path, method, timeout,
|
|
268
|
+
headers: { accept: 'application/json', ...(payload ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {}), ...headers } }, res => {
|
|
269
|
+
const chunks = [];
|
|
270
|
+
res.on('data', c => chunks.push(c));
|
|
271
|
+
res.on('end', () => {
|
|
272
|
+
let value;
|
|
273
|
+
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null'); } catch { value = undefined; }
|
|
274
|
+
resolve({ status: res.statusCode ?? 0, body: value });
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
278
|
+
req.on('error', reject);
|
|
279
|
+
req.end(payload);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Controller side: machines this DSH paired with. Remembered links persist (the credential has
|
|
285
|
+
* to, file mode 0600); temporary ones live in memory only.
|
|
286
|
+
*/
|
|
287
|
+
export class PairClient {
|
|
288
|
+
constructor({ file, now = Date.now, port = PAIR_PORT, request = requestJSON, name = hostname(), grace = UNTIL_GRACE_MS }) {
|
|
289
|
+
this.grace = grace;
|
|
290
|
+
this.file = file;
|
|
291
|
+
this.now = now;
|
|
292
|
+
this.port = port;
|
|
293
|
+
this.request = request;
|
|
294
|
+
this.name = name;
|
|
295
|
+
this.links = new Map();
|
|
296
|
+
this.refreshing = undefined;
|
|
297
|
+
const saved = loadJSON(file);
|
|
298
|
+
for (const l of Array.isArray(saved?.links) ? saved.links : []) {
|
|
299
|
+
if (typeof l?.id === 'string' && typeof l.token === 'string' && typeof l.host === 'string') this.links.set(l.id, { ...l, mode: 'remember', expires_at: null });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Ask a host whether it runs DSH pairing and is waiting for a number. */
|
|
304
|
+
async hello(host, port = this.port) {
|
|
305
|
+
try {
|
|
306
|
+
const r = await this.request({ host, port, path: '/collab/pair/hello', timeout: 1500 });
|
|
307
|
+
return r.status === 200 && r.body?.app === 'dsh-collab' ? r.body : undefined;
|
|
308
|
+
} catch { return undefined; }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Send the number once; on success keep the credential. */
|
|
312
|
+
async connect({ host, port = this.port, code, mode, name, duration_ms }) {
|
|
313
|
+
if (!MODES.includes(mode)) throw new PairError('mode', '连接方式只能是「临时」或「记住这台电脑」');
|
|
314
|
+
const duration = tempDuration(mode, duration_ms);
|
|
315
|
+
let r;
|
|
316
|
+
try {
|
|
317
|
+
r = await this.request({ host, port, path: '/collab/pair/claim', method: 'POST', body: { code: String(code ?? ''), mode, name: this.name, duration_ms: duration } });
|
|
318
|
+
} catch {
|
|
319
|
+
throw new PairError('unreachable', '连不上那台电脑:确认它开着 DSH、连着 Tailscale,并且点了「让别的电脑连我」');
|
|
320
|
+
}
|
|
321
|
+
if (r.status !== 200 || typeof r.body?.token !== 'string') {
|
|
322
|
+
throw new PairError(r.body?.code ?? 'refused', r.body?.error ?? '那台电脑拒绝了配对');
|
|
323
|
+
}
|
|
324
|
+
const link = { id: randomUUID(), name: String(name || r.body.host_name || host), host, port, token: r.body.token,
|
|
325
|
+
mode, created_at: this.now(), expires_at: r.body.device?.expires_at ?? null, alive_at: this.now() };
|
|
326
|
+
this.links.set(link.id, link);
|
|
327
|
+
if (mode === 'remember') this.save();
|
|
328
|
+
return this.view(link);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
get(id) {
|
|
332
|
+
const l = this.links.get(id);
|
|
333
|
+
if (l !== undefined && l.expires_at !== null && l.expires_at <= this.now()) { this.links.delete(id); return undefined; }
|
|
334
|
+
return l;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
list() {
|
|
338
|
+
for (const id of [...this.links.keys()]) this.get(id);
|
|
339
|
+
return [...this.links.values()].sort((a, b) => b.created_at - a.created_at).map(l => this.view(l));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** A DSH window on this machine is still open: keep its 「直到我断开」 links. */
|
|
343
|
+
alive() {
|
|
344
|
+
const now = this.now();
|
|
345
|
+
for (const l of this.links.values()) l.alive_at = now;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** End 「直到我断开」 links whose window stopped beating (closed, not just refreshed). */
|
|
349
|
+
async sweep() {
|
|
350
|
+
const stale = [...this.links.values()].filter(l => l.mode === 'temp' && l.expires_at === null && (l.alive_at ?? l.created_at) + this.grace <= this.now());
|
|
351
|
+
for (const l of stale) await this.disconnect(l.id);
|
|
352
|
+
return stale.map(l => l.id);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Ask each host whether this machine is still paired, and forget the ones it revoked, so the
|
|
357
|
+
* 「我连着的电脑」 list clears after a kick even if that link is never opened again. A host that
|
|
358
|
+
* cannot be reached is kept: it may only be off, which is not the same as being kicked.
|
|
359
|
+
*/
|
|
360
|
+
async refresh() {
|
|
361
|
+
// One round for every caller, so the open/connect/disconnect refreshes of devices.html share it
|
|
362
|
+
// instead of each paying the slowest host's timeout. No time-based cache: a kick must show at
|
|
363
|
+
// once, not after a grace period.
|
|
364
|
+
if (this.refreshing !== undefined) return this.refreshing;
|
|
365
|
+
this.refreshing = (async () => {
|
|
366
|
+
const dropped = [];
|
|
367
|
+
try {
|
|
368
|
+
await Promise.all([...this.links.values()].map(async l => {
|
|
369
|
+
const r = await this.request({ host: l.host, port: l.port, path: '/collab/pair/whoami', headers: { authorization: `Bearer ${l.token}` }, timeout: 1500 })
|
|
370
|
+
.catch(() => undefined);
|
|
371
|
+
if (r?.status === 401 && r.body?.code === 'revoked') { this.drop(l.id); dropped.push(l.id); }
|
|
372
|
+
})).catch(() => {}); // best effort: a broken host must not turn the list into an error
|
|
373
|
+
} finally {
|
|
374
|
+
this.refreshing = undefined;
|
|
375
|
+
}
|
|
376
|
+
return dropped;
|
|
377
|
+
})();
|
|
378
|
+
return this.refreshing;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Forget a link here and tell the host to drop its device (best effort). */
|
|
382
|
+
async disconnect(id) {
|
|
383
|
+
const l = this.links.get(id);
|
|
384
|
+
if (l === undefined) return false;
|
|
385
|
+
this.links.delete(id);
|
|
386
|
+
if (l.mode === 'remember') this.save();
|
|
387
|
+
try { await this.request({ host: l.host, port: l.port, path: '/collab/pair/forget', method: 'POST', headers: { authorization: `Bearer ${l.token}` }, timeout: 2000 }); } catch { /* host off: it expires or is kicked there */ }
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The host dropped this device: forget it here too. */
|
|
392
|
+
drop(id) {
|
|
393
|
+
const l = this.links.get(id);
|
|
394
|
+
if (l === undefined) return;
|
|
395
|
+
this.links.delete(id);
|
|
396
|
+
if (l.mode === 'remember') this.save();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
view(l) { return { id: l.id, name: l.name, host: l.host, mode: l.mode, created_at: l.created_at, expires_at: l.expires_at }; }
|
|
400
|
+
|
|
401
|
+
save() {
|
|
402
|
+
saveJSON(this.file, { links: [...this.links.values()].filter(l => l.mode === 'remember')
|
|
403
|
+
.map(({ id, name, host, port, token, created_at }) => ({ id, name, host, port, token, created_at })) });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Request headers the controller passes to the host; cookies, Origin, Host and Authorization
|
|
408
|
+
// stay on this side (same idea as panel.mjs PASS_REQUEST).
|
|
409
|
+
const PASS_REQUEST = ['accept', 'accept-language', 'content-type', 'content-length', 'if-none-match', 'if-modified-since', 'range', 'last-event-id', 'cache-control'];
|
|
410
|
+
const PASS_RESPONSE = ['content-type', 'cache-control', 'etag', 'last-modified', 'content-disposition', 'content-range', 'accept-ranges'];
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Point the host's absolute /collab/ URLs in an HTML page back through this proxy, so the
|
|
414
|
+
* channel page, desk, wiki galaxy and panel (<base href="/collab/panel/">) keep working.
|
|
415
|
+
*/
|
|
416
|
+
export function rewriteRemoteHTML(html, mount) {
|
|
417
|
+
// `}` covers template literals such as `${ROOT}/collab/api/...` (page.html, desk, wiki galaxy).
|
|
418
|
+
return html.replace(/(["'`(=}]\s*)\/collab\//g, `$1${mount}/`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Forward one controller request under /collab/remote/<id>/ to the paired host.
|
|
423
|
+
* @returns {Promise<void>|undefined} undefined when the path is not a remote path.
|
|
424
|
+
*/
|
|
425
|
+
export function remoteProxyRoute(req, res, url, { client, request = httpRequest }) {
|
|
426
|
+
if (url.pathname !== REMOTE_PREFIX && !url.pathname.startsWith(`${REMOTE_PREFIX}/`)) return undefined;
|
|
427
|
+
const m = /^\/collab\/remote\/([0-9a-f-]{36})(\/.*)?$/.exec(url.pathname);
|
|
428
|
+
const link = m === null ? undefined : client.get(m[1]);
|
|
429
|
+
if (link === undefined) return Promise.resolve(sendError(req, res, 404, { error: '没有这个连接,可能已断开,请重新连接', code: 'missing' }));
|
|
430
|
+
const mount = `${REMOTE_PREFIX}/${link.id}`;
|
|
431
|
+
if (m[2] === undefined) { res.writeHead(308, { location: `${mount}/${url.search}` }); res.end(); return Promise.resolve(); }
|
|
432
|
+
const rest = m[2] === '/' ? '' : m[2];
|
|
433
|
+
const headers = { authorization: `Bearer ${link.token}` };
|
|
434
|
+
for (const h of PASS_REQUEST) if (req.headers[h] !== undefined) headers[h] = req.headers[h];
|
|
435
|
+
return new Promise(resolve => {
|
|
436
|
+
const up = request({ host: link.host, port: link.port, method: req.method, path: `/collab${rest}${url.search}`, headers }, upRes => {
|
|
437
|
+
const status = upRes.statusCode ?? 502;
|
|
438
|
+
if (status === 401 && upRes.headers['x-collab-pair'] === 'revoked') {
|
|
439
|
+
client.drop(link.id);
|
|
440
|
+
upRes.resume();
|
|
441
|
+
sendError(req, res, 401, { error: '连接已断开(那台电脑点了断开、把这台移出,或临时连接到时间了),请重新配对', code: 'revoked' });
|
|
442
|
+
return resolve();
|
|
443
|
+
}
|
|
444
|
+
const out = {};
|
|
445
|
+
for (const h of PASS_RESPONSE) if (upRes.headers[h] !== undefined) out[h] = upRes.headers[h];
|
|
446
|
+
if (status >= 300 && status < 400 && typeof upRes.headers.location === 'string') {
|
|
447
|
+
out.location = upRes.headers.location.replace(/^\/collab(?=\/|$)/, mount);
|
|
448
|
+
}
|
|
449
|
+
if (String(upRes.headers['content-type'] ?? '').startsWith('text/html')) {
|
|
450
|
+
const chunks = [];
|
|
451
|
+
upRes.on('data', c => chunks.push(c));
|
|
452
|
+
upRes.on('end', () => {
|
|
453
|
+
const body = Buffer.from(rewriteRemoteHTML(Buffer.concat(chunks).toString('utf8'), mount));
|
|
454
|
+
res.writeHead(status, { ...out, 'content-length': body.length });
|
|
455
|
+
res.end(body);
|
|
456
|
+
resolve();
|
|
457
|
+
});
|
|
458
|
+
upRes.on('error', () => { res.destroy(); resolve(); });
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
res.writeHead(status, out);
|
|
462
|
+
res.flushHeaders?.();
|
|
463
|
+
upRes.pipe(res);
|
|
464
|
+
upRes.on('end', resolve);
|
|
465
|
+
upRes.on('error', () => { res.destroy(); resolve(); });
|
|
466
|
+
});
|
|
467
|
+
up.on('error', () => {
|
|
468
|
+
if (!res.headersSent) sendError(req, res, 502, { error: '连不上那台电脑:它可能关机、DSH 没开、Tailscale 断了,或已经把这台踢掉', code: 'unreachable' });
|
|
469
|
+
else res.destroy();
|
|
470
|
+
resolve();
|
|
471
|
+
});
|
|
472
|
+
// Stop the upstream stream (SSE) when the page goes away.
|
|
473
|
+
res.on('close', () => { if (!res.writableEnded) up.destroy(); });
|
|
474
|
+
req.pipe(up);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** A page navigation gets a readable page; fetch() callers get JSON. */
|
|
479
|
+
function sendError(req, res, status, value) {
|
|
480
|
+
if (!String(req.headers.accept ?? '').includes('text/html')) return sendJSON(res, status, value);
|
|
481
|
+
const esc = String(value.error).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
482
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
483
|
+
res.end(`<!doctype html><meta charset="utf-8"><title>连不上</title><style>:root{color-scheme:light dark}body{font:15px/1.6 -apple-system,"PingFang SC",sans-serif;display:grid;place-items:center;min-height:90vh;margin:0;padding:16px}</style><p>${esc}</p>`);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function sendJSON(res, status, value) {
|
|
487
|
+
const body = JSON.stringify(value);
|
|
488
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
489
|
+
res.end(body);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function readBody(req, limit = MAX_PAIR_BODY) {
|
|
493
|
+
return new Promise((resolve, reject) => {
|
|
494
|
+
let size = 0;
|
|
495
|
+
const chunks = [];
|
|
496
|
+
req.on('data', c => { size += c.length; if (size > limit) { reject(new PairError('mode', 'too large')); req.destroy(); } else chunks.push(c); });
|
|
497
|
+
req.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); } catch { reject(new PairError('mode', 'bad json')); } });
|
|
498
|
+
req.on('error', reject);
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function bearer(req) {
|
|
503
|
+
const m = /^Bearer\s+(\S+)$/i.exec(String(req.headers.authorization ?? ''));
|
|
504
|
+
return m === null ? undefined : m[1];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Host listener request handler: pairing endpoints for anyone in the tailnet, everything else
|
|
509
|
+
* under /collab only with a device credential, handed to the plugin's own route as the owner.
|
|
510
|
+
* @param {{ host: PairHost, name: string, route: (req, res) => any, whois?: (ip: string) => Promise<string|undefined>, allowLoopback?: boolean }} deps
|
|
511
|
+
*/
|
|
512
|
+
export function hostHandler({ host, name, route, whois = whoisName, allowLoopback = false }) {
|
|
513
|
+
return async (req, res) => {
|
|
514
|
+
const peer = req.socket.remoteAddress;
|
|
515
|
+
if (!isTailnetAddress(peer) && !(allowLoopback && isLoopback(peer))) return sendJSON(res, 403, { error: 'tailnet only' });
|
|
516
|
+
const url = new URL(req.url ?? '/', 'http://pair.invalid');
|
|
517
|
+
try {
|
|
518
|
+
if (url.pathname === '/collab/pair/hello' && req.method === 'GET') {
|
|
519
|
+
return sendJSON(res, 200, { app: 'dsh-collab', name, waiting: host.codeLive() });
|
|
520
|
+
}
|
|
521
|
+
if (url.pathname === '/collab/pair/claim' && req.method === 'POST') {
|
|
522
|
+
const body = await readBody(req);
|
|
523
|
+
const named = isTailnetAddress(peer) ? await whois(String(peer).replace(/^::ffff:/, '')) : undefined;
|
|
524
|
+
const { token, device } = host.claim({ code: body?.code, mode: body?.mode, name: named || body?.name, address: peer, duration_ms: body?.duration_ms });
|
|
525
|
+
return sendJSON(res, 200, { token, device, host_name: name });
|
|
526
|
+
}
|
|
527
|
+
const device = host.verify(bearer(req));
|
|
528
|
+
if (url.pathname === '/collab/pair/whoami' && req.method === 'GET') {
|
|
529
|
+
// Answers with the device's own view, so a controller whose link was kicked can tell
|
|
530
|
+
// "revoked" from "that machine is off". Same 401 shape as every other refused request.
|
|
531
|
+
if (device === undefined) {
|
|
532
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8', 'x-collab-pair': 'revoked' });
|
|
533
|
+
return res.end(JSON.stringify({ error: 'device not paired', code: 'revoked' }));
|
|
534
|
+
}
|
|
535
|
+
return sendJSON(res, 200, { ok: true, device: host.view(device), host_name: name });
|
|
536
|
+
}
|
|
537
|
+
if (url.pathname === '/collab/pair/forget' && req.method === 'POST') {
|
|
538
|
+
if (device !== undefined) host.revoke(device.id);
|
|
539
|
+
return sendJSON(res, 200, { ok: true });
|
|
540
|
+
}
|
|
541
|
+
if (device === undefined) {
|
|
542
|
+
res.writeHead(401, { 'content-type': 'application/json; charset=utf-8', 'x-collab-pair': 'revoked' });
|
|
543
|
+
return res.end(JSON.stringify({ error: 'device not paired', code: 'revoked' }));
|
|
544
|
+
}
|
|
545
|
+
// Pairing management stays with the person at this machine.
|
|
546
|
+
if (url.pathname.startsWith('/collab/api/pair') || url.pathname.startsWith('/collab/api/remote') || url.pathname.startsWith(`${REMOTE_PREFIX}/`) || url.pathname === '/collab/devices') {
|
|
547
|
+
return sendJSON(res, 403, { error: '配对和设备管理只能在这台电脑上操作' });
|
|
548
|
+
}
|
|
549
|
+
if (url.pathname !== '/collab' && !url.pathname.startsWith('/collab/')) return sendJSON(res, 404, { error: 'not found' });
|
|
550
|
+
req[REMOTE_OWNER] = true;
|
|
551
|
+
host.hold(device.id, res);
|
|
552
|
+
return await route(req, res);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
if (err instanceof PairError) return sendJSON(res, PAIR_STATUS[err.code] ?? 400, { error: err.message, code: err.code });
|
|
555
|
+
if (!res.headersSent) sendJSON(res, 500, { error: 'internal error' });
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Starts and stops the host listener on demand: on while a number is live or a device is
|
|
562
|
+
* paired, off otherwise, so an idle DSH opens no port.
|
|
563
|
+
*/
|
|
564
|
+
export class PairListener {
|
|
565
|
+
constructor({ host, handler, address = () => tailnetAddress(), port = PAIR_PORT, logger }) {
|
|
566
|
+
this.host = host;
|
|
567
|
+
this.handler = handler;
|
|
568
|
+
this.address = address;
|
|
569
|
+
this.port = port;
|
|
570
|
+
this.logger = logger;
|
|
571
|
+
this.server = undefined;
|
|
572
|
+
this.bound = undefined;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Make sure the listener runs; returns the address it is bound to. */
|
|
576
|
+
async ensure() {
|
|
577
|
+
if (this.server !== undefined) return this.bound;
|
|
578
|
+
const ip = await this.address();
|
|
579
|
+
const server = createServer(this.handler);
|
|
580
|
+
await new Promise((resolve, reject) => {
|
|
581
|
+
server.once('error', err => reject(err.code === 'EADDRINUSE'
|
|
582
|
+
? new PairError('tailnet', `端口 ${this.port} 被别的程序占着,配对用不了`) : err));
|
|
583
|
+
server.listen(this.port, ip, resolve);
|
|
584
|
+
});
|
|
585
|
+
this.server = server;
|
|
586
|
+
this.bound = `${ip}:${this.port}`;
|
|
587
|
+
this.logger?.info?.('conest-collab: pairing listener on %s', this.bound);
|
|
588
|
+
return this.bound;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Stop when nothing is left to serve. */
|
|
592
|
+
async settle() {
|
|
593
|
+
if (this.server === undefined || this.host.active() || this.host.kickGrace?.() === true) return;
|
|
594
|
+
await this.close();
|
|
595
|
+
this.logger?.info?.('conest-collab: pairing listener stopped');
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async close() {
|
|
599
|
+
const s = this.server;
|
|
600
|
+
this.server = undefined;
|
|
601
|
+
this.bound = undefined;
|
|
602
|
+
if (s === undefined) return;
|
|
603
|
+
s.closeAllConnections?.();
|
|
604
|
+
await new Promise(resolve => s.close(() => resolve()));
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Owner routes on this machine's own DSH (/collab/api/pair/* host side, /collab/api/remote/*
|
|
610
|
+
* controller side). The caller checks the owner first.
|
|
611
|
+
*/
|
|
612
|
+
export function pairApiRoute(req, res, url, { host, client, listener, peers = tailnetPeers, readJSON = readBody }) {
|
|
613
|
+
const p = url.pathname.replace(/\/+$/, '');
|
|
614
|
+
if (!p.startsWith('/collab/api/pair') && !p.startsWith('/collab/api/remote')) return undefined;
|
|
615
|
+
const run = async () => {
|
|
616
|
+
try {
|
|
617
|
+
if (p === '/collab/api/pair/code' && req.method === 'POST') {
|
|
618
|
+
const bound = await listener.ensure();
|
|
619
|
+
return sendJSON(res, 200, { ...host.newCode(), listening: bound });
|
|
620
|
+
}
|
|
621
|
+
if (p === '/collab/api/pair/code' && req.method === 'DELETE') {
|
|
622
|
+
host.cancelCode();
|
|
623
|
+
await listener.settle();
|
|
624
|
+
return sendJSON(res, 200, { ok: true });
|
|
625
|
+
}
|
|
626
|
+
if (p === '/collab/api/pair/code' && req.method === 'GET') {
|
|
627
|
+
return sendJSON(res, 200, { waiting: host.codeLive(), expires_at: host.code?.expires_at ?? null, listening: listener.bound ?? null });
|
|
628
|
+
}
|
|
629
|
+
if (p === '/collab/api/pair/devices' && req.method === 'GET') return sendJSON(res, 200, { devices: host.list() });
|
|
630
|
+
const dev = /^\/collab\/api\/pair\/devices\/([0-9a-f-]{36})$/.exec(p);
|
|
631
|
+
if (dev !== null && req.method === 'DELETE') {
|
|
632
|
+
// No settle() here: the other machine finds out it was kicked by asking /collab/pair/whoami,
|
|
633
|
+
// and closing the port in the same breath would make that look like "host is off" — the link
|
|
634
|
+
// would then stay in 「我连着的电脑」 and only fail with 502 when opened. The 60 s sweep in
|
|
635
|
+
// plugin.mjs closes an idle listener instead, which leaves room for that one question.
|
|
636
|
+
const ok = host.revoke(dev[1]);
|
|
637
|
+
return sendJSON(res, ok ? 200 : 404, ok ? { ok: true } : { error: '没有这台设备', code: 'missing' });
|
|
638
|
+
}
|
|
639
|
+
if (p === '/collab/api/remote/peers' && req.method === 'GET') {
|
|
640
|
+
const list = await peers();
|
|
641
|
+
const probed = await Promise.all(list.map(async peer => {
|
|
642
|
+
const hello = peer.online ? await client.hello(peer.ip) : undefined;
|
|
643
|
+
return { ...peer, dsh: hello !== undefined, waiting: hello?.waiting === true, dsh_name: hello?.name ?? null };
|
|
644
|
+
}));
|
|
645
|
+
probed.sort((a, b) => Number(b.waiting) - Number(a.waiting) || Number(b.dsh) - Number(a.dsh) || Number(b.online) - Number(a.online) || a.name.localeCompare(b.name));
|
|
646
|
+
return sendJSON(res, 200, { peers: probed });
|
|
647
|
+
}
|
|
648
|
+
if (p === '/collab/api/remote/alive' && req.method === 'POST') { client.alive(); return sendJSON(res, 200, { ok: true }); }
|
|
649
|
+
if (p === '/collab/api/remote/links' && req.method === 'GET') {
|
|
650
|
+
// Reading the list is the moment a kicked machine must disappear from it, before the
|
|
651
|
+
// person opens that link (remoteProxyRoute drops it then, too).
|
|
652
|
+
await client.refresh();
|
|
653
|
+
return sendJSON(res, 200, { links: client.list() });
|
|
654
|
+
}
|
|
655
|
+
if (p === '/collab/api/remote/links' && req.method === 'POST') {
|
|
656
|
+
const body = await readJSON(req);
|
|
657
|
+
const ip = String(body?.host ?? '').trim();
|
|
658
|
+
if (!isTailnetAddress(ip) && !isLoopback(ip)) throw new PairError('mode', '只能连同一个 Tailscale 网络里的电脑');
|
|
659
|
+
const link = await client.connect({ host: ip, port: Number.isInteger(body?.port) ? body.port : undefined, code: body?.code, mode: body?.mode, name: body?.name, duration_ms: body?.duration_ms });
|
|
660
|
+
return sendJSON(res, 200, { link });
|
|
661
|
+
}
|
|
662
|
+
const link = /^\/collab\/api\/remote\/links\/([0-9a-f-]{36})$/.exec(p);
|
|
663
|
+
if (link !== null && req.method === 'DELETE') {
|
|
664
|
+
const ok = await client.disconnect(link[1]);
|
|
665
|
+
return sendJSON(res, ok ? 200 : 404, ok ? { ok: true } : { error: '没有这个连接', code: 'missing' });
|
|
666
|
+
}
|
|
667
|
+
return sendJSON(res, 404, { error: 'not found' });
|
|
668
|
+
} catch (err) {
|
|
669
|
+
if (err instanceof PairError) return sendJSON(res, PAIR_STATUS[err.code] ?? 400, { error: err.message, code: err.code });
|
|
670
|
+
return sendJSON(res, 500, { error: String(err?.message ?? err) });
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
return run();
|
|
674
|
+
}
|