@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/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 围栏,不命中即拒;官方 cookie 有效 → 放行;
82
- * 否则导航 → token 输入页,非导航 → 403;无来源 → fail-closed。
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 {
@@ -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