@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
package/lib/index.js
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
2
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
5
|
+
//#endregion
|
|
6
|
+
//#region src/config.ts
|
|
7
|
+
/**
|
|
8
|
+
* 配置单一事实源:cordis 行级 Config(组合默认值,dev/prod patch 层可覆盖)
|
|
9
|
+
* 与 settings namespace(用户层,经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml)
|
|
10
|
+
* 共用同一 schemastery schema(notify-email 同款约定)。
|
|
11
|
+
* token 标记 secret 角色:任何读取通道不得回传明文。
|
|
12
|
+
* @module access-gate/config
|
|
13
|
+
*/
|
|
14
|
+
/** settings 命名空间;webui 配置卡片与插件运行期读取同一份(字面量见 ./ns.ts)。 */
|
|
15
|
+
const SETTINGS_NS = settingsNamespace("dsh-plus-access-gate");
|
|
16
|
+
const Config = z.object({
|
|
17
|
+
enabled: z.boolean().description("访问围栏总开关(false = 一切放行,等价插件缺席)").default(false),
|
|
18
|
+
token: z.string().role("secret").description("访问令牌;空字符串 = token 通道关闭。经登录页输入后以 HttpOnly cookie 持有").default(""),
|
|
19
|
+
allowedIps: z.array(z.string()).description("放行 IP 白名单:精确 IP 或 CIDR(IPv4/IPv6 皆可)").default([]),
|
|
20
|
+
trustForwardedFor: z.boolean().description("是否信任 x-forwarded-for 还原真实客户端 IP(仅当入口代理强制覆盖该头时开启;tailscale serve 满足)").default(true),
|
|
21
|
+
cookieMaxAgeHours: z.natural().max(8760).description("登录 cookie 有效期(小时)").default(720),
|
|
22
|
+
loginFailLimit: z.natural().description("登录失败节流阈值(每 IP 冷却窗口内允许的失败次数)").default(10),
|
|
23
|
+
loginCooldownMs: z.natural().description("登录失败节流冷却毫秒数").default(6e4)
|
|
24
|
+
});
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/ip.ts
|
|
27
|
+
/** 解析 IPv4 十进制点分字面量;非法返回 null。 */
|
|
28
|
+
function parseIpv4(text) {
|
|
29
|
+
const parts = text.split(".");
|
|
30
|
+
if (parts.length !== 4) return null;
|
|
31
|
+
let value = 0n;
|
|
32
|
+
for (const part of parts) {
|
|
33
|
+
if (part.length === 0 || part.length > 3 || !/^\d+$/.test(part)) return null;
|
|
34
|
+
const octet = Number(part);
|
|
35
|
+
if (octet > 255) return null;
|
|
36
|
+
value = value << 8n | BigInt(octet);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
/** 把一个组(4 位十六进制)压入累积值;越界(>0xffff)返回 null。 */
|
|
41
|
+
function pushGroup(value, group) {
|
|
42
|
+
if (group.length === 0 || group.length > 4 || !/^[0-9a-fA-F]+$/.test(group)) return null;
|
|
43
|
+
return value << 16n | BigInt(Number.parseInt(group, 16));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 解析 IPv6(含 `::` 压缩与 `::1.2.3.4` 形态的 v4 尾巴;拒绝 zone / 映射前缀)。
|
|
47
|
+
* 算法:按 `::` 切成左右两半 → 各自逐组展开(末组可为点分 v4,占 2 组)
|
|
48
|
+
* → 数值拼接,右半在低位,中间空洞补零。
|
|
49
|
+
*/
|
|
50
|
+
function parseIpv6(text) {
|
|
51
|
+
if (text.includes("%") || text.startsWith("::ffff:")) return null;
|
|
52
|
+
const gap = text.indexOf("::");
|
|
53
|
+
if (gap !== -1 && text.indexOf("::", gap + 1) !== -1) return null;
|
|
54
|
+
const headText = gap === -1 ? text : text.slice(0, gap);
|
|
55
|
+
const tailText = gap === -1 ? "" : text.slice(gap + 2);
|
|
56
|
+
const expandHalf = (half, atStart) => {
|
|
57
|
+
if (half === "") return {
|
|
58
|
+
value: 0n,
|
|
59
|
+
groups: 0
|
|
60
|
+
};
|
|
61
|
+
const rawGroups = half.split(":");
|
|
62
|
+
if (rawGroups.some((group) => group === "")) return null;
|
|
63
|
+
const dotted = rawGroups.findIndex((group) => group.includes("."));
|
|
64
|
+
if (dotted !== -1 && !(dotted === rawGroups.length - 1 && !atStart)) return null;
|
|
65
|
+
let value = 0n;
|
|
66
|
+
let groups = 0;
|
|
67
|
+
for (const group of rawGroups) if (group.includes(".")) {
|
|
68
|
+
const v4 = parseIpv4(group);
|
|
69
|
+
if (v4 === null) return null;
|
|
70
|
+
value = value << 32n | v4;
|
|
71
|
+
groups += 2;
|
|
72
|
+
} else {
|
|
73
|
+
const next = pushGroup(value, group);
|
|
74
|
+
if (next === null) return null;
|
|
75
|
+
value = next;
|
|
76
|
+
groups += 1;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
value,
|
|
80
|
+
groups
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
const head = expandHalf(headText, true);
|
|
84
|
+
const tail = expandHalf(tailText, false);
|
|
85
|
+
if (head === null || tail === null) return null;
|
|
86
|
+
const total = head.groups + tail.groups;
|
|
87
|
+
if (total > 8) return null;
|
|
88
|
+
if (gap === -1 && total !== 8) return null;
|
|
89
|
+
if (gap !== -1 && total >= 8) return null;
|
|
90
|
+
const holeAndTail = BigInt(8 - head.groups) * 16n;
|
|
91
|
+
return head.value << holeAndTail | tail.value;
|
|
92
|
+
}
|
|
93
|
+
/** 解析任意 IP 字面量;非法返回 null。 */
|
|
94
|
+
function parseIp(text) {
|
|
95
|
+
const trimmed = text.trim();
|
|
96
|
+
if (trimmed.includes(":")) {
|
|
97
|
+
const value = parseIpv6(trimmed);
|
|
98
|
+
return value === null ? null : {
|
|
99
|
+
value,
|
|
100
|
+
bits: 128
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const value = parseIpv4(trimmed);
|
|
104
|
+
return value === null ? null : {
|
|
105
|
+
value,
|
|
106
|
+
bits: 32
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** 归一化 CIDR;非法(IP 解析失败 / 前缀越界)返回 null。 */
|
|
110
|
+
function parseCidr(text) {
|
|
111
|
+
const slash = text.indexOf("/");
|
|
112
|
+
if (slash === -1) return null;
|
|
113
|
+
const ip = parseIp(text.slice(0, slash));
|
|
114
|
+
const prefixText = text.slice(slash + 1);
|
|
115
|
+
if (ip === null || !/^\d{1,3}$/.test(prefixText)) return null;
|
|
116
|
+
const prefix = Number(prefixText);
|
|
117
|
+
if (prefix > ip.bits) return null;
|
|
118
|
+
return {
|
|
119
|
+
ip,
|
|
120
|
+
prefix
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** 同族且数值相等。 */
|
|
124
|
+
function ipEquals(a, b) {
|
|
125
|
+
return a.bits === b.bits && a.value === b.value;
|
|
126
|
+
}
|
|
127
|
+
/** 判断 IP 是否落入 CIDR;跨族(v4 vs v6)恒 false。 */
|
|
128
|
+
function ipInCidr(ip, cidr) {
|
|
129
|
+
if (ip.bits !== cidr.ip.bits) return false;
|
|
130
|
+
const shift = BigInt(ip.bits - cidr.prefix);
|
|
131
|
+
return ip.value >> shift === cidr.ip.value >> shift;
|
|
132
|
+
}
|
|
133
|
+
/** 解析白名单条目(精确 IP 或 CIDR);返回可判定函数,非法条目返回 null。 */
|
|
134
|
+
function parseAllowEntry(text) {
|
|
135
|
+
const trimmed = text.trim();
|
|
136
|
+
const cidr = parseCidr(trimmed);
|
|
137
|
+
if (cidr !== null) return (ip) => ipInCidr(ip, cidr);
|
|
138
|
+
const exact = parseIp(trimmed);
|
|
139
|
+
if (exact !== null) return (ip) => ipEquals(ip, exact);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
/** 判断 remoteAddress 是否 loopback(node 形态:`127.x.x.x` / `::1` / `::ffff:127.x.x.x`)。 */
|
|
143
|
+
function isLoopbackAddress(remoteAddress) {
|
|
144
|
+
if (remoteAddress === void 0) return false;
|
|
145
|
+
const bare = remoteAddress.startsWith("::ffff:") ? remoteAddress.slice(7) : remoteAddress;
|
|
146
|
+
if (bare === "::1") return true;
|
|
147
|
+
const parsed = parseIp(bare);
|
|
148
|
+
if (parsed === null || parsed.bits !== 32) return false;
|
|
149
|
+
return parsed.value >= 2130706432n && parsed.value <= 2147483646n;
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/decision.ts
|
|
153
|
+
/**
|
|
154
|
+
* 围栏判定核心:`decideGate` 纯函数。
|
|
155
|
+
*
|
|
156
|
+
* 信任模型(决策记录详见 docs/README.md):
|
|
157
|
+
* - dsh web 仅绑 127.0.0.1,远程流量必经 tailscale serve → dsh-proxy 两跳,
|
|
158
|
+
* serve 会**覆盖**(而非追加)x-forwarded-for,客户端无法伪造;
|
|
159
|
+
* - 能直发 loopback 或伪造 XFF 的只有本机进程——与 dsh 官方 /api 围栏的
|
|
160
|
+
* loopback 信任边界一致,本机直连视为可信管理通道;
|
|
161
|
+
* - 因此 XFF 存在且 trustForwardedFor=true 时取最左条目为真实客户端 IP。
|
|
162
|
+
*
|
|
163
|
+
* token 比较使用 sha256 摘要 + timingSafeEqual 常时比较。
|
|
164
|
+
* @module access-gate/decision
|
|
165
|
+
*/
|
|
166
|
+
const GATE_COOKIE = "dsh_gate";
|
|
167
|
+
/** sha256 摘要为定长 32 字节,常时比较的前提。 */
|
|
168
|
+
function tokenMatches(candidate, expected) {
|
|
169
|
+
if (candidate === "" || expected === "") return false;
|
|
170
|
+
const a = createHash("sha256").update(candidate).digest();
|
|
171
|
+
const b = createHash("sha256").update(expected).digest();
|
|
172
|
+
return timingSafeEqual(a, b);
|
|
173
|
+
}
|
|
174
|
+
/** 从 cookie 头解析指定名字的值(手写最小解析,零依赖)。 */
|
|
175
|
+
function readCookie(headers, name) {
|
|
176
|
+
const raw = headers.cookie;
|
|
177
|
+
if (typeof raw !== "string") return void 0;
|
|
178
|
+
for (const part of raw.split(";")) {
|
|
179
|
+
const eq = part.indexOf("=");
|
|
180
|
+
if (eq === -1) continue;
|
|
181
|
+
if (part.slice(0, eq).trim() === name) {
|
|
182
|
+
const value = part.slice(eq + 1).trim();
|
|
183
|
+
return value === "" ? void 0 : value;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** 取 XFF 最左条目(serve 单跳覆盖语义下即真实客户端 IP)。 */
|
|
188
|
+
function forwardedIp(headers) {
|
|
189
|
+
const raw = headers["x-forwarded-for"];
|
|
190
|
+
if (typeof raw !== "string" || raw.trim() === "") return void 0;
|
|
191
|
+
const first = raw.split(",")[0]?.trim() ?? "";
|
|
192
|
+
return first === "" ? void 0 : first;
|
|
193
|
+
}
|
|
194
|
+
/** 浏览器导航请求判定:GET + accept 含 text/html(需要返回登录页而非 403)。 */
|
|
195
|
+
function isNavigationRequest(req) {
|
|
196
|
+
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
197
|
+
const accept = req.headers.accept;
|
|
198
|
+
return typeof accept === "string" && accept.includes("text/html");
|
|
199
|
+
}
|
|
200
|
+
function compileAllowlist(entries) {
|
|
201
|
+
const matchers = entries.flatMap((entry) => {
|
|
202
|
+
const matcher = parseAllowEntry(entry);
|
|
203
|
+
return matcher === null ? [] : [matcher];
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
match: (ip) => matchers.some((matcher) => matcher(ip)),
|
|
207
|
+
invalid: entries.filter((entry) => parseAllowEntry(entry) === null)
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 围栏判定(纯函数,除 token 哈希外无任何副作用与 IO)。
|
|
212
|
+
* 逻辑次序:豁免路径由调用方前置处理;enabled=false 直通;确定有效来源
|
|
213
|
+
* (受信 XFF 优先,否则 remoteAddress);来源为 loopback 直连 → 放行;
|
|
214
|
+
* 否则按白名单/token 判定;无来源 → fail-closed。
|
|
215
|
+
*
|
|
216
|
+
* loopback 语义:XFF 被信任时,存在 XFF 即代表请求经代理(serve→proxy→dsh
|
|
217
|
+
* 全链 loopback,真实客户端只在 XFF 里)——remoteAddress 是 loopback 也不
|
|
218
|
+
* 放行,必须按 XFF 判定;XFF 不被信任时,来源就是 remoteAddress,
|
|
219
|
+
* loopback 即本机直连 → 放行(防锁死兜底优先)。
|
|
220
|
+
*/
|
|
221
|
+
function decideGate(req, policy) {
|
|
222
|
+
if (!policy.enabled) return {
|
|
223
|
+
verdict: "pass",
|
|
224
|
+
reason: "local"
|
|
225
|
+
};
|
|
226
|
+
const forwarded = forwardedIp(req.headers);
|
|
227
|
+
const trustedForwarded = policy.trustForwardedFor ? forwarded : void 0;
|
|
228
|
+
const clientIpText = trustedForwarded ?? req.remoteAddress;
|
|
229
|
+
if (clientIpText === void 0) return {
|
|
230
|
+
verdict: "block",
|
|
231
|
+
reason: void 0
|
|
232
|
+
};
|
|
233
|
+
if (trustedForwarded === void 0 && isLoopbackAddress(req.remoteAddress)) return {
|
|
234
|
+
verdict: "pass",
|
|
235
|
+
reason: "local"
|
|
236
|
+
};
|
|
237
|
+
const allowlist = compileAllowlist(policy.allowedIps);
|
|
238
|
+
const clientIp = parseIp(clientIpText);
|
|
239
|
+
const allowedByIp = clientIp !== null && allowlist.match(clientIp);
|
|
240
|
+
const cookieToken = readCookie(req.headers, GATE_COOKIE);
|
|
241
|
+
const allowedByToken = cookieToken !== void 0 && tokenMatches(cookieToken, policy.token);
|
|
242
|
+
const decision = {
|
|
243
|
+
verdict: allowedByIp || allowedByToken ? "pass" : isNavigationRequest(req) ? "login" : "block",
|
|
244
|
+
clientIp: clientIpText
|
|
245
|
+
};
|
|
246
|
+
if (allowedByToken) decision.reason = "token";
|
|
247
|
+
else if (allowedByIp) decision.reason = "allowed-ip";
|
|
248
|
+
if (allowlist.invalid.length > 0) decision.invalidEntries = allowlist.invalid;
|
|
249
|
+
return decision;
|
|
250
|
+
}
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/login-page.ts
|
|
253
|
+
/**
|
|
254
|
+
* 登录页:围栏拦截浏览器导航请求时返回的内联 HTML。
|
|
255
|
+
* 自包含(无外部静态资源依赖)、中文、vanilla JS——输入 token → 调
|
|
256
|
+
* /dsh-plus/gate/login → 成功后 reload 放行。
|
|
257
|
+
* @module access-gate/login-page
|
|
258
|
+
*/
|
|
259
|
+
function renderLoginPage() {
|
|
260
|
+
return `<!DOCTYPE html>
|
|
261
|
+
<html lang="zh-CN">
|
|
262
|
+
<head>
|
|
263
|
+
<meta charset="utf-8">
|
|
264
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
265
|
+
<title>访问校验</title>
|
|
266
|
+
<style>
|
|
267
|
+
:root { color-scheme: dark; }
|
|
268
|
+
body {
|
|
269
|
+
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
270
|
+
background: #16181d; color: #e6e8ec;
|
|
271
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
|
272
|
+
}
|
|
273
|
+
.card {
|
|
274
|
+
width: min(92vw, 360px); padding: 32px 28px; border-radius: 14px;
|
|
275
|
+
background: #1e2128; border: 1px solid #2e323c; box-shadow: 0 8px 32px rgba(0,0,0,.4);
|
|
276
|
+
box-sizing: border-box;
|
|
277
|
+
}
|
|
278
|
+
h1 { margin: 0 0 6px; font-size: 20px; font-weight: 600; }
|
|
279
|
+
p { margin: 0 0 22px; font-size: 13px; color: #9aa1ad; line-height: 1.6; }
|
|
280
|
+
input {
|
|
281
|
+
width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 8px;
|
|
282
|
+
border: 1px solid #3a3f4b; background: #16181d; color: #e6e8ec; font-size: 15px;
|
|
283
|
+
outline: none;
|
|
284
|
+
}
|
|
285
|
+
input:focus { border-color: #5b8cff; }
|
|
286
|
+
button {
|
|
287
|
+
width: 100%; margin-top: 14px; padding: 10px 0; border: 0; border-radius: 8px;
|
|
288
|
+
background: #3d6dff; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
|
|
289
|
+
}
|
|
290
|
+
button:disabled { opacity: .6; cursor: default; }
|
|
291
|
+
.msg { min-height: 18px; margin-top: 12px; font-size: 13px; text-align: center; }
|
|
292
|
+
.err { color: #ff7a76; }
|
|
293
|
+
.ok { color: #63d17c; }
|
|
294
|
+
</style>
|
|
295
|
+
</head>
|
|
296
|
+
<body>
|
|
297
|
+
<form class="card" id="form">
|
|
298
|
+
<h1>此页面受访问控制保护</h1>
|
|
299
|
+
<p>当前网络不在放行名单内。输入访问令牌以继续;或使用名单内的设备/网络访问。</p>
|
|
300
|
+
<input id="token" type="password" placeholder="访问令牌" autocomplete="off" autofocus>
|
|
301
|
+
<button id="btn" type="submit">继续访问</button>
|
|
302
|
+
<div class="msg" id="msg"></div>
|
|
303
|
+
</form>
|
|
304
|
+
<script>
|
|
305
|
+
var form = document.getElementById('form');
|
|
306
|
+
var input = document.getElementById('token');
|
|
307
|
+
var btn = document.getElementById('btn');
|
|
308
|
+
var msg = document.getElementById('msg');
|
|
309
|
+
form.addEventListener('submit', function (event) {
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
var token = input.value;
|
|
312
|
+
if (!token) { msg.className = 'msg err'; msg.textContent = '请输入访问令牌'; return; }
|
|
313
|
+
btn.disabled = true;
|
|
314
|
+
msg.className = 'msg'; msg.textContent = '校验中…';
|
|
315
|
+
fetch('/dsh-plus/gate/login', {
|
|
316
|
+
method: 'POST',
|
|
317
|
+
headers: { 'content-type': 'application/json' },
|
|
318
|
+
body: JSON.stringify({ token: token })
|
|
319
|
+
}).then(function (res) {
|
|
320
|
+
if (res.ok) { msg.className = 'msg ok'; msg.textContent = '校验通过,正在进入…'; location.reload(); }
|
|
321
|
+
else if (res.status === 429) { msg.className = 'msg err'; msg.textContent = '失败次数过多,请稍后再试'; btn.disabled = false; }
|
|
322
|
+
else { msg.className = 'msg err'; msg.textContent = '令牌无效'; btn.disabled = false; }
|
|
323
|
+
}).catch(function () { msg.className = 'msg err'; msg.textContent = '网络错误,请重试'; btn.disabled = false; });
|
|
324
|
+
});
|
|
325
|
+
<\/script>
|
|
326
|
+
</body>
|
|
327
|
+
</html>`;
|
|
328
|
+
}
|
|
329
|
+
//#endregion
|
|
330
|
+
//#region src/routes.ts
|
|
331
|
+
/**
|
|
332
|
+
* 自有 HTTP 端点(/dsh-plus/gate 前缀,围栏豁免路径):
|
|
333
|
+
* - POST /dsh-plus/gate/login — token 换 cookie(HttpOnly + SameSite=Strict)
|
|
334
|
+
* - GET /dsh-plus/gate/status — 当前客户端判定状态(登录页与卡片诊断)
|
|
335
|
+
*
|
|
336
|
+
* 登录失败节流:按客户端 IP 记录失败次数与冷却截止(惰性清理,无定时器)。
|
|
337
|
+
* handler 工厂与路由注册分离,测试直接驱动 handler(禁起真实服务器)。
|
|
338
|
+
* @module access-gate/routes
|
|
339
|
+
*/
|
|
340
|
+
const GATE_ROUTE = "/dsh-plus/gate";
|
|
341
|
+
function createThrottleState() {
|
|
342
|
+
return { fails: /* @__PURE__ */ new Map() };
|
|
343
|
+
}
|
|
344
|
+
function sendJson(res, status, body, headers) {
|
|
345
|
+
res.writeHead(status, {
|
|
346
|
+
"content-type": "application/json; charset=utf-8",
|
|
347
|
+
...headers
|
|
348
|
+
});
|
|
349
|
+
res.end(JSON.stringify(body));
|
|
350
|
+
}
|
|
351
|
+
function readBody(req) {
|
|
352
|
+
return new Promise((resolve, reject) => {
|
|
353
|
+
const chunks = [];
|
|
354
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
355
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
356
|
+
req.on("error", reject);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
/** 解析 JSON body;非对象或解析失败返回 null。 */
|
|
360
|
+
async function readJson(req) {
|
|
361
|
+
if (!req.headers["content-type"]?.includes("application/json")) return null;
|
|
362
|
+
try {
|
|
363
|
+
const parsed = JSON.parse(await readBody(req));
|
|
364
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** 默认客户端 IP 还原:XFF 最左优先,否则 socket 地址。 */
|
|
370
|
+
function clientIpOf(req) {
|
|
371
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
372
|
+
if (typeof forwarded === "string" && forwarded.trim() !== "") {
|
|
373
|
+
const first = forwarded.split(",")[0]?.trim();
|
|
374
|
+
if (first) return first;
|
|
375
|
+
}
|
|
376
|
+
const remote = req.socket?.remoteAddress;
|
|
377
|
+
if (remote === void 0 || remote === "") return void 0;
|
|
378
|
+
return remote.startsWith("::ffff:") ? remote.slice(7) : remote;
|
|
379
|
+
}
|
|
380
|
+
/** 是否处于冷却期;冷却期满的条目惰性清除(计数中条目保留)。 */
|
|
381
|
+
function throttled(state, ip, now) {
|
|
382
|
+
const entry = state.fails.get(ip);
|
|
383
|
+
if (entry === void 0) return false;
|
|
384
|
+
if (entry.until > now) return true;
|
|
385
|
+
if (entry.until > 0) state.fails.delete(ip);
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
/** 记一次失败;达到阈值则进入冷却。 */
|
|
389
|
+
function recordFailure(state, ip, limit, cooldownMs, now) {
|
|
390
|
+
const entry = state.fails.get(ip) ?? {
|
|
391
|
+
count: 0,
|
|
392
|
+
until: 0
|
|
393
|
+
};
|
|
394
|
+
entry.count += 1;
|
|
395
|
+
if (entry.count >= limit) {
|
|
396
|
+
entry.until = now + cooldownMs;
|
|
397
|
+
entry.count = 0;
|
|
398
|
+
}
|
|
399
|
+
state.fails.set(ip, entry);
|
|
400
|
+
}
|
|
401
|
+
function createGateRoutes(deps) {
|
|
402
|
+
const throttle = deps.throttle ?? createThrottleState();
|
|
403
|
+
const clock = deps.clock ?? Date.now;
|
|
404
|
+
const resolveIp = deps.resolveClientIp ?? clientIpOf;
|
|
405
|
+
const handleLogin = async (req, res) => {
|
|
406
|
+
const config = deps.config();
|
|
407
|
+
const body = await readJson(req);
|
|
408
|
+
if (body === void 0 || body === null || typeof body.token !== "string") {
|
|
409
|
+
sendJson(res, 400, { error: "invalid json body" });
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const ip = resolveIp(req) ?? "unknown";
|
|
413
|
+
const now = clock();
|
|
414
|
+
if (throttled(throttle, ip, now)) {
|
|
415
|
+
sendJson(res, 429, { error: "too many attempts" });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (!verifyToken(body.token, config.token)) {
|
|
419
|
+
recordFailure(throttle, ip, config.loginFailLimit, config.loginCooldownMs, now);
|
|
420
|
+
sendJson(res, 403, { error: "invalid token" });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
throttle.fails.delete(ip);
|
|
424
|
+
const maxAge = config.cookieMaxAgeHours * 3600;
|
|
425
|
+
sendJson(res, 200, { ok: true }, { "set-cookie": `dsh_gate=${body.token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}` });
|
|
426
|
+
};
|
|
427
|
+
const handleStatus = (req, res) => {
|
|
428
|
+
const config = deps.config();
|
|
429
|
+
const decision = decideGate({
|
|
430
|
+
url: req.url ?? "/",
|
|
431
|
+
method: req.method ?? "GET",
|
|
432
|
+
headers: req.headers,
|
|
433
|
+
remoteAddress: req.socket?.remoteAddress
|
|
434
|
+
}, config);
|
|
435
|
+
sendJson(res, 200, {
|
|
436
|
+
enabled: config.enabled,
|
|
437
|
+
verdict: decision.verdict,
|
|
438
|
+
clientIp: decision.clientIp ?? null,
|
|
439
|
+
reason: decision.reason ?? null,
|
|
440
|
+
invalidEntries: decision.invalidEntries ?? [],
|
|
441
|
+
tokenConfigured: config.token !== "",
|
|
442
|
+
allowedCount: config.allowedIps.length
|
|
443
|
+
});
|
|
444
|
+
};
|
|
445
|
+
return async (req, res) => {
|
|
446
|
+
try {
|
|
447
|
+
const sub = new URL(req.url ?? "/", "http://localhost").pathname.slice(14);
|
|
448
|
+
const method = req.method ?? "GET";
|
|
449
|
+
if (sub === "/login") {
|
|
450
|
+
if (method !== "POST") return sendJson(res, 405, { error: "POST only" });
|
|
451
|
+
return await handleLogin(req, res);
|
|
452
|
+
}
|
|
453
|
+
if (sub === "/status") {
|
|
454
|
+
if (method !== "GET") return sendJson(res, 405, { error: "GET only" });
|
|
455
|
+
return handleStatus(req, res);
|
|
456
|
+
}
|
|
457
|
+
sendJson(res, 404, { error: "unknown endpoint" });
|
|
458
|
+
} catch (error) {
|
|
459
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
460
|
+
deps.onError?.(`gate api failed: ${message}`);
|
|
461
|
+
if (!res.headersSent) sendJson(res, 500, { error: message });
|
|
462
|
+
else res.end();
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/** token 校验(sha256 + 常时比较);空 token 通道关闭恒 false。 */
|
|
467
|
+
function verifyToken(candidate, expected) {
|
|
468
|
+
if (candidate === "" || expected === "") return false;
|
|
469
|
+
const a = createHash("sha256").update(candidate).digest();
|
|
470
|
+
const b = createHash("sha256").update(expected).digest();
|
|
471
|
+
return timingSafeEqual(a, b);
|
|
472
|
+
}
|
|
473
|
+
/** 注册路由(webServer 缺失时由调用方保证不调用)。 */
|
|
474
|
+
function registerGateRoutes(ctx, deps) {
|
|
475
|
+
ctx.webServer.register({
|
|
476
|
+
kind: "prefix",
|
|
477
|
+
path: GATE_ROUTE,
|
|
478
|
+
handler: createGateRoutes(deps)
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region src/interceptor.ts
|
|
483
|
+
/** 是否围栏豁免路径(自有登录/状态端点)。 */
|
|
484
|
+
function isExemptPath(pathname) {
|
|
485
|
+
return pathname === "/dsh-plus/gate" || pathname.startsWith(`/dsh-plus/gate/`);
|
|
486
|
+
}
|
|
487
|
+
function toGateRequest(req) {
|
|
488
|
+
return {
|
|
489
|
+
url: req.url ?? "/",
|
|
490
|
+
method: req.method ?? "GET",
|
|
491
|
+
headers: req.headers,
|
|
492
|
+
remoteAddress: req.socket?.remoteAddress
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
/** WS 升级拒绝:按官方同款 403 原始响应后关闭。 */
|
|
496
|
+
function rejectUpgrade(socket, clientIp, log) {
|
|
497
|
+
socket.end([
|
|
498
|
+
"HTTP/1.1 403 Forbidden",
|
|
499
|
+
"Connection: close",
|
|
500
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
501
|
+
"Content-Length: 9",
|
|
502
|
+
"",
|
|
503
|
+
"forbidden"
|
|
504
|
+
].join("\r\n"));
|
|
505
|
+
log(`blocked upgrade (${clientIp})`);
|
|
506
|
+
}
|
|
507
|
+
/** 单请求围栏判定;放行 true / 拒绝(已写响应)false。 */
|
|
508
|
+
function passHttp(req, res, deps) {
|
|
509
|
+
if (isExemptPath(new URL(req.url ?? "/", "http://x").pathname)) return true;
|
|
510
|
+
const decision = decideGate(toGateRequest(req), deps.config());
|
|
511
|
+
if (decision.verdict === "pass") return true;
|
|
512
|
+
const log = deps.onBlock ?? (() => {});
|
|
513
|
+
if (decision.verdict === "login" && !res.headersSent) {
|
|
514
|
+
res.writeHead(200, {
|
|
515
|
+
"content-type": "text/html; charset=utf-8",
|
|
516
|
+
"cache-control": "no-store"
|
|
517
|
+
});
|
|
518
|
+
res.end(renderLoginPage());
|
|
519
|
+
log(`login page ${req.method ?? "?"} ${req.url ?? "?"}`);
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
if (!res.headersSent) {
|
|
523
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
524
|
+
res.end("forbidden");
|
|
525
|
+
} else res.end();
|
|
526
|
+
log(`blocked ${req.method ?? "?"} ${req.url ?? "?"} (${decision.clientIp ?? "unknown"})`);
|
|
527
|
+
return false;
|
|
528
|
+
}
|
|
529
|
+
/** HTTP handler 包装体:先判定再派发。 */
|
|
530
|
+
function gatedHttp(handler, deps) {
|
|
531
|
+
return async (req, res) => {
|
|
532
|
+
if (isExemptPath(new URL(req.url ?? "/", "http://x").pathname)) return handler(req, res);
|
|
533
|
+
if (!passHttp(req, res, deps)) return;
|
|
534
|
+
return handler(req, res);
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/** WS 升级包装体:拒绝时不进协议协商。 */
|
|
538
|
+
function gatedUpgrade(handler, deps) {
|
|
539
|
+
return (req, socket, head) => {
|
|
540
|
+
const decision = decideGate(toGateRequest(req), deps.config());
|
|
541
|
+
if (decision.verdict === "pass") return handler(req, socket, head);
|
|
542
|
+
rejectUpgrade(socket, decision.clientIp ?? "unknown", deps.onBlock ?? (() => {}));
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* 安装围栏(结构包装 + fail-loud 守卫)。
|
|
547
|
+
* @returns 恢复函数(调用方登记进 ctx.effect)。
|
|
548
|
+
*/
|
|
549
|
+
function installGateInterceptor(ctx, server, deps) {
|
|
550
|
+
assertGateStructure(server);
|
|
551
|
+
const restores = [];
|
|
552
|
+
const originalMatch = server.match.bind(server);
|
|
553
|
+
const gatedMatch = (pathname) => {
|
|
554
|
+
if (isExemptPath(pathname)) return originalMatch(pathname);
|
|
555
|
+
if (!deps.config().enabled) return originalMatch(pathname);
|
|
556
|
+
const route = originalMatch(pathname);
|
|
557
|
+
if (route === void 0) return void 0;
|
|
558
|
+
return {
|
|
559
|
+
...route,
|
|
560
|
+
handler: gatedHttp(route.handler, deps)
|
|
561
|
+
};
|
|
562
|
+
};
|
|
563
|
+
server.match = gatedMatch;
|
|
564
|
+
restores.push({ undo: () => {
|
|
565
|
+
server.match = originalMatch;
|
|
566
|
+
} });
|
|
567
|
+
const wrapFallback = (handler) => gatedHttp(handler, deps);
|
|
568
|
+
const originalFallback = server.fallback;
|
|
569
|
+
let fallbackReplaced = false;
|
|
570
|
+
if (originalFallback !== void 0) {
|
|
571
|
+
server.fallback = wrapFallback(originalFallback);
|
|
572
|
+
fallbackReplaced = true;
|
|
573
|
+
}
|
|
574
|
+
const originalRegisterFallback = server.registerFallback.bind(server);
|
|
575
|
+
const patchedRegisterFallback = (handler) => originalRegisterFallback(wrapFallback(handler));
|
|
576
|
+
server.registerFallback = patchedRegisterFallback;
|
|
577
|
+
restores.push({ undo: () => {
|
|
578
|
+
server.registerFallback = originalRegisterFallback;
|
|
579
|
+
if (fallbackReplaced) server.fallback = originalFallback;
|
|
580
|
+
} });
|
|
581
|
+
const wrapped = /* @__PURE__ */ new Map();
|
|
582
|
+
const wrapUpgradeRoute = (route) => {
|
|
583
|
+
wrapped.set(route, route.handler);
|
|
584
|
+
route.handler = gatedUpgrade(route.handler, deps);
|
|
585
|
+
};
|
|
586
|
+
for (const route of server.upgrades.values()) wrapUpgradeRoute(route);
|
|
587
|
+
const originalRegisterUpgrade = server.registerUpgrade.bind(server);
|
|
588
|
+
const patchedRegisterUpgrade = (route) => {
|
|
589
|
+
const disposer = originalRegisterUpgrade(route);
|
|
590
|
+
if (server.upgrades.get(route.path) === route) wrapUpgradeRoute(route);
|
|
591
|
+
return disposer;
|
|
592
|
+
};
|
|
593
|
+
server.registerUpgrade = patchedRegisterUpgrade;
|
|
594
|
+
restores.push({ undo: () => {
|
|
595
|
+
server.registerUpgrade = originalRegisterUpgrade;
|
|
596
|
+
for (const [route, handler] of wrapped) route.handler = handler;
|
|
597
|
+
wrapped.clear();
|
|
598
|
+
} });
|
|
599
|
+
const undo = () => {
|
|
600
|
+
for (let i = restores.length - 1; i >= 0; i -= 1) restores[i].undo();
|
|
601
|
+
};
|
|
602
|
+
return undo;
|
|
603
|
+
}
|
|
604
|
+
/** 结构守卫:字段形状不符即抛错(fail-loud)。 */
|
|
605
|
+
function assertGateStructure(server) {
|
|
606
|
+
if (typeof server.match !== "function") throw new Error("access-gate: webServer.match is not a function (upstream shape changed)");
|
|
607
|
+
if (!(server.upgrades instanceof Map)) throw new Error("access-gate: webServer.upgrades is not a Map (upstream shape changed)");
|
|
608
|
+
if (typeof server.registerFallback !== "function") throw new Error("access-gate: webServer.registerFallback is not a function (upstream shape changed)");
|
|
609
|
+
if (typeof server.registerUpgrade !== "function") throw new Error("access-gate: webServer.registerUpgrade is not a function (upstream shape changed)");
|
|
610
|
+
}
|
|
611
|
+
//#endregion
|
|
612
|
+
//#region src/index.ts
|
|
613
|
+
/**
|
|
614
|
+
* dsh 插件:访问围栏(access-gate)。
|
|
615
|
+
*
|
|
616
|
+
* 为 dsh web 的全部 HTTP/WebSocket 流量提供访问安全校验与访问控制:
|
|
617
|
+
* - 本机直连(loopback 且无 x-forwarded-for)永久放行——管理通道,防锁死;
|
|
618
|
+
* - 远程流量按 tailscale serve 注入的 x-forwarded-for 还原真实客户端 IP,
|
|
619
|
+
* 命中白名单(精确 IP / CIDR,v4+v6)即放行;
|
|
620
|
+
* - 或持有效 token(登录页换取 HttpOnly cookie)放行;
|
|
621
|
+
* - 其余:浏览器导航返回登录页,API / WS 一律 403 / 拒绝升级。
|
|
622
|
+
*
|
|
623
|
+
* 配置:settings 命名空间 dsh-plus-access-gate(webui 配置卡片同一份,
|
|
624
|
+
* 热生效);enabled=false 时完全旁路(等价插件缺席)。
|
|
625
|
+
* 安全边界:结构守卫 fail-loud(upstream 形状变化 → 启动失败 → lifeboat
|
|
626
|
+
* 隔离止损,绝不静默 fail-open)。
|
|
627
|
+
* @module @dsh-plus/access-gate
|
|
628
|
+
*/
|
|
629
|
+
const name = "dsh-plus-access-gate";
|
|
630
|
+
const inject = ["webServer"];
|
|
631
|
+
/** 围栏服务:持有当前配置读取器(settings 用户层热更新经 setSource 注入)。 */
|
|
632
|
+
var AccessGateService = class extends Service {
|
|
633
|
+
current;
|
|
634
|
+
constructor(ctx, config) {
|
|
635
|
+
super(ctx, "accessGate");
|
|
636
|
+
this.current = () => config;
|
|
637
|
+
installSettingsSection(ctx, SETTINGS_NS, Config, config, {
|
|
638
|
+
setSource: (source) => {
|
|
639
|
+
this.current = source;
|
|
640
|
+
},
|
|
641
|
+
onChange: () => {}
|
|
642
|
+
});
|
|
643
|
+
const logger = ctx.logger("access-gate");
|
|
644
|
+
registerGateRoutes(ctx, {
|
|
645
|
+
config: () => this.current(),
|
|
646
|
+
onError: (message) => logger.warn(message)
|
|
647
|
+
});
|
|
648
|
+
const undo = installGateInterceptor(ctx, ctx.webServer, {
|
|
649
|
+
config: () => this.current(),
|
|
650
|
+
onBlock: (message) => logger.info(message)
|
|
651
|
+
});
|
|
652
|
+
ctx.effect(() => undo, "access-gate: interceptor");
|
|
653
|
+
}
|
|
654
|
+
/** 当前生效配置(诊断用)。 */
|
|
655
|
+
policy() {
|
|
656
|
+
return this.current();
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
function apply(ctx, config) {
|
|
660
|
+
ctx.plugin(AccessGateService, config);
|
|
661
|
+
}
|
|
662
|
+
//#endregion
|
|
663
|
+
export { AccessGateService, Config, apply, inject, name };
|