@dsh-plus/access-gate 0.2.4 → 0.3.1

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,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);