@cjhyy/code-shell-server 0.8.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/README.md +77 -0
- package/dist/attachment-service.d.ts +80 -0
- package/dist/attachment-service.js +587 -0
- package/dist/bin/code-shell-serve.d.ts +2 -0
- package/dist/bin/code-shell-serve.js +7 -0
- package/dist/client-message-id.d.ts +1 -0
- package/dist/client-message-id.js +8 -0
- package/dist/image-byte-probe.d.ts +17 -0
- package/dist/image-byte-probe.js +330 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.mobile-remote.d.ts +21 -0
- package/dist/index.mobile-remote.js +21 -0
- package/dist/index.serve.d.ts +3 -0
- package/dist/index.serve.js +3 -0
- package/dist/index.storage.d.ts +10 -0
- package/dist/index.storage.js +10 -0
- package/dist/index.worker.d.ts +2 -0
- package/dist/index.worker.js +2 -0
- package/dist/mobile-remote/access-passcode.d.ts +80 -0
- package/dist/mobile-remote/access-passcode.js +354 -0
- package/dist/mobile-remote/cloudflared-binary.d.ts +76 -0
- package/dist/mobile-remote/cloudflared-binary.js +242 -0
- package/dist/mobile-remote/codex-parse.d.ts +34 -0
- package/dist/mobile-remote/codex-parse.js +131 -0
- package/dist/mobile-remote/codex-room-agent.d.ts +94 -0
- package/dist/mobile-remote/codex-room-agent.js +213 -0
- package/dist/mobile-remote/mobile-attachments.d.ts +21 -0
- package/dist/mobile-remote/mobile-attachments.js +119 -0
- package/dist/mobile-remote/mobile-chat-turn.d.ts +32 -0
- package/dist/mobile-remote/mobile-chat-turn.js +67 -0
- package/dist/mobile-remote/mobile-history.d.ts +22 -0
- package/dist/mobile-remote/mobile-history.js +27 -0
- package/dist/mobile-remote/mobile-run-dispatch.d.ts +17 -0
- package/dist/mobile-remote/mobile-run-dispatch.js +56 -0
- package/dist/mobile-remote/mobile-static.d.ts +43 -0
- package/dist/mobile-remote/mobile-static.js +154 -0
- package/dist/mobile-remote/mobile-upload-service.d.ts +57 -0
- package/dist/mobile-remote/mobile-upload-service.js +370 -0
- package/dist/mobile-remote/pairing.d.ts +8 -0
- package/dist/mobile-remote/pairing.js +23 -0
- package/dist/mobile-remote/path-bins.d.ts +7 -0
- package/dist/mobile-remote/path-bins.js +16 -0
- package/dist/mobile-remote/pending-approvals.d.ts +14 -0
- package/dist/mobile-remote/pending-approvals.js +54 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +117 -0
- package/dist/mobile-remote/remote-host-manager.js +345 -0
- package/dist/mobile-remote/resident-agent.d.ts +98 -0
- package/dist/mobile-remote/resident-agent.js +216 -0
- package/dist/mobile-remote/room-manager.d.ts +286 -0
- package/dist/mobile-remote/room-manager.js +765 -0
- package/dist/mobile-remote/trusted-device-store.d.ts +19 -0
- package/dist/mobile-remote/trusted-device-store.js +118 -0
- package/dist/mobile-remote/tunnel-manager.d.ts +105 -0
- package/dist/mobile-remote/tunnel-manager.js +375 -0
- package/dist/mobile-remote/types.d.ts +1 -0
- package/dist/mobile-remote/types.js +1 -0
- package/dist/mobile-remote/viewer-identity.d.ts +5 -0
- package/dist/mobile-remote/viewer-identity.js +5 -0
- package/dist/serve/cli.d.ts +15 -0
- package/dist/serve/cli.js +115 -0
- package/dist/serve/headless-server.d.ts +40 -0
- package/dist/serve/headless-server.js +276 -0
- package/dist/sessions-service.d.ts +73 -0
- package/dist/sessions-service.js +303 -0
- package/dist/static-files.d.ts +1 -0
- package/dist/static-files.js +25 -0
- package/dist/worker-bridge-core.d.ts +154 -0
- package/dist/worker-bridge-core.js +315 -0
- package/package.json +63 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
5
|
+
const DEFAULT_LOCKOUT_MS = 60_000;
|
|
6
|
+
const DEFAULT_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
7
|
+
const COOKIE_NAME = "cs_access";
|
|
8
|
+
const SCRYPT_KEYLEN = 32;
|
|
9
|
+
/**
|
|
10
|
+
* The public-tunnel access gate. A single shared passcode (set by the desktop
|
|
11
|
+
* user) guards the tunnel; a phone that enters it correctly receives a signed
|
|
12
|
+
* "remember" token stored in a cookie so it is not re-challenged. Security
|
|
13
|
+
* properties this unit guarantees:
|
|
14
|
+
* - the passcode is only ever stored as a salted scrypt hash (no plaintext);
|
|
15
|
+
* - remember-tokens are HMACs over a per-passcode secret, so rotating the
|
|
16
|
+
* passcode invalidates every outstanding token;
|
|
17
|
+
* - brute-force is rate-limited: N consecutive wrong attempts lock the gate
|
|
18
|
+
* for a window, during which even the correct passcode is refused.
|
|
19
|
+
* The trusted-device + pairing layers still sit behind this (defense in depth).
|
|
20
|
+
*/
|
|
21
|
+
export class AccessPasscode {
|
|
22
|
+
filePath;
|
|
23
|
+
now;
|
|
24
|
+
maxAttempts;
|
|
25
|
+
lockoutMs;
|
|
26
|
+
tokenMaxAgeMs;
|
|
27
|
+
failures = 0;
|
|
28
|
+
lockedUntil = 0;
|
|
29
|
+
constructor(opts) {
|
|
30
|
+
this.filePath = opts.filePath;
|
|
31
|
+
this.now = opts.now ?? Date.now;
|
|
32
|
+
this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
33
|
+
this.lockoutMs = opts.lockoutMs ?? DEFAULT_LOCKOUT_MS;
|
|
34
|
+
this.tokenMaxAgeMs = opts.tokenMaxAgeMs ?? DEFAULT_TOKEN_MAX_AGE_MS;
|
|
35
|
+
}
|
|
36
|
+
isSet() {
|
|
37
|
+
return this.read() !== undefined;
|
|
38
|
+
}
|
|
39
|
+
/** Set (or rotate) the passcode. Rotating `secret` invalidates old tokens. */
|
|
40
|
+
set(passcode) {
|
|
41
|
+
const salt = randomBytes(16).toString("hex");
|
|
42
|
+
const hash = this.hashPasscode(passcode, salt);
|
|
43
|
+
const secret = randomBytes(32).toString("hex");
|
|
44
|
+
this.write({ hash, salt, secret });
|
|
45
|
+
this.failures = 0;
|
|
46
|
+
this.lockedUntil = 0;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Verify a passcode. On success returns a fresh remember-token and resets the
|
|
50
|
+
* failure counter; on failure (or while rate-limited) returns null and counts
|
|
51
|
+
* a failure. While locked out, even a correct passcode returns null.
|
|
52
|
+
*/
|
|
53
|
+
verify(passcode) {
|
|
54
|
+
const record = this.read();
|
|
55
|
+
if (!record)
|
|
56
|
+
return null;
|
|
57
|
+
if (this.isLocked())
|
|
58
|
+
return null;
|
|
59
|
+
const candidate = this.hashPasscode(passcode, record.salt);
|
|
60
|
+
if (!safeEqualHex(candidate, record.hash)) {
|
|
61
|
+
this.failures++;
|
|
62
|
+
if (this.failures >= this.maxAttempts) {
|
|
63
|
+
this.lockedUntil = this.now() + this.lockoutMs;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
this.failures = 0;
|
|
68
|
+
this.lockedUntil = 0;
|
|
69
|
+
return this.issueToken(record);
|
|
70
|
+
}
|
|
71
|
+
/** Validate a remember-token: HMAC signature must match AND be over the
|
|
72
|
+
* current secret (so it dies when the passcode is rotated), AND the signed
|
|
73
|
+
* issue timestamp must be within the validity window (so a stolen/synced
|
|
74
|
+
* cookie cannot grant access indefinitely). */
|
|
75
|
+
verifyToken(token) {
|
|
76
|
+
const record = this.read();
|
|
77
|
+
if (!record)
|
|
78
|
+
return false;
|
|
79
|
+
const dot = token.lastIndexOf(".");
|
|
80
|
+
if (dot <= 0)
|
|
81
|
+
return false;
|
|
82
|
+
const payload = token.slice(0, dot);
|
|
83
|
+
const sig = token.slice(dot + 1);
|
|
84
|
+
const expected = this.sign(payload, record.secret);
|
|
85
|
+
if (!safeEqualHex(sig, expected))
|
|
86
|
+
return false;
|
|
87
|
+
// The timestamp is covered by the HMAC, so it is trustworthy once the
|
|
88
|
+
// signature checks out.
|
|
89
|
+
const issuedAt = Number(payload.slice(payload.indexOf(".") + 1));
|
|
90
|
+
if (!Number.isFinite(issuedAt))
|
|
91
|
+
return false;
|
|
92
|
+
return this.now() - issuedAt <= this.tokenMaxAgeMs;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* HTTP gate. Returns true (allow) when the request carries a valid remember
|
|
96
|
+
* token (cookie) or the correct passcode (query/header); on a fresh correct
|
|
97
|
+
* passcode it also sets the remember cookie so the phone is not re-challenged.
|
|
98
|
+
* Returns false after writing either a redirect or a 401 challenge — the
|
|
99
|
+
* caller must stop in both cases.
|
|
100
|
+
*/
|
|
101
|
+
gate(req, res) {
|
|
102
|
+
if (!this.isSet()) {
|
|
103
|
+
// No passcode configured → tunnel must not be reachable. Refuse.
|
|
104
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
105
|
+
res.end("access passcode not configured");
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
const cookieToken = readCookie(req.headers.cookie, COOKIE_NAME);
|
|
109
|
+
if (cookieToken && this.verifyToken(cookieToken))
|
|
110
|
+
return true;
|
|
111
|
+
const supplied = readPasscodeParam(req);
|
|
112
|
+
let wrongAttempt = false;
|
|
113
|
+
if (supplied) {
|
|
114
|
+
const token = this.verify(supplied);
|
|
115
|
+
if (token) {
|
|
116
|
+
res.setHeader("Set-Cookie",
|
|
117
|
+
// SameSite=Lax (not Strict): the phone arrives via a QR-scan-launched
|
|
118
|
+
// top-level navigation into trycloudflare.com, which Strict treats as
|
|
119
|
+
// cross-site and refuses to send the cookie on — re-challenging the
|
|
120
|
+
// user every visit. Lax sends it on top-level navigations. Secure
|
|
121
|
+
// because the tunnel terminates TLS (always https).
|
|
122
|
+
`${COOKIE_NAME}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${Math.floor(this.tokenMaxAgeMs / 1000)}`);
|
|
123
|
+
const cleanLocation = acceptsHtml(req) ? locationWithoutPasscode(req.url) : undefined;
|
|
124
|
+
if (cleanLocation) {
|
|
125
|
+
// The HTML challenge has a no-query JavaScript path below, but keep a
|
|
126
|
+
// server-side redirect as a no-JS fallback. Never render the app while
|
|
127
|
+
// the plaintext passcode is still visible in the navigation URL.
|
|
128
|
+
res.writeHead(303, {
|
|
129
|
+
Location: cleanLocation,
|
|
130
|
+
"Cache-Control": "no-store",
|
|
131
|
+
});
|
|
132
|
+
res.end();
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
wrongAttempt = true;
|
|
138
|
+
}
|
|
139
|
+
// A browser navigating to a page needs a form to type the passcode into —
|
|
140
|
+
// a bare text/plain 401 was a dead end (the user saw the error with no way
|
|
141
|
+
// to enter the passcode). Non-navigation requests (WS upgrade, fetch) keep
|
|
142
|
+
// the lightweight text 401.
|
|
143
|
+
if (acceptsHtml(req)) {
|
|
144
|
+
res.writeHead(401, { "content-type": "text/html; charset=utf-8" });
|
|
145
|
+
res.end(challengePage(req.url, wrongAttempt, this.isLocked()));
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
res.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
|
149
|
+
res.end("访问口令无效或缺失");
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
/** True if a WS upgrade carrying these headers/url is allowed (no body to
|
|
153
|
+
* write). Used by the WS gate which cannot send a challenge page. */
|
|
154
|
+
allows(req) {
|
|
155
|
+
if (!this.isSet())
|
|
156
|
+
return false;
|
|
157
|
+
const cookieToken = readCookie(req.headers.cookie, COOKIE_NAME);
|
|
158
|
+
if (cookieToken && this.verifyToken(cookieToken))
|
|
159
|
+
return true;
|
|
160
|
+
const supplied = readPasscodeParam(req);
|
|
161
|
+
if (supplied)
|
|
162
|
+
return this.verify(supplied) !== null;
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
isLocked() {
|
|
166
|
+
if (this.lockedUntil === 0)
|
|
167
|
+
return false;
|
|
168
|
+
if (this.now() >= this.lockedUntil) {
|
|
169
|
+
this.lockedUntil = 0;
|
|
170
|
+
this.failures = 0;
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
issueToken(record) {
|
|
176
|
+
const payload = `${randomBytes(12).toString("hex")}.${this.now()}`;
|
|
177
|
+
const sig = this.sign(payload, record.secret);
|
|
178
|
+
return `${payload}.${sig}`;
|
|
179
|
+
}
|
|
180
|
+
sign(payload, secret) {
|
|
181
|
+
return createHmac("sha256", secret).update(payload).digest("hex");
|
|
182
|
+
}
|
|
183
|
+
hashPasscode(passcode, salt) {
|
|
184
|
+
return scryptSync(passcode, salt, SCRYPT_KEYLEN).toString("hex");
|
|
185
|
+
}
|
|
186
|
+
read() {
|
|
187
|
+
if (!existsSync(this.filePath))
|
|
188
|
+
return undefined;
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
191
|
+
if (parsed.hash && parsed.salt && parsed.secret) {
|
|
192
|
+
return { hash: parsed.hash, salt: parsed.salt, secret: parsed.secret };
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
write(record) {
|
|
201
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
202
|
+
writeFileSync(this.filePath, JSON.stringify(record, null, 2), "utf-8");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function readCookie(cookieHeader, name) {
|
|
206
|
+
if (!cookieHeader)
|
|
207
|
+
return undefined;
|
|
208
|
+
const header = Array.isArray(cookieHeader) ? cookieHeader.join("; ") : cookieHeader;
|
|
209
|
+
for (const part of header.split(/;\s*/)) {
|
|
210
|
+
const eq = part.indexOf("=");
|
|
211
|
+
if (eq <= 0)
|
|
212
|
+
continue;
|
|
213
|
+
if (part.slice(0, eq) === name) {
|
|
214
|
+
const raw = part.slice(eq + 1);
|
|
215
|
+
// The Cookie header is attacker-controlled on the mobile-remote boundary.
|
|
216
|
+
// decodeURIComponent THROWS URIError on a malformed %-sequence (e.g.
|
|
217
|
+
// `cs_access=%ZZ`); unguarded it escapes gate() and crashes the request
|
|
218
|
+
// handler (DoS). Fall back to the raw value like browsers do for bad cookies.
|
|
219
|
+
try {
|
|
220
|
+
return decodeURIComponent(raw);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return raw;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
function readPasscodeParam(req) {
|
|
230
|
+
// Header takes precedence (used by the WS/fetch path), then ?passcode= query.
|
|
231
|
+
// Node represents duplicate headers as string[]; handle that like readCookie
|
|
232
|
+
// does (take the first value) instead of silently falling through to query.
|
|
233
|
+
const rawHeader = req.headers["x-access-passcode"];
|
|
234
|
+
const headerVal = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
|
|
235
|
+
if (typeof headerVal === "string" && headerVal)
|
|
236
|
+
return headerVal;
|
|
237
|
+
if (!req.url)
|
|
238
|
+
return undefined;
|
|
239
|
+
const qIdx = req.url.indexOf("?");
|
|
240
|
+
if (qIdx < 0)
|
|
241
|
+
return undefined;
|
|
242
|
+
const params = new URLSearchParams(req.url.slice(qIdx + 1));
|
|
243
|
+
return params.get("passcode") ?? undefined;
|
|
244
|
+
}
|
|
245
|
+
function acceptsHtml(req) {
|
|
246
|
+
const accept = req.headers.accept;
|
|
247
|
+
const value = Array.isArray(accept) ? accept.join(",") : accept;
|
|
248
|
+
return typeof value === "string" && value.includes("text/html");
|
|
249
|
+
}
|
|
250
|
+
function locationWithoutPasscode(url) {
|
|
251
|
+
if (!url)
|
|
252
|
+
return undefined;
|
|
253
|
+
const qIdx = url.indexOf("?");
|
|
254
|
+
if (qIdx < 0)
|
|
255
|
+
return undefined;
|
|
256
|
+
const path = url.slice(0, qIdx) || "/";
|
|
257
|
+
const params = new URLSearchParams(url.slice(qIdx + 1));
|
|
258
|
+
if (!params.has("passcode"))
|
|
259
|
+
return undefined;
|
|
260
|
+
params.delete("passcode");
|
|
261
|
+
const query = params.toString();
|
|
262
|
+
return query ? `${path}?${query}` : path;
|
|
263
|
+
}
|
|
264
|
+
function escapeHtml(s) {
|
|
265
|
+
return s
|
|
266
|
+
.replace(/&/g, "&")
|
|
267
|
+
.replace(/</g, "<")
|
|
268
|
+
.replace(/>/g, ">")
|
|
269
|
+
.replace(/"/g, """);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The passcode challenge page. Submits via GET back to the SAME path so any
|
|
273
|
+
* existing query (notably `?pairing=<token>`) is preserved — the form re-adds
|
|
274
|
+
* `passcode`, and on success gate() sets the remember cookie and the next load
|
|
275
|
+
* serves the real /mobile page with the pairing token intact.
|
|
276
|
+
*/
|
|
277
|
+
function challengePage(url, wrong, locked) {
|
|
278
|
+
// Carry forward existing params except a stale passcode.
|
|
279
|
+
const qIdx = (url ?? "").indexOf("?");
|
|
280
|
+
const path = qIdx >= 0 ? url.slice(0, qIdx) : (url ?? "/mobile");
|
|
281
|
+
const params = new URLSearchParams(qIdx >= 0 ? url.slice(qIdx + 1) : "");
|
|
282
|
+
params.delete("passcode");
|
|
283
|
+
const hidden = [...params.entries()]
|
|
284
|
+
.map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`)
|
|
285
|
+
.join("");
|
|
286
|
+
const note = locked
|
|
287
|
+
? "尝试过多,已临时锁定,请稍后再试。"
|
|
288
|
+
: wrong
|
|
289
|
+
? "口令不正确,请重试。"
|
|
290
|
+
: "请输入访问口令以继续。";
|
|
291
|
+
return `<!doctype html>
|
|
292
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
293
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
294
|
+
<title>访问口令</title>
|
|
295
|
+
<style>
|
|
296
|
+
:root{color-scheme:light dark}
|
|
297
|
+
body{font-family:-apple-system,system-ui,sans-serif;margin:0;min-height:100vh;
|
|
298
|
+
display:flex;align-items:center;justify-content:center;background:#0b0b0c;color:#e7e7e9}
|
|
299
|
+
.card{width:min(92vw,360px);padding:28px 24px;border-radius:16px;background:#19191c;
|
|
300
|
+
box-shadow:0 8px 40px rgba(0,0,0,.4)}
|
|
301
|
+
h1{font-size:18px;margin:0 0 6px}
|
|
302
|
+
p{font-size:13px;color:#a1a1aa;margin:0 0 18px}
|
|
303
|
+
p.err{color:#f87171}
|
|
304
|
+
input[type=password]{width:100%;box-sizing:border-box;padding:12px 14px;font-size:16px;
|
|
305
|
+
border-radius:10px;border:1px solid #333;background:#0f0f11;color:#fff;margin-bottom:14px}
|
|
306
|
+
button{width:100%;padding:12px;font-size:15px;border:0;border-radius:10px;
|
|
307
|
+
background:#3b82f6;color:#fff;font-weight:600}
|
|
308
|
+
button:disabled{opacity:.5}
|
|
309
|
+
</style></head>
|
|
310
|
+
<body><form class="card" method="GET" action="${escapeHtml(path)}">
|
|
311
|
+
<h1>手机遥控</h1>
|
|
312
|
+
<p class="${wrong || locked ? "err" : ""}">${note}</p>
|
|
313
|
+
${hidden}
|
|
314
|
+
<input id="passcode" type="password" name="passcode" placeholder="访问口令" autofocus required ${locked ? "disabled" : ""}>
|
|
315
|
+
<button type="submit" ${locked ? "disabled" : ""}>进入</button>
|
|
316
|
+
</form>
|
|
317
|
+
<script>
|
|
318
|
+
const form = document.querySelector("form");
|
|
319
|
+
const input = document.querySelector("#passcode");
|
|
320
|
+
const note = document.querySelector("p");
|
|
321
|
+
form?.addEventListener("submit", async (event) => {
|
|
322
|
+
event.preventDefault();
|
|
323
|
+
const value = input?.value ?? "";
|
|
324
|
+
if (!value) return;
|
|
325
|
+
try {
|
|
326
|
+
const response = await fetch(window.location.href, {
|
|
327
|
+
headers: { "X-Access-Passcode": value, Accept: "application/json" },
|
|
328
|
+
credentials: "same-origin",
|
|
329
|
+
});
|
|
330
|
+
if (!response.ok) {
|
|
331
|
+
if (note) note.textContent = "口令不正确或尝试过多,请稍后重试。";
|
|
332
|
+
if (note) note.className = "err";
|
|
333
|
+
if (input) input.value = "";
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
window.location.replace(${JSON.stringify(path + (params.size ? `?${params.toString()}` : ""))});
|
|
337
|
+
} catch {
|
|
338
|
+
if (note) note.textContent = "连接失败,请重试。";
|
|
339
|
+
if (note) note.className = "err";
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
</script></body></html>`;
|
|
343
|
+
}
|
|
344
|
+
/** Constant-time hex comparison that tolerates length mismatch. */
|
|
345
|
+
function safeEqualHex(a, b) {
|
|
346
|
+
if (a.length !== b.length)
|
|
347
|
+
return false;
|
|
348
|
+
try {
|
|
349
|
+
return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pinned cloudflared release. We do NOT track `latest`: the downloaded asset is
|
|
3
|
+
* chmod +x'd and executed with the desktop user's privileges, so the bytes must
|
|
4
|
+
* be verifiable against a known SHA-256. `latest` would move under us and make
|
|
5
|
+
* any embedded digest wrong on the next release (breaking installs) or, worse,
|
|
6
|
+
* silently accept whatever `latest` now points at. Bump this tag AND the digests
|
|
7
|
+
* below together (see scripts note in the PR / CLAUDE memory).
|
|
8
|
+
*/
|
|
9
|
+
export declare const CLOUDFLARED_VERSION = "2026.6.1";
|
|
10
|
+
/**
|
|
11
|
+
* SHA-256 of each release asset we download, for {@link CLOUDFLARED_VERSION}.
|
|
12
|
+
* Keyed by the asset FILENAME. For macOS this is the `.tgz` archive (verified
|
|
13
|
+
* before extraction); for Linux/Windows it's the raw binary. Regenerate on every
|
|
14
|
+
* version bump:
|
|
15
|
+
* for a in cloudflared-darwin-amd64.tgz cloudflared-darwin-arm64.tgz \
|
|
16
|
+
* cloudflared-linux-amd64 cloudflared-linux-arm64 \
|
|
17
|
+
* cloudflared-windows-amd64.exe; do
|
|
18
|
+
* curl -sSL "https://github.com/cloudflare/cloudflared/releases/download/$V/$a" | shasum -a 256
|
|
19
|
+
* done
|
|
20
|
+
*/
|
|
21
|
+
export declare const CLOUDFLARED_SHA256: Record<string, string>;
|
|
22
|
+
/** The filename portion of an asset URL — the key into {@link CLOUDFLARED_SHA256}. */
|
|
23
|
+
export declare function cloudflaredAssetName(arch: string, platform?: NodeJS.Platform): string;
|
|
24
|
+
/**
|
|
25
|
+
* Verify `path`'s SHA-256 against the pinned digest for `assetName`. Throws (with
|
|
26
|
+
* the file left for the caller to clean up) on an unknown asset or a mismatch —
|
|
27
|
+
* never chmod/execute unverified bytes.
|
|
28
|
+
*/
|
|
29
|
+
export declare function verifyAssetDigest(path: string, assetName: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* Map a Node `process.platform` + `process.arch` to the official cloudflared
|
|
32
|
+
* release asset URL.
|
|
33
|
+
*
|
|
34
|
+
* - macOS: `cloudflared-darwin-<arch>.tgz` (a tarball with one binary).
|
|
35
|
+
* - Windows: `cloudflared-windows-<arch>.exe` (a RAW .exe, no extraction).
|
|
36
|
+
* - Linux: `cloudflared-linux-<arch>` (a raw binary).
|
|
37
|
+
*
|
|
38
|
+
* Pure + injectable so tests pin the URL without touching the network. Unknown
|
|
39
|
+
* arch falls back to amd64.
|
|
40
|
+
*/
|
|
41
|
+
export declare function cloudflaredDownloadUrl(arch: string, platform?: NodeJS.Platform): string;
|
|
42
|
+
/** Injectable download function. Writes the fetched bytes to `dest`. */
|
|
43
|
+
export type DownloadFn = (url: string, dest: string, onProgress?: (pct: number) => void) => Promise<void>;
|
|
44
|
+
export interface CloudflaredBinaryOptions {
|
|
45
|
+
/** Base dir, typically `<userData>/mobile-remote`. */
|
|
46
|
+
baseDir: string;
|
|
47
|
+
/** Override `process.arch` for tests. */
|
|
48
|
+
arch?: string;
|
|
49
|
+
/** Override the network download for tests. */
|
|
50
|
+
download?: DownloadFn;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Manages the cloudflared binary on disk: existence/executable check and a
|
|
54
|
+
* download-once-then-atomic-rename install. No process spawning, no network
|
|
55
|
+
* service — that lives in tunnel-manager. Network + arch are injected so the
|
|
56
|
+
* unit is fully deterministic in tests.
|
|
57
|
+
*/
|
|
58
|
+
export declare class CloudflaredBinary {
|
|
59
|
+
private readonly baseDir;
|
|
60
|
+
private readonly arch;
|
|
61
|
+
private readonly download;
|
|
62
|
+
constructor(opts: CloudflaredBinaryOptions);
|
|
63
|
+
/** `<baseDir>/bin/cloudflared` (`.exe` on Windows). */
|
|
64
|
+
binaryPath(): string;
|
|
65
|
+
/** True when the binary exists (and, on POSIX, has the owner-exec bit set).
|
|
66
|
+
* Windows has no exec bit, so existence is the only meaningful check. */
|
|
67
|
+
isInstalled(): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Ensure the binary is present and executable, returning its path. If already
|
|
70
|
+
* installed, skips the download. Otherwise downloads to a `.download` temp
|
|
71
|
+
* name, chmods it executable, and atomically renames to the final path — so a
|
|
72
|
+
* failed/partial download never leaves a half-written binary in place. On any
|
|
73
|
+
* failure the temp file is removed and the error rethrown.
|
|
74
|
+
*/
|
|
75
|
+
ensureBinary(onProgress?: (pct: number) => void): Promise<string>;
|
|
76
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, } from "node:fs";
|
|
4
|
+
import { get as httpsGet } from "node:https";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
/**
|
|
8
|
+
* Pinned cloudflared release. We do NOT track `latest`: the downloaded asset is
|
|
9
|
+
* chmod +x'd and executed with the desktop user's privileges, so the bytes must
|
|
10
|
+
* be verifiable against a known SHA-256. `latest` would move under us and make
|
|
11
|
+
* any embedded digest wrong on the next release (breaking installs) or, worse,
|
|
12
|
+
* silently accept whatever `latest` now points at. Bump this tag AND the digests
|
|
13
|
+
* below together (see scripts note in the PR / CLAUDE memory).
|
|
14
|
+
*/
|
|
15
|
+
export const CLOUDFLARED_VERSION = "2026.6.1";
|
|
16
|
+
/**
|
|
17
|
+
* SHA-256 of each release asset we download, for {@link CLOUDFLARED_VERSION}.
|
|
18
|
+
* Keyed by the asset FILENAME. For macOS this is the `.tgz` archive (verified
|
|
19
|
+
* before extraction); for Linux/Windows it's the raw binary. Regenerate on every
|
|
20
|
+
* version bump:
|
|
21
|
+
* for a in cloudflared-darwin-amd64.tgz cloudflared-darwin-arm64.tgz \
|
|
22
|
+
* cloudflared-linux-amd64 cloudflared-linux-arm64 \
|
|
23
|
+
* cloudflared-windows-amd64.exe; do
|
|
24
|
+
* curl -sSL "https://github.com/cloudflare/cloudflared/releases/download/$V/$a" | shasum -a 256
|
|
25
|
+
* done
|
|
26
|
+
*/
|
|
27
|
+
export const CLOUDFLARED_SHA256 = {
|
|
28
|
+
"cloudflared-darwin-amd64.tgz": "d7a66b525fe76820da6e5406611b61e48b40de682368ac00454d9158f085be4b",
|
|
29
|
+
"cloudflared-darwin-arm64.tgz": "f6d4c439c6c782b83264951d327989ce5e23373acc5942b872411601fedb020d",
|
|
30
|
+
"cloudflared-linux-amd64": "5861a10a438fe8ddcfebb3b830f83966cbf193edafce0fe2eeb198fbae1f7a22",
|
|
31
|
+
"cloudflared-linux-arm64": "59816ce9b16db71f5bc2a86d59b3632a96c8c3ee934bde2bc8641ee83a6070eb",
|
|
32
|
+
"cloudflared-windows-amd64.exe": "5253e66f1f493c4e13539749f1aa86fd0c61e3072900fec29a44ba046a6d97e2",
|
|
33
|
+
};
|
|
34
|
+
/** The filename portion of an asset URL — the key into {@link CLOUDFLARED_SHA256}. */
|
|
35
|
+
export function cloudflaredAssetName(arch, platform = process.platform) {
|
|
36
|
+
const a = arch === "arm64" ? "arm64" : "amd64";
|
|
37
|
+
if (platform === "win32")
|
|
38
|
+
return "cloudflared-windows-amd64.exe";
|
|
39
|
+
if (platform === "linux")
|
|
40
|
+
return `cloudflared-linux-${a}`;
|
|
41
|
+
return `cloudflared-darwin-${a}.tgz`;
|
|
42
|
+
}
|
|
43
|
+
/** SHA-256 of a file on disk, lowercase hex. */
|
|
44
|
+
function sha256File(path) {
|
|
45
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Verify `path`'s SHA-256 against the pinned digest for `assetName`. Throws (with
|
|
49
|
+
* the file left for the caller to clean up) on an unknown asset or a mismatch —
|
|
50
|
+
* never chmod/execute unverified bytes.
|
|
51
|
+
*/
|
|
52
|
+
export function verifyAssetDigest(path, assetName) {
|
|
53
|
+
const expected = CLOUDFLARED_SHA256[assetName];
|
|
54
|
+
if (!expected) {
|
|
55
|
+
throw new Error(`cloudflared 资产无内置校验值: ${assetName}`);
|
|
56
|
+
}
|
|
57
|
+
const actual = sha256File(path);
|
|
58
|
+
if (actual !== expected) {
|
|
59
|
+
throw new Error(`cloudflared 校验失败(${assetName}): 期望 ${expected}, 实际 ${actual}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Map a Node `process.platform` + `process.arch` to the official cloudflared
|
|
64
|
+
* release asset URL.
|
|
65
|
+
*
|
|
66
|
+
* - macOS: `cloudflared-darwin-<arch>.tgz` (a tarball with one binary).
|
|
67
|
+
* - Windows: `cloudflared-windows-<arch>.exe` (a RAW .exe, no extraction).
|
|
68
|
+
* - Linux: `cloudflared-linux-<arch>` (a raw binary).
|
|
69
|
+
*
|
|
70
|
+
* Pure + injectable so tests pin the URL without touching the network. Unknown
|
|
71
|
+
* arch falls back to amd64.
|
|
72
|
+
*/
|
|
73
|
+
export function cloudflaredDownloadUrl(arch, platform = process.platform) {
|
|
74
|
+
// Pinned version (NOT /latest/) so the asset matches an embedded SHA-256.
|
|
75
|
+
const base = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}`;
|
|
76
|
+
return `${base}/${cloudflaredAssetName(arch, platform)}`;
|
|
77
|
+
}
|
|
78
|
+
/** True when the release asset is a tarball needing extraction (macOS only). */
|
|
79
|
+
function assetIsTarball(platform = process.platform) {
|
|
80
|
+
return platform === "darwin";
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Manages the cloudflared binary on disk: existence/executable check and a
|
|
84
|
+
* download-once-then-atomic-rename install. No process spawning, no network
|
|
85
|
+
* service — that lives in tunnel-manager. Network + arch are injected so the
|
|
86
|
+
* unit is fully deterministic in tests.
|
|
87
|
+
*/
|
|
88
|
+
export class CloudflaredBinary {
|
|
89
|
+
baseDir;
|
|
90
|
+
arch;
|
|
91
|
+
download;
|
|
92
|
+
constructor(opts) {
|
|
93
|
+
this.baseDir = opts.baseDir;
|
|
94
|
+
this.arch = opts.arch ?? process.arch;
|
|
95
|
+
this.download = opts.download ?? defaultDownload;
|
|
96
|
+
}
|
|
97
|
+
/** `<baseDir>/bin/cloudflared` (`.exe` on Windows). */
|
|
98
|
+
binaryPath() {
|
|
99
|
+
const name = process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
|
|
100
|
+
return join(this.baseDir, "bin", name);
|
|
101
|
+
}
|
|
102
|
+
/** True when the binary exists (and, on POSIX, has the owner-exec bit set).
|
|
103
|
+
* Windows has no exec bit, so existence is the only meaningful check. */
|
|
104
|
+
isInstalled() {
|
|
105
|
+
const p = this.binaryPath();
|
|
106
|
+
if (!existsSync(p))
|
|
107
|
+
return false;
|
|
108
|
+
if (process.platform === "win32")
|
|
109
|
+
return true;
|
|
110
|
+
try {
|
|
111
|
+
return (statSync(p).mode & 0o100) === 0o100;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Ensure the binary is present and executable, returning its path. If already
|
|
119
|
+
* installed, skips the download. Otherwise downloads to a `.download` temp
|
|
120
|
+
* name, chmods it executable, and atomically renames to the final path — so a
|
|
121
|
+
* failed/partial download never leaves a half-written binary in place. On any
|
|
122
|
+
* failure the temp file is removed and the error rethrown.
|
|
123
|
+
*/
|
|
124
|
+
async ensureBinary(onProgress) {
|
|
125
|
+
const finalPath = this.binaryPath();
|
|
126
|
+
if (this.isInstalled())
|
|
127
|
+
return finalPath;
|
|
128
|
+
const tmpPath = `${finalPath}.download`;
|
|
129
|
+
mkdirSync(join(this.baseDir, "bin"), { recursive: true });
|
|
130
|
+
try {
|
|
131
|
+
const url = cloudflaredDownloadUrl(this.arch);
|
|
132
|
+
await this.download(url, tmpPath, onProgress);
|
|
133
|
+
if (!existsSync(tmpPath) || statSync(tmpPath).size === 0) {
|
|
134
|
+
throw new Error("下载的 cloudflared 为空");
|
|
135
|
+
}
|
|
136
|
+
chmodSync(tmpPath, 0o755);
|
|
137
|
+
renameSync(tmpPath, finalPath);
|
|
138
|
+
return finalPath;
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
rmSync(tmpPath, { force: true });
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Default download: the official macOS asset is a `.tgz` tarball containing a
|
|
148
|
+
* single `cloudflared` binary, so we fetch the tarball to a scratch dir, extract
|
|
149
|
+
* it with the system `tar`, then place the extracted binary at `dest`. The
|
|
150
|
+
* caller (ensureBinary) chmods + atomically renames `dest` into place, so a
|
|
151
|
+
* failed extract never yields a runnable-but-broken binary. Network + extract
|
|
152
|
+
* scratch is fully cleaned up.
|
|
153
|
+
*/
|
|
154
|
+
const defaultDownload = async (url, dest, onProgress) => {
|
|
155
|
+
// The asset filename is the key into the embedded SHA-256 table.
|
|
156
|
+
const assetName = cloudflaredAssetName(process.arch);
|
|
157
|
+
// Windows/Linux assets are RAW binaries — stream straight to dest, no tar.
|
|
158
|
+
if (!assetIsTarball()) {
|
|
159
|
+
await httpsDownloadTo(url, dest, onProgress);
|
|
160
|
+
if (!existsSync(dest) || statSync(dest).size === 0) {
|
|
161
|
+
throw new Error("下载的 cloudflared 为空");
|
|
162
|
+
}
|
|
163
|
+
// Verify BEFORE the caller chmods +x and renames into place. A mismatch
|
|
164
|
+
// throws; ensureBinary removes the temp file so nothing runnable survives.
|
|
165
|
+
verifyAssetDigest(dest, assetName);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// macOS asset is a .tgz with a single `cloudflared` binary → extract.
|
|
169
|
+
const scratch = mkdtempSync(join(tmpdir(), "cloudflared-dl-"));
|
|
170
|
+
const tgzPath = join(scratch, "cloudflared.tgz");
|
|
171
|
+
try {
|
|
172
|
+
await httpsDownloadTo(url, tgzPath, onProgress);
|
|
173
|
+
if (!existsSync(tgzPath) || statSync(tgzPath).size === 0) {
|
|
174
|
+
throw new Error("下载的 cloudflared 归档为空");
|
|
175
|
+
}
|
|
176
|
+
// Verify the ARCHIVE against its embedded digest before extracting — never
|
|
177
|
+
// hand attacker-controlled bytes to `tar`.
|
|
178
|
+
verifyAssetDigest(tgzPath, assetName);
|
|
179
|
+
await runTarExtract(tgzPath, scratch);
|
|
180
|
+
const extracted = join(scratch, "cloudflared");
|
|
181
|
+
if (!existsSync(extracted)) {
|
|
182
|
+
throw new Error("归档中未找到 cloudflared 二进制");
|
|
183
|
+
}
|
|
184
|
+
copyFileSync(extracted, dest);
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
/** Stream an https GET (following GitHub→CDN redirects) to a file with coarse
|
|
191
|
+
* progress. */
|
|
192
|
+
function httpsDownloadTo(url, dest, onProgress) {
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
const request = (target, redirects) => {
|
|
195
|
+
if (redirects > 5) {
|
|
196
|
+
reject(new Error("下载重定向过多"));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const req = httpsGet(target, (res) => {
|
|
200
|
+
const status = res.statusCode ?? 0;
|
|
201
|
+
if (status >= 300 && status < 400 && res.headers.location) {
|
|
202
|
+
res.resume();
|
|
203
|
+
request(res.headers.location, redirects + 1);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (status !== 200) {
|
|
207
|
+
res.resume();
|
|
208
|
+
reject(new Error(`下载失败,HTTP ${status}`));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const total = Number(res.headers["content-length"] ?? 0);
|
|
212
|
+
let received = 0;
|
|
213
|
+
const out = createWriteStream(dest);
|
|
214
|
+
res.on("data", (chunk) => {
|
|
215
|
+
received += chunk.length;
|
|
216
|
+
if (total > 0 && onProgress) {
|
|
217
|
+
onProgress(Math.min(100, Math.round((received / total) * 100)));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
res.pipe(out);
|
|
221
|
+
out.on("finish", () => out.close(() => resolve()));
|
|
222
|
+
out.on("error", reject);
|
|
223
|
+
res.on("error", reject);
|
|
224
|
+
});
|
|
225
|
+
req.on("error", reject);
|
|
226
|
+
};
|
|
227
|
+
request(url, 0);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/** Extract a `.tgz` into `cwd` using the system tar (always present on macOS). */
|
|
231
|
+
function runTarExtract(tgzPath, cwd) {
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
const child = spawn("tar", ["-xzf", tgzPath, "-C", cwd]);
|
|
234
|
+
child.on("error", reject);
|
|
235
|
+
child.on("exit", (code) => {
|
|
236
|
+
if (code === 0)
|
|
237
|
+
resolve();
|
|
238
|
+
else
|
|
239
|
+
reject(new Error(`tar 解压失败(code=${code ?? "?"})`));
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}
|