@dsh-plus/access-gate 0.1.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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * 围栏拦截器:对 webServer 服务做结构性包装,覆盖全部 HTTP/WS 流量。
3
+ *
4
+ * 机制(dsh-host-webserver 无中间件机制,路由分发必经以下三个面):
5
+ * 1. `match(pathname)` —— HTTP 分发总咽喉:patch 后**请求级**围栏判定——
6
+ * 放行则返回原 route(handler 外包一层,route 对象本体不动,webserver
7
+ * 拿到的是浅拷贝);拒绝则返回内联拒绝 route(导航→登录页,其余→403;
8
+ * 连「未知路径」的探测面也一并收敛)。与路由注册时序完全无关。
9
+ * 2. `fallback` 字段 + `registerFallback` 方法 —— SPA index/静态资源:
10
+ * 在位包装已注册的 fallback(frontend-static 先于本插件),并 patch
11
+ * 方法兜底后续注册。
12
+ * 3. `upgrades` Map 各 route 的 handler + `registerUpgrade` 方法 —— WS 升级
13
+ * 双路径覆盖(先/后注册的条目都被拦截)。
14
+ *
15
+ * 结构守卫:安装前校验字段形状(match 为函数、upgrades 为 Map 等),
16
+ * 不符即 fail-loud 抛错(启动失败由 lifeboat 隔离止损,绝不静默 fail-open)。
17
+ * 恢复:所有 patch 与在位 handler 替换均登记 undo,dispose/HMR 时完全还原。
18
+ * @module access-gate/interceptor
19
+ */
20
+ import type { IncomingMessage, ServerResponse } from 'node:http'
21
+ import type { Duplex } from 'node:stream'
22
+
23
+ import type { Context } from '@deepseek-ai/cordis'
24
+
25
+ import type { AccessGateConfig } from './config.ts'
26
+ import { decideGate, type GateRequest } from './decision.ts'
27
+ import { renderLoginPage } from './login-page.ts'
28
+ import { GATE_ROUTE } from './routes.ts'
29
+
30
+ type HttpHandler = (req: IncomingMessage, res: ServerResponse) => unknown
31
+ type UpgradeHandler = (req: IncomingMessage, socket: Duplex, head: Buffer) => unknown
32
+ type UpgradeRoute = { path: string; handler: UpgradeHandler }
33
+
34
+ /** 围栏所需的最小 webServer 结构面(结构守卫的契约)。 */
35
+ export interface GateWebServer {
36
+ match(pathname: string): unknown
37
+ upgrades: Map<string, UpgradeRoute>
38
+ fallback?: HttpHandler
39
+ registerFallback(handler: HttpHandler): unknown
40
+ registerUpgrade(route: UpgradeRoute): unknown
41
+ [key: string]: unknown
42
+ }
43
+
44
+ export interface InterceptorDeps {
45
+ config: () => AccessGateConfig
46
+ onBlock?: (message: string) => void
47
+ }
48
+
49
+ /** 是否围栏豁免路径(自有登录/状态端点)。 */
50
+ export function isExemptPath(pathname: string): boolean {
51
+ return pathname === GATE_ROUTE || pathname.startsWith(`${GATE_ROUTE}/`)
52
+ }
53
+
54
+ function toGateRequest(req: IncomingMessage): GateRequest {
55
+ return {
56
+ url: req.url ?? '/',
57
+ method: req.method ?? 'GET',
58
+ headers: req.headers,
59
+ remoteAddress: req.socket?.remoteAddress,
60
+ }
61
+ }
62
+
63
+ /** WS 升级拒绝:按官方同款 403 原始响应后关闭。 */
64
+ function rejectUpgrade(socket: Duplex, clientIp: string, log: (m: string) => void): void {
65
+ socket.end(
66
+ [
67
+ 'HTTP/1.1 403 Forbidden',
68
+ 'Connection: close',
69
+ 'Content-Type: text/plain; charset=utf-8',
70
+ 'Content-Length: 9',
71
+ '',
72
+ 'forbidden',
73
+ ].join('\r\n'),
74
+ )
75
+ log(`blocked upgrade (${clientIp})`)
76
+ }
77
+
78
+ /** 单请求围栏判定;放行 true / 拒绝(已写响应)false。 */
79
+ function passHttp(req: IncomingMessage, res: ServerResponse, deps: InterceptorDeps): boolean {
80
+ if (isExemptPath(new URL(req.url ?? '/', 'http://x').pathname)) return true
81
+ const decision = decideGate(toGateRequest(req), deps.config())
82
+ if (decision.verdict === 'pass') return true
83
+ const log = deps.onBlock ?? (() => {})
84
+ if (decision.verdict === 'login' && !res.headersSent) {
85
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
86
+ res.end(renderLoginPage())
87
+ log(`login page ${req.method ?? '?'} ${req.url ?? '?'}`)
88
+ return false
89
+ }
90
+ if (!res.headersSent) {
91
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
92
+ res.end('forbidden')
93
+ } else {
94
+ res.end()
95
+ }
96
+ log(`blocked ${req.method ?? '?'} ${req.url ?? '?'} (${decision.clientIp ?? 'unknown'})`)
97
+ return false
98
+ }
99
+
100
+ /** HTTP handler 包装体:先判定再派发。 */
101
+ function gatedHttp(handler: HttpHandler, deps: InterceptorDeps): HttpHandler {
102
+ return async (req, res) => {
103
+ if (isExemptPath(new URL(req.url ?? '/', 'http://x').pathname)) return handler(req, res)
104
+ if (!passHttp(req, res, deps)) return
105
+ return handler(req, res)
106
+ }
107
+ }
108
+
109
+ /** WS 升级包装体:拒绝时不进协议协商。 */
110
+ function gatedUpgrade(handler: UpgradeHandler, deps: InterceptorDeps): UpgradeHandler {
111
+ return (req, socket, head) => {
112
+ const decision = decideGate(toGateRequest(req), deps.config())
113
+ if (decision.verdict === 'pass') return handler(req, socket, head)
114
+ rejectUpgrade(socket, decision.clientIp ?? 'unknown', deps.onBlock ?? (() => {}))
115
+ return undefined
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 安装围栏(结构包装 + fail-loud 守卫)。
121
+ * @returns 恢复函数(调用方登记进 ctx.effect)。
122
+ */
123
+ export function installGateInterceptor(
124
+ ctx: Context,
125
+ server: GateWebServer,
126
+ deps: InterceptorDeps,
127
+ ): () => void {
128
+ assertGateStructure(server)
129
+ const restores: Array<{ undo(): void }> = []
130
+
131
+ // 1. match:HTTP 总咽喉——请求级判定;返回浅拷贝 route(handler 已包 gate),
132
+ // 原 route 对象与注册表不动,杜绝重复包装。
133
+ // 未命中任何路由时必须返回 undefined:webserver 的 handle() 据此转入
134
+ // fallback 原生路径(fallback 已被第 2 步包装,SPA 语义完整保留)。
135
+ const originalMatch = server.match.bind(server)
136
+ const gatedMatch = (pathname: string): unknown => {
137
+ if (isExemptPath(pathname)) return originalMatch(pathname)
138
+ const config = deps.config()
139
+ if (!config.enabled) return originalMatch(pathname)
140
+ const route = originalMatch(pathname) as { handler: HttpHandler } | undefined
141
+ if (route === undefined) return undefined
142
+ return { ...route, handler: gatedHttp(route.handler, deps) }
143
+ }
144
+ server.match = gatedMatch as typeof server.match
145
+ restores.push({
146
+ undo: () => {
147
+ server.match = originalMatch as typeof server.match
148
+ },
149
+ })
150
+
151
+ // 2. fallback 在位包装 + registerFallback 兜底(浅拷贝 route 不覆盖
152
+ // fallback 场景:webserver 对未命中请求直接调 this.fallback)。
153
+ const wrapFallback = (handler: HttpHandler): HttpHandler => gatedHttp(handler, deps)
154
+ const originalFallback = server.fallback
155
+ let fallbackReplaced = false
156
+ if (originalFallback !== undefined) {
157
+ server.fallback = wrapFallback(originalFallback) as typeof originalFallback
158
+ fallbackReplaced = true
159
+ }
160
+ const originalRegisterFallback = server.registerFallback.bind(server)
161
+ const patchedRegisterFallback = (handler: HttpHandler): unknown =>
162
+ originalRegisterFallback(wrapFallback(handler))
163
+ server.registerFallback = patchedRegisterFallback as typeof server.registerFallback
164
+ restores.push({
165
+ undo: () => {
166
+ server.registerFallback = originalRegisterFallback as typeof server.registerFallback
167
+ if (fallbackReplaced) server.fallback = originalFallback as typeof server.fallback
168
+ },
169
+ })
170
+
171
+ // 3. upgrades:在位包装已注册条目 + patch registerUpgrade(后续注册)。
172
+ const wrapped = new Map<UpgradeRoute, UpgradeHandler>()
173
+ const wrapUpgradeRoute = (route: UpgradeRoute): void => {
174
+ wrapped.set(route, route.handler)
175
+ route.handler = gatedUpgrade(route.handler, deps)
176
+ }
177
+ for (const route of server.upgrades.values()) wrapUpgradeRoute(route)
178
+ const originalRegisterUpgrade = server.registerUpgrade.bind(server)
179
+ const patchedRegisterUpgrade = (route: UpgradeRoute): unknown => {
180
+ const disposer = originalRegisterUpgrade(route)
181
+ // 未抛重复错即注册成功;在位包装(Map 值 === route 已证其已入表)。
182
+ if (server.upgrades.get(route.path) === route) wrapUpgradeRoute(route)
183
+ return disposer
184
+ }
185
+ server.registerUpgrade = patchedRegisterUpgrade as typeof server.registerUpgrade
186
+ restores.push({
187
+ undo: () => {
188
+ server.registerUpgrade = originalRegisterUpgrade as typeof server.registerUpgrade
189
+ for (const [route, handler] of wrapped) route.handler = handler
190
+ wrapped.clear()
191
+ },
192
+ })
193
+
194
+ const undo = (): void => {
195
+ for (let i = restores.length - 1; i >= 0; i -= 1) restores[i].undo()
196
+ }
197
+ void ctx
198
+ return undo
199
+ }
200
+
201
+ /** 结构守卫:字段形状不符即抛错(fail-loud)。 */
202
+ export function assertGateStructure(server: GateWebServer): void {
203
+ if (typeof server.match !== 'function')
204
+ throw new Error('access-gate: webServer.match is not a function (upstream shape changed)')
205
+ if (!(server.upgrades instanceof Map))
206
+ throw new Error('access-gate: webServer.upgrades is not a Map (upstream shape changed)')
207
+ if (typeof server.registerFallback !== 'function')
208
+ throw new Error(
209
+ 'access-gate: webServer.registerFallback is not a function (upstream shape changed)',
210
+ )
211
+ if (typeof server.registerUpgrade !== 'function')
212
+ throw new Error(
213
+ 'access-gate: webServer.registerUpgrade is not a function (upstream shape changed)',
214
+ )
215
+ }
package/src/ip.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * IP 与 CIDR 解析/匹配纯函数(IPv4 + IPv6,BigInt 位运算实现)。
3
+ * 零依赖、无副作用,node 半与测试共用;非法输入返回 null / false,绝不抛错。
4
+ *
5
+ * 表示约定:IP 一律归一化为「BigInt 数值 + 地址族位宽(32/128)」内部表示,
6
+ * IPv4 不映射进 IPv6 空间(tailscale 环境只会出现原生 v4/v6 字面量,
7
+ * 映射式 ::ffff:x.x.x.x 判等容易引入歧义,映射形式明确拒绝)。
8
+ * @module access-gate/ip
9
+ */
10
+
11
+ /** 归一化 IP:数值 + 地址族位宽。 */
12
+ export interface ParsedIp {
13
+ readonly value: bigint
14
+ readonly bits: 32 | 128
15
+ }
16
+
17
+ /** 解析 IPv4 十进制点分字面量;非法返回 null。 */
18
+ function parseIpv4(text: string): bigint | null {
19
+ const parts = text.split('.')
20
+ if (parts.length !== 4) return null
21
+ let value = 0n
22
+ for (const part of parts) {
23
+ if (part.length === 0 || part.length > 3 || !/^\d+$/.test(part)) return null
24
+ const octet = Number(part)
25
+ if (octet > 255) return null
26
+ value = (value << 8n) | BigInt(octet)
27
+ }
28
+ return value
29
+ }
30
+
31
+ /** 把一个组(4 位十六进制)压入累积值;越界(>0xffff)返回 null。 */
32
+ function pushGroup(value: bigint, group: string): bigint | null {
33
+ if (group.length === 0 || group.length > 4 || !/^[0-9a-fA-F]+$/.test(group)) return null
34
+ return (value << 16n) | BigInt(Number.parseInt(group, 16))
35
+ }
36
+
37
+ /**
38
+ * 解析 IPv6(含 `::` 压缩与 `::1.2.3.4` 形态的 v4 尾巴;拒绝 zone / 映射前缀)。
39
+ * 算法:按 `::` 切成左右两半 → 各自逐组展开(末组可为点分 v4,占 2 组)
40
+ * → 数值拼接,右半在低位,中间空洞补零。
41
+ */
42
+ function parseIpv6(text: string): bigint | null {
43
+ if (text.includes('%') || text.startsWith('::ffff:')) return null
44
+ const gap = text.indexOf('::')
45
+ if (gap !== -1 && text.indexOf('::', gap + 1) !== -1) return null
46
+ const headText = gap === -1 ? text : text.slice(0, gap)
47
+ const tailText = gap === -1 ? '' : text.slice(gap + 2)
48
+
49
+ const expandHalf = (half: string, atStart: boolean): { value: bigint; groups: number } | null => {
50
+ if (half === '') return { value: 0n, groups: 0 }
51
+ const rawGroups = half.split(':')
52
+ if (rawGroups.some((group) => group === '')) return null
53
+ // v4 尾巴只允许出现在整串的最末一组。
54
+ const dotted = rawGroups.findIndex((group) => group.includes('.'))
55
+ if (dotted !== -1 && !(dotted === rawGroups.length - 1 && !atStart)) return null
56
+ let value = 0n
57
+ let groups = 0
58
+ for (const group of rawGroups) {
59
+ if (group.includes('.')) {
60
+ const v4 = parseIpv4(group)
61
+ if (v4 === null) return null
62
+ value = (value << 32n) | v4
63
+ groups += 2
64
+ } else {
65
+ const next = pushGroup(value, group)
66
+ if (next === null) return null
67
+ value = next
68
+ groups += 1
69
+ }
70
+ }
71
+ return { value, groups }
72
+ }
73
+
74
+ const head = expandHalf(headText, true)
75
+ const tail = expandHalf(tailText, false)
76
+ if (head === null || tail === null) return null
77
+ const total = head.groups + tail.groups
78
+ if (total > 8) return null
79
+ if (gap === -1 && total !== 8) return null
80
+ if (gap !== -1 && total >= 8) return null
81
+ // 右半在最低位,其后才是洞:head 左移「洞 + 右半」的组数。
82
+ const holeAndTail = BigInt(8 - head.groups) * 16n
83
+ return (head.value << holeAndTail) | tail.value
84
+ }
85
+
86
+ /** 解析任意 IP 字面量;非法返回 null。 */
87
+ export function parseIp(text: string): ParsedIp | null {
88
+ const trimmed = text.trim()
89
+ if (trimmed.includes(':')) {
90
+ const value = parseIpv6(trimmed)
91
+ return value === null ? null : { value, bits: 128 }
92
+ }
93
+ const value = parseIpv4(trimmed)
94
+ return value === null ? null : { value, bits: 32 }
95
+ }
96
+
97
+ /** 归一化 CIDR;非法(IP 解析失败 / 前缀越界)返回 null。 */
98
+ export function parseCidr(text: string): { ip: ParsedIp; prefix: number } | null {
99
+ const slash = text.indexOf('/')
100
+ if (slash === -1) return null
101
+ const ip = parseIp(text.slice(0, slash))
102
+ const prefixText = text.slice(slash + 1)
103
+ if (ip === null || !/^\d{1,3}$/.test(prefixText)) return null
104
+ const prefix = Number(prefixText)
105
+ if (prefix > ip.bits) return null
106
+ return { ip, prefix }
107
+ }
108
+
109
+ /** 同族且数值相等。 */
110
+ export function ipEquals(a: ParsedIp, b: ParsedIp): boolean {
111
+ return a.bits === b.bits && a.value === b.value
112
+ }
113
+
114
+ /** 判断 IP 是否落入 CIDR;跨族(v4 vs v6)恒 false。 */
115
+ export function ipInCidr(ip: ParsedIp, cidr: { ip: ParsedIp; prefix: number }): boolean {
116
+ if (ip.bits !== cidr.ip.bits) return false
117
+ const shift = BigInt(ip.bits - cidr.prefix)
118
+ return ip.value >> shift === cidr.ip.value >> shift
119
+ }
120
+
121
+ /** 解析白名单条目(精确 IP 或 CIDR);返回可判定函数,非法条目返回 null。 */
122
+ export function parseAllowEntry(text: string): ((ip: ParsedIp) => boolean) | null {
123
+ const trimmed = text.trim()
124
+ const cidr = parseCidr(trimmed)
125
+ if (cidr !== null) {
126
+ return (ip) => ipInCidr(ip, cidr)
127
+ }
128
+ const exact = parseIp(trimmed)
129
+ if (exact !== null) {
130
+ return (ip) => ipEquals(ip, exact)
131
+ }
132
+ return null
133
+ }
134
+
135
+ /** 判断 remoteAddress 是否 loopback(node 形态:`127.x.x.x` / `::1` / `::ffff:127.x.x.x`)。 */
136
+ export function isLoopbackAddress(remoteAddress: string | undefined): boolean {
137
+ if (remoteAddress === undefined) return false
138
+ const bare = remoteAddress.startsWith('::ffff:') ? remoteAddress.slice(7) : remoteAddress
139
+ if (bare === '::1') return true
140
+ const parsed = parseIp(bare)
141
+ if (parsed === null || parsed.bits !== 32) return false
142
+ return parsed.value >= 0x7f000000n && parsed.value <= 0x7ffffffen
143
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 登录页:围栏拦截浏览器导航请求时返回的内联 HTML。
3
+ * 自包含(无外部静态资源依赖)、中文、vanilla JS——输入 token → 调
4
+ * /dsh-plus/gate/login → 成功后 reload 放行。
5
+ * @module access-gate/login-page
6
+ */
7
+
8
+ export function renderLoginPage(): string {
9
+ return `<!DOCTYPE html>
10
+ <html lang="zh-CN">
11
+ <head>
12
+ <meta charset="utf-8">
13
+ <meta name="viewport" content="width=device-width, initial-scale=1">
14
+ <title>访问校验</title>
15
+ <style>
16
+ :root { color-scheme: dark; }
17
+ body {
18
+ margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
19
+ background: #16181d; color: #e6e8ec;
20
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", sans-serif;
21
+ }
22
+ .card {
23
+ width: min(92vw, 360px); padding: 32px 28px; border-radius: 14px;
24
+ background: #1e2128; border: 1px solid #2e323c; box-shadow: 0 8px 32px rgba(0,0,0,.4);
25
+ box-sizing: border-box;
26
+ }
27
+ h1 { margin: 0 0 6px; font-size: 20px; font-weight: 600; }
28
+ p { margin: 0 0 22px; font-size: 13px; color: #9aa1ad; line-height: 1.6; }
29
+ input {
30
+ width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 8px;
31
+ border: 1px solid #3a3f4b; background: #16181d; color: #e6e8ec; font-size: 15px;
32
+ outline: none;
33
+ }
34
+ input:focus { border-color: #5b8cff; }
35
+ button {
36
+ width: 100%; margin-top: 14px; padding: 10px 0; border: 0; border-radius: 8px;
37
+ background: #3d6dff; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
38
+ }
39
+ button:disabled { opacity: .6; cursor: default; }
40
+ .msg { min-height: 18px; margin-top: 12px; font-size: 13px; text-align: center; }
41
+ .err { color: #ff7a76; }
42
+ .ok { color: #63d17c; }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <form class="card" id="form">
47
+ <h1>此页面受访问控制保护</h1>
48
+ <p>当前网络不在放行名单内。输入访问令牌以继续;或使用名单内的设备/网络访问。</p>
49
+ <input id="token" type="password" placeholder="访问令牌" autocomplete="off" autofocus>
50
+ <button id="btn" type="submit">继续访问</button>
51
+ <div class="msg" id="msg"></div>
52
+ </form>
53
+ <script>
54
+ var form = document.getElementById('form');
55
+ var input = document.getElementById('token');
56
+ var btn = document.getElementById('btn');
57
+ var msg = document.getElementById('msg');
58
+ form.addEventListener('submit', function (event) {
59
+ event.preventDefault();
60
+ var token = input.value;
61
+ if (!token) { msg.className = 'msg err'; msg.textContent = '请输入访问令牌'; return; }
62
+ btn.disabled = true;
63
+ msg.className = 'msg'; msg.textContent = '校验中…';
64
+ fetch('/dsh-plus/gate/login', {
65
+ method: 'POST',
66
+ headers: { 'content-type': 'application/json' },
67
+ body: JSON.stringify({ token: token })
68
+ }).then(function (res) {
69
+ if (res.ok) { msg.className = 'msg ok'; msg.textContent = '校验通过,正在进入…'; location.reload(); }
70
+ else if (res.status === 429) { msg.className = 'msg err'; msg.textContent = '失败次数过多,请稍后再试'; btn.disabled = false; }
71
+ else { msg.className = 'msg err'; msg.textContent = '令牌无效'; btn.disabled = false; }
72
+ }).catch(function () { msg.className = 'msg err'; msg.textContent = '网络错误,请重试'; btn.disabled = false; });
73
+ });
74
+ </script>
75
+ </body>
76
+ </html>`
77
+ }
package/src/ns.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
3
+ * 服务端经 settingsNamespace() 品牌化后使用(config.ts),浏览器半将其作为
4
+ * settings.plugin.item keyed 槽位的 key —— 同一字面量两处共用,防止漂移。
5
+ * @module access-gate/ns
6
+ */
7
+ export const SETTINGS_NS = 'dsh-plus-access-gate'
package/src/routes.ts ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * 自有 HTTP 端点(/dsh-plus/gate 前缀,围栏豁免路径):
3
+ * - POST /dsh-plus/gate/login — token 换 cookie(HttpOnly + SameSite=Strict)
4
+ * - GET /dsh-plus/gate/status — 当前客户端判定状态(登录页与卡片诊断)
5
+ *
6
+ * 登录失败节流:按客户端 IP 记录失败次数与冷却截止(惰性清理,无定时器)。
7
+ * handler 工厂与路由注册分离,测试直接驱动 handler(禁起真实服务器)。
8
+ * @module access-gate/routes
9
+ */
10
+
11
+ import { createHash, timingSafeEqual } from 'node:crypto'
12
+ import type { IncomingMessage, ServerResponse } from 'node:http'
13
+
14
+ import type { Context } from '@deepseek-ai/cordis'
15
+ import type {} from '@deepseek-ai/dsh-host-webserver'
16
+
17
+ import type { AccessGateConfig } from './config.ts'
18
+ import { decideGate } from './decision.ts'
19
+
20
+ export const GATE_ROUTE = '/dsh-plus/gate'
21
+
22
+ /** 节流状态(纯数据结构,可注入 clock 测试)。 */
23
+ export interface ThrottleState {
24
+ fails: Map<string, { count: number; until: number }>
25
+ }
26
+
27
+ export function createThrottleState(): ThrottleState {
28
+ return { fails: new Map() }
29
+ }
30
+
31
+ export interface RouteDeps {
32
+ config: () => AccessGateConfig
33
+ /** 客户端 IP 还原(与围栏同一规则);缺省时从请求自身推导。 */
34
+ resolveClientIp?: (req: IncomingMessage) => string | undefined
35
+ throttle?: ThrottleState
36
+ clock?: () => number
37
+ onError?: (message: string) => void
38
+ }
39
+
40
+ function sendJson(
41
+ res: ServerResponse,
42
+ status: number,
43
+ body: unknown,
44
+ headers?: Record<string, string>,
45
+ ): void {
46
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...headers })
47
+ res.end(JSON.stringify(body))
48
+ }
49
+
50
+ function readBody(req: IncomingMessage): Promise<string> {
51
+ return new Promise((resolve, reject) => {
52
+ const chunks: Buffer[] = []
53
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
54
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
55
+ req.on('error', reject)
56
+ })
57
+ }
58
+
59
+ /** 解析 JSON body;非对象或解析失败返回 null。 */
60
+ async function readJson(req: IncomingMessage): Promise<Record<string, unknown> | null> {
61
+ if (!req.headers['content-type']?.includes('application/json')) return null
62
+ try {
63
+ const parsed: unknown = JSON.parse(await readBody(req))
64
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
65
+ ? (parsed as Record<string, unknown>)
66
+ : null
67
+ } catch {
68
+ return null
69
+ }
70
+ }
71
+
72
+ /** 默认客户端 IP 还原:XFF 最左优先,否则 socket 地址。 */
73
+ function clientIpOf(req: IncomingMessage): string | undefined {
74
+ const forwarded = req.headers['x-forwarded-for']
75
+ if (typeof forwarded === 'string' && forwarded.trim() !== '') {
76
+ const first = forwarded.split(',')[0]?.trim()
77
+ if (first) return first
78
+ }
79
+ const remote = req.socket?.remoteAddress
80
+ if (remote === undefined || remote === '') return undefined
81
+ return remote.startsWith('::ffff:') ? remote.slice(7) : remote
82
+ }
83
+
84
+ /** 是否处于冷却期;冷却期满的条目惰性清除(计数中条目保留)。 */
85
+ function throttled(state: ThrottleState, ip: string, now: number): boolean {
86
+ const entry = state.fails.get(ip)
87
+ if (entry === undefined) return false
88
+ if (entry.until > now) return true
89
+ if (entry.until > 0) state.fails.delete(ip)
90
+ return false
91
+ }
92
+
93
+ /** 记一次失败;达到阈值则进入冷却。 */
94
+ function recordFailure(
95
+ state: ThrottleState,
96
+ ip: string,
97
+ limit: number,
98
+ cooldownMs: number,
99
+ now: number,
100
+ ): void {
101
+ const entry = state.fails.get(ip) ?? { count: 0, until: 0 }
102
+ entry.count += 1
103
+ if (entry.count >= limit) {
104
+ entry.until = now + cooldownMs
105
+ entry.count = 0
106
+ }
107
+ state.fails.set(ip, entry)
108
+ }
109
+
110
+ export function createGateRoutes(
111
+ deps: RouteDeps,
112
+ ): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
113
+ const throttle = deps.throttle ?? createThrottleState()
114
+ const clock = deps.clock ?? Date.now
115
+ const resolveIp = deps.resolveClientIp ?? clientIpOf
116
+
117
+ const handleLogin = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
118
+ const config = deps.config()
119
+ const body = await readJson(req)
120
+ if (body === undefined || body === null || typeof body.token !== 'string') {
121
+ sendJson(res, 400, { error: 'invalid json body' })
122
+ return
123
+ }
124
+ const ip = resolveIp(req) ?? 'unknown'
125
+ const now = clock()
126
+ if (throttled(throttle, ip, now)) {
127
+ sendJson(res, 429, { error: 'too many attempts' })
128
+ return
129
+ }
130
+ const ok = verifyToken(body.token, config.token)
131
+ if (!ok) {
132
+ recordFailure(throttle, ip, config.loginFailLimit, config.loginCooldownMs, now)
133
+ sendJson(res, 403, { error: 'invalid token' })
134
+ return
135
+ }
136
+ throttle.fails.delete(ip)
137
+ const maxAge = config.cookieMaxAgeHours * 3600
138
+ const cookie = `dsh_gate=${body.token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`
139
+ sendJson(res, 200, { ok: true }, { 'set-cookie': cookie })
140
+ }
141
+
142
+ const handleStatus = (req: IncomingMessage, res: ServerResponse): void => {
143
+ const config = deps.config()
144
+ const decision = decideGate(
145
+ {
146
+ url: req.url ?? '/',
147
+ method: req.method ?? 'GET',
148
+ headers: req.headers,
149
+ remoteAddress: req.socket?.remoteAddress,
150
+ },
151
+ config,
152
+ )
153
+ sendJson(res, 200, {
154
+ enabled: config.enabled,
155
+ verdict: decision.verdict,
156
+ clientIp: decision.clientIp ?? null,
157
+ reason: decision.reason ?? null,
158
+ invalidEntries: decision.invalidEntries ?? [],
159
+ tokenConfigured: config.token !== '',
160
+ allowedCount: config.allowedIps.length,
161
+ })
162
+ }
163
+
164
+ return async (req, res) => {
165
+ try {
166
+ const sub = new URL(req.url ?? '/', 'http://localhost').pathname.slice(GATE_ROUTE.length)
167
+ const method = req.method ?? 'GET'
168
+ if (sub === '/login') {
169
+ if (method !== 'POST') return sendJson(res, 405, { error: 'POST only' })
170
+ return await handleLogin(req, res)
171
+ }
172
+ if (sub === '/status') {
173
+ if (method !== 'GET') return sendJson(res, 405, { error: 'GET only' })
174
+ return handleStatus(req, res)
175
+ }
176
+ sendJson(res, 404, { error: 'unknown endpoint' })
177
+ } catch (error) {
178
+ const message = error instanceof Error ? error.message : String(error)
179
+ deps.onError?.(`gate api failed: ${message}`)
180
+ if (!res.headersSent) sendJson(res, 500, { error: message })
181
+ else res.end()
182
+ }
183
+ }
184
+ }
185
+
186
+ /** token 校验(sha256 + 常时比较);空 token 通道关闭恒 false。 */
187
+ function verifyToken(candidate: string, expected: string): boolean {
188
+ if (candidate === '' || expected === '') return false
189
+ const a = createHash('sha256').update(candidate).digest()
190
+ const b = createHash('sha256').update(expected).digest()
191
+ return timingSafeEqual(a, b)
192
+ }
193
+
194
+ /** 注册路由(webServer 缺失时由调用方保证不调用)。 */
195
+ export function registerGateRoutes(ctx: Context, deps: RouteDeps): void {
196
+ ctx.webServer.register({
197
+ kind: 'prefix',
198
+ path: GATE_ROUTE,
199
+ handler: createGateRoutes(deps),
200
+ })
201
+ }