@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.
- package/LICENSE +21 -0
- package/lib/client.js +718 -0
- package/lib/index.d.ts +40 -0
- package/lib/index.js +663 -0
- package/package.json +63 -0
- package/src/client/card.tsx +432 -0
- package/src/client/client.ts +156 -0
- package/src/client/i18n.ts +103 -0
- package/src/client/scope.ts +53 -0
- package/src/client/styles.ts +74 -0
- package/src/config.ts +61 -0
- package/src/decision.ts +137 -0
- package/src/index.ts +73 -0
- package/src/interceptor.ts +215 -0
- package/src/ip.ts +143 -0
- package/src/login-page.ts +77 -0
- package/src/ns.ts +7 -0
- package/src/routes.ts +201 -0
|
@@ -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
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
|
+
}
|