@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/client.js +119 -41
- package/lib/index.d.ts +10 -0
- package/lib/index.js +231 -5
- package/lib/style.css +72 -72
- package/package.json +10 -10
- package/src/client/card.tsx +24 -0
- package/src/client/i18n.ts +14 -0
- package/src/config.ts +7 -0
- package/src/cookie.ts +208 -0
- package/src/decision.ts +12 -4
- package/src/index.ts +52 -1
- package/src/interceptor.ts +81 -2
- package/src/routes.ts +4 -0
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
|
|
180
|
-
*
|
|
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);
|