@dsh-plus/access-gate 0.3.33 → 0.3.34
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 +838 -126
- package/lib/index.d.ts +10 -8
- package/lib/index.js +68 -69
- package/package.json +4 -2
- package/src/client/card.tsx +4 -19
- package/src/client/i18n.ts +0 -7
- package/src/config.ts +6 -10
- package/src/cookie.ts +44 -54
- package/src/decision.ts +1 -3
- package/src/index.ts +42 -24
- package/src/interceptor.ts +0 -2
- package/src/routes.ts +0 -1
package/lib/index.d.ts
CHANGED
|
@@ -3,12 +3,10 @@ import { UnwrapVolatile } from "@dsh-plus/shared";
|
|
|
3
3
|
import z from "@deepseek-ai/schemastery";
|
|
4
4
|
//#region src/config.d.ts
|
|
5
5
|
declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
6
|
-
enabled: z<boolean, boolean, "volatile-defined">;
|
|
7
6
|
allowedIps: z<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
|
|
8
7
|
trustForwardedFor: z<boolean, boolean, "volatile-defined">;
|
|
9
8
|
autoLoginTrustedIps: z<boolean, boolean, "volatile-defined">;
|
|
10
9
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
11
|
-
enabled: z<boolean, boolean, "volatile-defined">;
|
|
12
10
|
allowedIps: z<NoInfer<string[]>, NoInfer<string[]>, "volatile-defined">;
|
|
13
11
|
trustForwardedFor: z<boolean, boolean, "volatile-defined">;
|
|
14
12
|
autoLoginTrustedIps: z<boolean, boolean, "volatile-defined">;
|
|
@@ -29,17 +27,21 @@ declare module '@deepseek-ai/cordis' {
|
|
|
29
27
|
/** 围栏服务:持有当前配置读取器(volatile 活动引用的平面解包,读即最新)。 */
|
|
30
28
|
declare class AccessGateService extends Service {
|
|
31
29
|
private current;
|
|
32
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* 官方 browser-auth 签名密钥(经官方凭据服务的记录读接口取,成功即缓存;
|
|
32
|
+
* 读不到 = null,autoLogin fail-safe 降级为 token 输入页)。
|
|
33
|
+
*/
|
|
33
34
|
private signingSecret;
|
|
34
35
|
constructor(ctx: Context, config: AccessGateConfig | AccessGateConfigFields);
|
|
35
36
|
/** 当前生效配置(诊断用)。 */
|
|
36
37
|
policy(): AccessGateConfig;
|
|
37
|
-
/**
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* 读取官方签名密钥并缓存:记录缺席/未挂凭据服务时保持未就绪(不缓存失败,
|
|
40
|
+
* 记录建立后由 credentials/record-updated 触发重读)。
|
|
41
|
+
*/
|
|
42
|
+
private loadSigningSecret;
|
|
39
43
|
/** IP 信任自动登录可用性(诊断用):签名密钥可读即为可用。 */
|
|
40
|
-
autoLoginSecretReady(
|
|
41
|
-
warn(message: string): void;
|
|
42
|
-
}): boolean;
|
|
44
|
+
autoLoginSecretReady(): boolean;
|
|
43
45
|
}
|
|
44
46
|
declare function apply(ctx: Context, config: AccessGateConfig | AccessGateConfigFields): void;
|
|
45
47
|
//#endregion
|
package/lib/index.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { Service } from "@deepseek-ai/cordis";
|
|
2
|
-
import {
|
|
2
|
+
import { credentialKey } from "@deepseek-ai/dsh-credentials";
|
|
3
3
|
import { unwrapVolatile } from "@dsh-plus/shared";
|
|
4
4
|
import z from "@deepseek-ai/schemastery";
|
|
5
5
|
import { createHash, createHmac } from "node:crypto";
|
|
6
|
-
import { readFileSync } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
8
6
|
//#region src/config.ts
|
|
9
7
|
/**
|
|
10
8
|
* 配置单一事实源:cordis 行级 Config(组合默认值,dev/prod patch 层可覆盖)
|
|
@@ -13,13 +11,11 @@ import { join } from "node:path";
|
|
|
13
11
|
*
|
|
14
12
|
* 与官方认证合并后:访问凭据唯一化为官方 browser-auth cookie(dsh-auth-*,
|
|
15
13
|
* 由 client-connection 的 ?token= 启动令牌交换签发),本插件不再持有自有
|
|
16
|
-
* token/cookie;allowedIps 降级为可选的附加 IP
|
|
17
|
-
*
|
|
18
|
-
* schemastery 透传忽略,不阻断加载(已实测验证)。
|
|
14
|
+
* token/cookie;allowedIps 降级为可选的附加 IP 围栏。整行开关由官方行开关
|
|
15
|
+
* 承担(bundle 层默认 `disabled: true` = 一切放行),配置内无第二套总开关。
|
|
19
16
|
* @module access-gate/config
|
|
20
17
|
*/
|
|
21
18
|
const Config = z.object({
|
|
22
|
-
enabled: z.boolean().description("访问围栏总开关(false = 一切放行,等价插件缺席)").default(false).volatile(),
|
|
23
19
|
allowedIps: z.array(z.string()).description("附加 IP 围栏:非空时仅白名单条目(精确 IP 或 CIDR,IPv4/IPv6 皆可)可通行与登录;空 = 不限制来源 IP(仅官方登录保护)").default([]).volatile(),
|
|
24
20
|
trustForwardedFor: z.boolean().description("是否信任 x-forwarded-for 还原真实客户端 IP(仅当入口代理强制覆盖该头时开启;tailscale serve 满足)").default(true).volatile(),
|
|
25
21
|
autoLoginTrustedIps: z.boolean().description("IP 信任自动登录:来源命中 allowedIps 白名单且未认证时,服务端自动签发官方登录 cookie(免启动 token,仅对白名单来源生效);默认关闭,需显式开启").default(false).volatile()
|
|
@@ -35,10 +31,10 @@ const Config = z.object({
|
|
|
35
31
|
* 用户全程无感、免启动 token。
|
|
36
32
|
*
|
|
37
33
|
* 契约(对官方 client-connection browser-auth 逐行核实,见 docs/README.md
|
|
38
|
-
* 「信任模型」;0.2.1-
|
|
39
|
-
* -
|
|
40
|
-
*
|
|
41
|
-
*
|
|
34
|
+
* 「信任模型」;0.2.1-rc.2 复核不变):
|
|
35
|
+
* - 签名密钥由官方凭据服务持有:记录键 `client-connection/browser-session`,
|
|
36
|
+
* grant payload `{version: 1, secret: <base64url 无填充 32B>}`(跨重启不变);
|
|
37
|
+
* 本模块只解析官方 readRecord 交回的记录,不再自行读文件;
|
|
42
38
|
* - cookie 名 = `dsh-auth-<base64url(sha256(authority))>`,
|
|
43
39
|
* authority = 请求 Host 经 `new URL('http://'+host).host` 归一化
|
|
44
40
|
* (host:port,含端口;与官方 requestAuthority() 同实现);
|
|
@@ -49,8 +45,9 @@ const Config = z.object({
|
|
|
49
45
|
* - 服务端校验 `expiresAt - issuedAt <= cookieMaxAgeDays`(默认 30 天)且
|
|
50
46
|
* 下界 1 天;本模块取官方默认 30 天。
|
|
51
47
|
*
|
|
52
|
-
* 本模块不 import
|
|
53
|
-
*
|
|
48
|
+
* 本模块不 import 官方包:密钥以「已读出的记录值」入参(调用方经
|
|
49
|
+
* ctx.get('credentials').readRecord 取,见 index.ts),crypto 用 node: 内置。
|
|
50
|
+
* 全部为纯函数/可注入,测试零网络零依赖。
|
|
54
51
|
* 安全:任何解析失败都返回明确错误(fail-loud / fail-safe),绝不产出
|
|
55
52
|
* 半成品 cookie。
|
|
56
53
|
* @module access-gate/cookie
|
|
@@ -63,8 +60,10 @@ const PAYLOAD_VERSION = 1;
|
|
|
63
60
|
const SECRET_BYTES = 32;
|
|
64
61
|
/** 自签 cookie 有效期:官方默认 cookieMaxAgeDays。 */
|
|
65
62
|
const COOKIE_MAX_AGE_MS = 2592e6;
|
|
66
|
-
/**
|
|
63
|
+
/** 官方记录键(credentialKey('client-connection', 'browser-session') 的联合形态)。 */
|
|
67
64
|
const SECRET_SECTION = "client-connection/browser-session";
|
|
65
|
+
/** 官方 grant payload 版本(官方 STORED_SECRET_VERSION)。 */
|
|
66
|
+
const STORED_SECRET_VERSION = 1;
|
|
68
67
|
/** payload 键序(官方 encodeCookie 的 JSON.stringify 紧凑序列化键序)。 */
|
|
69
68
|
const PAYLOAD_KEYS = [
|
|
70
69
|
"version",
|
|
@@ -89,33 +88,29 @@ function requestAuthority(headers) {
|
|
|
89
88
|
function cookieName(authority) {
|
|
90
89
|
return COOKIE_PREFIX + base64url(createHash("sha256").update(authority).digest());
|
|
91
90
|
}
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
throw new Error(`browser-session secret not found in ${credentialsYamlPath}`);
|
|
91
|
+
/**
|
|
92
|
+
* 从官方记录里取签名密钥:只认 grant + payload.version === 1 + 32 字节
|
|
93
|
+
* base64url secret(与官方 storedSecret() 同判据);任何不符即抛错,
|
|
94
|
+
* 由调用方降级 fail-safe(不铸 cookie)。
|
|
95
|
+
* @param record - `ctx.credentials.readRecord(credentialKey('client-connection', 'browser-session'))` 的返回值。
|
|
96
|
+
* @returns 32 字节签名密钥。
|
|
97
|
+
*/
|
|
98
|
+
function signingSecretOfRecord(record) {
|
|
99
|
+
const payload = grantPayloadOf(record);
|
|
100
|
+
if (payload?.version !== STORED_SECRET_VERSION) throw new Error(`${SECRET_SECTION}: credential record has an unsupported format (want version ${STORED_SECRET_VERSION})`);
|
|
101
|
+
const raw = payload.secret;
|
|
102
|
+
if (typeof raw !== "string" || !/^[A-Za-z0-9_-]+$/.test(raw)) throw new Error(`${SECRET_SECTION}: credential record has an invalid secret`);
|
|
103
|
+
const secret = Buffer.from(raw, "base64url");
|
|
104
|
+
if (secret.byteLength !== SECRET_BYTES) throw new Error(`browser-session secret has invalid length (${secret.byteLength}, want ${SECRET_BYTES})`);
|
|
105
|
+
return secret;
|
|
106
|
+
}
|
|
107
|
+
/** 取 grant 记录的 payload 对象;非 grant/非对象返回 undefined。 */
|
|
108
|
+
function grantPayloadOf(record) {
|
|
109
|
+
if (typeof record !== "object" || record === null) return void 0;
|
|
110
|
+
const candidate = record;
|
|
111
|
+
if (candidate.kind !== "grant") return void 0;
|
|
112
|
+
const payload = candidate.payload;
|
|
113
|
+
return typeof payload === "object" && payload !== null && !Array.isArray(payload) ? payload : void 0;
|
|
119
114
|
}
|
|
120
115
|
/** 官方 sessionCookie() 序列化(属性逐字同款;nowMs 注入保持纯函数可测)。 */
|
|
121
116
|
function serializeSessionCookie(name, value, expiresAt, nowMs) {
|
|
@@ -134,14 +129,6 @@ function mintSessionCookie(secret, authority, nowMs) {
|
|
|
134
129
|
const signature = base64url(createHmac("sha256", secret).update(body).digest());
|
|
135
130
|
return serializeSessionCookie(cookieName(authority), `v1.${body}.${signature}`, expiresAt, nowMs);
|
|
136
131
|
}
|
|
137
|
-
/** 拼接 DSH_HOME 下凭据文件路径(默认 home 与 $DSH_HOME 语义同官方 home-paths)。 */
|
|
138
|
-
function credentialsPath(dshHome) {
|
|
139
|
-
return join(dshHome, ".credentials.yaml");
|
|
140
|
-
}
|
|
141
|
-
/** 完整铸造入口:读密钥 + 铸造;任何失败抛带上下文错误(调用方降级 fail-safe)。 */
|
|
142
|
-
function mintAutoLoginCookie(credentialsYamlPath, authority, nowMs) {
|
|
143
|
-
return mintSessionCookie(readSigningSecretFromFile(credentialsYamlPath), authority, nowMs);
|
|
144
|
-
}
|
|
145
132
|
//#endregion
|
|
146
133
|
//#region src/ip.ts
|
|
147
134
|
/** 解析 IPv4 十进制点分字面量;非法返回 null。 */
|
|
@@ -295,7 +282,7 @@ function compileAllowlist(entries) {
|
|
|
295
282
|
}
|
|
296
283
|
/**
|
|
297
284
|
* 围栏判定(纯函数,无任何副作用与 IO)。
|
|
298
|
-
*
|
|
285
|
+
* 逻辑次序:豁免路径由调用方前置处理;确定有效来源
|
|
299
286
|
* (受信 XFF 优先,否则 remoteAddress);来源为 loopback 直连 → 放行;
|
|
300
287
|
* 白名单非空时作附加 IP 围栏,不命中即拒;autoLoginTrustedIps=true 且
|
|
301
288
|
* 白名单命中但官方未认证 → autologin(拦截器负责落官方 cookie);
|
|
@@ -312,10 +299,6 @@ function compileAllowlist(entries) {
|
|
|
312
299
|
* 视同未认证)。
|
|
313
300
|
*/
|
|
314
301
|
function decideGate(req, policy, officialAuthed) {
|
|
315
|
-
if (!policy.enabled) return {
|
|
316
|
-
verdict: "pass",
|
|
317
|
-
reason: "local"
|
|
318
|
-
};
|
|
319
302
|
const forwarded = forwardedIp(req.headers);
|
|
320
303
|
const trustedForwarded = policy.trustForwardedFor ? forwarded : void 0;
|
|
321
304
|
const clientIpText = trustedForwarded ?? req.remoteAddress;
|
|
@@ -389,7 +372,6 @@ function createGateRoutes(deps) {
|
|
|
389
372
|
remoteAddress: req.socket?.remoteAddress
|
|
390
373
|
}, config, deps.officialAuth(req));
|
|
391
374
|
sendJson(res, 200, {
|
|
392
|
-
enabled: config.enabled,
|
|
393
375
|
verdict: decision.verdict,
|
|
394
376
|
clientIp: decision.clientIp ?? resolveIp(req) ?? null,
|
|
395
377
|
reason: decision.reason ?? null,
|
|
@@ -685,7 +667,6 @@ function installGateInterceptor(ctx, server, deps) {
|
|
|
685
667
|
const originalMatch = server.match.bind(server);
|
|
686
668
|
const gatedMatch = (pathname) => {
|
|
687
669
|
if (isExemptPath(pathname)) return originalMatch(pathname);
|
|
688
|
-
if (!deps.config().enabled) return originalMatch(pathname);
|
|
689
670
|
const route = originalMatch(pathname);
|
|
690
671
|
if (route === void 0) return void 0;
|
|
691
672
|
return {
|
|
@@ -744,24 +725,30 @@ function assertGateStructure(server) {
|
|
|
744
725
|
//#endregion
|
|
745
726
|
//#region src/index.ts
|
|
746
727
|
const name = "dsh-plus-access-gate";
|
|
728
|
+
/** 官方 browser-auth 签名密钥记录键(官方 AUTH_RECORD_KEY 同值)。 */
|
|
729
|
+
const AUTH_RECORD_KEY = credentialKey("client-connection", "browser-session");
|
|
747
730
|
const inject = ["webServer", "connection"];
|
|
748
731
|
/** 围栏服务:持有当前配置读取器(volatile 活动引用的平面解包,读即最新)。 */
|
|
749
732
|
var AccessGateService = class extends Service {
|
|
750
733
|
current;
|
|
751
|
-
/**
|
|
734
|
+
/**
|
|
735
|
+
* 官方 browser-auth 签名密钥(经官方凭据服务的记录读接口取,成功即缓存;
|
|
736
|
+
* 读不到 = null,autoLogin fail-safe 降级为 token 输入页)。
|
|
737
|
+
*/
|
|
752
738
|
signingSecret;
|
|
753
739
|
constructor(ctx, config) {
|
|
754
740
|
super(ctx, "accessGate");
|
|
755
741
|
this.current = () => unwrapVolatile(config);
|
|
756
742
|
const logger = ctx.logger("access-gate");
|
|
757
743
|
const officialAuth = (req) => ctx.connection.requestRejection({ headers: req.headers }) === void 0;
|
|
758
|
-
const secretOf = () => this.
|
|
744
|
+
const secretOf = () => this.signingSecret ?? null;
|
|
745
|
+
this.loadSigningSecret(ctx, logger);
|
|
759
746
|
const autoLoginCookie = (req) => {
|
|
760
747
|
const secret = secretOf();
|
|
761
748
|
const authority = requestAuthority(req.headers);
|
|
762
749
|
if (secret === null || authority === void 0) return null;
|
|
763
750
|
try {
|
|
764
|
-
return
|
|
751
|
+
return mintSessionCookie(secret, authority, Date.now());
|
|
765
752
|
} catch (error) {
|
|
766
753
|
const message = error instanceof Error ? error.message : String(error);
|
|
767
754
|
logger.warn(`auto-login cookie mint failed: ${message}`);
|
|
@@ -772,7 +759,7 @@ var AccessGateService = class extends Service {
|
|
|
772
759
|
config: () => this.current(),
|
|
773
760
|
officialAuth,
|
|
774
761
|
authenticatedUrl: (baseUrl) => ctx.connection.authenticatedUrl(baseUrl),
|
|
775
|
-
autoLoginReady: () => this.autoLoginSecretReady(
|
|
762
|
+
autoLoginReady: () => this.autoLoginSecretReady(),
|
|
776
763
|
onError: (message) => logger.warn(message)
|
|
777
764
|
});
|
|
778
765
|
const undo = installGateInterceptor(ctx, ctx.webServer, {
|
|
@@ -787,21 +774,33 @@ var AccessGateService = class extends Service {
|
|
|
787
774
|
policy() {
|
|
788
775
|
return this.current();
|
|
789
776
|
}
|
|
790
|
-
/**
|
|
791
|
-
|
|
792
|
-
|
|
777
|
+
/**
|
|
778
|
+
* 读取官方签名密钥并缓存:记录缺席/未挂凭据服务时保持未就绪(不缓存失败,
|
|
779
|
+
* 记录建立后由 credentials/record-updated 触发重读)。
|
|
780
|
+
*/
|
|
781
|
+
async loadSigningSecret(ctx, logger) {
|
|
782
|
+
if (this.signingSecret != null) return;
|
|
783
|
+
const credentials = ctx.get("credentials");
|
|
784
|
+
if (credentials === void 0) {
|
|
785
|
+
logger.warn("auto-login disabled: 当前组合未挂载凭据服务(dsh-credentials-local)");
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
793
788
|
try {
|
|
794
|
-
|
|
789
|
+
const record = await credentials.readRecord(AUTH_RECORD_KEY);
|
|
790
|
+
this.signingSecret = signingSecretOfRecord(record);
|
|
791
|
+
return;
|
|
795
792
|
} catch (error) {
|
|
796
793
|
const message = error instanceof Error ? error.message : String(error);
|
|
797
|
-
logger.warn(`auto-login
|
|
798
|
-
this.signingSecret = null;
|
|
794
|
+
logger.warn(`auto-login secret unavailable: ${message}`);
|
|
799
795
|
}
|
|
800
|
-
|
|
796
|
+
ctx.on("credentials/record-updated", (key) => {
|
|
797
|
+
if (String(key) !== String(AUTH_RECORD_KEY) || this.signingSecret != null) return;
|
|
798
|
+
this.loadSigningSecret(ctx, logger);
|
|
799
|
+
});
|
|
801
800
|
}
|
|
802
801
|
/** IP 信任自动登录可用性(诊断用):签名密钥可读即为可用。 */
|
|
803
|
-
autoLoginSecretReady(
|
|
804
|
-
return this.
|
|
802
|
+
autoLoginSecretReady() {
|
|
803
|
+
return this.signingSecret != null;
|
|
805
804
|
}
|
|
806
805
|
};
|
|
807
806
|
function apply(ctx, config) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/access-gate",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.34",
|
|
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,10 +31,11 @@
|
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@dsh-plus/shared": "0.1.
|
|
34
|
+
"@dsh-plus/shared": "0.1.44"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@deepseek-ai/cordis": "^4.0.5",
|
|
38
|
+
"@deepseek-ai/dsh-credentials": "^0.2.1-rc.2",
|
|
38
39
|
"@deepseek-ai/dsh-home-paths": "^0.2.1-rc.2",
|
|
39
40
|
"@deepseek-ai/dsh-settings": "^0.2.1-rc.2",
|
|
40
41
|
"@deepseek-ai/schemastery": "^3.18.5"
|
|
@@ -42,6 +43,7 @@
|
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@deepseek-ai/cordis": "4.0.5",
|
|
44
45
|
"@deepseek-ai/dsh-client-connection": "0.2.1-rc.2",
|
|
46
|
+
"@deepseek-ai/dsh-credentials": "0.2.1-rc.2",
|
|
45
47
|
"@deepseek-ai/dsh-home-paths": "0.2.1-rc.2",
|
|
46
48
|
"@deepseek-ai/dsh-host-webserver": "0.2.1-rc.2",
|
|
47
49
|
"@deepseek-ai/dsh-settings": "0.2.1-rc.2",
|
package/src/client/card.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 「访问控制」配置卡片:经 injectPluginConfigCard 注册(插件页 plugins.row.config /
|
|
3
3
|
* plugins.bundle.config,view 分发 summary/page)。
|
|
4
|
-
* 外壳与基础控件走 @dsh-plus/shared/client 套件(
|
|
4
|
+
* 外壳与基础控件走 @dsh-plus/shared/client 套件(ConfigShell/TextField/CheckRow),
|
|
5
5
|
* 本文件保留业务字段(白名单 textarea/诊断面板)与保存逻辑。
|
|
6
6
|
* 配置读写经共享层 ctx.remote.settings 直连(settingsScope 已由
|
|
7
7
|
* configForms 取代):value 为 schema 解析后的视图,保存经 settings.update 深合并。
|
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
|
-
CardChrome,
|
|
18
17
|
CardLoading,
|
|
19
18
|
type CardStatusState,
|
|
20
19
|
CheckRow,
|
|
20
|
+
ConfigShell,
|
|
21
21
|
IDLE_STATUS,
|
|
22
22
|
type NamespaceSettingsApi,
|
|
23
23
|
type PluginConfigViewProps,
|
|
@@ -35,21 +35,18 @@ export interface CardProps extends PluginConfigViewProps {
|
|
|
35
35
|
|
|
36
36
|
/** settings 命名空间的解析值。 */
|
|
37
37
|
export interface ConfigValue {
|
|
38
|
-
enabled: boolean
|
|
39
38
|
allowedIps: string[]
|
|
40
39
|
trustForwardedFor: boolean
|
|
41
40
|
autoLoginTrustedIps: boolean
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
interface Draft {
|
|
45
|
-
enabled: boolean
|
|
46
44
|
allowedIpsText: string
|
|
47
45
|
trustForwardedFor: boolean
|
|
48
46
|
autoLoginTrustedIps: boolean
|
|
49
47
|
}
|
|
50
48
|
|
|
51
49
|
interface Diag {
|
|
52
|
-
enabled: boolean
|
|
53
50
|
verdict: string
|
|
54
51
|
clientIp: string | null
|
|
55
52
|
reason: string | null
|
|
@@ -63,7 +60,6 @@ interface Diag {
|
|
|
63
60
|
|
|
64
61
|
function draftFromValue(value: ConfigValue): Draft {
|
|
65
62
|
return {
|
|
66
|
-
enabled: value.enabled,
|
|
67
63
|
allowedIpsText: value.allowedIps.join('\n'),
|
|
68
64
|
trustForwardedFor: value.trustForwardedFor,
|
|
69
65
|
autoLoginTrustedIps: value.autoLoginTrustedIps,
|
|
@@ -72,7 +68,6 @@ function draftFromValue(value: ConfigValue): Draft {
|
|
|
72
68
|
|
|
73
69
|
function toPatch(draft: Draft): Record<string, unknown> {
|
|
74
70
|
return {
|
|
75
|
-
enabled: draft.enabled,
|
|
76
71
|
allowedIps: draft.allowedIpsText
|
|
77
72
|
.split('\n')
|
|
78
73
|
.map((s) => s.trim())
|
|
@@ -96,7 +91,6 @@ function reasonText(t: Translate, reason: string): string {
|
|
|
96
91
|
}
|
|
97
92
|
|
|
98
93
|
function verdictLabel(t: Translate, diag: Diag): string {
|
|
99
|
-
if (!diag.enabled) return t('diagOff')
|
|
100
94
|
if (diag.verdict === 'pass') return t('diagPass')
|
|
101
95
|
if (diag.verdict === 'token-page') return t('diagTokenPage')
|
|
102
96
|
if (diag.verdict === 'autologin') return t('diagAutoLogin')
|
|
@@ -226,11 +220,10 @@ export function AccessGateCard(props: CardProps): ReactElement | string | null {
|
|
|
226
220
|
|
|
227
221
|
const disabled = !snapshot.writable
|
|
228
222
|
return (
|
|
229
|
-
<
|
|
223
|
+
<ConfigShell
|
|
230
224
|
prefix="dag"
|
|
231
225
|
title={t('title')}
|
|
232
226
|
description={t('description')}
|
|
233
|
-
statusBadge={{ text: t(draft.enabled ? 'enabledOn' : 'enabledOff'), on: draft.enabled }}
|
|
234
227
|
dirty={dirty}
|
|
235
228
|
dirtyLabel={t('unsaved')}
|
|
236
229
|
readOnlyNotice={disabled ? t('readOnly') : undefined}
|
|
@@ -255,14 +248,6 @@ export function AccessGateCard(props: CardProps): ReactElement | string | null {
|
|
|
255
248
|
]}
|
|
256
249
|
>
|
|
257
250
|
<p className="dag-hint">{t('mergedHint')}</p>
|
|
258
|
-
<CheckRow
|
|
259
|
-
prefix="dag"
|
|
260
|
-
id="dag-enabled"
|
|
261
|
-
label={t('enabledHint')}
|
|
262
|
-
checked={draft.enabled}
|
|
263
|
-
disabled={disabled}
|
|
264
|
-
onEdit={(v) => edit('enabled', v)}
|
|
265
|
-
/>
|
|
266
251
|
<div className="dag-field">
|
|
267
252
|
<div className="dag-head">
|
|
268
253
|
<label className="dag-label" htmlFor="dag-allowed">
|
|
@@ -296,6 +281,6 @@ export function AccessGateCard(props: CardProps): ReactElement | string | null {
|
|
|
296
281
|
onEdit={(v) => edit('trustForwardedFor', v)}
|
|
297
282
|
/>
|
|
298
283
|
<DiagPanel t={t} diag={diag} />
|
|
299
|
-
</
|
|
284
|
+
</ConfigShell>
|
|
300
285
|
)
|
|
301
286
|
}
|
package/src/client/i18n.ts
CHANGED
|
@@ -12,8 +12,6 @@ const ownZh = {
|
|
|
12
12
|
description:
|
|
13
13
|
'Web 访问围栏:与官方登录合并——官方 cookie 为唯一凭据;未认证导航显示启动令牌输入页(PWA 内可用);可选 IP 附加围栏。',
|
|
14
14
|
summaryLine: 'Web 访问围栏:官方登录合并、启动令牌页与可选 IP 白名单。',
|
|
15
|
-
enabled: '启用访问围栏',
|
|
16
|
-
enabledHint: '关闭即完全旁路(等价插件缺席);启用后未认证流量被拦截到令牌输入页或拒绝。',
|
|
17
15
|
mergedHint:
|
|
18
16
|
'登录凭据由官方认证持有(启动令牌换 30 天 cookie)。获取当前令牌:服务器执行 dshctl url,或本机访问 /dsh-plus/gate/launch-url。',
|
|
19
17
|
allowedIps: '附加 IP 围栏白名单',
|
|
@@ -41,7 +39,6 @@ const ownZh = {
|
|
|
41
39
|
diagAutoLoginVerdict: '自动登录(免 token 进入)',
|
|
42
40
|
diagAutoLoginReady: '已就绪(签名密钥可读)',
|
|
43
41
|
diagAutoLoginNoSecret: '不可用(签名密钥读取失败,已降级)',
|
|
44
|
-
diagOff: '围栏未启用',
|
|
45
42
|
diagInvalid: '白名单存在无法解析的条目:',
|
|
46
43
|
} as const
|
|
47
44
|
|
|
@@ -50,9 +47,6 @@ const ownEn = {
|
|
|
50
47
|
description:
|
|
51
48
|
'Web access gate merged with official auth: the official cookie is the sole credential; unauthenticated navigations get a launch-token page (works inside the PWA); optional IP fence.',
|
|
52
49
|
summaryLine: 'Web access gate: merged official auth, launch-token page, optional IP allowlist.',
|
|
53
|
-
enabled: 'Enable access gate',
|
|
54
|
-
enabledHint:
|
|
55
|
-
'Off means full bypass (as if absent); on, unauthenticated traffic hits the token page or is refused.',
|
|
56
50
|
mergedHint:
|
|
57
51
|
'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.',
|
|
58
52
|
allowedIps: 'Additional IP fence allowlist',
|
|
@@ -80,7 +74,6 @@ const ownEn = {
|
|
|
80
74
|
diagAutoLoginVerdict: 'auto login (token-free entry)',
|
|
81
75
|
diagAutoLoginReady: 'ready (signing secret readable)',
|
|
82
76
|
diagAutoLoginNoSecret: 'unavailable (signing secret unreadable, degraded)',
|
|
83
|
-
diagOff: 'gate disabled',
|
|
84
77
|
diagInvalid: 'Allowlist entries that failed to parse:',
|
|
85
78
|
} as const
|
|
86
79
|
|
package/src/config.ts
CHANGED
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 与官方认证合并后:访问凭据唯一化为官方 browser-auth cookie(dsh-auth-*,
|
|
7
7
|
* 由 client-connection 的 ?token= 启动令牌交换签发),本插件不再持有自有
|
|
8
|
-
* token/cookie;allowedIps 降级为可选的附加 IP
|
|
9
|
-
*
|
|
10
|
-
* schemastery 透传忽略,不阻断加载(已实测验证)。
|
|
8
|
+
* token/cookie;allowedIps 降级为可选的附加 IP 围栏。整行开关由官方行开关
|
|
9
|
+
* 承担(bundle 层默认 `disabled: true` = 一切放行),配置内无第二套总开关。
|
|
11
10
|
* @module access-gate/config
|
|
12
11
|
*/
|
|
13
12
|
import z from '@deepseek-ai/schemastery'
|
|
@@ -22,11 +21,6 @@ export const SETTINGS_NS = NS_LITERAL
|
|
|
22
21
|
// 行级 config,GUI 写入原位提交热生效),同时使本条目进入 settings describe
|
|
23
22
|
// 视图(配置卡片可读写);缺 volatile 标记的条目会被 describe 跳过。
|
|
24
23
|
export const Config = z.object({
|
|
25
|
-
enabled: z
|
|
26
|
-
.boolean()
|
|
27
|
-
.description('访问围栏总开关(false = 一切放行,等价插件缺席)')
|
|
28
|
-
.default(false)
|
|
29
|
-
.volatile(),
|
|
30
24
|
allowedIps: z
|
|
31
25
|
.array(z.string())
|
|
32
26
|
.description(
|
|
@@ -55,9 +49,11 @@ export type AccessGateConfigFields = Schemastery.TypeT<typeof Config>
|
|
|
55
49
|
/** 平面配置形态(服务与判定面的消费形态,由活动引用解包得到)。 */
|
|
56
50
|
export type AccessGateConfig = UnwrapVolatile<AccessGateConfigFields>
|
|
57
51
|
|
|
58
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* 判定所需的最小配置视图(测试与决策函数共用窄面)。
|
|
54
|
+
* 整行开关由官方行开关承担(bundle 层默认 `disabled: true`,等价「一切放行」)。
|
|
55
|
+
*/
|
|
59
56
|
export interface GatePolicy {
|
|
60
|
-
readonly enabled: boolean
|
|
61
57
|
readonly allowedIps: readonly string[]
|
|
62
58
|
readonly trustForwardedFor: boolean
|
|
63
59
|
readonly autoLoginTrustedIps: boolean
|
package/src/cookie.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* 用户全程无感、免启动 token。
|
|
8
8
|
*
|
|
9
9
|
* 契约(对官方 client-connection browser-auth 逐行核实,见 docs/README.md
|
|
10
|
-
* 「信任模型」;0.2.1-
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* 「信任模型」;0.2.1-rc.2 复核不变):
|
|
11
|
+
* - 签名密钥由官方凭据服务持有:记录键 `client-connection/browser-session`,
|
|
12
|
+
* grant payload `{version: 1, secret: <base64url 无填充 32B>}`(跨重启不变);
|
|
13
|
+
* 本模块只解析官方 readRecord 交回的记录,不再自行读文件;
|
|
14
14
|
* - cookie 名 = `dsh-auth-<base64url(sha256(authority))>`,
|
|
15
15
|
* authority = 请求 Host 经 `new URL('http://'+host).host` 归一化
|
|
16
16
|
* (host:port,含端口;与官方 requestAuthority() 同实现);
|
|
@@ -21,15 +21,14 @@
|
|
|
21
21
|
* - 服务端校验 `expiresAt - issuedAt <= cookieMaxAgeDays`(默认 30 天)且
|
|
22
22
|
* 下界 1 天;本模块取官方默认 30 天。
|
|
23
23
|
*
|
|
24
|
-
* 本模块不 import
|
|
25
|
-
*
|
|
24
|
+
* 本模块不 import 官方包:密钥以「已读出的记录值」入参(调用方经
|
|
25
|
+
* ctx.get('credentials').readRecord 取,见 index.ts),crypto 用 node: 内置。
|
|
26
|
+
* 全部为纯函数/可注入,测试零网络零依赖。
|
|
26
27
|
* 安全:任何解析失败都返回明确错误(fail-loud / fail-safe),绝不产出
|
|
27
28
|
* 半成品 cookie。
|
|
28
29
|
* @module access-gate/cookie
|
|
29
30
|
*/
|
|
30
31
|
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
|
|
31
|
-
import { readFileSync } from 'node:fs'
|
|
32
|
-
import { join } from 'node:path'
|
|
33
32
|
|
|
34
33
|
/** 官方 cookie 名前缀(client-connection browser-auth 常量)。 */
|
|
35
34
|
export const COOKIE_PREFIX = 'dsh-auth-'
|
|
@@ -39,8 +38,10 @@ const PAYLOAD_VERSION = 1
|
|
|
39
38
|
const SECRET_BYTES = 32
|
|
40
39
|
/** 自签 cookie 有效期:官方默认 cookieMaxAgeDays。 */
|
|
41
40
|
export const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
|
|
42
|
-
/**
|
|
41
|
+
/** 官方记录键(credentialKey('client-connection', 'browser-session') 的联合形态)。 */
|
|
43
42
|
const SECRET_SECTION = 'client-connection/browser-session'
|
|
43
|
+
/** 官方 grant payload 版本(官方 STORED_SECRET_VERSION)。 */
|
|
44
|
+
const STORED_SECRET_VERSION = 1
|
|
44
45
|
/** payload 键序(官方 encodeCookie 的 JSON.stringify 紧凑序列化键序)。 */
|
|
45
46
|
const PAYLOAD_KEYS = ['version', 'authority', 'issuedAt', 'expiresAt'] as const
|
|
46
47
|
|
|
@@ -135,38 +136,42 @@ export function isAuthenticatedCookie(
|
|
|
135
136
|
return false
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
/**
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
139
|
+
/**
|
|
140
|
+
* 从官方记录里取签名密钥:只认 grant + payload.version === 1 + 32 字节
|
|
141
|
+
* base64url secret(与官方 storedSecret() 同判据);任何不符即抛错,
|
|
142
|
+
* 由调用方降级 fail-safe(不铸 cookie)。
|
|
143
|
+
* @param record - `ctx.credentials.readRecord(credentialKey('client-connection', 'browser-session'))` 的返回值。
|
|
144
|
+
* @returns 32 字节签名密钥。
|
|
145
|
+
*/
|
|
146
|
+
export function signingSecretOfRecord(record: unknown): Buffer {
|
|
147
|
+
const payload = grantPayloadOf(record)
|
|
148
|
+
if (payload?.version !== STORED_SECRET_VERSION) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`${SECRET_SECTION}: credential record has an unsupported format (want version ${STORED_SECRET_VERSION})`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
const raw = payload.secret
|
|
154
|
+
if (typeof raw !== 'string' || !/^[A-Za-z0-9_-]+$/.test(raw)) {
|
|
155
|
+
throw new Error(`${SECRET_SECTION}: credential record has an invalid secret`)
|
|
146
156
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (indent <= 2) {
|
|
153
|
-
inSection = stripped === `${SECRET_SECTION}:`
|
|
154
|
-
continue
|
|
155
|
-
}
|
|
156
|
-
if (inSection) {
|
|
157
|
-
const match = /^secret:\s*(\S+)\s*$/.exec(stripped)
|
|
158
|
-
if (match !== null) {
|
|
159
|
-
const secret = Buffer.from(match[1] ?? '', 'base64url')
|
|
160
|
-
if (secret.byteLength !== SECRET_BYTES) {
|
|
161
|
-
throw new Error(
|
|
162
|
-
`browser-session secret has invalid length (${secret.byteLength}, want ${SECRET_BYTES})`,
|
|
163
|
-
)
|
|
164
|
-
}
|
|
165
|
-
return secret
|
|
166
|
-
}
|
|
167
|
-
}
|
|
157
|
+
const secret = Buffer.from(raw, 'base64url')
|
|
158
|
+
if (secret.byteLength !== SECRET_BYTES) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`browser-session secret has invalid length (${secret.byteLength}, want ${SECRET_BYTES})`,
|
|
161
|
+
)
|
|
168
162
|
}
|
|
169
|
-
|
|
163
|
+
return secret
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 取 grant 记录的 payload 对象;非 grant/非对象返回 undefined。 */
|
|
167
|
+
function grantPayloadOf(record: unknown): Record<string, unknown> | undefined {
|
|
168
|
+
if (typeof record !== 'object' || record === null) return undefined
|
|
169
|
+
const candidate = record as { kind?: unknown; payload?: unknown }
|
|
170
|
+
if (candidate.kind !== 'grant') return undefined
|
|
171
|
+
const payload = candidate.payload
|
|
172
|
+
return typeof payload === 'object' && payload !== null && !Array.isArray(payload)
|
|
173
|
+
? (payload as Record<string, unknown>)
|
|
174
|
+
: undefined
|
|
170
175
|
}
|
|
171
176
|
|
|
172
177
|
/** 官方 sessionCookie() 序列化(属性逐字同款;nowMs 注入保持纯函数可测)。 */
|
|
@@ -195,18 +200,3 @@ export function mintSessionCookie(secret: Buffer, authority: string, nowMs: numb
|
|
|
195
200
|
const signature = base64url(createHmac('sha256', secret).update(body).digest())
|
|
196
201
|
return serializeSessionCookie(cookieName(authority), `v1.${body}.${signature}`, expiresAt, nowMs)
|
|
197
202
|
}
|
|
198
|
-
|
|
199
|
-
/** 拼接 DSH_HOME 下凭据文件路径(默认 home 与 $DSH_HOME 语义同官方 home-paths)。 */
|
|
200
|
-
export function credentialsPath(dshHome: string): string {
|
|
201
|
-
return join(dshHome, '.credentials.yaml')
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** 完整铸造入口:读密钥 + 铸造;任何失败抛带上下文错误(调用方降级 fail-safe)。 */
|
|
205
|
-
export function mintAutoLoginCookie(
|
|
206
|
-
credentialsYamlPath: string,
|
|
207
|
-
authority: string,
|
|
208
|
-
nowMs: number,
|
|
209
|
-
): string {
|
|
210
|
-
const secret = readSigningSecretFromFile(credentialsYamlPath)
|
|
211
|
-
return mintSessionCookie(secret, authority, nowMs)
|
|
212
|
-
}
|