@dsh-plus/access-gate 0.2.3 → 0.3.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/client.js CHANGED
@@ -62111,25 +62111,29 @@ ${extra}
62111
62111
  return {
62112
62112
  enabled: value.enabled,
62113
62113
  allowedIpsText: value.allowedIps.join("\n"),
62114
- trustForwardedFor: value.trustForwardedFor
62114
+ trustForwardedFor: value.trustForwardedFor,
62115
+ autoLoginTrustedIps: value.autoLoginTrustedIps
62115
62116
  };
62116
62117
  }
62117
62118
  function toPatch(draft) {
62118
62119
  return {
62119
62120
  enabled: draft.enabled,
62120
62121
  allowedIps: draft.allowedIpsText.split("\n").map((s) => s.trim()).filter((s) => s.length > 0),
62121
- trustForwardedFor: draft.trustForwardedFor
62122
+ trustForwardedFor: draft.trustForwardedFor,
62123
+ autoLoginTrustedIps: draft.autoLoginTrustedIps
62122
62124
  };
62123
62125
  }
62124
62126
  /** reason 枚举 → 文案 key。 */
62125
62127
  const REASON_KEYS = {
62126
62128
  local: "diagLocal",
62127
- cookie: "diagCookie"
62129
+ cookie: "diagCookie",
62130
+ "trusted-ip": "diagTrustedIp"
62128
62131
  };
62129
62132
  function verdictLabel(t, diag) {
62130
62133
  if (!diag.enabled) return t("diagOff");
62131
62134
  if (diag.verdict === "pass") return t("diagPass");
62132
62135
  if (diag.verdict === "token-page") return t("diagTokenPage");
62136
+ if (diag.verdict === "autologin") return t("diagAutoLogin");
62133
62137
  return t("diagBlock");
62134
62138
  }
62135
62139
  function AccessGateCard(props) {
@@ -62254,6 +62258,14 @@ ${extra}
62254
62258
  })
62255
62259
  ]
62256
62260
  }),
62261
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckRow, {
62262
+ prefix: "dag",
62263
+ id: "dag-autologin",
62264
+ label: t("autoLoginHint"),
62265
+ checked: draft.autoLoginTrustedIps,
62266
+ disabled,
62267
+ onEdit: (v) => edit("autoLoginTrustedIps", v)
62268
+ }),
62257
62269
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CheckRow, {
62258
62270
  prefix: "dag",
62259
62271
  id: "dag-xff",
@@ -62309,6 +62321,16 @@ ${extra}
62309
62321
  children: diag.officialAuthed ? t("diagOfficialYes") : t("diagOfficialNo")
62310
62322
  })]
62311
62323
  }),
62324
+ diag.autoLoginActive ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
62325
+ className: "dag-diagRow",
62326
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
62327
+ className: "dag-diagKey",
62328
+ children: t("diagAutoLogin")
62329
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
62330
+ className: "dag-diagVal",
62331
+ children: diag.autoLoginReady ? t("diagAutoLoginReady") : t("diagAutoLoginNoSecret")
62332
+ })]
62333
+ }) : null,
62312
62334
  diag.invalidEntries.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
62313
62335
  className: "dag-diagRow",
62314
62336
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -62340,6 +62362,7 @@ ${extra}
62340
62362
  mergedHint: "登录凭据由官方认证持有(启动令牌换 30 天 cookie)。获取当前令牌:服务器执行 dshctl url,或本机访问 /dsh-plus/gate/launch-url。",
62341
62363
  allowedIps: "附加 IP 围栏白名单",
62342
62364
  allowedIpsHint: "每行一个:精确 IP 或 CIDR(IPv4/IPv6 皆可)。非空时仅白名单来源可通行与登录;留空 = 不限制来源 IP(仅官方登录保护)。",
62365
+ autoLoginHint: "IP 信任自动登录:白名单来源未认证时自动签发官方登录 cookie,免输启动令牌。开启即视为信任白名单内全部设备(tailnet 内),请确保白名单准确。",
62343
62366
  trustForwardedFor: "信任 x-forwarded-for 还原客户端 IP",
62344
62367
  trustForwardedForHint: "仅当入口代理强制覆盖该头时开启(tailscale serve 满足);直连暴露场景必须关闭。",
62345
62368
  diagTitle: "当前页面诊断",
@@ -62348,12 +62371,17 @@ ${extra}
62348
62371
  diagReason: "放行原因",
62349
62372
  diagLocal: "本机直连",
62350
62373
  diagCookie: "官方 cookie",
62374
+ diagTrustedIp: "IP 信任白名单",
62351
62375
  diagOfficial: "官方登录状态",
62352
62376
  diagOfficialYes: "已登录",
62353
62377
  diagOfficialNo: "未登录",
62354
62378
  diagPass: "放行",
62355
62379
  diagBlock: "拦截",
62356
62380
  diagTokenPage: "令牌输入页",
62381
+ diagAutoLogin: "自动登录",
62382
+ diagAutoLoginVerdict: "自动登录(免 token 进入)",
62383
+ diagAutoLoginReady: "已就绪(签名密钥可读)",
62384
+ diagAutoLoginNoSecret: "不可用(签名密钥读取失败,已降级)",
62357
62385
  diagOff: "围栏未启用",
62358
62386
  diagInvalid: "白名单存在无法解析的条目:"
62359
62387
  };
@@ -62365,6 +62393,7 @@ ${extra}
62365
62393
  mergedHint: "Credentials are owned by official auth (launch token exchange, 30-day cookie). Get the current token: run dshctl url on the server, or visit /dsh-plus/gate/launch-url locally.",
62366
62394
  allowedIps: "Additional IP fence allowlist",
62367
62395
  allowedIpsHint: "One per line: exact IP or CIDR (IPv4/IPv6). When non-empty only listed sources may pass or log in; empty = no IP restriction (official login only).",
62396
+ autoLoginHint: "Trusted-IP auto login: mint the official login cookie automatically for unauthenticated allowlisted sources, no launch token needed. Enabling trusts every allowlisted device (inside the tailnet); keep the allowlist accurate.",
62368
62397
  trustForwardedFor: "Trust x-forwarded-for for client IP",
62369
62398
  trustForwardedForHint: "Enable only when the entry proxy overwrites that header (tailscale serve does); must be off for direct exposure.",
62370
62399
  diagTitle: "This page",
@@ -62373,12 +62402,17 @@ ${extra}
62373
62402
  diagReason: "Reason",
62374
62403
  diagLocal: "loopback",
62375
62404
  diagCookie: "official cookie",
62405
+ diagTrustedIp: "trusted IP allowlist",
62376
62406
  diagOfficial: "Official login",
62377
62407
  diagOfficialYes: "authenticated",
62378
62408
  diagOfficialNo: "not authenticated",
62379
62409
  diagPass: "pass",
62380
62410
  diagBlock: "blocked",
62381
62411
  diagTokenPage: "token page",
62412
+ diagAutoLogin: "Auto login",
62413
+ diagAutoLoginVerdict: "auto login (token-free entry)",
62414
+ diagAutoLoginReady: "ready (signing secret readable)",
62415
+ diagAutoLoginNoSecret: "unavailable (signing secret unreadable, degraded)",
62382
62416
  diagOff: "gate disabled",
62383
62417
  diagInvalid: "Allowlist entries that failed to parse:"
62384
62418
  };
package/lib/index.d.ts CHANGED
@@ -5,10 +5,12 @@ declare const Config: z<Schemastery.ObjectS<{
5
5
  enabled: z<boolean, boolean>;
6
6
  allowedIps: z<string[], string[]>;
7
7
  trustForwardedFor: z<boolean, boolean>;
8
+ autoLoginTrustedIps: z<boolean, boolean>;
8
9
  }>, Schemastery.ObjectT<{
9
10
  enabled: z<boolean, boolean>;
10
11
  allowedIps: z<string[], string[]>;
11
12
  trustForwardedFor: z<boolean, boolean>;
13
+ autoLoginTrustedIps: z<boolean, boolean>;
12
14
  }>>;
13
15
  type AccessGateConfig = Schemastery.TypeT<typeof Config>;
14
16
  //#endregion
