@dsh-plus/access-gate 0.1.4 → 0.2.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.
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * 「访问控制」配置卡片:注册进 settings.plugin.item 插槽(官方插件配置页)。
3
3
  * 外壳与基础控件走 @dsh-plus/shared/client 套件(CardChrome/TextField/CheckRow),
4
- * 本文件保留业务字段(token/白名单 textarea/诊断面板)与保存逻辑。
5
- * 配置读写走官方 settingsScope 传输:value 为 schema 解析后的脱敏视图
6
- * (token 不出现,tokenConfigured 经 describe 的 secrets 探测),
7
- * 保存经 settings.update 深合并(空 token 剔除 = 保持不变)。
8
- * 卡片顶部「当前页面诊断」读 /dsh-plus/gate/status(本页放行原因/客户端 IP)。
4
+ * 本文件保留业务字段(白名单 textarea/诊断面板)与保存逻辑。
5
+ * 配置读写走官方 settingsScope 传输:value 为 schema 解析后的视图,
6
+ * 保存经 settings.update 深合并。
7
+ * 卡片顶部「当前页面诊断」读 /dsh-plus/gate/status(本页放行原因/客户端 IP/
8
+ * 官方 cookie 状态)。
9
+ *
10
+ * 与官方认证合并后:访问凭据由官方 browser-auth 持有(30 天 cookie),
11
+ * 卡片不再管理令牌;allowedIps 仅为可选附加 IP 围栏。
9
12
  * @module access-gate/client/card
10
13
  */
11
14
 
@@ -16,10 +19,8 @@ import {
16
19
  IDLE_STATUS,
17
20
  type NamespaceSettingsApi,
18
21
  type Scope,
19
- TextField,
20
22
  } from '@dsh-plus/shared/client'
21
23
  import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
22
- import { SETTINGS_NS } from '../ns.ts'
23
24
 
