@dsh-plus/access-gate 0.2.4 → 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 +37 -3
- package/lib/index.d.ts +10 -0
- package/lib/index.js +231 -5
- package/package.json +1 -1
- 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/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
|
|
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/access-gate",
|
|
3
|
-
"version": "0.
|
|
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",
|
package/src/client/card.tsx
CHANGED
|
@@ -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>
|
package/src/client/i18n.ts
CHANGED
|
@@ -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
|
|
82
|
-
*
|
|
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 {
|
package/src/interceptor.ts
CHANGED
|
@@ -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
|
|