@dsh-plus/access-gate 0.1.3 → 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.
- package/lib/client.js +83 -186
- package/lib/index.d.ts +1 -9
- package/lib/index.js +183 -258
- package/package.json +10 -10
- package/src/client/card.tsx +25 -123
- package/src/client/client.ts +13 -13
- package/src/client/i18n.ts +23 -37
- package/src/config.ts +9 -23
- package/src/decision.ts +42 -50
- package/src/index.ts +26 -7
- package/src/interceptor.ts +33 -11
- package/src/routes.ts +47 -109
- package/src/token-page.ts +88 -0
- package/src/login-page.ts +0 -77
package/src/client/card.tsx
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 「访问控制」配置卡片:注册进 settings.plugin.item 插槽(官方插件配置页)。
|
|
3
3
|
* 外壳与基础控件走 @dsh-plus/shared/client 套件(CardChrome/TextField/CheckRow),
|
|
4
|
-
*
|
|
5
|
-
* 配置读写走官方 settingsScope 传输:value 为 schema
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
|
@@ -14,37 +17,28 @@ import {
|
|
|
14
17
|
type CardStatusState,
|
|
15
18
|
CheckRow,
|
|
16
19
|
IDLE_STATUS,
|
|
20
|
+
type NamespaceSettingsApi,
|
|
17
21
|
type Scope,
|
|
18
|
-
type SettingsApi,
|
|
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
|
|
26
27
|
scope: Scope
|
|
27
|
-
api:
|
|
28
|
+
api: NamespaceSettingsApi
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
/** settings
|
|
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
|
-
|
|
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 === '
|
|
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((response) => {
|
|
131
|
-
if (!alive || !response.result.ok) return
|
|
132
|
-
const view = response.result.value?.namespaces.find((ns) => ns.ns === SETTINGS_NS)
|
|
133
|
-
setTokenConfigured(
|
|
134
|
-
view?.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,18 +140,10 @@ 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
|
-
.update(
|
|
196
|
-
|
|
197
|
-
patch,
|
|
198
|
-
...(revision !== undefined ? { expectedRevision: revision } : {}),
|
|
199
|
-
})
|
|
200
|
-
.then(async (response) => {
|
|
201
|
-
if (!response.result.ok) {
|
|
202
|
-
throw new Error(response.result.error?.message ?? t('saveFailed'))
|
|
203
|
-
}
|
|
145
|
+
.update(patch, revision)
|
|
146
|
+
.then(async () => {
|
|
204
147
|
await scope.load()
|
|
205
148
|
const next = scope.getSnapshot().value as ConfigValue | undefined
|
|
206
149
|
if (next !== undefined) setDraft(draftFromValue(next))
|
|
@@ -240,12 +183,12 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
|
|
|
240
183
|
key: 'save',
|
|
241
184
|
label: t(saving ? 'saving' : 'save'),
|
|
242
185
|
variant: 'primary',
|
|
243
|
-
disabled: !dirty ||
|
|
186
|
+
disabled: !dirty || saving || disabled,
|
|
244
187
|
onClick: onSave,
|
|
245
188
|
},
|
|
246
189
|
]}
|
|
247
190
|
>
|
|
248
|
-
|
|
191
|
+
<p className="dag-hint">{t('mergedHint')}</p>
|
|
249
192
|
<CheckRow
|
|
250
193
|
prefix="dag"
|
|
251
194
|
id="dag-enabled"
|
|
@@ -254,17 +197,6 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
|
|
|
254
197
|
disabled={disabled}
|
|
255
198
|
onEdit={(v) => edit('enabled', v)}
|
|
256
199
|
/>
|
|
257
|
-
<TextField
|
|
258
|
-
prefix="dag"
|
|
259
|
-
id="dag-token"
|
|
260
|
-
label={t('token')}
|
|
261
|
-
hint={t('tokenHint')}
|
|
262
|
-
value={draft.token}
|
|
263
|
-
password
|
|
264
|
-
disabled={disabled}
|
|
265
|
-
badge={{ text: t(tokenConfigured ? 'tokenSet' : 'tokenUnset'), set: tokenConfigured }}
|
|
266
|
-
onEdit={(v) => edit('token', v)}
|
|
267
|
-
/>
|
|
268
200
|
<div className="dag-field">
|
|
269
201
|
<div className="dag-head">
|
|
270
202
|
<label className="dag-label" htmlFor="dag-allowed">
|
|
@@ -289,42 +221,6 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
|
|
|
289
221
|
disabled={disabled}
|
|
290
222
|
onEdit={(v) => edit('trustForwardedFor', v)}
|
|
291
223
|
/>
|
|
292
|
-
<TextField
|
|
293
|
-
prefix="dag"
|
|
294
|
-
id="dag-cookie"
|
|
295
|
-
label={t('cookieMaxAgeHours')}
|
|
296
|
-
hint={t('cookieMaxAgeHoursHint')}
|
|
297
|
-
value={draft.cookieMaxAgeHours}
|
|
298
|
-
numeric
|
|
299
|
-
disabled={disabled}
|
|
300
|
-
invalid={!isPositiveInt(draft.cookieMaxAgeHours)}
|
|
301
|
-
invalidLabel={t('invalidNumber')}
|
|
302
|
-
onEdit={(v) => edit('cookieMaxAgeHours', v)}
|
|
303
|
-
/>
|
|
304
|
-
<TextField
|
|
305
|
-
prefix="dag"
|
|
306
|
-
id="dag-faillimit"
|
|
307
|
-
label={t('loginFailLimit')}
|
|
308
|
-
hint={t('loginFailLimitHint')}
|
|
309
|
-
value={draft.loginFailLimit}
|
|
310
|
-
numeric
|
|
311
|
-
disabled={disabled}
|
|
312
|
-
invalid={!isPositiveInt(draft.loginFailLimit)}
|
|
313
|
-
invalidLabel={t('invalidNumber')}
|
|
314
|
-
onEdit={(v) => edit('loginFailLimit', v)}
|
|
315
|
-
/>
|
|
316
|
-
<TextField
|
|
317
|
-
prefix="dag"
|
|
318
|
-
id="dag-cooldown"
|
|
319
|
-
label={t('loginCooldownMs')}
|
|
320
|
-
hint={t('loginCooldownMsHint')}
|
|
321
|
-
value={draft.loginCooldownMs}
|
|
322
|
-
numeric
|
|
323
|
-
disabled={disabled}
|
|
324
|
-
invalid={!isPositiveInt(draft.loginCooldownMs)}
|
|
325
|
-
invalidLabel={t('invalidNumber')}
|
|
326
|
-
onEdit={(v) => edit('loginCooldownMs', v)}
|
|
327
|
-
/>
|
|
328
224
|
{diag !== null ? (
|
|
329
225
|
<div className={`dag-diag${diag.invalidEntries.length > 0 ? ' dag-diagWarn' : ''}`}>
|
|
330
226
|
<p className="dag-diagTitle">{t('diagTitle')}</p>
|
|
@@ -342,6 +238,12 @@ export function AccessGateCard(props: CardProps): ReactElement | null {
|
|
|
342
238
|
{diag.reason !== null ? t(REASON_KEYS[diag.reason] ?? diag.reason) : '—'}
|
|
343
239
|
</span>
|
|
344
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>
|
|
345
247
|
{diag.invalidEntries.length > 0 ? (
|
|
346
248
|
<div className="dag-diagRow">
|
|
347
249
|
<span className="dag-diagKey">!</span>
|
package/src/client/client.ts
CHANGED
|
@@ -3,17 +3,21 @@
|
|
|
3
3
|
* 构建产物为 window.__ModuleLoader__.load({id, factory}) 形式的 CJS factory
|
|
4
4
|
* (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
|
|
5
5
|
*
|
|
6
|
-
* 类型说明:浏览器半只用到 slots/locale/
|
|
6
|
+
* 类型说明:浏览器半只用到 slots/locale/remote 的很窄一面,
|
|
7
7
|
* scope 与基础控件走 @dsh-plus/shared/client 套件(收编自本插件原实现)。
|
|
8
|
-
*
|
|
8
|
+
* 该插槽为 keyed 槽位:key 必须是卡片编辑的 settings 命名空间
|
|
9
9
|
* (即服务端 settingsNamespace() 注册的同一字面量,见 ../ns.ts),
|
|
10
10
|
* 官方配置页按此 key 与 Host 已注册命名空间配对分发。
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* 配置读写经 ctx.remote.settings 直连(0.1.2-alpha.1 起 connection.api.settings
|
|
12
|
+
* 已移除;不复用 settingsScope 服务——非 loopback 页面下它固定 memory 模式无数据)。
|
|
13
13
|
* @module @dsh-plus/access-gate/client
|
|
14
14
|
*/
|
|
15
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createNamespaceApi,
|
|
18
|
+
createSettingsScope,
|
|
19
|
+
type SettingsRemoteFace,
|
|
20
|
+
} from '@dsh-plus/shared/client'
|
|
17
21
|
import { SETTINGS_NS } from '../ns.ts'
|
|
18
22
|
import { AccessGateCard } from './card.tsx'
|
|
19
23
|
import { en, NS, zh } from './i18n.ts'
|
|
@@ -22,7 +26,7 @@ import { injectStyle } from './styles.ts'
|
|
|
22
26
|
export const name = 'dsh-plus-access-gate'
|
|
23
27
|
|
|
24
28
|
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
25
|
-
export const inject = ['slots', 'locale', '
|
|
29
|
+
export const inject = ['slots', 'locale', 'remote', 'remote.settings'] as const
|
|
26
30
|
|
|
27
31
|
interface SlotsLike {
|
|
28
32
|
inject(key: string, callback: () => unknown): unknown
|
|
@@ -35,17 +39,13 @@ interface LocaleLike {
|
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
interface RemoteLike {
|
|
42
|
+
settings: SettingsRemoteFace
|
|
38
43
|
$on(event: string, listener: (payload?: unknown) => void): () => void
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
interface ConnectionLike {
|
|
42
|
-
api: { settings: Parameters<typeof createApiScope>[0] }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
46
|
interface ClientContext {
|
|
46
47
|
slots: SlotsLike
|
|
47
48
|
locale: LocaleLike
|
|
48
|
-
get(key: 'connection'): ConnectionLike
|
|
49
49
|
get(key: 'remote'): RemoteLike
|
|
50
50
|
on(event: string, listener: () => void): () => void
|
|
51
51
|
effect(execute: () => () => void, label?: string): unknown
|
|
@@ -61,8 +61,8 @@ export function apply(ctx: Context): void {
|
|
|
61
61
|
'access-gate: style',
|
|
62
62
|
)
|
|
63
63
|
c.effect(() => c.locale.register(NS, { zh, en }), 'access-gate: locale')
|
|
64
|
-
const
|
|
65
|
-
const
|
|
64
|
+
const scope = createSettingsScope(c, SETTINGS_NS, 'access-gate: settings scope')
|
|
65
|
+
const api = createNamespaceApi(c.get('remote').settings, SETTINGS_NS)
|
|
66
66
|
c.slots.inject('settings.plugin.item', () =>
|
|
67
67
|
c.slots.register(
|
|
68
68
|
{
|
package/src/client/i18n.ts
CHANGED
|
@@ -9,77 +9,63 @@ export const NS = 'dsh-plus-access-gate'
|
|
|
9
9
|
|
|
10
10
|
const ownZh = {
|
|
11
11
|
title: '访问控制',
|
|
12
|
-
description:
|
|
12
|
+
description:
|
|
13
|
+
'Web 访问围栏:与官方登录合并——官方 cookie 为唯一凭据;未认证导航显示启动令牌输入页(PWA 内可用);可选 IP 附加围栏。',
|
|
13
14
|
enabled: '启用访问围栏',
|
|
14
|
-
enabledHint: '
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
29
|
+
diagCookie: '官方 cookie',
|
|
30
|
+
diagOfficial: '官方登录状态',
|
|
31
|
+
diagOfficialYes: '已登录',
|
|
32
|
+
diagOfficialNo: '未登录',
|
|
38
33
|
diagPass: '放行',
|
|
39
34
|
diagBlock: '拦截',
|
|
40
|
-
|
|
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:
|
|
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,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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)
|
|
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
|
-
|
|
75
|
-
|
|
60
|
+
diagCookie: 'official cookie',
|
|
61
|
+
diagOfficial: 'Official login',
|
|
62
|
+
diagOfficialYes: 'authenticated',
|
|
63
|
+
diagOfficialNo: 'not authenticated',
|
|
76
64
|
diagPass: 'pass',
|
|
77
65
|
diagBlock: 'blocked',
|
|
78
|
-
|
|
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
|
-
*
|
|
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(
|
|
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
|
|
8
|
-
* loopback 信任边界一致,本机直连视为可信管理通道;
|
|
7
|
+
* - 能直发 loopback 或伪造 XFF 的只有本机进程——本机直连视为可信管理通道;
|
|
9
8
|
* - 因此 XFF 存在且 trustForwardedFor=true 时取最左条目为真实客户端 IP。
|
|
10
9
|
*
|
|
11
|
-
*
|
|
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' /** 放行(本机直连 /
|
|
24
|
-
| 'block' /**
|
|
25
|
-
| '
|
|
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 /
|
|
33
|
-
reason?: 'local' | '
|
|
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
|
|
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
|
-
*
|
|
78
|
+
* 围栏判定(纯函数,无任何副作用与 IO)。
|
|
101
79
|
* 逻辑次序:豁免路径由调用方前置处理;enabled=false 直通;确定有效来源
|
|
102
80
|
* (受信 XFF 优先,否则 remoteAddress);来源为 loopback 直连 → 放行;
|
|
103
|
-
*
|
|
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(
|
|
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'
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
verdict:
|
|
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
|
}
|