@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.
- package/lib/client.js +49 -163
- package/lib/index.d.ts +1 -9
- package/lib/index.js +183 -258
- package/package.json +3 -2
- package/src/client/card.tsx +21 -112
- 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/interceptor.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 机制(dsh-host-webserver 无中间件机制,路由分发必经以下三个面):
|
|
5
5
|
* 1. `match(pathname)` —— HTTP 分发总咽喉:patch 后**请求级**围栏判定——
|
|
6
6
|
* 放行则返回原 route(handler 外包一层,route 对象本体不动,webserver
|
|
7
|
-
* 拿到的是浅拷贝);拒绝则返回内联拒绝 route
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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 === '
|
|
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(
|
|
87
|
-
log(`
|
|
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
|
-
|
|
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/routes.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 自有 HTTP 端点(/dsh-plus/gate 前缀,围栏豁免路径):
|
|
3
|
-
* -
|
|
4
|
-
* - GET
|
|
3
|
+
* - GET /dsh-plus/gate/status — 当前客户端判定状态(配置卡片诊断)
|
|
4
|
+
* - GET /dsh-plus/gate/launch-url — 当前进程认证链接(仅本机直连可用的管理通道)
|
|
5
5
|
*
|
|
6
|
-
*
|
|
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:
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
return
|
|
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
|
-
}
|