@dsh-plus/access-gate 0.1.4 → 0.2.1

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/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,8 +22,12 @@
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'
18
- import { installSettingsSection } from '@deepseek-ai/dsh-settings'
28
+ // HostConnectionHandle 的 Context 声明合并(connection 服务类型来源)。
29
+ import type {} from '@deepseek-ai/dsh-client-connection'
30
+ import type {} from '@deepseek-ai/dsh-settings'
19
31
 
20
32
  import { type AccessGateConfig, Config, SETTINGS_NS } from './config.ts'
21
33
  import { type GateWebServer, installGateInterceptor } from './interceptor.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
 
@@ -40,23 +52,34 @@ export class AccessGateService extends Service {
40
52
  constructor(ctx: Context, config: AccessGateConfig) {
41
53
  super(ctx, 'accessGate')
42
54
  this.current = () => config
43
- installSettingsSection(ctx, SETTINGS_NS, Config, config, {
44
- setSource: (source) => {
45
- this.current = source
46
- },
47
- onChange: () => {},
55
+ // 官方 installSection 范式(0.1.2-alpha.2):settings 提供方在时以行级 config 为
56
+ // base 注册用户层命名空间,缺席/ detach 时回落行级 config。
57
+ ctx.inject(['settings'], (settingsCtx) => {
58
+ settingsCtx.settings.installSection(ctx, SETTINGS_NS, Config, config, {
59
+ setSource: (source) => {
60
+ this.current = source
61
+ },
62
+ onChange: () => {},
63
+ })
48
64
  })
49
65
  const logger = ctx.logger('access-gate')
50
66
 
67
+ // 官方凭据校验委托:undefined = 已通过(含官方 Host 信任围栏)。
68
+ const officialAuth = (req: IncomingMessage): boolean =>
69
+ ctx.connection.requestRejection({ headers: req.headers }) === undefined
70
+
51
71
  // 自有端点(/dsh-plus/gate/*,围栏豁免路径)。
52
72
  registerGateRoutes(ctx, {
53
73
  config: () => this.current(),
74
+ officialAuth,
75
+ authenticatedUrl: (baseUrl) => ctx.connection.authenticatedUrl(baseUrl),
54
76
  onError: (message) => logger.warn(message),
55
77
  })
56
78
 
57
79
  // 结构包装:全部 HTTP/WS 流量的围栏。
58
80
  const undo = installGateInterceptor(ctx, ctx.webServer as unknown as GateWebServer, {
59
81
  config: () => this.current(),
82
+ officialAuth,
60
83
  onBlock: (message) => logger.info(message),
61
84
  })
62
85
  ctx.effect(() => undo, 'access-gate: interceptor')
@@ -4,7 +4,7 @@
4
4
  * 机制(dsh-host-webserver 无中间件机制,路由分发必经以下三个面):
5
5
  * 1. `match(pathname)` —— HTTP 分发总咽喉:patch 后**请求级**围栏判定——
6
6
  * 放行则返回原 route(handler 外包一层,route 对象本体不动,webserver
7
- * 拿到的是浅拷贝);拒绝则返回内联拒绝 route(导航→登录页,其余→403;
7
+ * 拿到的是浅拷贝);拒绝则返回内联拒绝 route(导航→token 输入页,其余→403;
8
8
  * 连「未知路径」的探测面也一并收敛)。与路由注册时序完全无关。
9
9
  * 2. `fallback` 字段 + `registerFallback` 方法 —— SPA index/静态资源:
10
10
  * 在位包装已注册的 fallback(frontend-static 先于本插件),并 patch
@@ -12,6 +12,12 @@
12
12
  * 3. `upgrades` Map 各 route 的 handler + `registerUpgrade` 方法 —— WS 升级
13
13
  * 双路径覆盖(先/后注册的条目都被拦截)。
14
14
  *
15
+ * 与官方认证合并后的放行面:
16
+ * - 凭据校验委托官方 `connection.requestRejection()`(officialAuth 注入);
17
+ * - 豁免:自有端点(/dsh-plus/gate/*)、PWA 安装资产(manifest/favicon)、
18
+ * 官方令牌交换(GET /?token=,校验本身由官方 authorizeIndex 完成);
19
+ * - 未认证导航 → token 输入页(PWA 内同路径可用);未认证非导航 → 403。
20
+ *
15
21
  * 结构守卫:安装前校验字段形状(match 为函数、upgrades 为 Map 等),
16
22
  * 不符即 fail-loud 抛错(启动失败由 lifeboat 隔离止损,绝不静默 fail-open)。
17
23
  * 恢复:所有 patch 与在位 handler 替换均登记 undo,dispose/HMR 时完全还原。
@@ -24,8 +30,8 @@ import type { Context } from '@deepseek-ai/cordis'
24
30
 
25
31
  import type { AccessGateConfig } from './config.ts'
26
32
  import { decideGate, type GateRequest } from './decision.ts'
27
- import { renderLoginPage } from './login-page.ts'
28
33
  import { GATE_ROUTE } from './routes.ts'
34
+ import { renderTokenPage } from './token-page.ts'
29
35
 
30
36
  type HttpHandler = (req: IncomingMessage, res: ServerResponse) => unknown
31
37
  type UpgradeHandler = (req: IncomingMessage, socket: Duplex, head: Buffer) => unknown
@@ -43,12 +49,26 @@ export interface GateWebServer {
43
49
 
44
50
  export interface InterceptorDeps {
45
51
  config: () => AccessGateConfig
52
+ /** 官方 browser-auth 校验(委托 connection.requestRejection;含官方 Host 围栏)。 */
53
+ officialAuth: (req: IncomingMessage) => boolean
46
54
  onBlock?: (message: string) => void
47
55
  }
48
56
 
49
- /** 是否围栏豁免路径(自有登录/状态端点)。 */
57
+ /** PWA 安装/图标资产:未认证也必须可达,否则浏览器无法安装 PWA。 */
58
+ const PWA_ASSETS: ReadonlySet<string> = new Set(['/manifest.webmanifest', '/favicon.svg'])
59
+
60
+ /** 是否围栏豁免路径(自有状态/管理端点 + PWA 安装资产)。 */
50
61
  export function isExemptPath(pathname: string): boolean {
51
- return pathname === GATE_ROUTE || pathname.startsWith(`${GATE_ROUTE}/`)
62
+ return (
63
+ pathname === GATE_ROUTE || pathname.startsWith(`${GATE_ROUTE}/`) || PWA_ASSETS.has(pathname)
64
+ )
65
+ }
66
+
67
+ /** 官方令牌交换请求:GET / 且携带 token 查询参数(放行给官方 authorizeIndex 校验)。 */
68
+ export function isTokenExchange(req: IncomingMessage): boolean {
69
+ if (req.method !== 'GET') return false
70
+ const url = new URL(req.url ?? '/', 'http://x')
71
+ return url.pathname === '/' && url.searchParams.has('token')
52
72
  }
53
73
 
54
74
  function toGateRequest(req: IncomingMessage): GateRequest {
@@ -77,14 +97,15 @@ function rejectUpgrade(socket: Duplex, clientIp: string, log: (m: string) => voi
77
97
 
78
98
  /** 单请求围栏判定;放行 true / 拒绝(已写响应)false。 */
79
99
  function passHttp(req: IncomingMessage, res: ServerResponse, deps: InterceptorDeps): boolean {
80
- if (isExemptPath(new URL(req.url ?? '/', 'http://x').pathname)) return true
81
- const decision = decideGate(toGateRequest(req), deps.config())
100
+ const url = new URL(req.url ?? '/', 'http://x')
101
+ if (isExemptPath(url.pathname) || isTokenExchange(req)) return true
102
+ const decision = decideGate(toGateRequest(req), deps.config(), deps.officialAuth(req))
82
103
  if (decision.verdict === 'pass') return true
83
104
  const log = deps.onBlock ?? (() => {})
84
- if (decision.verdict === 'login' && !res.headersSent) {
105
+ if (decision.verdict === 'token-page' && !res.headersSent) {
85
106
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
86
- res.end(renderLoginPage())
87
- log(`login page ${req.method ?? '?'} ${req.url ?? '?'}`)
107
+ res.end(renderTokenPage())
108
+ log(`token page ${req.method ?? '?'} ${req.url ?? '?'}`)
88
109
  return false
89
110
  }
90
111
  if (!res.headersSent) {
@@ -100,7 +121,6 @@ function passHttp(req: IncomingMessage, res: ServerResponse, deps: InterceptorDe
100
121
  /** HTTP handler 包装体:先判定再派发。 */
101
122
  function gatedHttp(handler: HttpHandler, deps: InterceptorDeps): HttpHandler {
102
123
  return async (req, res) => {
103
- if (isExemptPath(new URL(req.url ?? '/', 'http://x').pathname)) return handler(req, res)
104
124
  if (!passHttp(req, res, deps)) return
105
125
  return handler(req, res)
106
126
  }
@@ -109,7 +129,9 @@ function gatedHttp(handler: HttpHandler, deps: InterceptorDeps): HttpHandler {
109
129
  /** WS 升级包装体:拒绝时不进协议协商。 */
110
130
  function gatedUpgrade(handler: UpgradeHandler, deps: InterceptorDeps): UpgradeHandler {
111
131
  return (req, socket, head) => {
112
- const decision = decideGate(toGateRequest(req), deps.config())
132
+ if (isExemptPath(new URL(req.url ?? '/', 'http://x').pathname))
133
+ return handler(req, socket, head)
134
+ const decision = decideGate(toGateRequest(req), deps.config(), deps.officialAuth(req))
113
135
  if (decision.verdict === 'pass') return handler(req, socket, head)
114
136
  rejectUpgrade(socket, decision.clientIp ?? 'unknown', deps.onBlock ?? (() => {}))
115
137
  return undefined
package/src/ns.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
3
- * 服务端经 settingsNamespace() 品牌化后使用(config.ts),浏览器半将其作为
4
- * settings.plugin.item keyed 槽位的 key —— 同一字面量两处共用,防止漂移。
3
+ * 服务端直接以字面量注册(0.1.2-alpha.2 起 dsh-settings 编译期校验命名空间
4
+ * 语法,见 config.ts),浏览器半将其作为 settings.plugin.item keyed 槽位的
5
+ * key —— 同一字面量两处共用,防止漂移。
5
6
  * @module access-gate/ns
6
7
  */
7
8
  export const SETTINGS_NS = 'dsh-plus-access-gate'
package/src/routes.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * 自有 HTTP 端点(/dsh-plus/gate 前缀,围栏豁免路径):
3
- * - POST /dsh-plus/gate/login — token 换 cookie(HttpOnly + SameSite=Strict)
4
- * - GET /dsh-plus/gate/status — 当前客户端判定状态(登录页与卡片诊断)
3
+ * - GET /dsh-plus/gate/status — 当前客户端判定状态(配置卡片诊断)
4
+ * - GET /dsh-plus/gate/launch-url — 当前进程认证链接(仅本机直连可用的管理通道)
5
5
  *
6
- * 登录失败节流:按客户端 IP 记录失败次数与冷却截止(惰性清理,无定时器)。
6
+ * 与官方认证合并后不再提供 login 端点:令牌校验完全由官方 authorizeIndex
7
+ * 完成(token 输入页纯客户端跳转 `/?token=`),无失败节流面。
7
8
  * handler 工厂与路由注册分离,测试直接驱动 handler(禁起真实服务器)。
8
9
  * @module access-gate/routes
9
10
  */
10
11
 
11
- import { createHash, timingSafeEqual } from 'node:crypto'
12
12
  import type { IncomingMessage, ServerResponse } from 'node:http'
13
13
 
14
14
  import type { Context } from '@deepseek-ai/cordis'
@@ -16,59 +16,26 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
16
16
 
17
17
  import type { AccessGateConfig } from './config.ts'
18
18
  import { decideGate } from './decision.ts'
19
+ import { isLoopbackAddress } from './ip.ts'
19
20
 
20
21
  export const GATE_ROUTE = '/dsh-plus/gate'
21
22
 
22
- /** 节流状态(纯数据结构,可注入 clock 测试)。 */
23
- export interface ThrottleState {
24
- fails: Map<string, { count: number; until: number }>
25
- }
26
-
27
- export function createThrottleState(): ThrottleState {
28
- return { fails: new Map() }
29
- }
30
-
31
23
  export interface RouteDeps {
32
24
  config: () => AccessGateConfig
25
+ /** 官方 browser-auth cookie 校验(委托 connection.requestRejection)。 */
26
+ officialAuth: (req: IncomingMessage) => boolean
27
+ /** 生成当前进程认证链接(委托 connection.authenticatedUrl)。 */
28
+ authenticatedUrl: (baseUrl: string) => string
33
29
  /** 客户端 IP 还原(与围栏同一规则);缺省时从请求自身推导。 */
34
30
  resolveClientIp?: (req: IncomingMessage) => string | undefined
35
- throttle?: ThrottleState
36
- clock?: () => number
37
31
  onError?: (message: string) => void
38
32
  }
39
33
 
40
- function sendJson(
41
- res: ServerResponse,
42
- status: number,
43
- body: unknown,
44
- headers?: Record<string, string>,
45
- ): void {
46
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...headers })
34
+ function sendJson(res: ServerResponse, status: number, body: unknown): void {
35
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
47
36
  res.end(JSON.stringify(body))
48
37
  }
49
38
 
50
- function readBody(req: IncomingMessage): Promise<string> {
51
- return new Promise((resolve, reject) => {
52
- const chunks: Buffer[] = []
53
- req.on('data', (chunk: Buffer) => chunks.push(chunk))
54
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
55
- req.on('error', reject)
56
- })
57
- }
58
-
59
- /** 解析 JSON body;非对象或解析失败返回 null。 */
60
- async function readJson(req: IncomingMessage): Promise<Record<string, unknown> | null> {
61
- if (!req.headers['content-type']?.includes('application/json')) return null
62
- try {
63
- const parsed: unknown = JSON.parse(await readBody(req))
64
- return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
65
- ? (parsed as Record<string, unknown>)
66
- : null
67
- } catch {
68
- return null
69
- }
70
- }
71
-
72
39
  /** 默认客户端 IP 还原:XFF 最左优先,否则 socket 地址。 */
73
40
  function clientIpOf(req: IncomingMessage): string | undefined {
74
41
  const forwarded = req.headers['x-forwarded-for']
@@ -81,64 +48,20 @@ function clientIpOf(req: IncomingMessage): string | undefined {
81
48
  return remote.startsWith('::ffff:') ? remote.slice(7) : remote
82
49
  }
83
50
 
84
- /** 是否处于冷却期;冷却期满的条目惰性清除(计数中条目保留)。 */
85
- function throttled(state: ThrottleState, ip: string, now: number): boolean {
86
- const entry = state.fails.get(ip)
87
- if (entry === undefined) return false
88
- if (entry.until > now) return true
89
- if (entry.until > 0) state.fails.delete(ip)
90
- return false
91
- }
92
-
93
- /** 记一次失败;达到阈值则进入冷却。 */
94
- function recordFailure(
95
- state: ThrottleState,
96
- ip: string,
97
- limit: number,
98
- cooldownMs: number,
99
- now: number,
100
- ): void {
101
- const entry = state.fails.get(ip) ?? { count: 0, until: 0 }
102
- entry.count += 1
103
- if (entry.count >= limit) {
104
- entry.until = now + cooldownMs
105
- entry.count = 0
106
- }
107
- state.fails.set(ip, entry)
51
+ /**
52
+ * 本机直连判定(launch-url 管理通道专用):socket 为 loopback 且不带
53
+ * XFF(带 XFF 说明经代理,真实客户端在远端,不属本机管理通道)。
54
+ */
55
+ function isDirectLoopback(req: IncomingMessage): boolean {
56
+ if (req.headers['x-forwarded-for'] !== undefined) return false
57
+ return isLoopbackAddress(req.socket?.remoteAddress)
108
58
  }
109
59
 
110
60
  export function createGateRoutes(
111
61
  deps: RouteDeps,
112
62
  ): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
113
- const throttle = deps.throttle ?? createThrottleState()
114
- const clock = deps.clock ?? Date.now
115
63
  const resolveIp = deps.resolveClientIp ?? clientIpOf
116
64
 
117
- const handleLogin = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
118
- const config = deps.config()
119
- const body = await readJson(req)
120
- if (body === undefined || body === null || typeof body.token !== 'string') {
121
- sendJson(res, 400, { error: 'invalid json body' })
122
- return
123
- }
124
- const ip = resolveIp(req) ?? 'unknown'
125
- const now = clock()
126
- if (throttled(throttle, ip, now)) {
127
- sendJson(res, 429, { error: 'too many attempts' })
128
- return
129
- }
130
- const ok = verifyToken(body.token, config.token)
131
- if (!ok) {
132
- recordFailure(throttle, ip, config.loginFailLimit, config.loginCooldownMs, now)
133
- sendJson(res, 403, { error: 'invalid token' })
134
- return
135
- }
136
- throttle.fails.delete(ip)
137
- const maxAge = config.cookieMaxAgeHours * 3600
138
- const cookie = `dsh_gate=${body.token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`
139
- sendJson(res, 200, { ok: true }, { 'set-cookie': cookie })
140
- }
141
-
142
65
  const handleStatus = (req: IncomingMessage, res: ServerResponse): void => {
143
66
  const config = deps.config()
144
67
  const decision = decideGate(
@@ -149,30 +72,53 @@ export function createGateRoutes(
149
72
  remoteAddress: req.socket?.remoteAddress,
150
73
  },
151
74
  config,
75
+ deps.officialAuth(req),
152
76
  )
153
77
  sendJson(res, 200, {
154
78
  enabled: config.enabled,
155
79
  verdict: decision.verdict,
156
- clientIp: decision.clientIp ?? null,
80
+ clientIp: decision.clientIp ?? resolveIp(req) ?? null,
157
81
  reason: decision.reason ?? null,
82
+ officialAuthed: deps.officialAuth(req),
83
+ ipFenceActive: config.allowedIps.length > 0,
158
84
  invalidEntries: decision.invalidEntries ?? [],
159
- tokenConfigured: config.token !== '',
160
85
  allowedCount: config.allowedIps.length,
161
86
  })
162
87
  }
163
88
 
89
+ const handleLaunchUrl = (req: IncomingMessage, res: ServerResponse): void => {
90
+ if (!isDirectLoopback(req)) {
91
+ sendJson(res, 403, { error: 'loopback only' })
92
+ return
93
+ }
94
+ // 令牌本身跨 authority 有效(官方交换不校验 authority),支持按指定
95
+ // host/scheme 生成变体(如 tailscale 域名);缺省取请求的 loopback host。
96
+ const url = new URL(req.url ?? '/', 'http://localhost')
97
+ const host = url.searchParams.get('host') ?? req.headers.host
98
+ if (typeof host !== 'string' || host === '') {
99
+ sendJson(res, 400, { error: 'missing host' })
100
+ return
101
+ }
102
+ const scheme = url.searchParams.get('scheme') ?? 'http'
103
+ try {
104
+ sendJson(res, 200, { url: deps.authenticatedUrl(`${scheme}://${host}`) })
105
+ } catch {
106
+ sendJson(res, 400, { error: 'invalid host' })
107
+ }
108
+ }
109
+
164
110
  return async (req, res) => {
165
111
  try {
166
112
  const sub = new URL(req.url ?? '/', 'http://localhost').pathname.slice(GATE_ROUTE.length)
167
113
  const method = req.method ?? 'GET'
168
- if (sub === '/login') {
169
- if (method !== 'POST') return sendJson(res, 405, { error: 'POST only' })
170
- return await handleLogin(req, res)
171
- }
172
114
  if (sub === '/status') {
173
115
  if (method !== 'GET') return sendJson(res, 405, { error: 'GET only' })
174
116
  return handleStatus(req, res)
175
117
  }
118
+ if (sub === '/launch-url') {
119
+ if (method !== 'GET') return sendJson(res, 405, { error: 'GET only' })
120
+ return handleLaunchUrl(req, res)
121
+ }
176
122
  sendJson(res, 404, { error: 'unknown endpoint' })
177
123
  } catch (error) {
178
124
  const message = error instanceof Error ? error.message : String(error)
@@ -183,14 +129,6 @@ export function createGateRoutes(
183
129
  }
184
130
  }
185
131
 
186
- /** token 校验(sha256 + 常时比较);空 token 通道关闭恒 false。 */
187
- function verifyToken(candidate: string, expected: string): boolean {
188
- if (candidate === '' || expected === '') return false
189
- const a = createHash('sha256').update(candidate).digest()
190
- const b = createHash('sha256').update(expected).digest()
191
- return timingSafeEqual(a, b)
192
- }
193
-
194
132
  /** 注册路由(webServer 缺失时由调用方保证不调用)。 */
195
133
  export function registerGateRoutes(ctx: Context, deps: RouteDeps): void {
196
134
  ctx.webServer.register({
@@ -0,0 +1,88 @@
1
+ /**
2
+ * token 输入页:围栏拦截未认证浏览器导航时返回的内联 HTML。
3
+ * 自包含(无外部静态资源依赖——静态资产同样受围栏保护)、中文、vanilla JS。
4
+ *
5
+ * 工作方式(与官方 browser-auth 合并后的唯一登录路径):
6
+ * 用户粘贴当前启动令牌 → fetch `/?token=<令牌>`(同源,跟随 303)→ 官方
7
+ * 交换签发 dsh-auth-* HttpOnly cookie(30 天,按域名:端口分别持有)→
8
+ * 成功后跳回干净 `/`。PWA 内打开本页粘贴一次即可恢复(解决 PWA 无法
9
+ * 携带 ?token= 启动的官方限制)。
10
+ * 本页纯客户端跳转,无服务器端点,令牌校验完全由官方 authorizeIndex 完成。
11
+ * @module access-gate/token-page
12
+ */
13
+
14
+ export function renderTokenPage(): string {
15
+ return `<!DOCTYPE html>
16
+ <html lang="zh-CN">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <meta name="viewport" content="width=device-width, initial-scale=1">
20
+ <title>登录 - DeepSeek Harness</title>
21
+ <style>
22
+ :root { color-scheme: dark; }
23
+ body {
24
+ margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
25
+ background: #16181d; color: #e6e8ec;
26
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", sans-serif;
27
+ }
28
+ .card {
29
+ width: min(92vw, 420px); padding: 32px 28px; border-radius: 14px;
30
+ background: #1e2128; border: 1px solid #2e323c; box-shadow: 0 8px 32px rgba(0,0,0,.4);
31
+ box-sizing: border-box;
32
+ }
33
+ h1 { margin: 0 0 6px; font-size: 20px; font-weight: 600; }
34
+ p { margin: 0 0 14px; font-size: 13px; color: #9aa1ad; line-height: 1.7; }
35
+ p.tip { margin-bottom: 22px; }
36
+ code {
37
+ padding: 1px 5px; border-radius: 4px; background: #16181d; border: 1px solid #3a3f4b;
38
+ font-size: 12px; color: #c9d1e0; word-break: break-all;
39
+ }
40
+ input {
41
+ width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 8px;
42
+ border: 1px solid #3a3f4b; background: #16181d; color: #e6e8ec; font-size: 15px;
43
+ outline: none;
44
+ }
45
+ input:focus { border-color: #5b8cff; }
46
+ button {
47
+ width: 100%; margin-top: 14px; padding: 10px 0; border: 0; border-radius: 8px;
48
+ background: #3d6dff; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
49
+ }
50
+ button:disabled { opacity: .6; cursor: default; }
51
+ .msg { min-height: 18px; margin-top: 12px; font-size: 13px; text-align: center; }
52
+ .err { color: #ff7a76; }
53
+ .ok { color: #63d17c; }
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <form class="card" id="form">
58
+ <h1>需要登录</h1>
59
+ <p>粘贴当前<strong>启动令牌</strong>完成登录。登录后此设备免令牌 30 天(按域名:端口分别持有)。</p>
60
+ <p class="tip">令牌随每次 dsh web 重启更新,获取方式:服务器上执行 <code>dshctl url</code>,或查看启动日志 <code>journalctl -u dsh-web | grep "dsh web:"</code>(链接中 token= 后的部分)。PWA 内打开本页粘贴一次即可恢复。</p>
61
+ <input id="token" type="password" placeholder="启动令牌" autocomplete="off" autofocus>
62
+ <button id="btn" type="submit">登录</button>
63
+ <div class="msg" id="msg"></div>
64
+ </form>
65
+ <script>
66
+ var form = document.getElementById('form');
67
+ var input = document.getElementById('token');
68
+ var btn = document.getElementById('btn');
69
+ var msg = document.getElementById('msg');
70
+ form.addEventListener('submit', function (event) {
71
+ event.preventDefault();
72
+ var token = input.value.trim();
73
+ if (!token) { msg.className = 'msg err'; msg.textContent = '请输入启动令牌'; return; }
74
+ btn.disabled = true;
75
+ msg.className = 'msg'; msg.textContent = '登录中…';
76
+ // 官方令牌交换:同源 fetch 跟随 303,交换响应的 Set-Cookie 由浏览器落罐。
77
+ fetch('/?token=' + encodeURIComponent(token), { redirect: 'follow' })
78
+ .then(function (res) {
79
+ if (res.ok) { msg.className = 'msg ok'; msg.textContent = '登录成功,正在进入…'; location.replace('/'); }
80
+ else if (res.status === 401) { msg.className = 'msg err'; msg.textContent = '令牌无效或已过期(令牌随每次重启更新)'; btn.disabled = false; }
81
+ else { msg.className = 'msg err'; msg.textContent = '登录失败(HTTP ' + res.status + ')'; btn.disabled = false; }
82
+ })
83
+ .catch(function () { msg.className = 'msg err'; msg.textContent = '网络错误,请重试'; btn.disabled = false; });
84
+ });
85
+ </script>
86
+ </body>
87
+ </html>`
88
+ }
package/src/login-page.ts DELETED
@@ -1,77 +0,0 @@
1
- /**
2
- * 登录页:围栏拦截浏览器导航请求时返回的内联 HTML。
3
- * 自包含(无外部静态资源依赖)、中文、vanilla JS——输入 token → 调
4
- * /dsh-plus/gate/login → 成功后 reload 放行。
5
- * @module access-gate/login-page
6
- */
7
-
8
- export function renderLoginPage(): string {
9
- return `<!DOCTYPE html>
10
- <html lang="zh-CN">
11
- <head>
12
- <meta charset="utf-8">
13
- <meta name="viewport" content="width=device-width, initial-scale=1">
14
- <title>访问校验</title>
15
- <style>
16
- :root { color-scheme: dark; }
17
- body {
18
- margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
19
- background: #16181d; color: #e6e8ec;
20
- font-family: system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", sans-serif;
21
- }
22
- .card {
23
- width: min(92vw, 360px); padding: 32px 28px; border-radius: 14px;
24
- background: #1e2128; border: 1px solid #2e323c; box-shadow: 0 8px 32px rgba(0,0,0,.4);
25
- box-sizing: border-box;
26
- }
27
- h1 { margin: 0 0 6px; font-size: 20px; font-weight: 600; }
28
- p { margin: 0 0 22px; font-size: 13px; color: #9aa1ad; line-height: 1.6; }
29
- input {
30
- width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 8px;
31
- border: 1px solid #3a3f4b; background: #16181d; color: #e6e8ec; font-size: 15px;
32
- outline: none;
33
- }
34
- input:focus { border-color: #5b8cff; }
35
- button {
36
- width: 100%; margin-top: 14px; padding: 10px 0; border: 0; border-radius: 8px;
37
- background: #3d6dff; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer;
38
- }
39
- button:disabled { opacity: .6; cursor: default; }
40
- .msg { min-height: 18px; margin-top: 12px; font-size: 13px; text-align: center; }
41
- .err { color: #ff7a76; }
42
- .ok { color: #63d17c; }
43
- </style>
44
- </head>
45
- <body>
46
- <form class="card" id="form">
47
- <h1>此页面受访问控制保护</h1>
48
- <p>当前网络不在放行名单内。输入访问令牌以继续;或使用名单内的设备/网络访问。</p>
49
- <input id="token" type="password" placeholder="访问令牌" autocomplete="off" autofocus>
50
- <button id="btn" type="submit">继续访问</button>
51
- <div class="msg" id="msg"></div>
52
- </form>
53
- <script>
54
- var form = document.getElementById('form');
55
- var input = document.getElementById('token');
56
- var btn = document.getElementById('btn');
57
- var msg = document.getElementById('msg');
58
- form.addEventListener('submit', function (event) {
59
- event.preventDefault();
60
- var token = input.value;
61
- if (!token) { msg.className = 'msg err'; msg.textContent = '请输入访问令牌'; return; }
62
- btn.disabled = true;
63
- msg.className = 'msg'; msg.textContent = '校验中…';
64
- fetch('/dsh-plus/gate/login', {
65
- method: 'POST',
66
- headers: { 'content-type': 'application/json' },
67
- body: JSON.stringify({ token: token })
68
- }).then(function (res) {
69
- if (res.ok) { msg.className = 'msg ok'; msg.textContent = '校验通过,正在进入…'; location.reload(); }
70
- else if (res.status === 429) { msg.className = 'msg err'; msg.textContent = '失败次数过多,请稍后再试'; btn.disabled = false; }
71
- else { msg.className = 'msg err'; msg.textContent = '令牌无效'; btn.disabled = false; }
72
- }).catch(function () { msg.className = 'msg err'; msg.textContent = '网络错误,请重试'; btn.disabled = false; });
73
- });
74
- </script>
75
- </body>
76
- </html>`
77
- }