@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,103 @@
1
+ /**
2
+ * 配置卡片文案(zh/en)。经 ctx.locale.register 注册、bind 取用,与官方卡片同机制。
3
+ * @module access-gate/client/i18n
4
+ */
5
+
6
+ export const NS = 'dsh-plus-access-gate'
7
+
8
+ export const zh: Record<string, string> = {
9
+ title: '访问控制',
10
+ description: 'Web 访问围栏:本机直连放行;远程按白名单 IP 或访问令牌放行,其余拦截到登录页。',
11
+ enabled: '启用访问围栏',
12
+ enabledHint: '关闭即完全旁路(等价插件缺席);启用后远程流量需命中白名单或令牌。',
13
+ token: '访问令牌',
14
+ tokenHint: '留空表示保持已存令牌不变;清空并保存需先输入既有令牌确认。',
15
+ tokenSet: '已配置',
16
+ tokenUnset: '未配置',
17
+ allowedIps: '放行 IP 白名单',
18
+ allowedIpsHint:
19
+ '每行一个:精确 IP 或 CIDR(IPv4/IPv6 皆可),如 100.86.108.55 或 fd7a:115c:a1e0::/48。',
20
+ trustForwardedFor: '信任 x-forwarded-for 还原客户端 IP',
21
+ trustForwardedForHint:
22
+ '仅当入口代理强制覆盖该头时开启(tailscale serve 满足);直连暴露场景必须关闭。',
23
+ cookieMaxAgeHours: '登录有效期(小时)',
24
+ cookieMaxAgeHoursHint: '令牌登录后 cookie 的有效期。',
25
+ loginFailLimit: '登录失败阈值',
26
+ loginFailLimitHint: '每个 IP 在冷却窗口内允许的失败次数,超出进入冷却。',
27
+ loginCooldownMs: '登录冷却(毫秒)',
28
+ loginCooldownMsHint: '失败达到阈值后的冷却时长。',
29
+ diagTitle: '当前页面诊断',
30
+ diagVerdict: '本页判定',
31
+ diagClientIp: '客户端 IP',
32
+ diagReason: '放行原因',
33
+ diagLocal: '本机直连',
34
+ diagAllowedIp: '白名单 IP',
35
+ diagToken: '令牌',
36
+ diagPass: '放行',
37
+ diagBlock: '拦截',
38
+ diagLogin: '登录页',
39
+ diagOff: '围栏未启用',
40
+ diagInvalid: '白名单存在无法解析的条目:',
41
+ warnFailClosed: '警告:已启用但令牌与白名单均为空——所有远程访问将被拒绝(含本页之外的设备)。',
42
+ save: '保存',
43
+ saving: '保存中…',
44
+ discard: '放弃',
45
+ unsaved: '未保存',
46
+ saveFailed: '保存失败,请检查填写内容。',
47
+ loading: '加载中…',
48
+ unavailable: '配置服务不可用。',
49
+ readOnly: '当前部署无 settings provider,配置为只读;请编辑 settings.yaml。',
50
+ expand: '展开',
51
+ collapse: '收起',
52
+ invalidNumber: '请输入有效数字',
53
+ }
54
+
55
+ export const en: Record<string, string> = {
56
+ title: 'Access Control',
57
+ description:
58
+ 'Web access gate: loopback passes; remote traffic needs an allowlisted IP or the token, others hit the login page.',
59
+ enabled: 'Enable access gate',
60
+ enabledHint:
61
+ 'Off means full bypass (as if absent); on, remote traffic must match the allowlist or the token.',
62
+ token: 'Access token',
63
+ tokenHint: 'Leave blank to keep the stored token; clearing requires typing the current one.',
64
+ tokenSet: 'Set',
65
+ tokenUnset: 'Not set',
66
+ allowedIps: 'Allowed IP allowlist',
67
+ allowedIpsHint:
68
+ 'One per line: exact IP or CIDR (IPv4/IPv6), e.g. 100.86.108.55 or fd7a:115c:a1e0::/48.',
69
+ trustForwardedFor: 'Trust x-forwarded-for for client IP',
70
+ trustForwardedForHint:
71
+ 'Enable only when the entry proxy overwrites that header (tailscale serve does); must be off for direct exposure.',
72
+ cookieMaxAgeHours: 'Login validity (hours)',
73
+ cookieMaxAgeHoursHint: 'Cookie lifetime after a token login.',
74
+ loginFailLimit: 'Login failure limit',
75
+ loginFailLimitHint: 'Failures per IP within the cooldown window before throttling.',
76
+ loginCooldownMs: 'Login cooldown (ms)',
77
+ loginCooldownMsHint: 'Cooldown duration after reaching the limit.',
78
+ diagTitle: 'This page',
79
+ diagVerdict: 'Verdict',
80
+ diagClientIp: 'Client IP',
81
+ diagReason: 'Reason',
82
+ diagLocal: 'loopback',
83
+ diagAllowedIp: 'allowlisted IP',
84
+ diagToken: 'token',
85
+ diagPass: 'pass',
86
+ diagBlock: 'blocked',
87
+ diagLogin: 'login page',
88
+ diagOff: 'gate disabled',
89
+ diagInvalid: 'Allowlist entries that failed to parse:',
90
+ warnFailClosed:
91
+ 'Warning: enabled with no token and an empty allowlist — all remote access will be refused.',
92
+ save: 'Save',
93
+ saving: 'Saving…',
94
+ discard: 'Discard',
95
+ unsaved: 'Unsaved',
96
+ saveFailed: 'Save failed; please check the inputs.',
97
+ loading: 'Loading…',
98
+ unavailable: 'Settings service unavailable.',
99
+ readOnly: 'No settings provider in this deployment; read-only. Edit settings.yaml instead.',
100
+ expand: 'Expand',
101
+ collapse: 'Collapse',
102
+ invalidNumber: 'Enter a valid number',
103
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 浏览器半 settingsScope/connection 的最小本地接口声明(notify-email 同款)。
3
+ * 只声明本插件用到的窄面,避免为构建期类型引入整条官方 client 依赖树;
4
+ * 运行时契约以官方 dsh-client-connection 的 settings RPC 面为准。
5
+ * @module access-gate/client/scope
6
+ */
7
+
8
+ /** settings 命名空间 scope 的快照(官方 SettingsScopeSnapshot 的最小投影)。 */
9
+ export interface ScopeSnapshot {
10
+ status: 'loading' | 'ready' | 'unavailable'
11
+ /** schema 解析后的配置值(secret 字段已脱敏剥除)。 */
12
+ value: unknown
13
+ /** 命名空间 revision,写操作 fencing 用;首次 Host 应答前为 undefined。 */
14
+ revision: number | undefined
15
+ /** Host 文档是否可写。 */
16
+ writable: boolean
17
+ }
18
+
19
+ /** settingsScope.bind({namespace}) 返回的控制器面(本插件由 api 直连实现)。 */
20
+ export interface Scope {
21
+ getSnapshot(): ScopeSnapshot
22
+ subscribe(listener: () => void): () => void
23
+ load(): Promise<void>
24
+ }
25
+
26
+ /** connection api.settings 的 RPC 面(本插件用到的三个方法)。 */
27
+ export interface SettingsApi {
28
+ describe(payload?: Record<string, never>): Promise<{
29
+ result: {
30
+ ok: boolean
31
+ value?: {
32
+ writable: boolean
33
+ namespaces: Array<{
34
+ ns: string
35
+ value: unknown
36
+ revision: number
37
+ secrets: Array<{ path: string[]; set: boolean }>
38
+ }>
39
+ }
40
+ error?: { message?: string }
41
+ }
42
+ }>
43
+ update(request: {
44
+ ns: string
45
+ patch: Record<string, unknown>
46
+ expectedRevision?: number
47
+ }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
48
+ replace(request: {
49
+ ns: string
50
+ section: Record<string, unknown>
51
+ expectedRevision?: number
52
+ }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
53
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * 配置卡片样式:沿用官方 data-plugin / data-plugin-css 约定(HMR 据此卸载),
3
+ * 视觉对齐官方卡片(--dsw-alias-* 变量),不覆盖上游任何选择器。
4
+ * @module access-gate/client/styles
5
+ */
6
+
7
+ export const PLUGIN_ID = '@dsh-plus/access-gate'
8
+ export const STYLE_TAG_ID = `${PLUGIN_ID}/card.css`
9
+
10
+ export const cardCss = `
11
+ .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}
12
+ .dag-card:hover{border-color:var(--dsw-alias-label-dimmed)}
13
+ .dag-cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
14
+ .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}
15
+ .dag-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
16
+ .dag-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
17
+ .dag-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
18
+ .dag-description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
19
+ .dag-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s;font-size:12px}
20
+ .dag-chevronOpen{transform:rotate(180deg)}
21
+ .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}
22
+ .dag-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}
23
+ .dag-field{flex-direction:column;gap:6px;padding:12px 0;display:flex}
24
+ .dag-field+.dag-field{border-top:1px solid var(--dsw-alias-border-l2)}
25
+ .dag-head{align-items:center;gap:8px;display:flex}
26
+ .dag-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}
27
+ .dag-badge{white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}
28
+ .dag-badgeSet{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
29
+ .dag-badgeUnset{color:var(--dsw-alias-label-tertiary)}
30
+ .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}
31
+ .dag-input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}
32
+ .dag-input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
33
+ .dag-inputInvalid{border-color:var(--dsw-alias-label-error)}
34
+ .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)}
35
+ .dag-textarea:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}
36
+ .dag-textarea:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
37
+ .dag-hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}
38
+ .dag-invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}
39
+ .dag-checkRow{align-items:center;gap:8px;display:flex;padding:6px 0}
40
+ .dag-checkRow input{accent-color:var(--dsw-alias-brand-primary)}
41
+ .dag-checkRow label{color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;cursor:pointer}
42
+ .dag-groupLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600;padding:12px 0 2px;margin:0}
43
+ .dag-readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}
44
+ .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}
45
+ .dag-status{min-width:0;color:var(--dsw-alias-label-secondary);flex:1;margin:0;font-size:12px;line-height:1.5}
46
+ .dag-statusError{color:var(--dsw-alias-label-error)}
47
+ .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}
48
+ .dag-btnGhost{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}
49
+ .dag-btnGhost:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}
50
+ .dag-btnPrimary{background:var(--dsh-plus-brand,#3d6dff);color:#fff}
51
+ .dag-btn:disabled{opacity:.4;cursor:default}
52
+ .dag-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
53
+ .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}
54
+ .dag-diagTitle{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600;margin:0}
55
+ .dag-diagRow{display:flex;gap:8px;font-size:12px;line-height:1.6}
56
+ .dag-diagKey{color:var(--dsw-alias-label-tertiary);flex:none;min-width:72px}
57
+ .dag-diagVal{color:var(--dsw-alias-label-primary);margin:0}
58
+ .dag-diagWarn{color:var(--dsw-alias-label-error);border-color:var(--dsw-alias-label-error)}
59
+ .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}
60
+ `
61
+
62
+ /** 幂等注入样式标签;返回标签(已存在或环境无 document 时为 null)。 */
63
+ export function injectStyle(): HTMLStyleElement | null {
64
+ if (typeof document === 'undefined') return null
65
+ if (document.querySelector(`style[data-plugin-css=${JSON.stringify(STYLE_TAG_ID)}]`) !== null) {
66
+ return null
67
+ }
68
+ const tag = document.createElement('style')
69
+ tag.dataset.plugin = PLUGIN_ID
70
+ tag.dataset.pluginCss = STYLE_TAG_ID
71
+ tag.textContent = cardCss
72
+ document.head.append(tag)
73
+ return tag
74
+ }
package/src/config.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 配置单一事实源:cordis 行级 Config(组合默认值,dev/prod patch 层可覆盖)
3
+ * 与 settings namespace(用户层,经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml)
4
+ * 共用同一 schemastery schema(notify-email 同款约定)。
5
+ * token 标记 secret 角色:任何读取通道不得回传明文。
6
+ * @module access-gate/config
7
+ */
8
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
9
+ import z from '@deepseek-ai/schemastery'
10
+
11
+ import { SETTINGS_NS as NS_LITERAL } from './ns.ts'
12
+
13
+ /** settings 命名空间;webui 配置卡片与插件运行期读取同一份(字面量见 ./ns.ts)。 */
14
+ export const SETTINGS_NS = settingsNamespace(NS_LITERAL)
15
+
16
+ export const Config = z.object({
17
+ enabled: z
18
+ .boolean()
19
+ .description('访问围栏总开关(false = 一切放行,等价插件缺席)')
20
+ .default(false),
21
+ token: z
22
+ .string()
23
+ .role('secret')
24
+ .description('访问令牌;空字符串 = token 通道关闭。经登录页输入后以 HttpOnly cookie 持有')
25
+ .default(''),
26
+ allowedIps: z
27
+ .array(z.string())
28
+ .description('放行 IP 白名单:精确 IP 或 CIDR(IPv4/IPv6 皆可)')
29
+ .default([]),
30
+ trustForwardedFor: z
31
+ .boolean()
32
+ .description(
33
+ '是否信任 x-forwarded-for 还原真实客户端 IP(仅当入口代理强制覆盖该头时开启;tailscale serve 满足)',
34
+ )
35
+ .default(true),
36
+ cookieMaxAgeHours: z
37
+ .natural()
38
+ .max(24 * 365)
39
+ .description('登录 cookie 有效期(小时)')
40
+ .default(720),
41
+ loginFailLimit: z
42
+ .natural()
43
+ .description('登录失败节流阈值(每 IP 冷却窗口内允许的失败次数)')
44
+ .default(10),
45
+ loginCooldownMs: z.natural().description('登录失败节流冷却毫秒数').default(60000),
46
+ })
47
+
48
+ export type AccessGateConfig = Schemastery.TypeT<typeof Config>
49
+
50
+ /** 判定所需的最小配置视图(测试与决策函数共用窄面)。 */
51
+ export interface GatePolicy {
52
+ readonly enabled: boolean
53
+ readonly token: string
54
+ readonly allowedIps: readonly string[]
55
+ readonly trustForwardedFor: boolean
56
+ }
57
+
58
+ /** 远程放行通道是否全部关闭(enabled 且无 token 且白名单空 = 拒绝一切远程)。 */
59
+ export function isFailClosed(policy: GatePolicy): boolean {
60
+ return policy.enabled && policy.token === '' && policy.allowedIps.length === 0
61
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 围栏判定核心:`decideGate` 纯函数。
3
+ *
4
+ * 信任模型(决策记录详见 docs/README.md):
5
+ * - dsh web 仅绑 127.0.0.1,远程流量必经 tailscale serve → dsh-proxy 两跳,
6
+ * serve 会**覆盖**(而非追加)x-forwarded-for,客户端无法伪造;
7
+ * - 能直发 loopback 或伪造 XFF 的只有本机进程——与 dsh 官方 /api 围栏的
8
+ * loopback 信任边界一致,本机直连视为可信管理通道;
9
+ * - 因此 XFF 存在且 trustForwardedFor=true 时取最左条目为真实客户端 IP。
10
+ *
11
+ * token 比较使用 sha256 摘要 + timingSafeEqual 常时比较。
12
+ * @module access-gate/decision
13
+ */
14
+ import { createHash, timingSafeEqual } from 'node:crypto'
15
+
16
+ import type { GatePolicy } from './config.ts'
17
+ import { isLoopbackAddress, parseAllowEntry, parseIp } from './ip.ts'
18
+
19
+ export const GATE_COOKIE = 'dsh_gate'
20
+
21
+ /** 围栏判定结果。 */
22
+ export type GateVerdict =
23
+ | 'pass' /** 放行(本机直连 / 白名单 IP / 有效 token) */
24
+ | 'block' /** 拒绝:API / WebSocket 等非导航请求 → 403 */
25
+ | 'login' /** 拒绝:浏览器导航请求 → 登录页 */
26
+
27
+ /** 判定输出。 */
28
+ export interface GateDecision {
29
+ verdict: GateVerdict
30
+ /** 还原出的客户端 IP(有 XFF 或非 loopback 直连时存在)。 */
31
+ clientIp?: string
32
+ /** 放行原因(诊断/日志用):local / allowed-ip / token。 */
33
+ reason?: 'local' | 'allowed-ip' | 'token'
34
+ /** 无效白名单条目(卡片诊断用,判定时忽略它们)。 */
35
+ invalidEntries?: string[]
36
+ }
37
+
38
+ /** 判定输入的请求事实(headers 小写键,node:http 原生形态)。 */
39
+ export interface GateRequest {
40
+ readonly url: string
41
+ readonly method: string
42
+ readonly headers: Record<string, unknown>
43
+ readonly remoteAddress?: string
44
+ }
45
+
46
+ /** sha256 摘要为定长 32 字节,常时比较的前提。 */
47
+ function tokenMatches(candidate: string, expected: string): boolean {
48
+ if (candidate === '' || expected === '') return false
49
+ const a = createHash('sha256').update(candidate).digest()
50
+ const b = createHash('sha256').update(expected).digest()
51
+ return timingSafeEqual(a, b)
52
+ }
53
+
54
+ /** 从 cookie 头解析指定名字的值(手写最小解析,零依赖)。 */
55
+ export function readCookie(headers: Record<string, unknown>, name: string): string | undefined {
56
+ const raw = headers.cookie
57
+ if (typeof raw !== 'string') return undefined
58
+ for (const part of raw.split(';')) {
59
+ const eq = part.indexOf('=')
60
+ if (eq === -1) continue
61
+ if (part.slice(0, eq).trim() === name) {
62
+ const value = part.slice(eq + 1).trim()
63
+ return value === '' ? undefined : value
64
+ }
65
+ }
66
+ return undefined
67
+ }
68
+
69
+ /** 取 XFF 最左条目(serve 单跳覆盖语义下即真实客户端 IP)。 */
70
+ function forwardedIp(headers: Record<string, unknown>): string | undefined {
71
+ const raw = headers['x-forwarded-for']
72
+ if (typeof raw !== 'string' || raw.trim() === '') return undefined
73
+ const first = raw.split(',')[0]?.trim() ?? ''
74
+ return first === '' ? undefined : first
75
+ }
76
+
77
+ /** 浏览器导航请求判定:GET + accept 含 text/html(需要返回登录页而非 403)。 */
78
+ export function isNavigationRequest(req: GateRequest): boolean {
79
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false
80
+ const accept = req.headers.accept
81
+ return typeof accept === 'string' && accept.includes('text/html')
82
+ }
83
+
84
+ /** 预解析白名单(含非法条目收集),每次判定复用。 */
85
+ export interface CompiledAllowlist {
86
+ match: (ip: { value: bigint; bits: 32 | 128 }) => boolean
87
+ invalid: string[]
88
+ }
89
+
90
+ export function compileAllowlist(entries: readonly string[]): CompiledAllowlist {
91
+ const matchers = entries.flatMap((entry) => {
92
+ const matcher = parseAllowEntry(entry)
93
+ return matcher === null ? [] : [matcher]
94
+ })
95
+ const invalid = entries.filter((entry) => parseAllowEntry(entry) === null)
96
+ return { match: (ip) => matchers.some((matcher) => matcher(ip)), invalid }
97
+ }
98
+
99
+ /**
100
+ * 围栏判定(纯函数,除 token 哈希外无任何副作用与 IO)。
101
+ * 逻辑次序:豁免路径由调用方前置处理;enabled=false 直通;确定有效来源
102
+ * (受信 XFF 优先,否则 remoteAddress);来源为 loopback 直连 → 放行;
103
+ * 否则按白名单/token 判定;无来源 → fail-closed。
104
+ *
105
+ * loopback 语义:XFF 被信任时,存在 XFF 即代表请求经代理(serve→proxy→dsh
106
+ * 全链 loopback,真实客户端只在 XFF 里)——remoteAddress 是 loopback 也不
107
+ * 放行,必须按 XFF 判定;XFF 不被信任时,来源就是 remoteAddress,
108
+ * loopback 即本机直连 → 放行(防锁死兜底优先)。
109
+ */
110
+ export function decideGate(req: GateRequest, policy: GatePolicy): GateDecision {
111
+ if (!policy.enabled) return { verdict: 'pass', reason: 'local' }
112
+
113
+ const forwarded = forwardedIp(req.headers)
114
+ const trustedForwarded = policy.trustForwardedFor ? forwarded : undefined
115
+ const clientIpText = trustedForwarded ?? req.remoteAddress
116
+ if (clientIpText === undefined) {
117
+ return { verdict: 'block', reason: undefined }
118
+ }
119
+ if (trustedForwarded === undefined && isLoopbackAddress(req.remoteAddress)) {
120
+ return { verdict: 'pass', reason: 'local' }
121
+ }
122
+
123
+ const allowlist = compileAllowlist(policy.allowedIps)
124
+ const clientIp = parseIp(clientIpText)
125
+ const allowedByIp = clientIp !== null && allowlist.match(clientIp)
126
+ const cookieToken = readCookie(req.headers, GATE_COOKIE)
127
+ const allowedByToken = cookieToken !== undefined && tokenMatches(cookieToken, policy.token)
128
+
129
+ const decision: GateDecision = {
130
+ verdict: allowedByIp || allowedByToken ? 'pass' : isNavigationRequest(req) ? 'login' : 'block',
131
+ clientIp: clientIpText,
132
+ }
133
+ if (allowedByToken) decision.reason = 'token'
134
+ else if (allowedByIp) decision.reason = 'allowed-ip'
135
+ if (allowlist.invalid.length > 0) decision.invalidEntries = allowlist.invalid
136
+ return decision
137
+ }
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * dsh 插件:访问围栏(access-gate)。
3
+ *
4
+ * 为 dsh web 的全部 HTTP/WebSocket 流量提供访问安全校验与访问控制:
5
+ * - 本机直连(loopback 且无 x-forwarded-for)永久放行——管理通道,防锁死;
6
+ * - 远程流量按 tailscale serve 注入的 x-forwarded-for 还原真实客户端 IP,
7
+ * 命中白名单(精确 IP / CIDR,v4+v6)即放行;
8
+ * - 或持有效 token(登录页换取 HttpOnly cookie)放行;
9
+ * - 其余:浏览器导航返回登录页,API / WS 一律 403 / 拒绝升级。
10
+ *
11
+ * 配置:settings 命名空间 dsh-plus-access-gate(webui 配置卡片同一份,
12
+ * 热生效);enabled=false 时完全旁路(等价插件缺席)。
13
+ * 安全边界:结构守卫 fail-loud(upstream 形状变化 → 启动失败 → lifeboat
14
+ * 隔离止损,绝不静默 fail-open)。
15
+ * @module @dsh-plus/access-gate
16
+ */
17
+ import { type Context, Service } from '@deepseek-ai/cordis'
18
+ import { installSettingsSection } from '@deepseek-ai/dsh-settings'
19
+
20
+ import { type AccessGateConfig, Config, SETTINGS_NS } from './config.ts'
21
+ import { type GateWebServer, installGateInterceptor } from './interceptor.ts'
22
+ import { registerGateRoutes } from './routes.ts'
23
+
24
+ export const name = 'dsh-plus-access-gate'
25
+
26
+ export const inject = ['webServer'] as const
27
+
28
+ export { Config }
29
+
30
+ declare module '@deepseek-ai/cordis' {
31
+ interface Context {
32
+ accessGate: AccessGateService
33
+ }
34
+ }
35
+
36
+ /** 围栏服务:持有当前配置读取器(settings 用户层热更新经 setSource 注入)。 */
37
+ export class AccessGateService extends Service {
38
+ private current: () => AccessGateConfig
39
+
40
+ constructor(ctx: Context, config: AccessGateConfig) {
41
+ super(ctx, 'accessGate')
42
+ this.current = () => config
43
+ installSettingsSection(ctx, SETTINGS_NS, Config, config, {
44
+ setSource: (source) => {
45
+ this.current = source
46
+ },
47
+ onChange: () => {},
48
+ })
49
+ const logger = ctx.logger('access-gate')
50
+
51
+ // 自有端点(/dsh-plus/gate/*,围栏豁免路径)。
52
+ registerGateRoutes(ctx, {
53
+ config: () => this.current(),
54
+ onError: (message) => logger.warn(message),
55
+ })
56
+
57
+ // 结构包装:全部 HTTP/WS 流量的围栏。
58
+ const undo = installGateInterceptor(ctx, ctx.webServer as unknown as GateWebServer, {
59
+ config: () => this.current(),
60
+ onBlock: (message) => logger.info(message),
61
+ })
62
+ ctx.effect(() => undo, 'access-gate: interceptor')
63
+ }
64
+
65
+ /** 当前生效配置(诊断用)。 */
66
+ policy(): AccessGateConfig {
67
+ return this.current()
68
+ }
69
+ }
70
+
71
+ export function apply(ctx: Context, config: AccessGateConfig): void {
72
+ ctx.plugin(AccessGateService, config)
73
+ }