@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/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
|
|