dsh-jace-remote 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/DESIGN.md +208 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/cordis.patch.yml +19 -0
- package/lib/client.js +123 -0
- package/package.json +72 -0
- package/src/devices.js +105 -0
- package/src/gateway.js +286 -0
- package/src/html/pair.html.js +45 -0
- package/src/index.js +263 -0
- package/src/pairing.js +98 -0
- package/src/upstream.js +62 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// dsh-jace-remote — dsh 插件入口
|
|
2
|
+
//
|
|
3
|
+
// 职责:在 dsh web 进程内起一个局域网网关(默认 0.0.0.0:3081),
|
|
4
|
+
// 用动态 4 位数字配对放行设备,再反向代理到本机 dsh web(127.0.0.1:<webServer.port>)。
|
|
5
|
+
//
|
|
6
|
+
// 注意:本文件 import 了 @deepseek-ai/*,因此**安装目录必须位于
|
|
7
|
+
// $DSH_HOME/profiles/<profile>/ 树内**(Node ESM 按 realpath 解析);
|
|
8
|
+
// 开发目录在工作区,安装由 scripts/install.mjs 负责拷贝。
|
|
9
|
+
import { mkdirSync, writeFileSync, chmodSync } from 'node:fs'
|
|
10
|
+
import { homedir, networkInterfaces } from 'node:os'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import z from '@deepseek-ai/schemastery'
|
|
13
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
14
|
+
import { PairingManager } from './pairing.js'
|
|
15
|
+
import { DeviceStore } from './devices.js'
|
|
16
|
+
import { UpstreamAuth } from './upstream.js'
|
|
17
|
+
import { createGateway } from './gateway.js'
|
|
18
|
+
|
|
19
|
+
export const name = 'dsh-jace-remote'
|
|
20
|
+
|
|
21
|
+
// connection 用于拿带 launch token 的 loopback URL;tools 用于注册 agent 工具
|
|
22
|
+
export const inject = ['webServer', 'connection', 'tools']
|
|
23
|
+
|
|
24
|
+
export const Config = z.object({
|
|
25
|
+
enabled: z.boolean().default(true),
|
|
26
|
+
/** 网关自身监听地址(不是 dsh web 的 bind);0.0.0.0 = 局域网可访问 */
|
|
27
|
+
bindHost: z.string().default('0.0.0.0'),
|
|
28
|
+
port: z.number().step(1).min(1).max(65535).default(3081),
|
|
29
|
+
codeTtlSeconds: z.number().step(1).min(30).default(300),
|
|
30
|
+
sessionTtlHours: z.number().step(1).min(1).default(168),
|
|
31
|
+
maxAttemptsPerWindow: z.number().step(1).min(1).default(5),
|
|
32
|
+
rateWindowMinutes: z.number().step(1).min(1).default(10),
|
|
33
|
+
/** 是否把配对码打到 dsh 日志(本机终端) */
|
|
34
|
+
logCodes: z.boolean().default(true),
|
|
35
|
+
/** 只接受私网来源 */
|
|
36
|
+
allowPrivateOnly: z.boolean().default(true),
|
|
37
|
+
/** 空 = $DSH_HOME/jace-remote/state.json */
|
|
38
|
+
stateFile: z.string().default(''),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export function apply(ctx, config) {
|
|
42
|
+
const log = ctx.logger('jace-remote')
|
|
43
|
+
if (!config.enabled) {
|
|
44
|
+
log.info('dsh-jace-remote disabled by config')
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const upstreamPort = ctx.webServer.port
|
|
49
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
50
|
+
const stateDir = join(home, 'jace-remote')
|
|
51
|
+
const stateFile = config.stateFile || join(stateDir, 'state.json')
|
|
52
|
+
const pairingFile = join(dirname(stateFile), 'pairing.json')
|
|
53
|
+
|
|
54
|
+
const pairing = new PairingManager({
|
|
55
|
+
codeTtlSeconds: config.codeTtlSeconds,
|
|
56
|
+
maxAttemptsPerWindow: config.maxAttemptsPerWindow,
|
|
57
|
+
rateWindowMinutes: config.rateWindowMinutes,
|
|
58
|
+
})
|
|
59
|
+
const devices = new DeviceStore({ filePath: stateFile, sessionTtlHours: config.sessionTtlHours })
|
|
60
|
+
const upstreamAuth = new UpstreamAuth({
|
|
61
|
+
getAuthenticatedUrl: () => ctx.connection.authenticatedUrl(`http://127.0.0.1:${String(upstreamPort)}`),
|
|
62
|
+
log: (m) => log.debug(m),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const gateway = createGateway({
|
|
66
|
+
host: config.bindHost,
|
|
67
|
+
port: config.port,
|
|
68
|
+
upstreamPort,
|
|
69
|
+
pairing,
|
|
70
|
+
devices,
|
|
71
|
+
upstreamAuth,
|
|
72
|
+
allowPrivateOnly: config.allowPrivateOnly,
|
|
73
|
+
log: (m) => log.info(m),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
let lastCode = null
|
|
77
|
+
let listening = null
|
|
78
|
+
|
|
79
|
+
function persistCode(cur) {
|
|
80
|
+
try {
|
|
81
|
+
mkdirSync(dirname(pairingFile), { recursive: true, mode: 0o700 })
|
|
82
|
+
writeFileSync(pairingFile, JSON.stringify({ code: cur.code, expiresAt: cur.expiresAt, updatedAt: Date.now() }), { mode: 0o600 })
|
|
83
|
+
chmodSync(pairingFile, 0o600)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
log.warn(`cannot persist pairing code: ${err?.message || err}`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function announce(force = false) {
|
|
90
|
+
const cur = pairing.current()
|
|
91
|
+
if (!force && cur.code === lastCode) return cur
|
|
92
|
+
lastCode = cur.code
|
|
93
|
+
persistCode(cur)
|
|
94
|
+
if (config.logCodes) log.info(`pairing code ${cur.code} (expires in ${Math.round(cur.remainingMs / 1000)}s)`)
|
|
95
|
+
return cur
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 取一个私网 IPv4,拼出给手机用的 URL。 */
|
|
99
|
+
function lanUrl() {
|
|
100
|
+
for (const list of Object.values(networkInterfaces())) {
|
|
101
|
+
for (const iface of list ?? []) {
|
|
102
|
+
if (iface.family !== 'IPv4' || iface.internal) continue
|
|
103
|
+
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(iface.address)) {
|
|
104
|
+
return `http://${iface.address}:${typeof gateway.port === 'number' ? gateway.port : config.port}/`
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return `http://<本机IP>:${config.port}/`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 生命周期:起停网关 + 轮换/展示配对码(webServer.register 要包 effect,自建 server 同理)
|
|
112
|
+
ctx.effect(() => {
|
|
113
|
+
gateway.start().then((info) => { listening = info }).catch((err) => log.error(`gateway start failed: ${err?.message || err}`))
|
|
114
|
+
announce(true)
|
|
115
|
+
const timer = setInterval(() => announce(), 10_000)
|
|
116
|
+
return async () => {
|
|
117
|
+
clearInterval(timer)
|
|
118
|
+
await Promise.race([gateway.stop(), new Promise((r) => setTimeout(r, 2000))])
|
|
119
|
+
log.info('gateway stopped')
|
|
120
|
+
}
|
|
121
|
+
}, 'jace-remote: lan gateway')
|
|
122
|
+
|
|
123
|
+
/** 供设置页 / agent 工具共用的状态快照。 */
|
|
124
|
+
function statusPayload() {
|
|
125
|
+
const cur = pairing.current()
|
|
126
|
+
return {
|
|
127
|
+
code: cur.code,
|
|
128
|
+
expiresAt: cur.expiresAt,
|
|
129
|
+
expiresInSeconds: Math.round(cur.remainingMs / 1000),
|
|
130
|
+
lanUrl: lanUrl(),
|
|
131
|
+
listening: Boolean(listening),
|
|
132
|
+
gatewayPort: config.port,
|
|
133
|
+
devices: devices.list(),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 设置页读取入口:挂在本机 dsh web(127.0.0.1:<webServer.port>)上,同源、受 dsh 自身访问控制保护。
|
|
138
|
+
// LAN 网关只对「已配对」设备转发此路径,未配对设备拿不到配对码。
|
|
139
|
+
ctx.effect(() => ctx.webServer.register({
|
|
140
|
+
kind: 'exact',
|
|
141
|
+
path: '/__jace/remote/status',
|
|
142
|
+
handler: (_req, res) => {
|
|
143
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
144
|
+
res.end(JSON.stringify(statusPayload()))
|
|
145
|
+
},
|
|
146
|
+
}), 'jace-remote: settings status route')
|
|
147
|
+
|
|
148
|
+
// ── agent 工具 ───────────────────────────────────────────────
|
|
149
|
+
ctx.tools.register(defineTool({
|
|
150
|
+
name: 'jace_remote_code',
|
|
151
|
+
description: '获取 dsh-jace-remote 的当前 4 位配对码与局域网访问地址(用于在手机/其他设备上配对 dsh web)。',
|
|
152
|
+
parameters: {},
|
|
153
|
+
output: {
|
|
154
|
+
schema: {
|
|
155
|
+
type: 'object',
|
|
156
|
+
additionalProperties: false,
|
|
157
|
+
properties: {
|
|
158
|
+
code: { type: 'string', required: true },
|
|
159
|
+
expiresInSeconds: { type: 'integer', required: true },
|
|
160
|
+
url: { type: 'string', required: true },
|
|
161
|
+
listening: { type: 'boolean', required: true },
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
render: (_args, value) => [{
|
|
165
|
+
type: 'text',
|
|
166
|
+
text: `配对码:${value.code}(${value.expiresInSeconds}s 后过期)\n局域网地址:${value.url}\n网关状态:${value.listening ? 'listening' : 'not listening'}`,
|
|
167
|
+
}],
|
|
168
|
+
},
|
|
169
|
+
async execute() {
|
|
170
|
+
const cur = announce(true)
|
|
171
|
+
return {
|
|
172
|
+
code: cur.code,
|
|
173
|
+
expiresInSeconds: Math.round(cur.remainingMs / 1000),
|
|
174
|
+
url: lanUrl(),
|
|
175
|
+
listening: Boolean(listening),
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
}))
|
|
179
|
+
|
|
180
|
+
ctx.tools.register(defineTool({
|
|
181
|
+
name: 'jace_remote_devices',
|
|
182
|
+
description: '列出已通过配对码授权的局域网设备(含 id / 名称 / IP / 最后活跃 / 过期时间)。',
|
|
183
|
+
parameters: {},
|
|
184
|
+
output: {
|
|
185
|
+
schema: {
|
|
186
|
+
type: 'object',
|
|
187
|
+
additionalProperties: false,
|
|
188
|
+
properties: {
|
|
189
|
+
count: { type: 'integer', required: true },
|
|
190
|
+
devices: {
|
|
191
|
+
type: 'array',
|
|
192
|
+
required: true,
|
|
193
|
+
items: {
|
|
194
|
+
type: 'object',
|
|
195
|
+
additionalProperties: false,
|
|
196
|
+
properties: {
|
|
197
|
+
id: { type: 'string', required: true },
|
|
198
|
+
name: { type: 'string', required: true },
|
|
199
|
+
ip: { type: 'string', required: true },
|
|
200
|
+
lastSeenAt: { type: 'integer', required: true },
|
|
201
|
+
expiresAt: { type: 'integer', required: true },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
render: (_args, value) => [{
|
|
208
|
+
type: 'text',
|
|
209
|
+
text: value.count === 0
|
|
210
|
+
? '当前没有已配对设备。'
|
|
211
|
+
: value.devices.map((d) => `· ${d.id} ${d.name} ${d.ip} 最后活跃 ${new Date(d.lastSeenAt).toLocaleString()}`).join('\n'),
|
|
212
|
+
}],
|
|
213
|
+
},
|
|
214
|
+
async execute() {
|
|
215
|
+
const list = devices.list()
|
|
216
|
+
return { count: list.length, devices: list }
|
|
217
|
+
},
|
|
218
|
+
}))
|
|
219
|
+
|
|
220
|
+
ctx.tools.register(defineTool({
|
|
221
|
+
name: 'jace_remote_revoke',
|
|
222
|
+
description: '撤销一个已配对的局域网设备(写入操作)。传入 jace_remote_devices 返回的设备 id。',
|
|
223
|
+
parameters: {
|
|
224
|
+
id: { type: 'string', required: true, description: '设备 id' },
|
|
225
|
+
},
|
|
226
|
+
output: {
|
|
227
|
+
schema: {
|
|
228
|
+
type: 'object',
|
|
229
|
+
additionalProperties: false,
|
|
230
|
+
properties: {
|
|
231
|
+
revoked: { type: 'boolean', required: true },
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
render: (_args, value) => [{ type: 'text', text: value.revoked ? '已撤销该设备。' : '未找到该设备 id。' }],
|
|
235
|
+
},
|
|
236
|
+
async execute(args) {
|
|
237
|
+
return { revoked: devices.revoke(args.id) }
|
|
238
|
+
},
|
|
239
|
+
}))
|
|
240
|
+
|
|
241
|
+
ctx.tools.register(defineTool({
|
|
242
|
+
name: 'jace_remote_panic',
|
|
243
|
+
description: '紧急关闭局域网网关(停止监听 3081,已配对设备立即断连)。需要重新启用时重启 dsh web 或改回配置。',
|
|
244
|
+
parameters: {},
|
|
245
|
+
output: {
|
|
246
|
+
schema: {
|
|
247
|
+
type: 'object',
|
|
248
|
+
additionalProperties: false,
|
|
249
|
+
properties: { stopped: { type: 'boolean', required: true } },
|
|
250
|
+
},
|
|
251
|
+
render: (_args, value) => [{ type: 'text', text: value.stopped ? '局域网网关已关闭。' : '网关未在运行。' }],
|
|
252
|
+
},
|
|
253
|
+
async execute() {
|
|
254
|
+
const wasListening = Boolean(listening)
|
|
255
|
+
await gateway.stop()
|
|
256
|
+
listening = null
|
|
257
|
+
log.warn('gateway stopped by jace_remote_panic')
|
|
258
|
+
return { stopped: wasListening }
|
|
259
|
+
},
|
|
260
|
+
}))
|
|
261
|
+
|
|
262
|
+
log.info(`dsh-jace-remote ready → upstream 127.0.0.1:${String(upstreamPort)}, lan ${config.bindHost}:${String(config.port)}`)
|
|
263
|
+
}
|
package/src/pairing.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// dsh-jace-remote — 动态 4 位配对码:生成 / 轮换 / 校验 / 限速(纯逻辑,无 IO,可单测)
|
|
2
|
+
import { randomInt, timingSafeEqual } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
const CODE_RE = /^\d{4}$/
|
|
5
|
+
|
|
6
|
+
export class PairingManager {
|
|
7
|
+
/**
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {number} [opts.codeTtlSeconds=300] 单个码有效期(秒),到期自动轮换
|
|
10
|
+
* @param {number} [opts.maxAttemptsPerWindow=5] 每 IP 每窗口允许的失败次数
|
|
11
|
+
* @param {number} [opts.maxGlobalAttemptsPerWindow=20] 全局失败上限
|
|
12
|
+
* @param {number} [opts.rateWindowMinutes=10] 限速窗口(分钟)
|
|
13
|
+
* @param {() => number} [opts.now] 注入时钟(测试用)
|
|
14
|
+
*/
|
|
15
|
+
constructor(opts = {}) {
|
|
16
|
+
this.codeTtlMs = (opts.codeTtlSeconds ?? 300) * 1000
|
|
17
|
+
this.maxAttempts = opts.maxAttemptsPerWindow ?? 5
|
|
18
|
+
this.maxGlobalAttempts = opts.maxGlobalAttemptsPerWindow ?? 20
|
|
19
|
+
this.rateWindowMs = (opts.rateWindowMinutes ?? 10) * 60_000
|
|
20
|
+
this.now = opts.now ?? (() => Date.now())
|
|
21
|
+
|
|
22
|
+
/** @type {Map<string, number[]>} ip -> 失败时间戳 */
|
|
23
|
+
this.failuresByIp = new Map()
|
|
24
|
+
/** @type {number[]} 全局失败时间戳 */
|
|
25
|
+
this.failuresGlobal = []
|
|
26
|
+
/** @type {string|null} */
|
|
27
|
+
this.code = null
|
|
28
|
+
this.expiresAt = 0
|
|
29
|
+
this.rotate()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 生成新码(旧码立即失效)。返回 {code, expiresAt}。 */
|
|
33
|
+
rotate() {
|
|
34
|
+
this.code = String(randomInt(0, 10_000)).padStart(4, '0')
|
|
35
|
+
this.expiresAt = this.now() + this.codeTtlMs
|
|
36
|
+
return { code: this.code, expiresAt: this.expiresAt }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 当前码与剩余时间(过期则自动轮换)。 */
|
|
40
|
+
current() {
|
|
41
|
+
if (this.now() >= this.expiresAt) this.rotate()
|
|
42
|
+
return { code: this.code, expiresAt: this.expiresAt, remainingMs: Math.max(0, this.expiresAt - this.now()) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_prune(list, cutoff) {
|
|
46
|
+
while (list.length > 0 && list[0] < cutoff) list.shift()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_rateState(ip) {
|
|
50
|
+
const cutoff = this.now() - this.rateWindowMs
|
|
51
|
+
this._prune(this.failuresGlobal, cutoff)
|
|
52
|
+
const list = this.failuresByIp.get(ip) ?? []
|
|
53
|
+
this._prune(list, cutoff)
|
|
54
|
+
this.failuresByIp.set(ip, list)
|
|
55
|
+
return list
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 该 ip 当前是否已被限速。 */
|
|
59
|
+
isRateLimited(ip) {
|
|
60
|
+
const list = this._rateState(ip)
|
|
61
|
+
return list.length >= this.maxAttempts || this.failuresGlobal.length >= this.maxGlobalAttempts
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_recordFailure(ip) {
|
|
65
|
+
const list = this._rateState(ip)
|
|
66
|
+
list.push(this.now())
|
|
67
|
+
this.failuresGlobal.push(this.now())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 校验配对码。
|
|
72
|
+
* @param {unknown} input 用户输入的 4 位码
|
|
73
|
+
* @param {string} ip 来源 IP(用于限速)
|
|
74
|
+
* @returns {{ok: true} | {ok: false, reason: 'rate-limited'|'malformed'|'invalid'|'expired'}}
|
|
75
|
+
*/
|
|
76
|
+
verify(input, ip = 'unknown') {
|
|
77
|
+
if (this.isRateLimited(ip)) return { ok: false, reason: 'rate-limited' }
|
|
78
|
+
if (typeof input !== 'string' || !CODE_RE.test(input)) {
|
|
79
|
+
this._recordFailure(ip)
|
|
80
|
+
return { ok: false, reason: 'malformed' }
|
|
81
|
+
}
|
|
82
|
+
if (this.now() >= this.expiresAt) {
|
|
83
|
+
this.rotate()
|
|
84
|
+
this._recordFailure(ip)
|
|
85
|
+
return { ok: false, reason: 'expired' }
|
|
86
|
+
}
|
|
87
|
+
const a = Buffer.from(input, 'utf8')
|
|
88
|
+
const b = Buffer.from(this.code, 'utf8')
|
|
89
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
90
|
+
this._recordFailure(ip)
|
|
91
|
+
return { ok: false, reason: 'invalid' }
|
|
92
|
+
}
|
|
93
|
+
// 成功:单次有效 → 立即作废并换新码
|
|
94
|
+
this.rotate()
|
|
95
|
+
this.failuresByIp.delete(ip)
|
|
96
|
+
return { ok: true }
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/upstream.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// dsh-jace-remote — dsh 上游认证:用 launch token 换 dsh 会话 cookie,供代理注入
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要:dsh web 的会话 cookie 绑定 authority(host:port),LAN 浏览器拿不到、也不该拿。
|
|
4
|
+
// 网关在进程内用 connection.authenticatedUrl() 拿带 token 的 loopback URL,换出 cookie 自用。
|
|
5
|
+
|
|
6
|
+
export class UpstreamAuth {
|
|
7
|
+
/**
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {() => (string|Promise<string>)} opts.getAuthenticatedUrl 返回带 token 的 loopback URL
|
|
10
|
+
* @param {(msg: string) => void} [opts.log]
|
|
11
|
+
*/
|
|
12
|
+
constructor({ getAuthenticatedUrl, log = () => {} }) {
|
|
13
|
+
this.getAuthenticatedUrl = getAuthenticatedUrl
|
|
14
|
+
this.log = log
|
|
15
|
+
this.cookie = null
|
|
16
|
+
this.refreshing = null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 确保有可用 cookie(并发去重)。 */
|
|
20
|
+
async ensure() {
|
|
21
|
+
if (this.cookie) return this.cookie
|
|
22
|
+
if (!this.refreshing) {
|
|
23
|
+
this.refreshing = this._exchange().finally(() => { this.refreshing = null })
|
|
24
|
+
}
|
|
25
|
+
return this.refreshing
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 上游返回 401 时调用:丢弃旧 cookie,重新换一次。 */
|
|
29
|
+
async refresh() {
|
|
30
|
+
this.cookie = null
|
|
31
|
+
return this.ensure()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
invalidate() {
|
|
35
|
+
this.cookie = null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async _exchange() {
|
|
39
|
+
const url = await this.getAuthenticatedUrl()
|
|
40
|
+
const res = await fetch(url, { redirect: 'manual' })
|
|
41
|
+
const cookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
|
|
42
|
+
const first = cookies.find((c) => typeof c === 'string' && c.includes('='))
|
|
43
|
+
if (!first) {
|
|
44
|
+
this.log(`upstream auth: no Set-Cookie from ${redact(url)} (status ${res.status})`)
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
this.cookie = first.split(';')[0]
|
|
48
|
+
this.log(`upstream auth: session cookie acquired (${this.cookie.split('=')[0]}=…)`)
|
|
49
|
+
return this.cookie
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 去掉 query 里的 token,避免日志泄露。 */
|
|
54
|
+
function redact(url) {
|
|
55
|
+
try {
|
|
56
|
+
const u = new URL(url)
|
|
57
|
+
if (u.searchParams.has('token')) u.searchParams.set('token', '***')
|
|
58
|
+
return u.href
|
|
59
|
+
} catch {
|
|
60
|
+
return '<invalid-url>'
|
|
61
|
+
}
|
|
62
|
+
}
|