@@ -23,9 +25,17 @@ declare module '@deepseek-ai/cordis' {
23
25
  /** 围栏服务:持有当前配置读取器(settings 用户层热更新经 setSource 注入)。 */
24
26
  declare class AccessGateService extends Service {
25
27
  private current;
28
+ /** 官方 browser-auth 签名密钥(惰性读取缓存;读取失败 = null,autoLogin fail-safe 降级)。 */
29
+ private signingSecret;
26
30
  constructor(ctx: Context, config: AccessGateConfig);
27
31
  /** 当前生效配置(诊断用)。 */
28
32
  policy(): AccessGateConfig;
33
+ /** 官方签名密钥(惰性缓存;失败返回 null 并记录,autoLogin 降级 fail-safe)。 */
34
+ private readSigningSecret;
35
+ /** IP 信任自动登录可用性(诊断用):签名密钥可读即为可用。 */
36
+ autoLoginSecretReady(logger: {
37
+ warn(message: string): void;
38
+ }): boolean;
29
39
  }
30
40
  declare function apply(ctx: Context, config: AccessGateConfig): void;
31
41
  //#endregion
package/lib/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { Service } from "@deepseek-ai/cordis";
2
+ import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
2
3
  import z from "@deepseek-ai/schemastery";
4
+ import { createHash, createHmac } from "node:crypto";
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
3
7
  //#endregion
4
8
  //#region src/config.ts
5
9
  /**
@@ -19,9 +23,128 @@ const SETTINGS_NS = "dsh-plus-access-gate";
19
23
  const Config = z.object({
20
24
  enabled: z.boolean().description("访问围栏总开关(false = 一切放行,等价插件缺席)").default(false),
21
25
  allowedIps: z.array(z.string()).description("附加 IP 围栏:非空时仅白名单条目(精确 IP 或 CIDR,IPv4/IPv6 皆可)可通行与登录;空 = 不限制来源 IP(仅官方登录保护)").default([]),
22
- trustForwardedFor: z.boolean().description("是否信任 x-forwarded-for 还原真实客户端 IP(仅当入口代理强制覆盖该头时开启;tailscale serve 满足)").default(true)
26
+ trustForwardedFor: z.boolean().description("是否信任 x-forwarded-for 还原真实客户端 IP(仅当入口代理强制覆盖该头时开启;tailscale serve 满足)").default(true),
27
+ autoLoginTrustedIps: z.boolean().description("IP 信任自动登录:来源命中 allowedIps 白名单且未认证时,服务端自动签发官方登录 cookie(免启动 token,仅对白名单来源生效);默认关闭,需显式开启").default(false)
23
28
  });
24
29
  //#endregion
30
+ //#region src/cookie.ts
31
+ /**
32
+ * 官方 browser-auth cookie 铸造(IP 信任自动登录的核心纯函数面)。
33
+ *
34
+ * 目标:对「IP 信任白名单来源」的未认证请求,在服务端自签与官方
35
+ * `?token=` 启动令牌交换产物等价的 `dsh-auth-*` cookie——写进响应后
36
+ * 浏览器落罐,后续 /api、WS 的官方 requestRejection() 自然通过,
37
+ * 用户全程无感、免启动 token。
38
+ *
39
+ * 契约(对 dsh 0.1.2-alpha.2 / 0.1.3-alpha.2 官方 client-connection
40
+ * browser-auth 逐行核实,见 docs/README.md「信任模型」):
41
+ * - 签名密钥持久化于 $DSH_HOME/.credentials.yaml
42
+ * (records → client-connection/browser-session → payload.secret,
43
+ * base64url 无填充 32B;跨重启不变);
44
+ * - cookie 名 = `dsh-auth-<base64url(sha256(authority))>`,
45
+ * authority = 请求 Host 经 `new URL('http://'+host).host` 归一化
46
+ * (host:port,含端口;与官方 requestAuthority() 同实现);
47
+ * - cookie 值 = `v1.<base64url(payload-json)>.<base64url(hmac-sha256(secret, body))>`,
48
+ * payload 键序固定 {"version":1,"authority":…,"issuedAt":ms,"expiresAt":ms},
49
+ * JSON.stringify 紧凑序列化;
50
+ * - 属性与官方 sessionCookie() 同款:Max-Age/Expires/Path=HttpOnly/SameSite=Strict;
51
+ * - 服务端校验 `expiresAt - issuedAt <= cookieMaxAgeDays`(默认 30 天)且
52
+ * 下界 1 天;本模块取官方默认 30 天。
53
+ *
54
+ * 本模块不 import 官方包:密钥从文件读取(dshctl auth.py 同款定向文本
55
+ * 解析),crypto 用 node: 内置。全部为纯函数/可注入,测试零网络零依赖。
56
+ * 安全:任何解析失败都返回明确错误(fail-loud / fail-safe),绝不产出
57
+ * 半成品 cookie。
58
+ * @module access-gate/cookie
59
+ */
60
+ /** 官方 cookie 名前缀(client-connection browser-auth 常量)。 */
61
+ const COOKIE_PREFIX = "dsh-auth-";
62
+ /** payload version(官方 COOKIE_PAYLOAD_VERSION)。 */
63
+ const PAYLOAD_VERSION = 1;
64
+ /** 密钥长度(官方 SECRET_BYTES)。 */
65
+ const SECRET_BYTES = 32;
66
+ /** 自签 cookie 有效期:官方默认 cookieMaxAgeDays。 */
67
+ const COOKIE_MAX_AGE_MS = 2592e6;
68
+ /** .credentials.yaml 中 browser-session 段名(AUTH_RECORD_KEY 的 id)。 */
69
+ const SECRET_SECTION = "client-connection/browser-session";
70
+ /** payload 键序(官方 encodeCookie 的 JSON.stringify 紧凑序列化键序)。 */
71
+ const PAYLOAD_KEYS = [
72
+ "version",
73
+ "authority",
74
+ "issuedAt",
75
+ "expiresAt"
76
+ ];
77
+ function base64url(data) {
78
+ return data.toString("base64url");
79
+ }
80
+ /** 取请求 authority(官方 requestAuthority 同款:Host → URL.host,host:port)。 */
81
+ function requestAuthority(headers) {
82
+ const host = headers.host;
83
+ if (typeof host !== "string" || host === "") return void 0;
84
+ try {
85
+ return new URL(`http://${host}`).host;
86
+ } catch {
87
+ return;
88
+ }
89
+ }
90
+ /** 官方 cookie 名(authority 绑定,同 requestAuthority 输出)。 */
91
+ function cookieName(authority) {
92
+ return COOKIE_PREFIX + base64url(createHash("sha256").update(authority).digest());
93
+ }
94
+ /** 定向解析 .credentials.yaml 中 browser-session 段(dshctl auth.py 同款)。 */
95
+ function readSigningSecretFromFile(credentialsYamlPath) {
96
+ let text;
97
+ try {
98
+ text = readFileSync(credentialsYamlPath, "utf8");
99
+ } catch (error) {
100
+ const detail = error instanceof Error ? error.message : String(error);
101
+ throw new Error(`read .credentials.yaml failed (${credentialsYamlPath}): ${detail}`);
102
+ }
103
+ let inSection = false;
104
+ for (const line of text.split(/\r?\n/)) {
105
+ const stripped = line.trim();
106
+ if (stripped === "" || stripped.startsWith("#")) continue;
107
+ if (line.length - line.replace(/^\s*/, "").length <= 2) {
108
+ inSection = stripped === `${SECRET_SECTION}:`;
109
+ continue;
110
+ }
111
+ if (inSection) {
112
+ const match = /^secret:\s*(\S+)\s*$/.exec(stripped);
113
+ if (match !== null) {
114
+ const secret = Buffer.from(match[1] ?? "", "base64url");
115
+ if (secret.byteLength !== SECRET_BYTES) throw new Error(`browser-session secret has invalid length (${secret.byteLength}, want ${SECRET_BYTES})`);
116
+ return secret;
117
+ }
118
+ }
119
+ }
120
+ throw new Error(`browser-session secret not found in ${credentialsYamlPath}`);
121
+ }
122
+ /** 官方 sessionCookie() 序列化(属性逐字同款;nowMs 注入保持纯函数可测)。 */
123
+ function serializeSessionCookie(name, value, expiresAt, nowMs) {
124
+ const maxAgeSeconds = Math.floor((expiresAt - nowMs) / 1e3);
125
+ return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`;
126
+ }
127
+ /** 铸造官方同款 cookie(键序/序列化/属性全对齐),返回可直接写 Set-Cookie 的串。 */
128
+ function mintSessionCookie(secret, authority, nowMs) {
129
+ const expiresAt = nowMs + COOKIE_MAX_AGE_MS;
130
+ const payload = { version: PAYLOAD_VERSION };
131
+ for (const key of PAYLOAD_KEYS) {
132
+ if (key === "version") continue;
133
+ payload[key] = key === "authority" ? authority : key === "issuedAt" ? nowMs : expiresAt;
134
+ }
135
+ const body = base64url(Buffer.from(JSON.stringify(payload), "utf8"));
136
+ const signature = base64url(createHmac("sha256", secret).update(body).digest());
137
+ return serializeSessionCookie(cookieName(authority), `v1.${body}.${signature}`, expiresAt, nowMs);
138
+ }
139
+ /** 拼接 DSH_HOME 下凭据文件路径(默认 home 与 $DSH_HOME 语义同官方 home-paths)。 */
140
+ function credentialsPath(dshHome) {
141
+ return join(dshHome, ".credentials.yaml");
142
+ }
143
+ /** 完整铸造入口:读密钥 + 铸造;任何失败抛带上下文错误(调用方降级 fail-safe)。 */
144
+ function mintAutoLoginCookie(credentialsYamlPath, authority, nowMs) {
145
+ return mintSessionCookie(readSigningSecretFromFile(credentialsYamlPath), authority, nowMs);
146
+ }
147
+ //#endregion
25
148
  //#region src/ip.ts
26
149
  /** 解析 IPv4 十进制点分字面量;非法返回 null。 */
27
150
  function parseIpv4(text) {
@@ -176,8 +299,10 @@ function compileAllowlist(entries) {
176
299
  * 围栏判定(纯函数,无任何副作用与 IO)。
177
300
  * 逻辑次序:豁免路径由调用方前置处理;enabled=false 直通;确定有效来源
178
301
  * (受信 XFF 优先,否则 remoteAddress);来源为 loopback 直连 → 放行;
179
- * 白名单非空时作附加 IP 围栏,不命中即拒;官方 cookie 有效 → 放行;
180
- * 否则导航 → token 输入页,非导航 → 403;无来源 → fail-closed。
302
+ * 白名单非空时作附加 IP 围栏,不命中即拒;autoLoginTrustedIps=true 且
303
+ * 白名单命中但官方未认证 → autologin(拦截器负责落官方 cookie);
304
+ * 官方 cookie 有效 → 放行;否则导航 → token 输入页,非导航 → 403;
305
+ * 无来源 → fail-closed。
181
306
  *
182
307
  * loopback 语义:XFF 被信任时,存在 XFF 即代表请求经代理(serve→proxy→dsh
183
308
  * 全链 loopback,真实客户端只在 XFF 里)——remoteAddress 是 loopback 也不
@@ -210,6 +335,12 @@ function decideGate(req, policy, officialAuthed) {
210
335
  clientIp: clientIpText,
211
336
  invalidEntries
212
337
  };
338
+ if (policy.autoLoginTrustedIps && !officialAuthed) return {
339
+ verdict: "autologin",
340
+ clientIp: clientIpText,
341
+ reason: "trusted-ip",
342
+ invalidEntries
343
+ };
213
344
  }
214
345
  if (officialAuthed) return {
215
346
  verdict: "pass",
@@ -267,7 +398,9 @@ function createGateRoutes(deps) {
267
398
  officialAuthed: deps.officialAuth(req),
268
399
  ipFenceActive: config.allowedIps.length > 0,
269
400
  invalidEntries: decision.invalidEntries ?? [],
270
- allowedCount: config.allowedIps.length
401
+ allowedCount: config.allowedIps.length,
402
+ autoLoginActive: config.autoLoginTrustedIps,
403
+ autoLoginReady: deps.autoLoginReady?.() ?? false
271
404
  });
272
405
  };
273
406
  const handleLaunchUrl = (req, res) => {
@@ -440,12 +573,71 @@ function rejectUpgrade(socket, clientIp, log) {
440
573
  ].join("\r\n"));
441
574
  log(`blocked upgrade (${clientIp})`);
442
575
  }
576
+ /** IP 信任自动登录的引导页:已落官方 cookie,纯客户端跳回干净首页。 */
577
+ function renderAutoLoginPage() {
578
+ return `<!DOCTYPE html>
579
+ <html lang="zh-CN">
580
+ <head>
581
+ <meta charset="utf-8">
582
+ <meta name="viewport" content="width=device-width, initial-scale=1">
583
+ <title>正在进入 - DeepSeek Harness</title>
584
+ <style>
585
+ :root { color-scheme: dark; }
586
+ body {
587
+ margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
588
+ background: #16181d; color: #e6e8ec;
589
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", sans-serif;
590
+ }
591
+ .card { width: min(92vw, 420px); padding: 32px 28px; border-radius: 14px; text-align: center;
592
+ background: #1e2128; border: 1px solid #2e323c; box-shadow: 0 8px 32px rgba(0,0,0,.4); box-sizing: border-box; }
593
+ .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #3d6dff; margin: 0 2px; animation: b 1s infinite ease-in-out; }
594
+ .dot:nth-child(2) { animation-delay: .15s; } .dot:nth-child(3) { animation-delay: .3s; }
595
+ @keyframes b { 0%,80%,100% { opacity:.25; transform:scale(.8); } 40% { opacity:1; transform:scale(1); } }
596
+ h1 { margin: 0 0 8px; font-size: 18px; font-weight: 600; }
597
+ p { margin: 0; font-size: 13px; color: #9aa1ad; }
598
+ </style>
599
+ </head>
600
+ <body>
601
+ <div class="card">
602
+ <h1><span class="dot"></span><span class="dot"></span><span class="dot"></span></h1>
603
+ <h1>已自动登录</h1>
604
+ <p>正在进入…(本设备已在信任名单,无需手动登录)</p>
605
+ </div>
606
+ <script>location.replace('/')<\/script>
607
+ </body>
608
+ </html>`;
609
+ }
443
610
  /** 单请求围栏判定;放行 true / 拒绝(已写响应)false。 */
444
611
  function passHttp(req, res, deps) {
445
612
  if (isExemptPath(new URL(req.url ?? "/", "http://x").pathname) || isTokenExchange(req)) return true;
446
613
  const decision = decideGate(toGateRequest(req), deps.config(), deps.officialAuth(req));
447
614
  if (decision.verdict === "pass") return true;
448
615
  const log = deps.onBlock ?? (() => {});
616
+ if (decision.verdict === "autologin") {
617
+ const cookie = deps.autoLoginCookie?.(req);
618
+ if (cookie !== null && cookie !== void 0) {
619
+ if (isNavigationRequest(toGateRequest(req)) && !res.headersSent) {
620
+ res.writeHead(200, {
621
+ "content-type": "text/html; charset=utf-8",
622
+ "cache-control": "no-store",
623
+ "set-cookie": cookie
624
+ });
625
+ res.end(renderAutoLoginPage());
626
+ log(`auto-login ${req.method ?? "?"} ${req.url ?? "?"} (${decision.clientIp ?? "unknown"})`);
627
+ return false;
628
+ }
629
+ return true;
630
+ }
631
+ log(`auto-login skipped: cookie unavailable (${decision.clientIp ?? "unknown"})`);
632
+ if (isNavigationRequest(toGateRequest(req)) && !res.headersSent) {
633
+ res.writeHead(200, {
634
+ "content-type": "text/html; charset=utf-8",
635
+ "cache-control": "no-store"
636
+ });
637
+ res.end(renderTokenPage());
638
+ return false;
639
+ }
640
+ }
449
641
  if (decision.verdict === "token-page" && !res.headersSent) {
450
642
  res.writeHead(200, {
451
643
  "content-type": "text/html; charset=utf-8",
@@ -469,12 +661,13 @@ function gatedHttp(handler, deps) {
469
661
  return handler(req, res);
470
662
  };
471
663
  }
472
- /** WS 升级包装体:拒绝时不进协议协商。 */
664
+ /** WS 升级包装体:拒绝时不进协议协商;IP 信任自动登录(cookie 可铸)放行。 */
473
665
  function gatedUpgrade(handler, deps) {
474
666
  return (req, socket, head) => {
475
667
  if (isExemptPath(new URL(req.url ?? "/", "http://x").pathname)) return handler(req, socket, head);
476
668
  const decision = decideGate(toGateRequest(req), deps.config(), deps.officialAuth(req));
477
669
  if (decision.verdict === "pass") return handler(req, socket, head);
670
+ if (decision.verdict === "autologin" && deps.autoLoginCookie !== void 0 && deps.autoLoginCookie(req) !== null) return handler(req, socket, head);
478
671
  rejectUpgrade(socket, decision.clientIp ?? "unknown", deps.onBlock ?? (() => {}));
479
672
  };
480
673
  }
@@ -551,6 +744,8 @@ const inject = ["webServer", "connection"];
551
744
  /** 围栏服务:持有当前配置读取器(settings 用户层热更新经 setSource 注入)。 */
552
745
  var AccessGateService = class extends Service {
553
746
  current;
747
+ /** 官方 browser-auth 签名密钥(惰性读取缓存;读取失败 = null,autoLogin fail-safe 降级)。 */
748
+ signingSecret;
554
749
  constructor(ctx, config) {
555
750
  super(ctx, "accessGate");
556
751
  this.current = () => config;
@@ -564,15 +759,30 @@ var AccessGateService = class extends Service {
564
759
  });
565
760
  const logger = ctx.logger("access-gate");
566
761
  const officialAuth = (req) => ctx.connection.requestRejection({ headers: req.headers }) === void 0;
762
+ const secretOf = () => this.readSigningSecret(logger);
763
+ const autoLoginCookie = (req) => {
764
+ const secret = secretOf();
765
+ const authority = requestAuthority(req.headers);
766
+ if (secret === null || authority === void 0) return null;
767
+ try {
768
+ return mintAutoLoginCookie(credentialsPath(dshHomePath()), authority, Date.now());
769
+ } catch (error) {
770
+ const message = error instanceof Error ? error.message : String(error);
771
+ logger.warn(`auto-login cookie mint failed: ${message}`);
772
+ return null;
773
+ }
774
+ };
567
775
  registerGateRoutes(ctx, {
568
776
  config: () => this.current(),
569
777
  officialAuth,
570
778
  authenticatedUrl: (baseUrl) => ctx.connection.authenticatedUrl(baseUrl),
779
+ autoLoginReady: () => this.autoLoginSecretReady(logger),
571
780
  onError: (message) => logger.warn(message)
572
781
  });
573
782
  const undo = installGateInterceptor(ctx, ctx.webServer, {
574
783
  config: () => this.current(),
575
784
  officialAuth,
785
+ autoLoginCookie,
576
786
  onBlock: (message) => logger.info(message)
577
787
  });
578
788
  ctx.effect(() => undo, "access-gate: interceptor");
@@ -581,6 +791,22 @@ var AccessGateService = class extends Service {
581
791
  policy() {
582
792
  return this.current();
583
793
  }
794
+ /** 官方签名密钥(惰性缓存;失败返回 null 并记录,autoLogin 降级 fail-safe)。 */
795
+ readSigningSecret(logger) {
796
+ if (this.signingSecret !== void 0) return this.signingSecret;
797
+ try {
798
+ this.signingSecret = readSigningSecretFromFile(credentialsPath(dshHomePath()));
799
+ } catch (error) {
800
+ const message = error instanceof Error ? error.message : String(error);
801
+ logger.warn(`auto-login disabled: ${message}`);
802
+ this.signingSecret = null;
803
+ }
804
+ return this.signingSecret;
805
+ }
806
+ /** IP 信任自动登录可用性(诊断用):签名密钥可读即为可用。 */
807
+ autoLoginSecretReady(logger) {
808
+ return this.readSigningSecret(logger) !== null;
809
+ }
584
810
  };
585
811
  function apply(ctx, config) {
586
812
  ctx.plugin(AccessGateService, config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/access-gate",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
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",
@@ -31,7 +31,7 @@
31
31
  }
32
32
  },
33
33
  "dependencies": {
34
- "@dsh-plus/shared": "0.1.9"
34
+ "@dsh-plus/shared": "0.1.10"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@deepseek-ai/cordis": "^4.0.1",
@@ -33,12 +33,14 @@ export interface ConfigValue {
33
33
  enabled: boolean
34
34
  allowedIps: string[]
35
35
  trustForwardedFor: boolean
36
+ autoLoginTrustedIps: boolean
36
37
  }
37
38
 
38
39
  interface Draft {
39
40
  enabled: boolean
40
41
  allowedIpsText: string
41
42
  trustForwardedFor: boolean
43
+ autoLoginTrustedIps: boolean
42
44
  }
43
45
 
44
46
  interface Diag {
@@ -50,6 +52,8 @@ interface Diag {
50
52
  ipFenceActive: boolean
51
53
  invalidEntries: string[]
52
54
  allowedCount: number
55
+ autoLoginActive: boolean
56
+ autoLoginReady: boolean
53
57
  }
54
58
 
55
59
  function draftFromValue(value: ConfigValue): Draft {
@@ -57,6 +61,7 @@ function draftFromValue(value: ConfigValue): Draft {
57
61
  enabled: value.enabled,
58
62
  allowedIpsText: value.allowedIps.join('\n'),
59
63
  trustForwardedFor: value.trustForwardedFor,
64
+ autoLoginTrustedIps: value.autoLoginTrustedIps,
60
65
  }
61
66
  }
62
67
 
@@ -68,6 +73,7 @@ function toPatch(draft: Draft): Record<string, unknown> {
68
73
  .map((s) => s.trim())
69
74
  .filter((s) => s.length > 0),
70
75
  trustForwardedFor: draft.trustForwardedFor,
76
+ autoLoginTrustedIps: draft.autoLoginTrustedIps,
71
77
  }
72
78
  }
73
79
 
@@ -75,12 +81,14 @@ function toPatch(draft: Draft): Record<string, unknown> {
75
81
  const REASON_KEYS: Record<string, string> = {
76
82
  local: 'diagLocal',
77
83
  cookie: 'diagCookie',
84
+ 'trusted-ip': 'diagTrustedIp',
78
85
  }
79
86
 
80
87
  function verdictLabel(t: (key: string) => string, diag: Diag): string {
81
88
  if (!diag.enabled) return t('diagOff')
82
89
  if (diag.verdict === 'pass') return t('diagPass')
83
90
  if (diag.verdict === 'token-page') return t('diagTokenPage')
91
+ if (diag.verdict === 'autologin') return t('diagAutoLogin')
84
92
  return t('diagBlock')
85
93
  }
86
94
 
@@ -213,6 +221,14 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
213
221
  />
214
222
  <p className="dag-hint">{t('allowedIpsHint')}</p>
215
223
  </div>
224
+ <CheckRow
225
+ prefix="dag"
226
+ id="dag-autologin"
227
+ label={t('autoLoginHint')}
228
+ checked={draft.autoLoginTrustedIps}
229
+ disabled={disabled}
230
+ onEdit={(v) => edit('autoLoginTrustedIps', v)}
231
+ />
216
232
  <CheckRow
217
233
  prefix="dag"
218
234
  id="dag-xff"
@@ -244,6 +260,14 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
244
260
  {diag.officialAuthed ? t('diagOfficialYes') : t('diagOfficialNo')}
245
261
  </span>
246
262
  </div>
263
+ {diag.autoLoginActive ? (
264
+ <div className="dag-diagRow">
265
+ <span className="dag-diagKey">{t('diagAutoLogin')}</span>
266
+ <span className="dag-diagVal">
267
+ {diag.autoLoginReady ? t('diagAutoLoginReady') : t('diagAutoLoginNoSecret')}
268
+ </span>
269
+ </div>
270
+ ) : null}
247
271
  {diag.invalidEntries.length > 0 ? (
248
272
  <div className="dag-diagRow">
249
273
  <span className="dag-diagKey">!</span>
@@ -18,6 +18,8 @@ const ownZh = {
18
18
  allowedIps: '附加 IP 围栏白名单',
19
19
  allowedIpsHint:
20
20
  '每行一个:精确 IP 或 CIDR(IPv4/IPv6 皆可)。非空时仅白名单来源可通行与登录;留空 = 不限制来源 IP(仅官方登录保护)。',
21
+ autoLoginHint:
22
+ 'IP 信任自动登录:白名单来源未认证时自动签发官方登录 cookie,免输启动令牌。开启即视为信任白名单内全部设备(tailnet 内),请确保白名单准确。',
21
23
  trustForwardedFor: '信任 x-forwarded-for 还原客户端 IP',
22
24
  trustForwardedForHint:
23
25
  '仅当入口代理强制覆盖该头时开启(tailscale serve 满足);直连暴露场景必须关闭。',
@@ -27,12 +29,17 @@ const ownZh = {
27
29
  diagReason: '放行原因',
28
30
  diagLocal: '本机直连',
29
31
  diagCookie: '官方 cookie',
32
+ diagTrustedIp: 'IP 信任白名单',
30
33
  diagOfficial: '官方登录状态',
31
34
  diagOfficialYes: '已登录',
32
35
  diagOfficialNo: '未登录',
33
36
  diagPass: '放行',
34
37
  diagBlock: '拦截',
35
38
  diagTokenPage: '令牌输入页',
39
+ diagAutoLogin: '自动登录',
40
+ diagAutoLoginVerdict: '自动登录(免 token 进入)',
41
+ diagAutoLoginReady: '已就绪(签名密钥可读)',
42
+ diagAutoLoginNoSecret: '不可用(签名密钥读取失败,已降级)',
36
43
  diagOff: '围栏未启用',
37
44
  diagInvalid: '白名单存在无法解析的条目:',
38
45
  }
@@ -49,6 +56,8 @@ const ownEn = {
49
56
  allowedIps: 'Additional IP fence allowlist',
50
57
  allowedIpsHint:
51
58
  'One per line: exact IP or CIDR (IPv4/IPv6). When non-empty only listed sources may pass or log in; empty = no IP restriction (official login only).',
59
+ autoLoginHint:
60
+ 'Trusted-IP auto login: mint the official login cookie automatically for unauthenticated allowlisted sources, no launch token needed. Enabling trusts every allowlisted device (inside the tailnet); keep the allowlist accurate.',
52
61
  trustForwardedFor: 'Trust x-forwarded-for for client IP',
53
62
  trustForwardedForHint:
54
63
  'Enable only when the entry proxy overwrites that header (tailscale serve does); must be off for direct exposure.',
@@ -58,12 +67,17 @@ const ownEn = {
58
67
  diagReason: 'Reason',
59
68
  diagLocal: 'loopback',
60
69
  diagCookie: 'official cookie',
70
+ diagTrustedIp: 'trusted IP allowlist',
61
71
  diagOfficial: 'Official login',
62
72
  diagOfficialYes: 'authenticated',
63
73
  diagOfficialNo: 'not authenticated',
64
74
  diagPass: 'pass',
65
75
  diagBlock: 'blocked',
66
76
  diagTokenPage: 'token page',
77
+ diagAutoLogin: 'Auto login',
78
+ diagAutoLoginVerdict: 'auto login (token-free entry)',
79
+ diagAutoLoginReady: 'ready (signing secret readable)',
80
+ diagAutoLoginNoSecret: 'unavailable (signing secret unreadable, degraded)',
67
81
  diagOff: 'gate disabled',
68
82
  diagInvalid: 'Allowlist entries that failed to parse:',
69
83
  }
package/src/config.ts CHANGED
@@ -34,6 +34,12 @@ export const Config = z.object({
34
34
  '是否信任 x-forwarded-for 还原真实客户端 IP(仅当入口代理强制覆盖该头时开启;tailscale serve 满足)',
35
35
  )
36
36
  .default(true),
37
+ autoLoginTrustedIps: z
38
+ .boolean()
39
+ .description(
40
+ 'IP 信任自动登录:来源命中 allowedIps 白名单且未认证时,服务端自动签发官方登录 cookie(免启动 token,仅对白名单来源生效);默认关闭,需显式开启',
41
+ )
42
+ .default(false),
37
43
  })
38
44
 
39
45
  export type AccessGateConfig = Schemastery.TypeT<typeof Config>
@@ -43,4 +49,5 @@ export interface GatePolicy {
43
49
  readonly enabled: boolean
44
50
  readonly allowedIps: readonly string[]
45
51
  readonly trustForwardedFor: boolean
52
+ readonly autoLoginTrustedIps: boolean
46
53
  }
package/src/cookie.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * 官方 browser-auth cookie 铸造(IP 信任自动登录的核心纯函数面)。
3
+ *
4
+ * 目标:对「IP 信任白名单来源」的未认证请求,在服务端自签与官方
5
+ * `?token=` 启动令牌交换产物等价的 `dsh-auth-*` cookie——写进响应后
6
+ * 浏览器落罐,后续 /api、WS 的官方 requestRejection() 自然通过,
7
+ * 用户全程无感、免启动 token。
8
+ *
9
+ * 契约(对 dsh 0.1.2-alpha.2 / 0.1.3-alpha.2 官方 client-connection
10
+ * browser-auth 逐行核实,见 docs/README.md「信任模型」):
11
+ * - 签名密钥持久化于 $DSH_HOME/.credentials.yaml
12
+ * (records → client-connection/browser-session → payload.secret,
13
+ * base64url 无填充 32B;跨重启不变);
14
+ * - cookie 名 = `dsh-auth-<base64url(sha256(authority))>`,
15
+ * authority = 请求 Host 经 `new URL('http://'+host).host` 归一化
16
+ * (host:port,含端口;与官方 requestAuthority() 同实现);
17
+ * - cookie 值 = `v1.<base64url(payload-json)>.<base64url(hmac-sha256(secret, body))>`,
18
+ * payload 键序固定 {"version":1,"authority":…,"issuedAt":ms,"expiresAt":ms},
19
+ * JSON.stringify 紧凑序列化;
20
+ * - 属性与官方 sessionCookie() 同款:Max-Age/Expires/Path=HttpOnly/SameSite=Strict;
21
+ * - 服务端校验 `expiresAt - issuedAt <= cookieMaxAgeDays`(默认 30 天)且
22
+ * 下界 1 天;本模块取官方默认 30 天。
23
+ *
24
+ * 本模块不 import 官方包:密钥从文件读取(dshctl auth.py 同款定向文本
25
+ * 解析),crypto 用 node: 内置。全部为纯函数/可注入,测试零网络零依赖。
26
+ * 安全:任何解析失败都返回明确错误(fail-loud / fail-safe),绝不产出
27
+ * 半成品 cookie。
28
+ * @module access-gate/cookie
29
+ */
30
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
31
+ import { readFileSync } from 'node:fs'
32
+ import { join } from 'node:path'
33
+
34
+ /** 官方 cookie 名前缀(client-connection browser-auth 常量)。 */
35
+ export const COOKIE_PREFIX = 'dsh-auth-'
36
+ /** payload version(官方 COOKIE_PAYLOAD_VERSION)。 */
37
+ const PAYLOAD_VERSION = 1
38
+ /** 密钥长度(官方 SECRET_BYTES)。 */
39
+ const SECRET_BYTES = 32
40
+ /** 自签 cookie 有效期:官方默认 cookieMaxAgeDays。 */
41
+ export const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
42
+ /** .credentials.yaml 中 browser-session 段名(AUTH_RECORD_KEY 的 id)。 */
43
+ const SECRET_SECTION = 'client-connection/browser-session'
44
+ /** payload 键序(官方 encodeCookie 的 JSON.stringify 紧凑序列化键序)。 */
45
+ const PAYLOAD_KEYS = ['version', 'authority', 'issuedAt', 'expiresAt'] as const
46
+
47
+ function base64url(data: Buffer): string {
48
+ return data.toString('base64url')
49
+ }
50
+
51
+ /** 取请求 authority(官方 requestAuthority 同款:Host → URL.host,host:port)。 */
52
+ export function requestAuthority(headers: { host?: unknown }): string | undefined {
53
+ const host = headers.host
54
+ if (typeof host !== 'string' || host === '') return undefined
55
+ try {
56
+ return new URL(`http://${host}`).host
57
+ } catch {
58
+ return undefined
59
+ }
60
+ }
61
+
62
+ /** 官方 cookie 名(authority 绑定,同 requestAuthority 输出)。 */
63
+ export function cookieName(authority: string): string {
64
+ return COOKIE_PREFIX + base64url(createHash('sha256').update(authority).digest())
65
+ }
66
+
67
+ /** 校验签名(官方 isAuthenticated/decodeCookie 同款防时序侧信道比较)。 */
68
+ function validSignature(value: string, secret: Buffer): boolean {
69
+ const [version, body, encodedSignature] = value.split('.')
70
+ if (version !== 'v1' || body === undefined || encodedSignature === undefined) return false
71
+ const actual = Buffer.from(encodedSignature, 'base64url')
72
+ if (actual.byteLength === 0) return false
73
+ const expected = createHmac('sha256', secret).update(body).digest()
74
+ return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
75
+ }
76
+
77
+ /** 解码 cookie 载荷(返回 null = 值损坏/签名不符/字段缺失,语义与官方一致)。 */
78
+ export function decodeCookiePayload(
79
+ value: string,
80
+ secret: Buffer,
81
+ ): { authority: string; issuedAt: number; expiresAt: number } | null {
82
+ if (!validSignature(value, secret)) return null
83
+ const body = value.split('.')[1]
84
+ if (body === undefined) return null
85
+ let decoded: unknown
86
+ try {
87
+ decoded = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
88
+ } catch {
89
+ return null
90
+ }
91
+ if (
92
+ typeof decoded !== 'object' ||
93
+ decoded === null ||
94
+ !('version' in decoded) ||
95
+ (decoded as { version?: unknown }).version !== PAYLOAD_VERSION ||
96
+ typeof (decoded as { authority?: unknown }).authority !== 'string' ||
97
+ !Number.isSafeInteger((decoded as { issuedAt?: unknown }).issuedAt) ||
98
+ !Number.isSafeInteger((decoded as { expiresAt?: unknown }).expiresAt)
99
+ ) {
100
+ return null
101
+ }
102
+ const { authority, issuedAt, expiresAt } = decoded as {
103
+ authority: string
104
+ issuedAt: number
105
+ expiresAt: number
106
+ }
107
+ const now = Date.now()
108
+ if (issuedAt > now || expiresAt <= now || expiresAt <= issuedAt) return null
109
+ if (expiresAt - issuedAt > COOKIE_MAX_AGE_MS) return null
110
+ return { authority, issuedAt, expiresAt }
111
+ }
112
+
113
+ /**
114
+ * 校验既有 cookie 是否(对给定 authority)有效——等价官方
115
+ * `browserAuth.isAuthenticated`(无 authority 匹配即 false)。
116
+ */
117
+ export function isAuthenticatedCookie(
118
+ cookieHeader: string | undefined,
119
+ authority: string,
120
+ secret: Buffer,
121
+ ): boolean {
122
+ if (cookieHeader === undefined) return false
123
+ const name = cookieName(authority)
124
+ for (const segment of cookieHeader.split(';')) {
125
+ const at = segment.indexOf('=')
126
+ if (at === -1 || segment.slice(0, at).trim() !== name) continue
127
+ const payload = decodeCookiePayload(segment.slice(at + 1).trim(), secret)
128
+ if (payload === null) return false
129
+ return payload.authority === authority
130
+ }
131
+ return false
132
+ }
133
+
134
+ /** 定向解析 .credentials.yaml 中 browser-session 段(dshctl auth.py 同款)。 */
135
+ export function readSigningSecretFromFile(credentialsYamlPath: string): Buffer {
136
+ let text: string
137
+ try {
138
+ text = readFileSync(credentialsYamlPath, 'utf8')
139
+ } catch (error) {
140
+ const detail = error instanceof Error ? error.message : String(error)
141
+ throw new Error(`read .credentials.yaml failed (${credentialsYamlPath}): ${detail}`)
142
+ }
143
+ let inSection = false
144
+ for (const line of text.split(/\r?\n/)) {
145
+ const stripped = line.trim()
146
+ if (stripped === '' || stripped.startsWith('#')) continue
147
+ const indent = line.length - line.replace(/^\s*/, '').length
148
+ if (indent <= 2) {
149
+ inSection = stripped === `${SECRET_SECTION}:`
150
+ continue
151
+ }
152
+ if (inSection) {
153
+ const match = /^secret:\s*(\S+)\s*$/.exec(stripped)
154
+ if (match !== null) {
155
+ const secret = Buffer.from(match[1] ?? '', 'base64url')
156
+ if (secret.byteLength !== SECRET_BYTES) {
157
+ throw new Error(
158
+ `browser-session secret has invalid length (${secret.byteLength}, want ${SECRET_BYTES})`,
159
+ )
160
+ }
161
+ return secret
162
+ }
163
+ }
164
+ }
165
+ throw new Error(`browser-session secret not found in ${credentialsYamlPath}`)
166
+ }
167
+
168
+ /** 官方 sessionCookie() 序列化(属性逐字同款;nowMs 注入保持纯函数可测)。 */
169
+ export function serializeSessionCookie(
170
+ name: string,
171
+ value: string,
172
+ expiresAt: number,
173
+ nowMs: number,
174
+ ): string {
175
+ const maxAgeSeconds = Math.floor((expiresAt - nowMs) / 1000)
176
+ return (
177
+ `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; ` +
178
+ `Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict`
179
+ )
180
+ }
181
+
182
+ /** 铸造官方同款 cookie(键序/序列化/属性全对齐),返回可直接写 Set-Cookie 的串。 */
183
+ export function mintSessionCookie(secret: Buffer, authority: string, nowMs: number): string {
184
+ const expiresAt = nowMs + COOKIE_MAX_AGE_MS
185
+ const payload: Record<string, unknown> = { version: PAYLOAD_VERSION }
186
+ for (const key of PAYLOAD_KEYS) {
187
+ if (key === 'version') continue
188
+ payload[key] = key === 'authority' ? authority : key === 'issuedAt' ? nowMs : expiresAt
189
+ }
190
+ const body = base64url(Buffer.from(JSON.stringify(payload), 'utf8'))
191
+ const signature = base64url(createHmac('sha256', secret).update(body).digest())
192
+ return serializeSessionCookie(cookieName(authority), `v1.${body}.${signature}`, expiresAt, nowMs)
193
+ }
194
+
195
+ /** 拼接 DSH_HOME 下凭据文件路径(默认 home 与 $DSH_HOME 语义同官方 home-paths)。 */
196
+ export function credentialsPath(dshHome: string): string {
197
+ return join(dshHome, '.credentials.yaml')
198
+ }
199
+
200
+ /** 完整铸造入口:读密钥 + 铸造;任何失败抛带上下文错误(调用方降级 fail-safe)。 */
201
+ export function mintAutoLoginCookie(
202
+ credentialsYamlPath: string,
203
+ authority: string,
204
+ nowMs: number,
205
+ ): string {
206
+ const secret = readSigningSecretFromFile(credentialsYamlPath)
207
+ return mintSessionCookie(secret, authority, nowMs)
208
+ }
package/src/decision.ts CHANGED
@@ -22,6 +22,7 @@ import { isLoopbackAddress, parseAllowEntry, parseIp } from './ip.ts'
22
22
  /** 围栏判定结果。 */
23
23
  export type GateVerdict =
24
24
  | 'pass' /** 放行(本机直连 / 官方 cookie 有效) */
25
+ | 'autologin' /** IP 信任自动登录:白名单来源未认证 → 由拦截器落官方 cookie 后放行 */
25
26
  | 'block' /** 拒绝:非导航请求或 IP 围栏拦截 → 403 */
26
27
  | 'token-page' /** 拒绝:浏览器导航请求 → token 输入页 */
27
28
 
@@ -30,8 +31,8 @@ export interface GateDecision {
30
31
  verdict: GateVerdict
31
32
  /** 还原出的客户端 IP(有 XFF 或非 loopback 直连时存在)。 */
32
33
  clientIp?: string
33
- /** 放行原因(诊断/日志用):local / cookie。 */
34
- reason?: 'local' | 'cookie'
34
+ /** 放行原因(诊断/日志用):local / cookie / trusted-ip。 */
35
+ reason?: 'local' | 'cookie' | 'trusted-ip'
35
36
  /** 无效白名单条目(卡片诊断用,判定时忽略它们)。 */
36
37
  invalidEntries?: string[]
37
38
  }
@@ -78,8 +79,10 @@ export function compileAllowlist(entries: readonly string[]): CompiledAllowlist
78
79
  * 围栏判定(纯函数,无任何副作用与 IO)。
79
80
  * 逻辑次序:豁免路径由调用方前置处理;enabled=false 直通;确定有效来源
80
81
  * (受信 XFF 优先,否则 remoteAddress);来源为 loopback 直连 → 放行;
81
- * 白名单非空时作附加 IP 围栏,不命中即拒;官方 cookie 有效 → 放行;
82
- * 否则导航 → token 输入页,非导航 → 403;无来源 → fail-closed。
82
+ * 白名单非空时作附加 IP 围栏,不命中即拒;autoLoginTrustedIps=true 且
83
+ * 白名单命中但官方未认证 → autologin(拦截器负责落官方 cookie);
84
+ * 官方 cookie 有效 → 放行;否则导航 → token 输入页,非导航 → 403;
85
+ * 无来源 → fail-closed。
83
86
  *
84
87
  * loopback 语义:XFF 被信任时,存在 XFF 即代表请求经代理(serve→proxy→dsh
85
88
  * 全链 loopback,真实客户端只在 XFF 里)——remoteAddress 是 loopback 也不
@@ -116,6 +119,11 @@ export function decideGate(
116
119
  if (clientIp === null || !allowlist.match(clientIp)) {
117
120
  return { verdict: 'block', clientIp: clientIpText, invalidEntries }
118
121
  }
122
+ // IP 信任自动登录:白名单命中 + 未认证 → 拦截器落官方 cookie 后放行
123
+ // (仅当显式开启且白名单非空;已认证走下方 cookie 正常路径)。
124
+ if (policy.autoLoginTrustedIps && !officialAuthed) {
125
+ return { verdict: 'autologin', clientIp: clientIpText, reason: 'trusted-ip', invalidEntries }
126
+ }
119
127
  }
120
128
 
121
129
  if (officialAuthed) {
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * 本插件不再持有自有 token/cookie;
8
8
  * - 本机直连(loopback 且无 x-forwarded-for)gate 层永久放行——管理通道;
9
9
  * - allowedIps 为可选附加 IP 围栏(XFF 还原,精确 IP / CIDR,v4+v6);
10
+ * - autoLoginTrustedIps=true 时,白名单来源未认证请求自动铸造官方登录
11
+ * cookie(免启动 token,仅对白名单来源生效;密钥读取失败自动降级);
10
12
  * - 未认证的浏览器导航 → token 输入页(粘贴启动令牌走官方交换,**PWA 内
11
13
  * 同路径可用**——恢复官方 start_url 无法携带 token 导致的 PWA 死结);
12
14
  * 未认证的 API / WS / 静态资产 → 403 / 拒绝升级;
@@ -19,7 +21,8 @@
19
21
  * 配置:settings 命名空间 dsh-plus-access-gate(webui 配置卡片同一份,
20
22
  * 热生效);enabled=false 时完全旁路(等价插件缺席)。
21
23
  * 安全边界:结构守卫 fail-loud(upstream 形状变化 → 启动失败 → lifeboat
22
- * 隔离止损,绝不静默 fail-open)。
24
+ * 隔离止损,绝不静默 fail-open);autoLogin 依赖官方签名密钥,读取失败
25
+ * 自动降级为不自动登录(fail-safe)。
23
26
  * @module @dsh-plus/access-gate
24
27
  */
25
28
 
@@ -27,9 +30,16 @@ import type { IncomingMessage } from 'node:http'
27
30
  import { type Context, Service } from '@deepseek-ai/cordis'
28
31
  // HostConnectionHandle 的 Context 声明合并(connection 服务类型来源)。
29
32
  import type {} from '@deepseek-ai/dsh-client-connection'
33
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
30
34
  import type {} from '@deepseek-ai/dsh-settings'
31
35
 
32
36
  import { type AccessGateConfig, Config, SETTINGS_NS } from './config.ts'
37
+ import {
38
+ credentialsPath,
39
+ mintAutoLoginCookie,
40
+ readSigningSecretFromFile,
41
+ requestAuthority,
42
+ } from './cookie.ts'
33
43
  import { type GateWebServer, installGateInterceptor } from './interceptor.ts'
34
44
  import { registerGateRoutes } from './routes.ts'
35
45
 
@@ -48,6 +58,8 @@ declare module '@deepseek-ai/cordis' {
48
58
  /** 围栏服务:持有当前配置读取器(settings 用户层热更新经 setSource 注入)。 */
49
59
  export class AccessGateService extends Service {
50
60
  private current: () => AccessGateConfig
61
+ /** 官方 browser-auth 签名密钥(惰性读取缓存;读取失败 = null,autoLogin fail-safe 降级)。 */
62
+ private signingSecret: Buffer | null | undefined
51
63
 
52
64
  constructor(ctx: Context, config: AccessGateConfig) {
53
65
  super(ctx, 'accessGate')
@@ -68,11 +80,31 @@ export class AccessGateService extends Service {
68
80
  const officialAuth = (req: IncomingMessage): boolean =>
69
81
  ctx.connection.requestRejection({ headers: req.headers }) === undefined
70
82
 
83
+ // 官方 browser-auth 签名密钥(.credentials.yaml):与官方 cookie 同源,
84
+ // 供 IP 信任自动登录铸造等价 cookie。惰性读取并缓存;失败记一次 warn 并
85
+ // 降级(autoLogin 不生效),围栏本身不受影响。
86
+ const secretOf = (): Buffer | null => this.readSigningSecret(logger)
87
+ // IP 信任自动登录的 cookie 铸造器(判定 autologin 时调用;不可铸返回 null,
88
+ // 拦截器据此回退 token 输入页/403,绝不 fail-open)。
89
+ const autoLoginCookie = (req: IncomingMessage): string | null => {
90
+ const secret = secretOf()
91
+ const authority = requestAuthority(req.headers)
92
+ if (secret === null || authority === undefined) return null
93
+ try {
94
+ return mintAutoLoginCookie(credentialsPath(dshHomePath()), authority, Date.now())
95
+ } catch (error) {
96
+ const message = error instanceof Error ? error.message : String(error)
97
+ logger.warn(`auto-login cookie mint failed: ${message}`)
98
+ return null
99
+ }
100
+ }
101
+
71
102
  // 自有端点(/dsh-plus/gate/*,围栏豁免路径)。
72
103
  registerGateRoutes(ctx, {
73
104
  config: () => this.current(),
74
105
  officialAuth,
75
106
  authenticatedUrl: (baseUrl) => ctx.connection.authenticatedUrl(baseUrl),
107
+ autoLoginReady: () => this.autoLoginSecretReady(logger),
76
108
  onError: (message) => logger.warn(message),
77
109
  })
78
110
 
@@ -80,6 +112,7 @@ export class AccessGateService extends Service {
80
112
  const undo = installGateInterceptor(ctx, ctx.webServer as unknown as GateWebServer, {
81
113
  config: () => this.current(),
82
114
  officialAuth,
115
+ autoLoginCookie,
83
116
  onBlock: (message) => logger.info(message),
84
117
  })
85
118
  ctx.effect(() => undo, 'access-gate: interceptor')
@@ -89,6 +122,24 @@ export class AccessGateService extends Service {
89
122
  policy(): AccessGateConfig {
90
123
  return this.current()
91
124
  }
125
+
126
+ /** 官方签名密钥(惰性缓存;失败返回 null 并记录,autoLogin 降级 fail-safe)。 */
127
+ private readSigningSecret(logger: { warn(message: string): void }): Buffer | null {
128
+ if (this.signingSecret !== undefined) return this.signingSecret
129
+ try {
130
+ this.signingSecret = readSigningSecretFromFile(credentialsPath(dshHomePath()))
131
+ } catch (error) {
132
+ const message = error instanceof Error ? error.message : String(error)
133
+ logger.warn(`auto-login disabled: ${message}`)
134
+ this.signingSecret = null
135
+ }
136
+ return this.signingSecret
137
+ }
138
+
139
+ /** IP 信任自动登录可用性(诊断用):签名密钥可读即为可用。 */
140
+ autoLoginSecretReady(logger: { warn(message: string): void }): boolean {
141
+ return this.readSigningSecret(logger) !== null
142
+ }
92
143
  }
93
144
 
94
145
  export function apply(ctx: Context, config: AccessGateConfig): void {
@@ -29,7 +29,7 @@ import type { Duplex } from 'node:stream'
29
29
  import type { Context } from '@deepseek-ai/cordis'
30
30
 
31
31
  import type { AccessGateConfig } from './config.ts'
32
- import { decideGate, type GateRequest } from './decision.ts'
32
+ import { decideGate, type GateRequest, isNavigationRequest } from './decision.ts'
33
33
  import { GATE_ROUTE } from './routes.ts'
34
34
  import { renderTokenPage } from './token-page.ts'
35
35
 
@@ -51,6 +51,12 @@ export interface InterceptorDeps {
51
51
  config: () => AccessGateConfig
52
52
  /** 官方 browser-auth 校验(委托 connection.requestRejection;含官方 Host 围栏)。 */
53
53
  officialAuth: (req: IncomingMessage) => boolean
54
+ /**
55
+ * IP 信任自动登录的 cookie 铸造器:返回可直接写 Set-Cookie 的官方同款
56
+ * cookie 串;无法铸造(密钥缺失/authority 不可得等)返回 null(fail-safe,
57
+ * 调用方回退普通未认证路径)。仅在判定 autologin 时被调用。
58
+ */
59
+ autoLoginCookie?: (req: IncomingMessage) => string | null
54
60
  onBlock?: (message: string) => void
55
61
  }
56
62
 
@@ -95,6 +101,41 @@ function rejectUpgrade(socket: Duplex, clientIp: string, log: (m: string) => voi
95
101
  log(`blocked upgrade (${clientIp})`)
96
102
  }
97
103
 
104
+ /** IP 信任自动登录的引导页:已落官方 cookie,纯客户端跳回干净首页。 */
105
+ function renderAutoLoginPage(): string {
106
+ return `<!DOCTYPE html>
107
+ <html lang="zh-CN">
108
+ <head>
109
+ <meta charset="utf-8">
110
+ <meta name="viewport" content="width=device-width, initial-scale=1">
111
+ <title>正在进入 - DeepSeek Harness</title>
112
+ <style>
113
+ :root { color-scheme: dark; }
114
+ body {
115
+ margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
116
+ background: #16181d; color: #e6e8ec;
117
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", sans-serif;
118
+ }
119
+ .card { width: min(92vw, 420px); padding: 32px 28px; border-radius: 14px; text-align: center;
120
+ background: #1e2128; border: 1px solid #2e323c; box-shadow: 0 8px 32px rgba(0,0,0,.4); box-sizing: border-box; }
121
+ .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #3d6dff; margin: 0 2px; animation: b 1s infinite ease-in-out; }
122
+ .dot:nth-child(2) { animation-delay: .15s; } .dot:nth-child(3) { animation-delay: .3s; }
123
+ @keyframes b { 0%,80%,100% { opacity:.25; transform:scale(.8); } 40% { opacity:1; transform:scale(1); } }
124
+ h1 { margin: 0 0 8px; font-size: 18px; font-weight: 600; }
125
+ p { margin: 0; font-size: 13px; color: #9aa1ad; }
126
+ </style>
127
+ </head>
128
+ <body>
129
+ <div class="card">
130
+ <h1><span class="dot"></span><span class="dot"></span><span class="dot"></span></h1>
131
+ <h1>已自动登录</h1>
132
+ <p>正在进入…(本设备已在信任名单,无需手动登录)</p>
133
+ </div>
134
+ <script>location.replace('/')</script>
135
+ </body>
136
+ </html>`
137
+ }
138
+
98
139
  /** 单请求围栏判定;放行 true / 拒绝(已写响应)false。 */
99
140
  function passHttp(req: IncomingMessage, res: ServerResponse, deps: InterceptorDeps): boolean {
100
141
  const url = new URL(req.url ?? '/', 'http://x')
@@ -102,6 +143,35 @@ function passHttp(req: IncomingMessage, res: ServerResponse, deps: InterceptorDe
102
143
  const decision = decideGate(toGateRequest(req), deps.config(), deps.officialAuth(req))
103
144
  if (decision.verdict === 'pass') return true
104
145
  const log = deps.onBlock ?? (() => {})
146
+ if (decision.verdict === 'autologin') {
147
+ const cookie = deps.autoLoginCookie?.(req)
148
+ if (cookie !== null && cookie !== undefined) {
149
+ if (isNavigationRequest(toGateRequest(req)) && !res.headersSent) {
150
+ // 导航:落官方 cookie + 引导页跳回干净首页(浏览器后续请求自动携带)。
151
+ res.writeHead(200, {
152
+ 'content-type': 'text/html; charset=utf-8',
153
+ 'cache-control': 'no-store',
154
+ 'set-cookie': cookie,
155
+ })
156
+ res.end(renderAutoLoginPage())
157
+ log(`auto-login ${req.method ?? '?'} ${req.url ?? '?'} (${decision.clientIp ?? 'unknown'})`)
158
+ return false
159
+ }
160
+ // 非导航(API/静态资产):cookie 可铸即放行——浏览器正常流程先导航落罐,
161
+ // 直呼请求由官方 requestRejection 按其携带的 cookie 判定。
162
+ return true
163
+ }
164
+ // 铸造失败(fail-safe):导航回退 token 输入页,非导航落入下方 403。
165
+ log(`auto-login skipped: cookie unavailable (${decision.clientIp ?? 'unknown'})`)
166
+ if (isNavigationRequest(toGateRequest(req)) && !res.headersSent) {
167
+ res.writeHead(200, {
168
+ 'content-type': 'text/html; charset=utf-8',
169
+ 'cache-control': 'no-store',
170
+ })
171
+ res.end(renderTokenPage())
172
+ return false
173
+ }
174
+ }
105
175
  if (decision.verdict === 'token-page' && !res.headersSent) {
106
176
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
107
177
  res.end(renderTokenPage())
@@ -126,13 +196,22 @@ function gatedHttp(handler: HttpHandler, deps: InterceptorDeps): HttpHandler {
126
196
  }
127
197
  }
128
198
 
129
- /** WS 升级包装体:拒绝时不进协议协商。 */
199
+ /** WS 升级包装体:拒绝时不进协议协商;IP 信任自动登录(cookie 可铸)放行。 */
130
200
  function gatedUpgrade(handler: UpgradeHandler, deps: InterceptorDeps): UpgradeHandler {
131
201
  return (req, socket, head) => {
132
202
  if (isExemptPath(new URL(req.url ?? '/', 'http://x').pathname))
133
203
  return handler(req, socket, head)
134
204
  const decision = decideGate(toGateRequest(req), deps.config(), deps.officialAuth(req))
135
205
  if (decision.verdict === 'pass') return handler(req, socket, head)
206
+ // IP 信任自动登录:cookie 可铸时放行(浏览器 WebSocket 会带此前导航落罐的
207
+ // cookie;官方 requestRejection 由连接携带的 cookie 通过)。
208
+ if (
209
+ decision.verdict === 'autologin' &&
210
+ deps.autoLoginCookie !== undefined &&
211
+ deps.autoLoginCookie(req) !== null
212
+ ) {
213
+ return handler(req, socket, head)
214
+ }
136
215
  rejectUpgrade(socket, decision.clientIp ?? 'unknown', deps.onBlock ?? (() => {}))
137
216
  return undefined
138
217
  }
package/src/routes.ts CHANGED
@@ -28,6 +28,8 @@ export interface RouteDeps {
28
28
  authenticatedUrl: (baseUrl: string) => string
29
29
  /** 客户端 IP 还原(与围栏同一规则);缺省时从请求自身推导。 */
30
30
  resolveClientIp?: (req: IncomingMessage) => string | undefined
31
+ /** IP 信任自动登录可用性(签名密钥可读);缺省视为不可用(诊断展示)。 */
32
+ autoLoginReady?: () => boolean
31
33
  onError?: (message: string) => void
32
34
  }
33
35
 
@@ -83,6 +85,8 @@ export function createGateRoutes(
83
85
  ipFenceActive: config.allowedIps.length > 0,
84
86
  invalidEntries: decision.invalidEntries ?? [],
85
87
  allowedCount: config.allowedIps.length,
88
+ autoLoginActive: config.autoLoginTrustedIps,
89
+ autoLoginReady: deps.autoLoginReady?.() ?? false,
86
90
  })
87
91
  }
88
92