@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/lib/client.js ADDED
@@ -0,0 +1,718 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@dsh-plus/access-gate",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let react_jsx_runtime = require("react/jsx-runtime");
9
+ //#region src/ns.ts
10
+ /**
11
+ * settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
12
+ * 服务端经 settingsNamespace() 品牌化后使用(config.ts),浏览器半将其作为
13
+ * settings.plugin.item keyed 槽位的 key —— 同一字面量两处共用,防止漂移。
14
+ * @module access-gate/ns
15
+ */
16
+ const SETTINGS_NS = "dsh-plus-access-gate";
17
+ //#endregion
18
+ //#region src/client/card.tsx
19
+ /**
20
+ * 「访问控制」配置卡片:注册进 settings.plugin.item 插槽(官方插件配置页)。
21
+ * 交互对齐官方卡片:折叠/展开、staged draft、未保存标记、保存/放弃。
22
+ * 配置读写走官方 settingsScope 传输(scope.ts):value 为 schema 解析后的
23
+ * 脱敏视图(token 不出现,tokenConfigured 经 describe 的 secrets 探测),
24
+ * 保存经 settings.update 深合并(空 token 剔除 = 保持不变)。
25
+ * 卡片顶部「当前页面诊断」读 /dsh-plus/gate/status(本页放行原因/客户端 IP)。
26
+ * @module access-gate/client/card
27
+ */
28
+ const IDLE_STATUS = {
29
+ kind: "idle",
30
+ text: ""
31
+ };
32
+ function draftFromValue(value) {
33
+ return {
34
+ enabled: value.enabled,
35
+ token: "",
36
+ allowedIpsText: value.allowedIps.join("\n"),
37
+ trustForwardedFor: value.trustForwardedFor,
38
+ cookieMaxAgeHours: String(value.cookieMaxAgeHours),
39
+ loginFailLimit: String(value.loginFailLimit),
40
+ loginCooldownMs: String(value.loginCooldownMs)
41
+ };
42
+ }
43
+ function isPositiveInt(text) {
44
+ return /^[0-9]+$/.test(text) && Number(text) > 0;
45
+ }
46
+ function toPatch(draft) {
47
+ return {
48
+ enabled: draft.enabled,
49
+ allowedIps: draft.allowedIpsText.split("\n").map((s) => s.trim()).filter((s) => s.length > 0),
50
+ trustForwardedFor: draft.trustForwardedFor,
51
+ cookieMaxAgeHours: Number(draft.cookieMaxAgeHours),
52
+ loginFailLimit: Number(draft.loginFailLimit),
53
+ loginCooldownMs: Number(draft.loginCooldownMs)
54
+ };
55
+ }
56
+ /** reason 枚举 → 文案 key。 */
57
+ const REASON_KEYS = {
58
+ local: "diagLocal",
59
+ "allowed-ip": "diagAllowedIp",
60
+ token: "diagToken"
61
+ };
62
+ function verdictLabel(t, diag) {
63
+ if (!diag.enabled) return t("diagOff");
64
+ if (diag.verdict === "pass") return t("diagPass");
65
+ if (diag.verdict === "login") return t("diagLogin");
66
+ return t("diagBlock");
67
+ }
68
+ function AccessGateCard(props) {
69
+ const { t, scope, api } = props;
70
+ const snapshot = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
71
+ const value = snapshot.value;
72
+ const [open, setOpen] = (0, react.useState)(false);
73
+ const [draft, setDraft] = (0, react.useState)(null);
74
+ const [tokenConfigured, setTokenConfigured] = (0, react.useState)(false);
75
+ const [diag, setDiag] = (0, react.useState)(null);
76
+ const [saving, setSaving] = (0, react.useState)(false);
77
+ const [status, setStatus] = (0, react.useState)(IDLE_STATUS);
78
+ (0, react.useEffect)(() => {
79
+ if (value === void 0 || draft !== null) return;
80
+ setDraft(draftFromValue(value));
81
+ }, [value, draft]);
82
+ (0, react.useEffect)(() => {
83
+ let alive = true;
84
+ api.describe({}).then((response) => {
85
+ if (!alive || !response.result.ok) return;
86
+ const view = response.result.value?.namespaces.find((ns) => ns.ns === SETTINGS_NS);
87
+ setTokenConfigured(view?.secrets.some((secret) => secret.path.join(".") === "token" && secret.set) ?? false);
88
+ }).catch(() => {});
89
+ return () => {
90
+ alive = false;
91
+ };
92
+ }, [api, snapshot]);
93
+ (0, react.useEffect)(() => {
94
+ let alive = true;
95
+ fetch("/dsh-plus/gate/status").then((res) => res.ok ? res.json() : null).then((body) => {
96
+ if (alive && body !== null) setDiag(body);
97
+ }).catch(() => {});
98
+ return () => {
99
+ alive = false;
100
+ };
101
+ }, [snapshot]);
102
+ const failClosed = (0, react.useMemo)(() => value?.enabled === true && value.allowedIps.length === 0 && !tokenConfigured, [value, tokenConfigured]);
103
+ const dirty = (0, react.useMemo)(() => value !== void 0 && draft !== null && JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromValue(value))), [value, draft]);
104
+ const invalid = (0, react.useMemo)(() => draft !== null && (!isPositiveInt(draft.cookieMaxAgeHours) || !isPositiveInt(draft.loginFailLimit) || !isPositiveInt(draft.loginCooldownMs)), [draft]);
105
+ if (value === void 0 || draft === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
106
+ className: "dag-card",
107
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
108
+ className: "dag-readOnly",
109
+ children: t("loading")
110
+ })
111
+ });
112
+ const edit = (key, editValue) => {
113
+ setDraft({
114
+ ...draft,
115
+ [key]: editValue
116
+ });
117
+ setStatus(IDLE_STATUS);
118
+ };
119
+ const onSave = () => {
120
+ setSaving(true);
121
+ const patch = toPatch(draft);
122
+ if (draft.token !== "") patch.token = draft.token;
123
+ const revision = scope.getSnapshot().revision;
124
+ api.update({
125
+ ns: SETTINGS_NS,
126
+ patch,
127
+ ...revision !== void 0 ? { expectedRevision: revision } : {}
128
+ }).then(async (response) => {
129
+ if (!response.result.ok) throw new Error(response.result.error?.message ?? t("saveFailed"));
130
+ await scope.load();
131
+ const next = scope.getSnapshot().value;
132
+ if (next !== void 0) setDraft(draftFromValue(next));
133
+ setStatus(IDLE_STATUS);
134
+ }).catch((error) => {
135
+ const message = error instanceof Error ? error.message : String(error);
136
+ setStatus({
137
+ kind: "error",
138
+ text: `${t("saveFailed")}${message}`
139
+ });
140
+ }).finally(() => setSaving(false));
141
+ };
142
+ const disabled = !snapshot.writable;
143
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
144
+ className: `dag-card${open ? " dag-cardOpen" : ""}`,
145
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
146
+ type: "button",
147
+ className: "dag-header",
148
+ "aria-expanded": open,
149
+ "aria-label": `${t(open ? "collapse" : "expand")}: ${t("title")}`,
150
+ onClick: () => setOpen(!open),
151
+ children: [
152
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
153
+ className: "dag-headText",
154
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
155
+ className: "dag-name",
156
+ children: t("title")
157
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
158
+ className: "dag-description",
159
+ children: t("description")
160
+ })]
161
+ }),
162
+ dirty ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
163
+ className: "dag-pending",
164
+ children: t("unsaved")
165
+ }) : null,
166
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
167
+ className: `dag-chevron${open ? " dag-chevronOpen" : ""}`,
168
+ children: "▾"
169
+ })
170
+ ]
171
+ }), open ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
172
+ className: "dag-body",
173
+ children: [
174
+ disabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
175
+ className: "dag-readOnly",
176
+ role: "status",
177
+ children: t("readOnly")
178
+ }) : null,
179
+ failClosed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
180
+ className: "dag-warn",
181
+ children: t("warnFailClosed")
182
+ }) : null,
183
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
184
+ className: "dag-field",
185
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
186
+ className: "dag-head",
187
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
188
+ className: "dag-label",
189
+ htmlFor: "dag-enabled",
190
+ children: t("enabled")
191
+ })
192
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
193
+ className: "dag-checkRow",
194
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
195
+ id: "dag-enabled",
196
+ type: "checkbox",
197
+ checked: draft.enabled,
198
+ disabled,
199
+ onChange: (event) => edit("enabled", event.target.checked)
200
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
201
+ htmlFor: "dag-enabled",
202
+ children: t("enabledHint")
203
+ })]
204
+ })]
205
+ }),
206
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
207
+ className: "dag-field",
208
+ children: [
209
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
210
+ className: "dag-head",
211
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
212
+ className: "dag-label",
213
+ htmlFor: "dag-token",
214
+ children: t("token")
215
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
216
+ className: `dag-badge ${tokenConfigured ? "dag-badgeSet" : "dag-badgeUnset"}`,
217
+ children: t(tokenConfigured ? "tokenSet" : "tokenUnset")
218
+ })]
219
+ }),
220
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
221
+ id: "dag-token",
222
+ className: "dag-input",
223
+ type: "password",
224
+ autoComplete: "off",
225
+ value: draft.token,
226
+ disabled,
227
+ onChange: (event) => edit("token", event.target.value)
228
+ }),
229
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
230
+ className: "dag-hint",
231
+ children: t("tokenHint")
232
+ })
233
+ ]
234
+ }),
235
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
236
+ className: "dag-field",
237
+ children: [
238
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
239
+ className: "dag-head",
240
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
241
+ className: "dag-label",
242
+ htmlFor: "dag-allowed",
243
+ children: t("allowedIps")
244
+ })
245
+ }),
246
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
247
+ id: "dag-allowed",
248
+ className: "dag-textarea",
249
+ rows: Math.min(8, Math.max(3, draft.allowedIpsText.split("\n").length)),
250
+ value: draft.allowedIpsText,
251
+ disabled,
252
+ onChange: (event) => edit("allowedIpsText", event.target.value)
253
+ }),
254
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
255
+ className: "dag-hint",
256
+ children: t("allowedIpsHint")
257
+ })
258
+ ]
259
+ }),
260
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
261
+ className: "dag-field",
262
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
263
+ className: "dag-head",
264
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
265
+ className: "dag-label",
266
+ htmlFor: "dag-xff",
267
+ children: t("trustForwardedFor")
268
+ })
269
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
270
+ className: "dag-checkRow",
271
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
272
+ id: "dag-xff",
273
+ type: "checkbox",
274
+ checked: draft.trustForwardedFor,
275
+ disabled,
276
+ onChange: (event) => edit("trustForwardedFor", event.target.checked)
277
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
278
+ htmlFor: "dag-xff",
279
+ children: t("trustForwardedForHint")
280
+ })]
281
+ })]
282
+ }),
283
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
284
+ className: "dag-field",
285
+ children: [
286
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
287
+ className: "dag-head",
288
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
289
+ className: "dag-label",
290
+ htmlFor: "dag-cookie",
291
+ children: t("cookieMaxAgeHours")
292
+ })
293
+ }),
294
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
295
+ id: "dag-cookie",
296
+ className: `dag-input${isPositiveInt(draft.cookieMaxAgeHours) ? "" : " dag-inputInvalid"}`,
297
+ type: "text",
298
+ inputMode: "numeric",
299
+ value: draft.cookieMaxAgeHours,
300
+ disabled,
301
+ "aria-invalid": !isPositiveInt(draft.cookieMaxAgeHours) || void 0,
302
+ onChange: (event) => edit("cookieMaxAgeHours", event.target.value)
303
+ }),
304
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
305
+ className: isPositiveInt(draft.cookieMaxAgeHours) ? "dag-hint" : "dag-invalid",
306
+ children: isPositiveInt(draft.cookieMaxAgeHours) ? t("cookieMaxAgeHoursHint") : t("invalidNumber")
307
+ })
308
+ ]
309
+ }),
310
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
311
+ className: "dag-field",
312
+ children: [
313
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
314
+ className: "dag-head",
315
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
316
+ className: "dag-label",
317
+ htmlFor: "dag-faillimit",
318
+ children: t("loginFailLimit")
319
+ })
320
+ }),
321
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
322
+ id: "dag-faillimit",
323
+ className: `dag-input${isPositiveInt(draft.loginFailLimit) ? "" : " dag-inputInvalid"}`,
324
+ type: "text",
325
+ inputMode: "numeric",
326
+ value: draft.loginFailLimit,
327
+ disabled,
328
+ "aria-invalid": !isPositiveInt(draft.loginFailLimit) || void 0,
329
+ onChange: (event) => edit("loginFailLimit", event.target.value)
330
+ }),
331
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
332
+ className: isPositiveInt(draft.loginFailLimit) ? "dag-hint" : "dag-invalid",
333
+ children: isPositiveInt(draft.loginFailLimit) ? t("loginFailLimitHint") : t("invalidNumber")
334
+ })
335
+ ]
336
+ }),
337
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
338
+ className: "dag-field",
339
+ children: [
340
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
341
+ className: "dag-head",
342
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
343
+ className: "dag-label",
344
+ htmlFor: "dag-cooldown",
345
+ children: t("loginCooldownMs")
346
+ })
347
+ }),
348
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
349
+ id: "dag-cooldown",
350
+ className: `dag-input${isPositiveInt(draft.loginCooldownMs) ? "" : " dag-inputInvalid"}`,
351
+ type: "text",
352
+ inputMode: "numeric",
353
+ value: draft.loginCooldownMs,
354
+ disabled,
355
+ "aria-invalid": !isPositiveInt(draft.loginCooldownMs) || void 0,
356
+ onChange: (event) => edit("loginCooldownMs", event.target.value)
357
+ }),
358
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
359
+ className: isPositiveInt(draft.loginCooldownMs) ? "dag-hint" : "dag-invalid",
360
+ children: isPositiveInt(draft.loginCooldownMs) ? t("loginCooldownMsHint") : t("invalidNumber")
361
+ })
362
+ ]
363
+ }),
364
+ diag !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
365
+ className: `dag-diag${diag.invalidEntries.length > 0 ? " dag-diagWarn" : ""}`,
366
+ children: [
367
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
368
+ className: "dag-diagTitle",
369
+ children: t("diagTitle")
370
+ }),
371
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
372
+ className: "dag-diagRow",
373
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
374
+ className: "dag-diagKey",
375
+ children: t("diagVerdict")
376
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
377
+ className: "dag-diagVal",
378
+ children: verdictLabel(t, diag)
379
+ })]
380
+ }),
381
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
382
+ className: "dag-diagRow",
383
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
384
+ className: "dag-diagKey",
385
+ children: t("diagClientIp")
386
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
387
+ className: "dag-diagVal",
388
+ children: diag.clientIp ?? "—"
389
+ })]
390
+ }),
391
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
392
+ className: "dag-diagRow",
393
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
394
+ className: "dag-diagKey",
395
+ children: t("diagReason")
396
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
397
+ className: "dag-diagVal",
398
+ children: diag.reason !== null ? t(REASON_KEYS[diag.reason] ?? diag.reason) : "—"
399
+ })]
400
+ }),
401
+ diag.invalidEntries.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
402
+ className: "dag-diagRow",
403
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
404
+ className: "dag-diagKey",
405
+ children: "!"
406
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
407
+ className: "dag-diagVal",
408
+ children: [t("diagInvalid"), diag.invalidEntries.join("、")]
409
+ })]
410
+ }) : null
411
+ ]
412
+ }) : null,
413
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
414
+ className: "dag-footer",
415
+ children: [
416
+ status.kind !== "idle" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
417
+ className: `dag-status${status.kind === "error" ? " dag-statusError" : ""}`,
418
+ role: "status",
419
+ children: status.text
420
+ }) : null,
421
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
422
+ type: "button",
423
+ className: "dag-btn dag-btnGhost",
424
+ disabled: !dirty || saving,
425
+ onClick: () => {
426
+ setDraft(draftFromValue(value));
427
+ setStatus(IDLE_STATUS);
428
+ },
429
+ children: t("discard")
430
+ }),
431
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
432
+ type: "button",
433
+ className: "dag-btn dag-btnPrimary",
434
+ disabled: !dirty || invalid || saving || disabled,
435
+ onClick: onSave,
436
+ children: t(saving ? "saving" : "save")
437
+ })
438
+ ]
439
+ })
440
+ ]
441
+ }) : null]
442
+ });
443
+ }
444
+ //#endregion
445
+ //#region src/client/i18n.ts
446
+ /**
447
+ * 配置卡片文案(zh/en)。经 ctx.locale.register 注册、bind 取用,与官方卡片同机制。
448
+ * @module access-gate/client/i18n
449
+ */
450
+ const NS = "dsh-plus-access-gate";
451
+ const zh = {
452
+ title: "访问控制",
453
+ description: "Web 访问围栏:本机直连放行;远程按白名单 IP 或访问令牌放行,其余拦截到登录页。",
454
+ enabled: "启用访问围栏",
455
+ enabledHint: "关闭即完全旁路(等价插件缺席);启用后远程流量需命中白名单或令牌。",
456
+ token: "访问令牌",
457
+ tokenHint: "留空表示保持已存令牌不变;清空并保存需先输入既有令牌确认。",
458
+ tokenSet: "已配置",
459
+ tokenUnset: "未配置",
460
+ allowedIps: "放行 IP 白名单",
461
+ allowedIpsHint: "每行一个:精确 IP 或 CIDR(IPv4/IPv6 皆可),如 100.86.108.55 或 fd7a:115c:a1e0::/48。",
462
+ trustForwardedFor: "信任 x-forwarded-for 还原客户端 IP",
463
+ trustForwardedForHint: "仅当入口代理强制覆盖该头时开启(tailscale serve 满足);直连暴露场景必须关闭。",
464
+ cookieMaxAgeHours: "登录有效期(小时)",
465
+ cookieMaxAgeHoursHint: "令牌登录后 cookie 的有效期。",
466
+ loginFailLimit: "登录失败阈值",
467
+ loginFailLimitHint: "每个 IP 在冷却窗口内允许的失败次数,超出进入冷却。",
468
+ loginCooldownMs: "登录冷却(毫秒)",
469
+ loginCooldownMsHint: "失败达到阈值后的冷却时长。",
470
+ diagTitle: "当前页面诊断",
471
+ diagVerdict: "本页判定",
472
+ diagClientIp: "客户端 IP",
473
+ diagReason: "放行原因",
474
+ diagLocal: "本机直连",
475
+ diagAllowedIp: "白名单 IP",
476
+ diagToken: "令牌",
477
+ diagPass: "放行",
478
+ diagBlock: "拦截",
479
+ diagLogin: "登录页",
480
+ diagOff: "围栏未启用",
481
+ diagInvalid: "白名单存在无法解析的条目:",
482
+ warnFailClosed: "警告:已启用但令牌与白名单均为空——所有远程访问将被拒绝(含本页之外的设备)。",
483
+ save: "保存",
484
+ saving: "保存中…",
485
+ discard: "放弃",
486
+ unsaved: "未保存",
487
+ saveFailed: "保存失败,请检查填写内容。",
488
+ loading: "加载中…",
489
+ unavailable: "配置服务不可用。",
490
+ readOnly: "当前部署无 settings provider,配置为只读;请编辑 settings.yaml。",
491
+ expand: "展开",
492
+ collapse: "收起",
493
+ invalidNumber: "请输入有效数字"
494
+ };
495
+ const en = {
496
+ title: "Access Control",
497
+ description: "Web access gate: loopback passes; remote traffic needs an allowlisted IP or the token, others hit the login page.",
498
+ enabled: "Enable access gate",
499
+ enabledHint: "Off means full bypass (as if absent); on, remote traffic must match the allowlist or the token.",
500
+ token: "Access token",
501
+ tokenHint: "Leave blank to keep the stored token; clearing requires typing the current one.",
502
+ tokenSet: "Set",
503
+ tokenUnset: "Not set",
504
+ allowedIps: "Allowed IP allowlist",
505
+ allowedIpsHint: "One per line: exact IP or CIDR (IPv4/IPv6), e.g. 100.86.108.55 or fd7a:115c:a1e0::/48.",
506
+ trustForwardedFor: "Trust x-forwarded-for for client IP",
507
+ trustForwardedForHint: "Enable only when the entry proxy overwrites that header (tailscale serve does); must be off for direct exposure.",
508
+ cookieMaxAgeHours: "Login validity (hours)",
509
+ cookieMaxAgeHoursHint: "Cookie lifetime after a token login.",
510
+ loginFailLimit: "Login failure limit",
511
+ loginFailLimitHint: "Failures per IP within the cooldown window before throttling.",
512
+ loginCooldownMs: "Login cooldown (ms)",
513
+ loginCooldownMsHint: "Cooldown duration after reaching the limit.",
514
+ diagTitle: "This page",
515
+ diagVerdict: "Verdict",
516
+ diagClientIp: "Client IP",
517
+ diagReason: "Reason",
518
+ diagLocal: "loopback",
519
+ diagAllowedIp: "allowlisted IP",
520
+ diagToken: "token",
521
+ diagPass: "pass",
522
+ diagBlock: "blocked",
523
+ diagLogin: "login page",
524
+ diagOff: "gate disabled",
525
+ diagInvalid: "Allowlist entries that failed to parse:",
526
+ warnFailClosed: "Warning: enabled with no token and an empty allowlist — all remote access will be refused.",
527
+ save: "Save",
528
+ saving: "Saving…",
529
+ discard: "Discard",
530
+ unsaved: "Unsaved",
531
+ saveFailed: "Save failed; please check the inputs.",
532
+ loading: "Loading…",
533
+ unavailable: "Settings service unavailable.",
534
+ readOnly: "No settings provider in this deployment; read-only. Edit settings.yaml instead.",
535
+ expand: "Expand",
536
+ collapse: "Collapse",
537
+ invalidNumber: "Enter a valid number"
538
+ };
539
+ //#endregion
540
+ //#region src/client/styles.ts
541
+ /**
542
+ * 配置卡片样式:沿用官方 data-plugin / data-plugin-css 约定(HMR 据此卸载),
543
+ * 视觉对齐官方卡片(--dsw-alias-* 变量),不覆盖上游任何选择器。
544
+ * @module access-gate/client/styles
545
+ */
546
+ const PLUGIN_ID = "@dsh-plus/access-gate";
547
+ const STYLE_TAG_ID = `${PLUGIN_ID}/card.css`;
548
+ const cardCss = `
549
+ .dag-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}
550
+ .dag-card:hover{border-color:var(--dsw-alias-label-dimmed)}
551
+ .dag-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
552
+ .dag-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
553
+ .dag-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
554
+ .dag-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
555
+ .dag-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
556
+ .dag-description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
557
+ .dag-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s;font-size:12px}
558
+ .dag-chevronOpen{transform:rotate(180deg)}
559
+ .dag-pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}
560
+ .dag-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}
561
+ .dag-field{flex-direction:column;gap:6px;padding:12px 0;display:flex}
562
+ .dag-field+.dag-field{border-top:1px solid var(--dsw-alias-border-l2)}
563
+ .dag-head{align-items:center;gap:8px;display:flex}
564
+ .dag-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}
565
+ .dag-badge{white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}
566
+ .dag-badgeSet{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
567
+ .dag-badgeUnset{color:var(--dsw-alias-label-tertiary)}
568
+ .dag-input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}
569
+ .dag-input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}
570
+ .dag-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
571
+ .dag-inputInvalid{border-color:var(--dsw-alias-label-error)}
572
+ .dag-textarea{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:8px 12px;font-size:13px;line-height:1.6;min-height:96px;resize:vertical;font-family:var(--dsw-alias-font-mono,ui-monospace,monospace)}
573
+ .dag-textarea:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}
574
+ .dag-textarea:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
575
+ .dag-hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}
576
+ .dag-invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}
577
+ .dag-checkRow{align-items:center;gap:8px;display:flex;padding:6px 0}
578
+ .dag-checkRow input{accent-color:var(--dsw-alias-brand-primary)}
579
+ .dag-checkRow label{color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;cursor:pointer}
580
+ .dag-groupLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600;padding:12px 0 2px;margin:0}
581
+ .dag-readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}
582
+ .dag-footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex;flex-wrap:wrap}
583
+ .dag-status{min-width:0;color:var(--dsw-alias-label-secondary);flex:1;margin:0;font-size:12px;line-height:1.5}
584
+ .dag-statusError{color:var(--dsw-alias-label-error)}
585
+ .dag-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}
586
+ .dag-btnGhost{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}
587
+ .dag-btnGhost:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}
588
+ .dag-btnPrimary{background:var(--dsh-plus-brand,#3d6dff);color:#fff}
589
+ .dag-btn:disabled{opacity:.4;cursor:default}
590
+ .dag-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
591
+ .dag-diag{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px;margin:12px 0 0;flex-direction:column;gap:4px;display:flex}
592
+ .dag-diagTitle{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600;margin:0}
593
+ .dag-diagRow{display:flex;gap:8px;font-size:12px;line-height:1.6}
594
+ .dag-diagKey{color:var(--dsw-alias-label-tertiary);flex:none;min-width:72px}
595
+ .dag-diagVal{color:var(--dsw-alias-label-primary);margin:0}
596
+ .dag-diagWarn{color:var(--dsw-alias-label-error);border-color:var(--dsw-alias-label-error)}
597
+ .dag-warn{color:var(--dsh-plus-warn,#c9503f);border:1px solid var(--dsh-plus-warn-border,rgba(201,80,63,.4));border-radius:8px;padding:8px 12px;margin:12px 0 0;font-size:12px;line-height:1.6}
598
+ `;
599
+ /** 幂等注入样式标签;返回标签(已存在或环境无 document 时为 null)。 */
600
+ function injectStyle() {
601
+ if (typeof document === "undefined") return null;
602
+ if (document.querySelector(`style[data-plugin-css=${JSON.stringify(STYLE_TAG_ID)}]`) !== null) return null;
603
+ const tag = document.createElement("style");
604
+ tag.dataset.plugin = PLUGIN_ID;
605
+ tag.dataset.pluginCss = STYLE_TAG_ID;
606
+ tag.textContent = cardCss;
607
+ document.head.append(tag);
608
+ return tag;
609
+ }
610
+ //#endregion
611
+ //#region src/client/client.ts
612
+ const name = "dsh-plus-access-gate";
613
+ /** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
614
+ const inject = [
615
+ "slots",
616
+ "locale",
617
+ "connection",
618
+ "remote"
619
+ ];
620
+ /**
621
+ * api 直连实现的命名空间 scope(官方配置页 tab 同款模式)。
622
+ * 读:describe 取命名空间视图(脱敏 value/revision + 顶层 writable);
623
+ * 刷新:remote `settings/document-updated`(按命名空间过滤)与
624
+ * `connection/reset`;generation 防旧读覆盖新发布。任何页面 origin
625
+ * (loopback 或 tailnet 信任域名)下行为一致。
626
+ */
627
+ function createApiScope(api, ns, c) {
628
+ let state = {
629
+ status: "loading",
630
+ value: void 0,
631
+ revision: void 0,
632
+ writable: false
633
+ };
634
+ const listeners = /* @__PURE__ */ new Set();
635
+ let generation = 0;
636
+ const publish = (next) => {
637
+ state = next;
638
+ for (const listener of [...listeners]) listener();
639
+ };
640
+ const load = async () => {
641
+ const gen = ++generation;
642
+ let response;
643
+ try {
644
+ response = await api.describe({});
645
+ } catch {
646
+ return;
647
+ }
648
+ if (gen !== generation || !response.result.ok) return;
649
+ const writable = response.result.value?.writable ?? false;
650
+ const view = response.result.value?.namespaces.find((candidate) => candidate.ns === ns);
651
+ if (view === void 0) {
652
+ publish({
653
+ status: "unavailable",
654
+ value: void 0,
655
+ revision: void 0,
656
+ writable
657
+ });
658
+ return;
659
+ }
660
+ publish({
661
+ status: "ready",
662
+ value: view.value,
663
+ revision: view.revision,
664
+ writable
665
+ });
666
+ };
667
+ const refresh = (namespace) => {
668
+ if (namespace !== void 0 && namespace !== ns) return;
669
+ load();
670
+ };
671
+ const disposers = [c.get("remote").$on("settings/document-updated", refresh), c.on("connection/reset", () => {
672
+ load();
673
+ })];
674
+ c.effect(() => () => {
675
+ for (const dispose of disposers) dispose();
676
+ }, "access-gate: settings scope");
677
+ load();
678
+ return {
679
+ getSnapshot: () => state,
680
+ subscribe: (listener) => {
681
+ listeners.add(listener);
682
+ return () => {
683
+ listeners.delete(listener);
684
+ };
685
+ },
686
+ load
687
+ };
688
+ }
689
+ function apply(ctx) {
690
+ const c = ctx;
691
+ const tag = injectStyle();
692
+ c.effect(() => () => {
693
+ tag?.remove();
694
+ }, "access-gate: style");
695
+ c.effect(() => c.locale.register(NS, {
696
+ zh,
697
+ en
698
+ }), "access-gate: locale");
699
+ const api = c.get("connection").api.settings;
700
+ const scope = createApiScope(api, SETTINGS_NS, c);
701
+ c.slots.inject("settings.plugin.item", () => c.slots.register({
702
+ name: "settings.plugin.item",
703
+ key: SETTINGS_NS,
704
+ locale: NS,
705
+ inject: () => ({
706
+ t: c.locale.bind(NS),
707
+ scope,
708
+ api
709
+ })
710
+ }, AccessGateCard));
711
+ }
712
+ //#endregion
713
+ exports.apply = apply;
714
+ exports.inject = inject;
715
+ exports.name = name;
716
+ return module.exports;
717
+ }
718
+ });