@dsh-plus/access-gate 0.1.3 → 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/lib/index.js CHANGED
@@ -1,26 +1,26 @@
1
1
  import { Service } from "@deepseek-ai/cordis";
2
2
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
3
  import z from "@deepseek-ai/schemastery";
4
- import { createHash, timingSafeEqual } from "node:crypto";
5
4
  //#endregion
6
5
  //#region src/config.ts
7
6
  /**
8
7
  * 配置单一事实源:cordis 行级 Config(组合默认值,dev/prod patch 层可覆盖)
9
8
  * 与 settings namespace(用户层,经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml)
10
9
  * 共用同一 schemastery schema(notify-email 同款约定)。
11
- * token 标记 secret 角色:任何读取通道不得回传明文。
10
+ *
11
+ * 与官方认证合并后:访问凭据唯一化为官方 browser-auth cookie(dsh-auth-*,
12
+ * 由 client-connection 的 ?token= 启动令牌交换签发),本插件不再持有自有
13
+ * token/cookie;allowedIps 降级为可选的附加 IP 围栏。
14
+ * 旧配置中的 token/cookieMaxAgeHours/loginFailLimit/loginCooldownMs 键由
15
+ * schemastery 透传忽略,不阻断加载(已实测验证)。
12
16
  * @module access-gate/config
13
17
  */
14
18
  /** settings 命名空间;webui 配置卡片与插件运行期读取同一份(字面量见 ./ns.ts)。 */
15
19
  const SETTINGS_NS = settingsNamespace("dsh-plus-access-gate");
16
20
  const Config = z.object({
17
21
  enabled: z.boolean().description("访问围栏总开关(false = 一切放行,等价插件缺席)").default(false),
18
- token: z.string().role("secret").description("访问令牌;空字符串 = token 通道关闭。经登录页输入后以 HttpOnly cookie 持有").default(""),
19
- allowedIps: z.array(z.string()).description("放行 IP 白名单:精确 IP 或 CIDR(IPv4/IPv6 皆可)").default([]),
20
- trustForwardedFor: z.boolean().description("是否信任 x-forwarded-for 还原真实客户端 IP(仅当入口代理强制覆盖该头时开启;tailscale serve 满足)").default(true),
21
- cookieMaxAgeHours: z.natural().max(8760).description("登录 cookie 有效期(小时)").default(720),
22
- loginFailLimit: z.natural().description("登录失败节流阈值(每 IP 冷却窗口内允许的失败次数)").default(10),
23
- loginCooldownMs: z.natural().description("登录失败节流冷却毫秒数").default(6e4)
22
+ allowedIps: z.array(z.string()).description("附加 IP 围栏:非空时仅白名单条目(精确 IP 或 CIDR,IPv4/IPv6 皆可)可通行与登录;空 = 不限制来源 IP(仅官方登录保护)").default([]),
23
+ trustForwardedFor: z.boolean().description("是否信任 x-forwarded-for 还原真实客户端 IP(仅当入口代理强制覆盖该头时开启;tailscale serve 满足)").default(true)
24
24
  });
25
25
  //#endregion
26
26
  //#region src/ip.ts
@@ -150,40 +150,6 @@ function isLoopbackAddress(remoteAddress) {
150
150
  }
151
151
  //#endregion
152
152
  //#region src/decision.ts
153
- /**
154
- * 围栏判定核心:`decideGate` 纯函数。
155
- *
156
- * 信任模型(决策记录详见 docs/README.md):
157
- * - dsh web 仅绑 127.0.0.1,远程流量必经 tailscale serve → dsh-proxy 两跳,
158
- * serve 会**覆盖**(而非追加)x-forwarded-for,客户端无法伪造;
159
- * - 能直发 loopback 或伪造 XFF 的只有本机进程——与 dsh 官方 /api 围栏的
160
- * loopback 信任边界一致,本机直连视为可信管理通道;
161
- * - 因此 XFF 存在且 trustForwardedFor=true 时取最左条目为真实客户端 IP。
162
- *
163
- * token 比较使用 sha256 摘要 + timingSafeEqual 常时比较。
164
- * @module access-gate/decision
165
- */
166
- const GATE_COOKIE = "dsh_gate";
167
- /** sha256 摘要为定长 32 字节,常时比较的前提。 */
168
- function tokenMatches(candidate, expected) {
169
- if (candidate === "" || expected === "") return false;
170
- const a = createHash("sha256").update(candidate).digest();
171
- const b = createHash("sha256").update(expected).digest();
172
- return timingSafeEqual(a, b);
173
- }
174
- /** 从 cookie 头解析指定名字的值(手写最小解析,零依赖)。 */
175
- function readCookie(headers, name) {
176
- const raw = headers.cookie;
177
- if (typeof raw !== "string") return void 0;
178
- for (const part of raw.split(";")) {
179
- const eq = part.indexOf("=");
180
- if (eq === -1) continue;
181
- if (part.slice(0, eq).trim() === name) {
182
- const value = part.slice(eq + 1).trim();
183
- return value === "" ? void 0 : value;
184
- }
185
- }
186
- }
187
153
  /** 取 XFF 最左条目(serve 单跳覆盖语义下即真实客户端 IP)。 */