24
25
  export interface CardProps {
25
26
  t(key: string): string
@@ -27,24 +28,17 @@ export interface CardProps {
27
28
  api: NamespaceSettingsApi
28
29
  }
29
30
 
30
- /** settings 命名空间的脱敏解析值(token 被脱敏剥除)。 */
31
+ /** settings 命名空间的解析值。 */
31
32
  export interface ConfigValue {
32
33
  enabled: boolean
33
34
  allowedIps: string[]
34
35
  trustForwardedFor: boolean
35
- cookieMaxAgeHours: number
36
- loginFailLimit: number
37
- loginCooldownMs: number
38
36
  }
39
37
 
40
38
  interface Draft {
41
39
  enabled: boolean
42
- token: string
43
40
  allowedIpsText: string
44
41
  trustForwardedFor: boolean
45
- cookieMaxAgeHours: string
46
- loginFailLimit: string
47
- loginCooldownMs: string
48
42
  }
49
43
 
50
44
  interface Diag {
@@ -52,27 +46,20 @@ interface Diag {
52
46
  verdict: string
53
47
  clientIp: string | null
54
48
  reason: string | null
49
+ officialAuthed: boolean
50
+ ipFenceActive: boolean
55
51
  invalidEntries: string[]
56
- tokenConfigured: boolean
57
52
  allowedCount: number
58
53
  }
59
54
 
60
55
  function draftFromValue(value: ConfigValue): Draft {
61
56
  return {
62
57
  enabled: value.enabled,
63
- token: '',
64
58
  allowedIpsText: value.allowedIps.join('\n'),
65
59
  trustForwardedFor: value.trustForwardedFor,
66
- cookieMaxAgeHours: String(value.cookieMaxAgeHours),
67
- loginFailLimit: String(value.loginFailLimit),
68
- loginCooldownMs: String(value.loginCooldownMs),
69
60
  }
70
61
  }
71
62
 
72
- function isPositiveInt(text: string): boolean {
73
- return /^[0-9]+$/.test(text) && Number(text) > 0
74
- }
75
-
76
63
  function toPatch(draft: Draft): Record<string, unknown> {
77
64
  return {
78
65
  enabled: draft.enabled,
@@ -81,23 +68,19 @@ function toPatch(draft: Draft): Record<string, unknown> {
81
68
  .map((s) => s.trim())
82
69
  .filter((s) => s.length > 0),
83
70
  trustForwardedFor: draft.trustForwardedFor,
84
- cookieMaxAgeHours: Number(draft.cookieMaxAgeHours),
85
- loginFailLimit: Number(draft.loginFailLimit),
86
- loginCooldownMs: Number(draft.loginCooldownMs),
87
71
  }
88
72
  }
89
73
 
90
74
  /** reason 枚举 → 文案 key。 */
91
75
  const REASON_KEYS: Record<string, string> = {
92
76
  local: 'diagLocal',
93
- 'allowed-ip': 'diagAllowedIp',
94
- token: 'diagToken',
77
+ cookie: 'diagCookie',
95
78
  }
96
79
 
97
80
  function verdictLabel(t: (key: string) => string, diag: Diag): string {
98
81
  if (!diag.enabled) return t('diagOff')
99
82
  if (diag.verdict === 'pass') return t('diagPass')
100
- if (diag.verdict === 'login') return t('diagLogin')
83
+ if (diag.verdict === 'token-page') return t('diagTokenPage')
101
84
  return t('diagBlock')
102
85
  }
103
86
 
@@ -110,7 +93,6 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
110
93
  const value = snapshot.value as ConfigValue | undefined
111
94
  const [open, setOpen] = useState(false)
112
95
  const [draft, setDraft] = useState<Draft | null>(null)
113
- const [tokenConfigured, setTokenConfigured] = useState(false)
114
96
  const [diag, setDiag] = useState<Diag | null>(null)
115
97
  const [saving, setSaving] = useState(false)
116
98
  const [status, setStatus] = useState<CardStatusState>(IDLE_STATUS)
@@ -121,25 +103,6 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
121
103
  setDraft(draftFromValue(value))
122
104
  }, [value, draft])
123
105
 
124
- // tokenConfigured 探测:经 describe 的 secrets 列表判断。
125
- // biome-ignore lint/correctness/useExhaustiveDependencies: snapshot 变化(保存后 revision 推进)时刻意重探 secrets 状态
126
- useEffect(() => {
127
- let alive = true
128
- api
129
- .describe()
130
- .then((view) => {
131
- if (!alive) return
132
- const ns = view.namespaces.find((candidate) => candidate.ns === SETTINGS_NS)
133
- setTokenConfigured(
134
- ns?.secrets.some((secret) => secret.path.join('.') === 'token' && secret.set) ?? false,
135
- )
136
- })
137
- .catch(() => {})
138
- return () => {
139
- alive = false
140
- }
141
- }, [api, snapshot])
142
-
143
106
  // 本页诊断:读围栏状态端点(配置保存后 snapshot 推进时重读)。
144
107
  // biome-ignore lint/correctness/useExhaustiveDependencies: 配置保存后刻意重取诊断
145
108
  useEffect(() => {
@@ -155,10 +118,6 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
155
118
  }
156
119
  }, [snapshot])
157
120
 
158
- const failClosed = useMemo(
159
- () => value?.enabled === true && value.allowedIps.length === 0 && !tokenConfigured,
160
- [value, tokenConfigured],
161
- )
162
121
  const dirty = useMemo(
163
122
  () =>
164
123
  value !== undefined &&
@@ -166,14 +125,6 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
166
125
  JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromValue(value))),
167
126
  [value, draft],
168
127
  )
169
- const invalid = useMemo(
170
- () =>
171
- draft !== null &&
172
- (!isPositiveInt(draft.cookieMaxAgeHours) ||
173
- !isPositiveInt(draft.loginFailLimit) ||
174
- !isPositiveInt(draft.loginCooldownMs)),
175
- [draft],
176
- )
177
128
 
178
129
  if (value === undefined || draft === null) {
179
130
  return (
@@ -189,7 +140,6 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
189
140
  const onSave = (): void => {
190
141
  setSaving(true)
191
142
  const patch = toPatch(draft)
192
- if (draft.token !== '') patch.token = draft.token
193
143
  const revision = scope.getSnapshot().revision
194
144
  api
195
145
  .update(patch, revision)
@@ -233,12 +183,12 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
233
183
  key: 'save',
234
184
  label: t(saving ? 'saving' : 'save'),
235
185
  variant: 'primary',
236
- disabled: !dirty || invalid || saving || disabled,
186
+ disabled: !dirty || saving || disabled,
237
187
  onClick: onSave,
238
188
  },
239
189
  ]}
240
190
  >
241
- {failClosed ? <p className="dag-warn">{t('warnFailClosed')}</p> : null}
191
+ <p className="dag-hint">{t('mergedHint')}</p>
242
192
  <CheckRow
243
193
  prefix="dag"
244
194
  id="dag-enabled"
@@ -247,17 +197,6 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
247
197
  disabled={disabled}
248
198
  onEdit={(v) => edit('enabled', v)}
249
199
  />
250
- <TextField
251
- prefix="dag"
252
- id="dag-token"
253
- label={t('token')}
254
- hint={t('tokenHint')}
255
- value={draft.token}
256
- password
257
- disabled={disabled}
258
- badge={{ text: t(tokenConfigured ? 'tokenSet' : 'tokenUnset'), set: tokenConfigured }}
259
- onEdit={(v) => edit('token', v)}
260
- />
261
200
  <div className="dag-field">
262
201
  <div className="dag-head">
263
202
  <label className="dag-label" htmlFor="dag-allowed">
@@ -282,42 +221,6 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
282
221
  disabled={disabled}
283
222
  onEdit={(v) => edit('trustForwardedFor', v)}
284
223
  />
285
- <TextField
286
- prefix="dag"
287
- id="dag-cookie"
288
- label={t('cookieMaxAgeHours')}
289
- hint={t('cookieMaxAgeHoursHint')}
290
- value={draft.cookieMaxAgeHours}
291
- numeric
292
- disabled={disabled}
293
- invalid={!isPositiveInt(draft.cookieMaxAgeHours)}
294
- invalidLabel={t('invalidNumber')}
295
- onEdit={(v) => edit('cookieMaxAgeHours', v)}
296
- />
297
- <TextField
298
- prefix="dag"
299
- id="dag-faillimit"
300
- label={t('loginFailLimit')}
301
- hint={t('loginFailLimitHint')}
302
- value={draft.loginFailLimit}
303
- numeric
304
- disabled={disabled}
305
- invalid={!isPositiveInt(draft.loginFailLimit)}
306
- invalidLabel={t('invalidNumber')}
307
- onEdit={(v) => edit('loginFailLimit', v)}
308
- />
309
- <TextField
310
- prefix="dag"
311
- id="dag-cooldown"
312
- label={t('loginCooldownMs')}
313
- hint={t('loginCooldownMsHint')}
314
- value={draft.loginCooldownMs}
315
- numeric
316
- disabled={disabled}
317
- invalid={!isPositiveInt(draft.loginCooldownMs)}
318
- invalidLabel={t('invalidNumber')}
319
- onEdit={(v) => edit('loginCooldownMs', v)}
320
- />
321
224
  {diag !== null ? (
322
225
  <div className={`dag-diag${diag.invalidEntries.length > 0 ? ' dag-diagWarn' : ''}`}>
323
226
  <p className="dag-diagTitle">{t('diagTitle')}</p>
@@ -335,6 +238,12 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
335
238
  {diag.reason !== null ? t(REASON_KEYS[diag.reason] ?? diag.reason) : '—'}
336
239
  </span>
337
240
  </div>
241
+ <div className="dag-diagRow">
242
+ <span className="dag-diagKey">{t('diagOfficial')}</span>
243
+ <span className="dag-diagVal">
244
+ {diag.officialAuthed ? t('diagOfficialYes') : t('diagOfficialNo')}
245
+ </span>
246
+ </div>
338
247
  {diag.invalidEntries.length > 0 ? (
339
248
  <div className="dag-diagRow">
340
249
  <span className="dag-diagKey">!</span>
@@ -9,77 +9,63 @@ export const NS = 'dsh-plus-access-gate'
9
9
 
10
10
  const ownZh = {
11
11
  title: '访问控制',
12
- description: 'Web 访问围栏:本机直连放行;远程按白名单 IP 或访问令牌放行,其余拦截到登录页。',
12
+ description:
13
+ 'Web 访问围栏:与官方登录合并——官方 cookie 为唯一凭据;未认证导航显示启动令牌输入页(PWA 内可用);可选 IP 附加围栏。',
13
14
  enabled: '启用访问围栏',
14
- enabledHint: '关闭即完全旁路(等价插件缺席);启用后远程流量需命中白名单或令牌。',
15
- token: '访问令牌',
16
- tokenHint: '留空表示保持已存令牌不变;清空并保存需先输入既有令牌确认。',
17
- tokenSet: '已配置',
18
- tokenUnset: '未配置',
19
- allowedIps: '放行 IP 白名单',
15
+ enabledHint: '关闭即完全旁路(等价插件缺席);启用后未认证流量被拦截到令牌输入页或拒绝。',
16
+ mergedHint:
17
+ '登录凭据由官方认证持有(启动令牌换 30 天 cookie)。获取当前令牌:服务器执行 dshctl url,或本机访问 /dsh-plus/gate/launch-url。',
18
+ allowedIps: '附加 IP 围栏白名单',
20
19
  allowedIpsHint:
21
- '每行一个:精确 IP 或 CIDR(IPv4/IPv6 皆可),如 100.86.108.55 或 fd7a:115c:a1e0::/48。',
20
+ '每行一个:精确 IP 或 CIDR(IPv4/IPv6 皆可)。非空时仅白名单来源可通行与登录;留空 = 不限制来源 IP(仅官方登录保护)。',
22
21
  trustForwardedFor: '信任 x-forwarded-for 还原客户端 IP',
23
22
  trustForwardedForHint:
24
23
  '仅当入口代理强制覆盖该头时开启(tailscale serve 满足);直连暴露场景必须关闭。',
25
- cookieMaxAgeHours: '登录有效期(小时)',
26
- cookieMaxAgeHoursHint: '令牌登录后 cookie 的有效期。',
27
- loginFailLimit: '登录失败阈值',
28
- loginFailLimitHint: '每个 IP 在冷却窗口内允许的失败次数,超出进入冷却。',
29
- loginCooldownMs: '登录冷却(毫秒)',
30
- loginCooldownMsHint: '失败达到阈值后的冷却时长。',
31
24
  diagTitle: '当前页面诊断',
32
25
  diagVerdict: '本页判定',
33
26
  diagClientIp: '客户端 IP',
34
27
  diagReason: '放行原因',
35
28
  diagLocal: '本机直连',
36
- diagAllowedIp: '白名单 IP',
37
- diagToken: '令牌',
29
+ diagCookie: '官方 cookie',
30
+ diagOfficial: '官方登录状态',
31
+ diagOfficialYes: '已登录',
32
+ diagOfficialNo: '未登录',
38
33
  diagPass: '放行',
39
34
  diagBlock: '拦截',
40
- diagLogin: '登录页',
35
+ diagTokenPage: '令牌输入页',
41
36
  diagOff: '围栏未启用',
42
37
  diagInvalid: '白名单存在无法解析的条目:',
43
- warnFailClosed: '警告:已启用但令牌与白名单均为空——所有远程访问将被拒绝(含本页之外的设备)。',
44
38
  }
45
39
 
46
40
  const ownEn = {
47
41
  title: 'Access Control',
48
42
  description:
49
- 'Web access gate: loopback passes; remote traffic needs an allowlisted IP or the token, others hit the login page.',
43
+ 'Web access gate merged with official auth: the official cookie is the sole credential; unauthenticated navigations get a launch-token page (works inside the PWA); optional IP fence.',
50
44
  enabled: 'Enable access gate',
51
45
  enabledHint:
52
- 'Off means full bypass (as if absent); on, remote traffic must match the allowlist or the token.',
53
- token: 'Access token',
54
- tokenHint: 'Leave blank to keep the stored token; clearing requires typing the current one.',
55
- tokenSet: 'Set',
56
- tokenUnset: 'Not set',
57
- allowedIps: 'Allowed IP allowlist',
46
+ 'Off means full bypass (as if absent); on, unauthenticated traffic hits the token page or is refused.',
47
+ mergedHint:
48
+ 'Credentials are owned by official auth (launch token exchange, 30-day cookie). Get the current token: run dshctl url on the server, or visit /dsh-plus/gate/launch-url locally.',
49
+ allowedIps: 'Additional IP fence allowlist',
58
50
  allowedIpsHint:
59
- 'One per line: exact IP or CIDR (IPv4/IPv6), e.g. 100.86.108.55 or fd7a:115c:a1e0::/48.',
51
+ 'One per line: exact IP or CIDR (IPv4/IPv6). When non-empty only listed sources may pass or log in; empty = no IP restriction (official login only).',
60
52
  trustForwardedFor: 'Trust x-forwarded-for for client IP',
61
53
  trustForwardedForHint:
62
54
  'Enable only when the entry proxy overwrites that header (tailscale serve does); must be off for direct exposure.',
63
- cookieMaxAgeHours: 'Login validity (hours)',
64
- cookieMaxAgeHoursHint: 'Cookie lifetime after a token login.',
65
- loginFailLimit: 'Login failure limit',
66
- loginFailLimitHint: 'Failures per IP within the cooldown window before throttling.',
67
- loginCooldownMs: 'Login cooldown (ms)',
68
- loginCooldownMsHint: 'Cooldown duration after reaching the limit.',
69
55
  diagTitle: 'This page',
70
56
  diagVerdict: 'Verdict',
71
57
  diagClientIp: 'Client IP',
72
58
  diagReason: 'Reason',
73
59
  diagLocal: 'loopback',
74
- diagAllowedIp: 'allowlisted IP',
75
- diagToken: 'token',
60
+ diagCookie: 'official cookie',
61
+ diagOfficial: 'Official login',
62
+ diagOfficialYes: 'authenticated',
63
+ diagOfficialNo: 'not authenticated',
76
64
  diagPass: 'pass',
77
65
  diagBlock: 'blocked',
78
- diagLogin: 'login page',
66
+ diagTokenPage: 'token page',
79
67
  diagOff: 'gate disabled',
80
68
  diagInvalid: 'Allowlist entries that failed to parse:',
81
- warnFailClosed:
82
- 'Warning: enabled with no token and an empty allowlist — all remote access will be refused.',
83
69
  }
84
70
 
85
71
  export const zh: Record<string, string> = mergeDict(commonZh, ownZh)
package/src/config.ts CHANGED
@@ -2,7 +2,12 @@
2
2
  * 配置单一事实源:cordis 行级 Config(组合默认值,dev/prod patch 层可覆盖)
3
3
  * 与 settings namespace(用户层,经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml)
4
4
  * 共用同一 schemastery schema(notify-email 同款约定)。
5
- * token 标记 secret 角色:任何读取通道不得回传明文。
5
+ *
6
+ * 与官方认证合并后:访问凭据唯一化为官方 browser-auth cookie(dsh-auth-*,
7
+ * 由 client-connection 的 ?token= 启动令牌交换签发),本插件不再持有自有
8
+ * token/cookie;allowedIps 降级为可选的附加 IP 围栏。
9
+ * 旧配置中的 token/cookieMaxAgeHours/loginFailLimit/loginCooldownMs 键由
10
+ * schemastery 透传忽略,不阻断加载(已实测验证)。
6
11
  * @module access-gate/config
7
12
  */
8
13
  import { settingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -18,14 +23,11 @@ export const Config = z.object({
18
23
  .boolean()
19
24
  .description('访问围栏总开关(false = 一切放行,等价插件缺席)')
20
25
  .default(false),
21
- token: z
22
- .string()
23
- .role('secret')
24
- .description('访问令牌;空字符串 = token 通道关闭。经登录页输入后以 HttpOnly cookie 持有')
25
- .default(''),
26
26
  allowedIps: z
27
27
  .array(z.string())
28
- .description('放行 IP 白名单:精确 IP 或 CIDR(IPv4/IPv6 皆可)')
28
+ .description(
29
+ '附加 IP 围栏:非空时仅白名单条目(精确 IP 或 CIDR,IPv4/IPv6 皆可)可通行与登录;空 = 不限制来源 IP(仅官方登录保护)',
30
+ )
29
31
  .default([]),
30
32
  trustForwardedFor: z
31
33
  .boolean()
@@ -33,16 +35,6 @@ export const Config = z.object({
33
35
  '是否信任 x-forwarded-for 还原真实客户端 IP(仅当入口代理强制覆盖该头时开启;tailscale serve 满足)',
34
36
  )
35
37
  .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
38
  })
47
39
 
48
40
  export type AccessGateConfig = Schemastery.TypeT<typeof Config>
@@ -50,12 +42,6 @@ export type AccessGateConfig = Schemastery.TypeT<typeof Config>
50
42
  /** 判定所需的最小配置视图(测试与决策函数共用窄面)。 */
51
43
  export interface GatePolicy {
52
44
  readonly enabled: boolean
53
- readonly token: string
54
45
  readonly allowedIps: readonly string[]
55
46
  readonly trustForwardedFor: boolean
56
47
  }
57
-
58
- /** 远程放行通道是否全部关闭(enabled 且无 token 且白名单空 = 拒绝一切远程)。 */
59
- export function isFailClosed(policy: GatePolicy): boolean {
60
- return policy.enabled && policy.token === '' && policy.allowedIps.length === 0
61
- }
package/src/decision.ts CHANGED
@@ -4,33 +4,34 @@
4
4
  * 信任模型(决策记录详见 docs/README.md):
5
5
  * - dsh web 仅绑 127.0.0.1,远程流量必经 tailscale serve → dsh-proxy 两跳,
6
6
  * serve 会**覆盖**(而非追加)x-forwarded-for,客户端无法伪造;
7
- * - 能直发 loopback 或伪造 XFF 的只有本机进程——与 dsh 官方 /api 围栏的
8
- * loopback 信任边界一致,本机直连视为可信管理通道;
7
+ * - 能直发 loopback 或伪造 XFF 的只有本机进程——本机直连视为可信管理通道;
9
8
  * - 因此 XFF 存在且 trustForwardedFor=true 时取最左条目为真实客户端 IP。
10
9
  *
11
- * token 比较使用 sha256 摘要 + timingSafeEqual 常时比较。
10
+ * 与官方认证合并后的判定语义:
11
+ * - 访问凭据唯一化为官方 browser-auth cookie——`officialAuthed` 由调用方
12
+ * 委托 `connection.requestRejection()` 得出,本模块不感知 cookie 细节;
13
+ * - `allowedIps` 为可选附加围栏:非空时来源 IP 不命中即拒(纵深防御),
14
+ * 空则不限制来源 IP(仅官方登录保护);
15
+ * - 未认证的浏览器导航 → token 输入页(粘贴启动令牌走官方交换,PWA 同路径);
16
+ * 未认证的非导航请求 → 403。
12
17
  * @module access-gate/decision
13
18
  */
14
- import { createHash, timingSafeEqual } from 'node:crypto'
15
-
16
19
  import type { GatePolicy } from './config.ts'
17
20
  import { isLoopbackAddress, parseAllowEntry, parseIp } from './ip.ts'
18
21
 
19
- export const GATE_COOKIE = 'dsh_gate'
20
-
21
22
  /** 围栏判定结果。 */
22
23
  export type GateVerdict =
23
- | 'pass' /** 放行(本机直连 / 白名单 IP / 有效 token) */
24
- | 'block' /** 拒绝:API / WebSocket 等非导航请求 → 403 */
25
- | 'login' /** 拒绝:浏览器导航请求 → 登录页 */
24
+ | 'pass' /** 放行(本机直连 / 官方 cookie 有效) */
25
+ | 'block' /** 拒绝:非导航请求或 IP 围栏拦截 → 403 */
26
+ | 'token-page' /** 拒绝:浏览器导航请求 → token 输入页 */
26
27
 
27
28
  /** 判定输出。 */
28
29
  export interface GateDecision {
29
30
  verdict: GateVerdict
30
31
  /** 还原出的客户端 IP(有 XFF 或非 loopback 直连时存在)。 */
31
32
  clientIp?: string
32
- /** 放行原因(诊断/日志用):local / allowed-ip / token。 */
33
- reason?: 'local' | 'allowed-ip' | 'token'
33
+ /** 放行原因(诊断/日志用):local / cookie。 */
34
+ reason?: 'local' | 'cookie'
34
35
  /** 无效白名单条目(卡片诊断用,判定时忽略它们)。 */
35
36
  invalidEntries?: string[]
36
37
  }
@@ -43,29 +44,6 @@ export interface GateRequest {
43
44
  readonly remoteAddress?: string
44
45
  }
45
46
 
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
47
  /** 取 XFF 最左条目(serve 单跳覆盖语义下即真实客户端 IP)。 */
70
48
  function forwardedIp(headers: Record<string, unknown>): string | undefined {
71
49
  const raw = headers['x-forwarded-for']
@@ -74,7 +52,7 @@ function forwardedIp(headers: Record<string, unknown>): string | undefined {
74
52
  return first === '' ? undefined : first
75
53
  }
76
54
 
77
- /** 浏览器导航请求判定:GET + accept 含 text/html(需要返回登录页而非 403)。 */
55
+ /** 浏览器导航请求判定:GET + accept 含 text/html(需要返回 token 输入页而非 403)。 */
78
56
  export function isNavigationRequest(req: GateRequest): boolean {
79
57
  if (req.method !== 'GET' && req.method !== 'HEAD') return false
80
58
  const accept = req.headers.accept
@@ -97,41 +75,55 @@ export function compileAllowlist(entries: readonly string[]): CompiledAllowlist
97
75
  }
98
76
 
99
77
  /**
100
- * 围栏判定(纯函数,除 token 哈希外无任何副作用与 IO)。
78
+ * 围栏判定(纯函数,无任何副作用与 IO)。
101
79
  * 逻辑次序:豁免路径由调用方前置处理;enabled=false 直通;确定有效来源
102
80
  * (受信 XFF 优先,否则 remoteAddress);来源为 loopback 直连 → 放行;
103
- * 否则按白名单/token 判定;无来源 → fail-closed。
81
+ * 白名单非空时作附加 IP 围栏,不命中即拒;官方 cookie 有效 → 放行;
82
+ * 否则导航 → token 输入页,非导航 → 403;无来源 → fail-closed。
104
83
  *
105
84
  * loopback 语义:XFF 被信任时,存在 XFF 即代表请求经代理(serve→proxy→dsh
106
85
  * 全链 loopback,真实客户端只在 XFF 里)——remoteAddress 是 loopback 也不
107
86
  * 放行,必须按 XFF 判定;XFF 不被信任时,来源就是 remoteAddress,
108
87
  * loopback 即本机直连 → 放行(防锁死兜底优先)。
88
+ *
89
+ * @param officialAuthed - 官方 browser-auth cookie 校验结果(调用方委托
90
+ * connection.requestRejection() 得出;含官方 Host 信任围栏,403 情形
91
+ * 视同未认证)。
109
92
  */
110
- export function decideGate(req: GateRequest, policy: GatePolicy): GateDecision {
93
+ export function decideGate(
94
+ req: GateRequest,
95
+ policy: GatePolicy,
96
+ officialAuthed: boolean,
97
+ ): GateDecision {
111
98
  if (!policy.enabled) return { verdict: 'pass', reason: 'local' }
112
99
 
113
100
  const forwarded = forwardedIp(req.headers)
114
101
  const trustedForwarded = policy.trustForwardedFor ? forwarded : undefined
115
102
  const clientIpText = trustedForwarded ?? req.remoteAddress
116
103
  if (clientIpText === undefined) {
117
- return { verdict: 'block', reason: undefined }
104
+ return { verdict: 'block' }
118
105
  }
119
106
  if (trustedForwarded === undefined && isLoopbackAddress(req.remoteAddress)) {
120
107
  return { verdict: 'pass', reason: 'local' }
121
108
  }
122
109
 
123
110
  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)
111
+ const invalidEntries = allowlist.invalid.length > 0 ? allowlist.invalid : undefined
112
+
113
+ // 附加 IP 围栏:白名单非空时,来源不命中即拒(含持有效官方 cookie 者)。
114
+ if (policy.allowedIps.length > 0) {
115
+ const clientIp = parseIp(clientIpText)
116
+ if (clientIp === null || !allowlist.match(clientIp)) {
117
+ return { verdict: 'block', clientIp: clientIpText, invalidEntries }
118
+ }
119
+ }
128
120
 
129
- const decision: GateDecision = {
130
- verdict: allowedByIp || allowedByToken ? 'pass' : isNavigationRequest(req) ? 'login' : 'block',
121
+ if (officialAuthed) {
122
+ return { verdict: 'pass', reason: 'cookie', clientIp: clientIpText, invalidEntries }
123
+ }
124
+ return {
125
+ verdict: isNavigationRequest(req) ? 'token-page' : 'block',
131
126
  clientIp: clientIpText,
127
+ invalidEntries,
132
128
  }
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
129
  }
package/src/index.ts CHANGED
@@ -1,12 +1,20 @@
1
1
  /**
2
2
  * dsh 插件:访问围栏(access-gate)。
3
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 / 拒绝升级。
4
+ * 与官方 browser-auth 合并后的全量访问围栏:
5
+ * - 访问凭据唯一化为官方 dsh-auth-* cookie(`?token=` 启动令牌交换签发,
6
+ * 30 天、按 authority 分别持有)——凭据校验委托 connection.requestRejection(),
7
+ * 本插件不再持有自有 token/cookie;
8
+ * - 本机直连(loopback 且无 x-forwarded-for)gate 层永久放行——管理通道;
9
+ * - allowedIps 为可选附加 IP 围栏(XFF 还原,精确 IP / CIDR,v4+v6);
10
+ * - 未认证的浏览器导航 → token 输入页(粘贴启动令牌走官方交换,**PWA 内
11
+ * 同路径可用**——恢复官方 start_url 无法携带 token 导致的 PWA 死结);
12
+ * 未认证的 API / WS / 静态资产 → 403 / 拒绝升级;
13
+ * - 豁免:自有端点(/dsh-plus/gate/*)、PWA 安装资产、官方令牌交换请求。
14
+ *
15
+ * 自有端点:GET /dsh-plus/gate/status(卡片诊断)、GET /dsh-plus/gate/launch-url
16
+ * (仅本机直连,返回当前进程认证链接——令牌跨 authority 有效,支持 host/scheme
17
+ * 参数生成远程变体)。
10
18
  *
11
19
  * 配置:settings 命名空间 dsh-plus-access-gate(webui 配置卡片同一份,
12
20
  * 热生效);enabled=false 时完全旁路(等价插件缺席)。
@@ -14,7 +22,11 @@
14
22
  * 隔离止损,绝不静默 fail-open)。
15
23
  * @module @dsh-plus/access-gate
16
24
  */
25
+
26
+ import type { IncomingMessage } from 'node:http'
17
27
  import { type Context, Service } from '@deepseek-ai/cordis'
28
+ // HostConnectionHandle 的 Context 声明合并(connection 服务类型来源)。
29
+ import type {} from '@deepseek-ai/dsh-client-connection'
18
30
  import { installSettingsSection } from '@deepseek-ai/dsh-settings'
19
31
 
20
32
  import { type AccessGateConfig, Config, SETTINGS_NS } from './config.ts'
@@ -23,7 +35,7 @@ import { registerGateRoutes } from './routes.ts'
23
35
 
24
36
  export const name = 'dsh-plus-access-gate'
25
37
 
26
- export const inject = ['webServer'] as const
38
+ export const inject = ['webServer', 'connection'] as const
27
39
 
28
40
  export { Config }
29
41
 
@@ -48,15 +60,22 @@ export class AccessGateService extends Service {
48
60
  })
49
61
  const logger = ctx.logger('access-gate')
50
62
 
63
+ // 官方凭据校验委托:undefined = 已通过(含官方 Host 信任围栏)。
64
+ const officialAuth = (req: IncomingMessage): boolean =>
65
+ ctx.connection.requestRejection({ headers: req.headers }) === undefined
66
+
51
67
  // 自有端点(/dsh-plus/gate/*,围栏豁免路径)。
52
68
  registerGateRoutes(ctx, {
53
69
  config: () => this.current(),
70
+ officialAuth,
71
+ authenticatedUrl: (baseUrl) => ctx.connection.authenticatedUrl(baseUrl),
54
72
  onError: (message) => logger.warn(message),
55
73
  })
56
74
 
57
75
  // 结构包装:全部 HTTP/WS 流量的围栏。
58
76
  const undo = installGateInterceptor(ctx, ctx.webServer as unknown as GateWebServer, {
59
77
  config: () => this.current(),
78
+ officialAuth,
60
79
  onBlock: (message) => logger.info(message),
61
80
  })
62
81
  ctx.effect(() => undo, 'access-gate: interceptor')