188
154
  function forwardedIp(headers) {
189
155
  const raw = headers["x-forwarded-for"];
@@ -191,7 +157,7 @@ function forwardedIp(headers) {
191
157
  const first = raw.split(",")[0]?.trim() ?? "";
192
158
  return first === "" ? void 0 : first;
193
159
  }
194
- /** 浏览器导航请求判定:GET + accept 含 text/html(需要返回登录页而非 403)。 */
160
+ /** 浏览器导航请求判定:GET + accept 含 text/html(需要返回 token 输入页而非 403)。 */
195
161
  function isNavigationRequest(req) {
196
162
  if (req.method !== "GET" && req.method !== "HEAD") return false;
197
163
  const accept = req.headers.accept;
@@ -208,17 +174,22 @@ function compileAllowlist(entries) {
208
174
  };
209
175
  }
210
176
  /**
211
- * 围栏判定(纯函数,除 token 哈希外无任何副作用与 IO)。
177
+ * 围栏判定(纯函数,无任何副作用与 IO)。
212
178
  * 逻辑次序:豁免路径由调用方前置处理;enabled=false 直通;确定有效来源
213
179
  * (受信 XFF 优先,否则 remoteAddress);来源为 loopback 直连 → 放行;
214
- * 否则按白名单/token 判定;无来源 → fail-closed。
180
+ * 白名单非空时作附加 IP 围栏,不命中即拒;官方 cookie 有效 → 放行;
181
+ * 否则导航 → token 输入页,非导航 → 403;无来源 → fail-closed。
215
182
  *
216
183
  * loopback 语义:XFF 被信任时,存在 XFF 即代表请求经代理(serve→proxy→dsh
217
184
  * 全链 loopback,真实客户端只在 XFF 里)——remoteAddress 是 loopback 也不
218
185
  * 放行,必须按 XFF 判定;XFF 不被信任时,来源就是 remoteAddress,
219
186
  * loopback 即本机直连 → 放行(防锁死兜底优先)。
187
+ *
188
+ * @param officialAuthed - 官方 browser-auth cookie 校验结果(调用方委托
189
+ * connection.requestRejection() 得出;含官方 Host 信任围栏,403 情形
190
+ * 视同未认证)。
220
191
  */
221
- function decideGate(req, policy) {
192
+ function decideGate(req, policy, officialAuthed) {
222
193
  if (!policy.enabled) return {
223
194
  verdict: "pass",
224
195
  reason: "local"
@@ -226,146 +197,40 @@ function decideGate(req, policy) {
226
197
  const forwarded = forwardedIp(req.headers);
227
198
  const trustedForwarded = policy.trustForwardedFor ? forwarded : void 0;
228
199
  const clientIpText = trustedForwarded ?? req.remoteAddress;
229
- if (clientIpText === void 0) return {
230
- verdict: "block",
231
- reason: void 0
232
- };
200
+ if (clientIpText === void 0) return { verdict: "block" };
233
201
  if (trustedForwarded === void 0 && isLoopbackAddress(req.remoteAddress)) return {
234
202
  verdict: "pass",
235
203
  reason: "local"
236
204
  };
237
205
  const allowlist = compileAllowlist(policy.allowedIps);
238
- const clientIp = parseIp(clientIpText);
239
- const allowedByIp = clientIp !== null && allowlist.match(clientIp);
240
- const cookieToken = readCookie(req.headers, GATE_COOKIE);
241
- const allowedByToken = cookieToken !== void 0 && tokenMatches(cookieToken, policy.token);
242
- const decision = {
243
- verdict: allowedByIp || allowedByToken ? "pass" : isNavigationRequest(req) ? "login" : "block",
244
- clientIp: clientIpText
206
+ const invalidEntries = allowlist.invalid.length > 0 ? allowlist.invalid : void 0;
207
+ if (policy.allowedIps.length > 0) {
208
+ const clientIp = parseIp(clientIpText);
209
+ if (clientIp === null || !allowlist.match(clientIp)) return {
210
+ verdict: "block",
211
+ clientIp: clientIpText,
212
+ invalidEntries
213
+ };
214
+ }
215
+ if (officialAuthed) return {
216
+ verdict: "pass",
217
+ reason: "cookie",
218
+ clientIp: clientIpText,
219
+ invalidEntries
220
+ };
221
+ return {
222
+ verdict: isNavigationRequest(req) ? "token-page" : "block",
223
+ clientIp: clientIpText,
224
+ invalidEntries
245
225
  };
246
- if (allowedByToken) decision.reason = "token";
247
- else if (allowedByIp) decision.reason = "allowed-ip";
248
- if (allowlist.invalid.length > 0) decision.invalidEntries = allowlist.invalid;
249
- return decision;
250
- }
251
- //#endregion
252
- //#region src/login-page.ts
253
- /**
254
- * 登录页:围栏拦截浏览器导航请求时返回的内联 HTML。
255
- * 自包含(无外部静态资源依赖)、中文、vanilla JS——输入 token → 调
256
- * /dsh-plus/gate/login → 成功后 reload 放行。
257
- * @module access-gate/login-page
258
- */
259
- function renderLoginPage() {
260
- return `<!DOCTYPE html>
261
- <html lang="zh-CN">
262
- <head>
263
- <meta charset="utf-8">
264
- <meta name="viewport" content="width=device-width, initial-scale=1">
265
- <title>访问校验</title>
266
- <style>
267
- :root { color-scheme: dark; }
268
- body {
269
- margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
270
- background: #16181d; color: #e6e8ec;
271
- font-family: system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", sans-serif;
272
- }
273
- .card {
274
- width: min(92vw, 360px); padding: 32px 28px; border-radius: 14px;
275
- background: #1e2128; border: 1px solid #2e323c; box-shadow: 0 8px 32px rgba(0,0,0,.4);
276
- box-sizing: border-box;
277
- }
278
- h1 { margin: 0 0 6px; font-size: 20px; font-weight: 600; }
279
- p { margin: 0 0 22px; font-size: 13px; color: #9aa1ad; line-height: 1.6; }
280
- input {
281
- width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 8px;
282
- border: 1px solid #3a3f4b; background: #16181d; color: #e6e8ec; font-size: 15px;
283
- outline: none;
284
- }
285
- input:focus { border-color: #5b8cff; }
286
- button {
287
- width: 100%; margin-top: 14px; padding: 10px 0; border: 0; border-radius: 8px;
288
- background: #3d6dff; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
289
- }
290
- button:disabled { opacity: .6; cursor: default; }
291
- .msg { min-height: 18px; margin-top: 12px; font-size: 13px; text-align: center; }
292
- .err { color: #ff7a76; }
293
- .ok { color: #63d17c; }
294
- </style>
295
- </head>
296
- <body>
297
- <form class="card" id="form">
298
- <h1>此页面受访问控制保护</h1>
299
- <p>当前网络不在放行名单内。输入访问令牌以继续;或使用名单内的设备/网络访问。</p>
300
- <input id="token" type="password" placeholder="访问令牌" autocomplete="off" autofocus>
301
- <button id="btn" type="submit">继续访问</button>
302
- <div class="msg" id="msg"></div>
303
- </form>
304
- <script>
305
- var form = document.getElementById('form');
306
- var input = document.getElementById('token');
307
- var btn = document.getElementById('btn');
308
- var msg = document.getElementById('msg');
309
- form.addEventListener('submit', function (event) {
310
- event.preventDefault();
311
- var token = input.value;
312
- if (!token) { msg.className = 'msg err'; msg.textContent = '请输入访问令牌'; return; }
313
- btn.disabled = true;
314
- msg.className = 'msg'; msg.textContent = '校验中…';
315
- fetch('/dsh-plus/gate/login', {
316
- method: 'POST',
317
- headers: { 'content-type': 'application/json' },
318
- body: JSON.stringify({ token: token })
319
- }).then(function (res) {
320
- if (res.ok) { msg.className = 'msg ok'; msg.textContent = '校验通过,正在进入…'; location.reload(); }
321
- else if (res.status === 429) { msg.className = 'msg err'; msg.textContent = '失败次数过多,请稍后再试'; btn.disabled = false; }
322
- else { msg.className = 'msg err'; msg.textContent = '令牌无效'; btn.disabled = false; }
323
- }).catch(function () { msg.className = 'msg err'; msg.textContent = '网络错误,请重试'; btn.disabled = false; });
324
- });
325
- <\/script>
326
- </body>
327
- </html>`;
328
226
  }
329
227
  //#endregion
330
228
  //#region src/routes.ts
331
- /**
332
- * 自有 HTTP 端点(/dsh-plus/gate 前缀,围栏豁免路径):
333
- * - POST /dsh-plus/gate/login — token 换 cookie(HttpOnly + SameSite=Strict)
334
- * - GET /dsh-plus/gate/status — 当前客户端判定状态(登录页与卡片诊断)
335
- *
336
- * 登录失败节流:按客户端 IP 记录失败次数与冷却截止(惰性清理,无定时器)。
337
- * handler 工厂与路由注册分离,测试直接驱动 handler(禁起真实服务器)。
338
- * @module access-gate/routes
339
- */
340
229
  const GATE_ROUTE = "/dsh-plus/gate";
341
- function createThrottleState() {
342
- return { fails: /* @__PURE__ */ new Map() };
343
- }
344
- function sendJson(res, status, body, headers) {
345
- res.writeHead(status, {
346
- "content-type": "application/json; charset=utf-8",
347
- ...headers
348
- });
230
+ function sendJson(res, status, body) {
231
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
349
232
  res.end(JSON.stringify(body));
350
233
  }
351
- function readBody(req) {
352
- return new Promise((resolve, reject) => {
353
- const chunks = [];
354
- req.on("data", (chunk) => chunks.push(chunk));
355
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
356
- req.on("error", reject);
357
- });
358
- }
359
- /** 解析 JSON body;非对象或解析失败返回 null。 */
360
- async function readJson(req) {
361
- if (!req.headers["content-type"]?.includes("application/json")) return null;
362
- try {
363
- const parsed = JSON.parse(await readBody(req));
364
- return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
365
- } catch {
366
- return null;
367
- }
368
- }
369
234
  /** 默认客户端 IP 还原:XFF 最左优先,否则 socket 地址。 */
370
235
  function clientIpOf(req) {
371
236
  const forwarded = req.headers["x-forwarded-for"];
@@ -377,53 +242,16 @@ function clientIpOf(req) {
377
242
  if (remote === void 0 || remote === "") return void 0;
378
243
  return remote.startsWith("::ffff:") ? remote.slice(7) : remote;
379
244
  }
380
- /** 是否处于冷却期;冷却期满的条目惰性清除(计数中条目保留)。 */
381
- function throttled(state, ip, now) {
382
- const entry = state.fails.get(ip);
383
- if (entry === void 0) return false;
384
- if (entry.until > now) return true;
385
- if (entry.until > 0) state.fails.delete(ip);
386
- return false;
387
- }
388
- /** 记一次失败;达到阈值则进入冷却。 */
389
- function recordFailure(state, ip, limit, cooldownMs, now) {
390
- const entry = state.fails.get(ip) ?? {
391
- count: 0,
392
- until: 0
393
- };
394
- entry.count += 1;
395
- if (entry.count >= limit) {
396
- entry.until = now + cooldownMs;
397
- entry.count = 0;
398
- }
399
- state.fails.set(ip, entry);
245
+ /**
246
+ * 本机直连判定(launch-url 管理通道专用):socket 为 loopback 且不带
247
+ * XFF(带 XFF 说明经代理,真实客户端在远端,不属本机管理通道)。
248
+ */
249
+ function isDirectLoopback(req) {
250
+ if (req.headers["x-forwarded-for"] !== void 0) return false;
251
+ return isLoopbackAddress(req.socket?.remoteAddress);
400
252
  }
401
253
  function createGateRoutes(deps) {
402
- const throttle = deps.throttle ?? createThrottleState();
403
- const clock = deps.clock ?? Date.now;
404
254
  const resolveIp = deps.resolveClientIp ?? clientIpOf;
405
- const handleLogin = async (req, res) => {
406
- const config = deps.config();
407
- const body = await readJson(req);
408
- if (body === void 0 || body === null || typeof body.token !== "string") {
409
- sendJson(res, 400, { error: "invalid json body" });
410
- return;
411
- }
412
- const ip = resolveIp(req) ?? "unknown";
413
- const now = clock();
414
- if (throttled(throttle, ip, now)) {
415
- sendJson(res, 429, { error: "too many attempts" });
416
- return;
417
- }
418
- if (!verifyToken(body.token, config.token)) {
419
- recordFailure(throttle, ip, config.loginFailLimit, config.loginCooldownMs, now);
420
- sendJson(res, 403, { error: "invalid token" });
421
- return;
422
- }
423
- throttle.fails.delete(ip);
424
- const maxAge = config.cookieMaxAgeHours * 3600;
425
- sendJson(res, 200, { ok: true }, { "set-cookie": `dsh_gate=${body.token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}` });
426
- };
427
255
  const handleStatus = (req, res) => {
428
256
  const config = deps.config();
429
257
  const decision = decideGate({
@@ -431,29 +259,48 @@ function createGateRoutes(deps) {
431
259
  method: req.method ?? "GET",
432
260
  headers: req.headers,
433
261
  remoteAddress: req.socket?.remoteAddress
434
- }, config);
262
+ }, config, deps.officialAuth(req));
435
263
  sendJson(res, 200, {
436
264
  enabled: config.enabled,
437
265
  verdict: decision.verdict,
438
- clientIp: decision.clientIp ?? null,
266
+ clientIp: decision.clientIp ?? resolveIp(req) ?? null,
439
267
  reason: decision.reason ?? null,
268
+ officialAuthed: deps.officialAuth(req),
269
+ ipFenceActive: config.allowedIps.length > 0,
440
270
  invalidEntries: decision.invalidEntries ?? [],
441
- tokenConfigured: config.token !== "",
442
271
  allowedCount: config.allowedIps.length
443
272
  });
444
273
  };
274
+ const handleLaunchUrl = (req, res) => {
275
+ if (!isDirectLoopback(req)) {
276
+ sendJson(res, 403, { error: "loopback only" });
277
+ return;
278
+ }
279
+ const url = new URL(req.url ?? "/", "http://localhost");
280
+ const host = url.searchParams.get("host") ?? req.headers.host;
281
+ if (typeof host !== "string" || host === "") {
282
+ sendJson(res, 400, { error: "missing host" });
283
+ return;
284
+ }
285
+ const scheme = url.searchParams.get("scheme") ?? "http";
286
+ try {
287
+ sendJson(res, 200, { url: deps.authenticatedUrl(`${scheme}://${host}`) });
288
+ } catch {
289
+ sendJson(res, 400, { error: "invalid host" });
290
+ }
291
+ };
445
292
  return async (req, res) => {
446
293
  try {
447
294
  const sub = new URL(req.url ?? "/", "http://localhost").pathname.slice(14);
448
295
  const method = req.method ?? "GET";
449
- if (sub === "/login") {
450
- if (method !== "POST") return sendJson(res, 405, { error: "POST only" });
451
- return await handleLogin(req, res);
452
- }
453
296
  if (sub === "/status") {
454
297
  if (method !== "GET") return sendJson(res, 405, { error: "GET only" });
455
298
  return handleStatus(req, res);
456
299
  }
300
+ if (sub === "/launch-url") {
301
+ if (method !== "GET") return sendJson(res, 405, { error: "GET only" });
302
+ return handleLaunchUrl(req, res);
303
+ }
457
304
  sendJson(res, 404, { error: "unknown endpoint" });
458
305
  } catch (error) {
459
306
  const message = error instanceof Error ? error.message : String(error);
@@ -463,13 +310,6 @@ function createGateRoutes(deps) {
463
310
  }
464
311
  };
465
312
  }
466
- /** token 校验(sha256 + 常时比较);空 token 通道关闭恒 false。 */
467
- function verifyToken(candidate, expected) {
468
- if (candidate === "" || expected === "") return false;
469
- const a = createHash("sha256").update(candidate).digest();
470
- const b = createHash("sha256").update(expected).digest();
471
- return timingSafeEqual(a, b);
472
- }
473
313
  /** 注册路由(webServer 缺失时由调用方保证不调用)。 */
474
314
  function registerGateRoutes(ctx, deps) {
475
315
  ctx.webServer.register({
@@ -479,10 +319,107 @@ function registerGateRoutes(ctx, deps) {
479
319
  });
480
320
  }
481
321
  //#endregion
322
+ //#region src/token-page.ts
323
+ /**
324
+ * token 输入页:围栏拦截未认证浏览器导航时返回的内联 HTML。
325
+ * 自包含(无外部静态资源依赖——静态资产同样受围栏保护)、中文、vanilla JS。
326
+ *
327
+ * 工作方式(与官方 browser-auth 合并后的唯一登录路径):
328
+ * 用户粘贴当前启动令牌 → fetch `/?token=<令牌>`(同源,跟随 303)→ 官方
329
+ * 交换签发 dsh-auth-* HttpOnly cookie(30 天,按域名:端口分别持有)→
330
+ * 成功后跳回干净 `/`。PWA 内打开本页粘贴一次即可恢复(解决 PWA 无法
331
+ * 携带 ?token= 启动的官方限制)。
332
+ * 本页纯客户端跳转,无服务器端点,令牌校验完全由官方 authorizeIndex 完成。
333
+ * @module access-gate/token-page
334
+ */
335
+ function renderTokenPage() {
336
+ return `<!DOCTYPE html>
337
+ <html lang="zh-CN">
338
+ <head>
339
+ <meta charset="utf-8">
340
+ <meta name="viewport" content="width=device-width, initial-scale=1">
341
+ <title>登录 - DeepSeek Harness</title>
342
+ <style>
343
+ :root { color-scheme: dark; }
344
+ body {
345
+ margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
346
+ background: #16181d; color: #e6e8ec;
347
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", sans-serif;
348
+ }
349
+ .card {
350
+ width: min(92vw, 420px); padding: 32px 28px; border-radius: 14px;
351
+ background: #1e2128; border: 1px solid #2e323c; box-shadow: 0 8px 32px rgba(0,0,0,.4);
352
+ box-sizing: border-box;
353
+ }
354
+ h1 { margin: 0 0 6px; font-size: 20px; font-weight: 600; }
355
+ p { margin: 0 0 14px; font-size: 13px; color: #9aa1ad; line-height: 1.7; }
356
+ p.tip { margin-bottom: 22px; }
357
+ code {
358
+ padding: 1px 5px; border-radius: 4px; background: #16181d; border: 1px solid #3a3f4b;
359
+ font-size: 12px; color: #c9d1e0; word-break: break-all;
360
+ }
361
+ input {
362
+ width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 8px;
363
+ border: 1px solid #3a3f4b; background: #16181d; color: #e6e8ec; font-size: 15px;
364
+ outline: none;
365
+ }
366
+ input:focus { border-color: #5b8cff; }
367
+ button {
368
+ width: 100%; margin-top: 14px; padding: 10px 0; border: 0; border-radius: 8px;
369
+ background: #3d6dff; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
370
+ }
371
+ button:disabled { opacity: .6; cursor: default; }
372
+ .msg { min-height: 18px; margin-top: 12px; font-size: 13px; text-align: center; }
373
+ .err { color: #ff7a76; }
374
+ .ok { color: #63d17c; }
375
+ </style>
376
+ </head>
377
+ <body>
378
+ <form class="card" id="form">
379
+ <h1>需要登录</h1>
380
+ <p>粘贴当前<strong>启动令牌</strong>完成登录。登录后此设备免令牌 30 天(按域名:端口分别持有)。</p>
381
+ <p class="tip">令牌随每次 dsh web 重启更新,获取方式:服务器上执行 <code>dshctl url</code>,或查看启动日志 <code>journalctl -u dsh-web | grep "dsh web:"</code>(链接中 token= 后的部分)。PWA 内打开本页粘贴一次即可恢复。</p>
382
+ <input id="token" type="password" placeholder="启动令牌" autocomplete="off" autofocus>
383
+ <button id="btn" type="submit">登录</button>
384
+ <div class="msg" id="msg"></div>
385
+ </form>
386
+ <script>
387
+ var form = document.getElementById('form');
388
+ var input = document.getElementById('token');
389
+ var btn = document.getElementById('btn');
390
+ var msg = document.getElementById('msg');
391
+ form.addEventListener('submit', function (event) {
392
+ event.preventDefault();
393
+ var token = input.value.trim();
394
+ if (!token) { msg.className = 'msg err'; msg.textContent = '请输入启动令牌'; return; }
395
+ btn.disabled = true;
396
+ msg.className = 'msg'; msg.textContent = '登录中…';
397
+ // 官方令牌交换:同源 fetch 跟随 303,交换响应的 Set-Cookie 由浏览器落罐。
398
+ fetch('/?token=' + encodeURIComponent(token), { redirect: 'follow' })
399
+ .then(function (res) {
400
+ if (res.ok) { msg.className = 'msg ok'; msg.textContent = '登录成功,正在进入…'; location.replace('/'); }
401
+ else if (res.status === 401) { msg.className = 'msg err'; msg.textContent = '令牌无效或已过期(令牌随每次重启更新)'; btn.disabled = false; }
402
+ else { msg.className = 'msg err'; msg.textContent = '登录失败(HTTP ' + res.status + ')'; btn.disabled = false; }
403
+ })
404
+ .catch(function () { msg.className = 'msg err'; msg.textContent = '网络错误,请重试'; btn.disabled = false; });
405
+ });
406
+ <\/script>
407
+ </body>
408
+ </html>`;
409
+ }
410
+ //#endregion
482
411
  //#region src/interceptor.ts
483
- /** 是否围栏豁免路径(自有登录/状态端点)。 */
412
+ /** PWA 安装/图标资产:未认证也必须可达,否则浏览器无法安装 PWA。 */
413
+ const PWA_ASSETS = /* @__PURE__ */ new Set(["/manifest.webmanifest", "/favicon.svg"]);
414
+ /** 是否围栏豁免路径(自有状态/管理端点 + PWA 安装资产)。 */
484
415
  function isExemptPath(pathname) {
485
- return pathname === "/dsh-plus/gate" || pathname.startsWith(`/dsh-plus/gate/`);
416
+ return pathname === "/dsh-plus/gate" || pathname.startsWith(`/dsh-plus/gate/`) || PWA_ASSETS.has(pathname);
417
+ }
418
+ /** 官方令牌交换请求:GET / 且携带 token 查询参数(放行给官方 authorizeIndex 校验)。 */
419
+ function isTokenExchange(req) {
420
+ if (req.method !== "GET") return false;
421
+ const url = new URL(req.url ?? "/", "http://x");
422
+ return url.pathname === "/" && url.searchParams.has("token");
486
423
  }
487
424
  function toGateRequest(req) {
488
425
  return {
@@ -506,17 +443,17 @@ function rejectUpgrade(socket, clientIp, log) {
506
443
  }
507
444
  /** 单请求围栏判定;放行 true / 拒绝(已写响应)false。 */
508
445
  function passHttp(req, res, deps) {
509
- if (isExemptPath(new URL(req.url ?? "/", "http://x").pathname)) return true;
510
- const decision = decideGate(toGateRequest(req), deps.config());
446
+ if (isExemptPath(new URL(req.url ?? "/", "http://x").pathname) || isTokenExchange(req)) return true;
447
+ const decision = decideGate(toGateRequest(req), deps.config(), deps.officialAuth(req));
511
448
  if (decision.verdict === "pass") return true;
512
449
  const log = deps.onBlock ?? (() => {});
513
- if (decision.verdict === "login" && !res.headersSent) {
450
+ if (decision.verdict === "token-page" && !res.headersSent) {
514
451
  res.writeHead(200, {
515
452
  "content-type": "text/html; charset=utf-8",
516
453
  "cache-control": "no-store"
517
454
  });
518
- res.end(renderLoginPage());
519
- log(`login page ${req.method ?? "?"} ${req.url ?? "?"}`);
455
+ res.end(renderTokenPage());
456
+ log(`token page ${req.method ?? "?"} ${req.url ?? "?"}`);
520
457
  return false;
521
458
  }
522
459
  if (!res.headersSent) {
@@ -529,7 +466,6 @@ function passHttp(req, res, deps) {
529
466
  /** HTTP handler 包装体:先判定再派发。 */
530
467
  function gatedHttp(handler, deps) {
531
468
  return async (req, res) => {
532
- if (isExemptPath(new URL(req.url ?? "/", "http://x").pathname)) return handler(req, res);
533
469
  if (!passHttp(req, res, deps)) return;
534
470
  return handler(req, res);
535
471
  };
@@ -537,7 +473,8 @@ function gatedHttp(handler, deps) {
537
473
  /** WS 升级包装体:拒绝时不进协议协商。 */
538
474
  function gatedUpgrade(handler, deps) {
539
475
  return (req, socket, head) => {
540
- const decision = decideGate(toGateRequest(req), deps.config());
476
+ if (isExemptPath(new URL(req.url ?? "/", "http://x").pathname)) return handler(req, socket, head);
477
+ const decision = decideGate(toGateRequest(req), deps.config(), deps.officialAuth(req));
541
478
  if (decision.verdict === "pass") return handler(req, socket, head);
542
479
  rejectUpgrade(socket, decision.clientIp ?? "unknown", deps.onBlock ?? (() => {}));
543
480
  };
@@ -610,24 +547,8 @@ function assertGateStructure(server) {
610
547
  }
611
548
  //#endregion
612
549
  //#region src/index.ts
613
- /**
614
- * dsh 插件:访问围栏(access-gate)。
615
- *
616
- * 为 dsh web 的全部 HTTP/WebSocket 流量提供访问安全校验与访问控制:
617
- * - 本机直连(loopback 且无 x-forwarded-for)永久放行——管理通道,防锁死;
618
- * - 远程流量按 tailscale serve 注入的 x-forwarded-for 还原真实客户端 IP,
619
- * 命中白名单(精确 IP / CIDR,v4+v6)即放行;
620
- * - 或持有效 token(登录页换取 HttpOnly cookie)放行;
621
- * - 其余:浏览器导航返回登录页,API / WS 一律 403 / 拒绝升级。
622
- *
623
- * 配置:settings 命名空间 dsh-plus-access-gate(webui 配置卡片同一份,
624
- * 热生效);enabled=false 时完全旁路(等价插件缺席)。
625
- * 安全边界:结构守卫 fail-loud(upstream 形状变化 → 启动失败 → lifeboat
626
- * 隔离止损,绝不静默 fail-open)。
627
- * @module @dsh-plus/access-gate
628
- */
629
550
  const name = "dsh-plus-access-gate";
630
- const inject = ["webServer"];
551
+ const inject = ["webServer", "connection"];
631
552
  /** 围栏服务:持有当前配置读取器(settings 用户层热更新经 setSource 注入)。 */
632
553
  var AccessGateService = class extends Service {
633
554
  current;
@@ -641,12 +562,16 @@ var AccessGateService = class extends Service {
641
562
  onChange: () => {}
642
563
  });
643
564
  const logger = ctx.logger("access-gate");
565
+ const officialAuth = (req) => ctx.connection.requestRejection({ headers: req.headers }) === void 0;
644
566
  registerGateRoutes(ctx, {
645
567
  config: () => this.current(),
568
+ officialAuth,
569
+ authenticatedUrl: (baseUrl) => ctx.connection.authenticatedUrl(baseUrl),
646
570
  onError: (message) => logger.warn(message)
647
571
  });
648
572
  const undo = installGateInterceptor(ctx, ctx.webServer, {
649
573
  config: () => this.current(),
574
+ officialAuth,
650
575
  onBlock: (message) => logger.info(message)
651
576
  });
652
577
  ctx.effect(() => undo, "access-gate: interceptor");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dsh-plus/access-gate",
3
- "version": "0.1.3",
4
- "description": "dsh-plus service+ui plugin: dsh web 全量访问围栏——本机直连永久放行,远程按白名单 IP(XFF 还原)或 token(登录页 + cookie)放行",
3
+ "version": "0.2.0",
4
+ "description": "dsh-plus service+ui plugin: dsh web 全量访问围栏——与官方 browser-auth 合并:官方 cookie 为唯一凭据,可选 IP 附加围栏,未认证导航渲染 token 输入页(PWA 可恢复),loopback 管理通道",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -23,28 +23,28 @@
23
23
  "dsh": {
24
24
  "client": {
25
25
  "inject": [
26
- "@deepseek-ai/dsh-client-runtime",
27
26
  "@deepseek-ai/dsh-client-locale",
28
- "@deepseek-ai/dsh-client-connection",
27
+ "@deepseek-ai/dsh-client-ui-renderer",
29
28
  "@deepseek-ai/dsh-api-remotes"
30
29
  ],
31
30
  "platform": "web"
32
31
  }
33
32
  },
34
33
  "dependencies": {
35
- "@dsh-plus/shared": "0.1.5"
34
+ "@dsh-plus/shared": "0.1.6"
36
35
  },
37
36
  "peerDependencies": {
38
37
  "@deepseek-ai/cordis": "^4.0.1",
39
- "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.8",
40
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
38
+ "@deepseek-ai/dsh-home-paths": "^0.1.2-alpha.1",
39
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.1",
41
40
  "@deepseek-ai/schemastery": "^3.18.1"
42
41
  },
43
42
  "devDependencies": {
44
43
  "@deepseek-ai/cordis": "4.0.1",
45
- "@deepseek-ai/dsh-home-paths": "0.1.1-rc.2",
46
- "@deepseek-ai/dsh-settings": "0.1.1-rc.2",
47
- "@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
44
+ "@deepseek-ai/dsh-client-connection": "0.1.2-alpha.1",
45
+ "@deepseek-ai/dsh-home-paths": "0.1.2-alpha.1",
46
+ "@deepseek-ai/dsh-settings": "0.1.2-alpha.1",
47
+ "@deepseek-ai/dsh-host-webserver": "0.1.2-alpha.1",
48
48
  "@deepseek-ai/schemastery": "3.18.1",
49
49
  "@types/react": "~18.3.1",
50
50
  "react": "^18.2.0"