dsh-ssh-vault 0.0.0-stage → 2.0.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/CHANGELOG.md +404 -0
- package/CHANGELOG.zh-CN.md +352 -0
- package/CONTRIBUTING.md +179 -0
- package/CONTRIBUTING.zh-CN.md +167 -0
- package/LICENSE +21 -0
- package/README.md +452 -2
- package/README.zh-CN.md +450 -0
- package/SECURITY.md +42 -0
- package/SECURITY.zh-CN.md +38 -0
- package/assets/banner.png +0 -0
- package/assets/icon-180.png +0 -0
- package/assets/screenshot-1.png +0 -0
- package/client.js +1134 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +11 -0
- package/index.js +1180 -0
- package/lib/background.js +65 -0
- package/lib/i18n.js +589 -0
- package/lib/locales/ssh.js +130 -0
- package/lib/platform.js +68 -0
- package/lib/ssh.js +723 -0
- package/lib/vault-api.js +417 -0
- package/lib/vault.js +397 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +75 -4
- package/scripts/dhcp-reserve-kali.ps1 +81 -0
- package/scripts/make-market-icon.py +63 -0
- package/scripts/set-secret.mjs +127 -0
package/lib/vault-api.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置界面的 HTTP API —— 让 Web 设置页能够管理保险库。
|
|
3
|
+
*
|
|
4
|
+
* 这条通道与模型面向的 `ssh_hosts` 工具有两点本质差别,也正是它的价值所在:
|
|
5
|
+
*
|
|
6
|
+
* 1. 口令经 HTTP body 提交,**不进入会话日志**;工具参数则会。
|
|
7
|
+
* 2. 私钥可以整份上传,由 Host 写进保险库目录并收紧权限,用户不必自己处理文件 ACL。
|
|
8
|
+
*
|
|
9
|
+
* 路由挂在 Web 服务器的 `/ssh-vault/` 前缀下,客户端用同源、document-relative 的
|
|
10
|
+
* `fetch('ssh-vault/list', ...)` 提交 JSON,响应统一是 `{ ok, value }` / `{ ok, error }`。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-ssh-vault/vault-api
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFile, spawn } from 'node:child_process'
|
|
16
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { learnLocaleFromHeader, t } from './i18n.js'
|
|
19
|
+
import { maskHost, maskUser } from './vault.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 路由前缀。
|
|
23
|
+
*
|
|
24
|
+
* **不要带尾斜杠**:Web 服务器的 `kind: 'prefix'` 按「`path` 或 `path + '/'`」匹配,
|
|
25
|
+
* 注册 `/ssh-vault/` 时 `/ssh-vault/list` 两者都不命中,请求会落到 fallback 而不是这里。
|
|
26
|
+
* 官方插件注册的是 `/git`,这里与它保持一致。
|
|
27
|
+
*/
|
|
28
|
+
export const API_PREFIX = '/ssh-vault'
|
|
29
|
+
/** 请求体上限:私钥经 base64 上传,留出足够余量。 */
|
|
30
|
+
const MAX_BODY_BYTES = 512 * 1024
|
|
31
|
+
/** 私钥副本的存放子目录。 */
|
|
32
|
+
const KEYS_DIR = 'keys'
|
|
33
|
+
/**
|
|
34
|
+
* 设置页自动体检的超时。
|
|
35
|
+
*
|
|
36
|
+
* 卡片一出来就会各自摸一次连接,用默认的两分钟会让一台不通的主机把整个页面拖成"一直转圈"。
|
|
37
|
+
* 八秒足够越过一次正常握手,也短到能接受。
|
|
38
|
+
*/
|
|
39
|
+
export const SETTINGS_PROBE_TIMEOUT_MS = 15000
|
|
40
|
+
|
|
41
|
+
const JSON_HEADERS = {
|
|
42
|
+
'content-type': 'application/json; charset=utf-8',
|
|
43
|
+
'referrer-policy': 'no-referrer',
|
|
44
|
+
'cache-control': 'no-store',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 写一个 JSON 响应。
|
|
49
|
+
* @param res - HTTP 响应对象。
|
|
50
|
+
* @param status - 状态码。
|
|
51
|
+
* @param body - 可序列化的响应体。
|
|
52
|
+
*/
|
|
53
|
+
function writeJson(res, status, body) {
|
|
54
|
+
res.writeHead(status, JSON_HEADERS)
|
|
55
|
+
res.end(JSON.stringify(body))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 写一个成功 envelope。 */
|
|
59
|
+
const ok = (res, value) => writeJson(res, 200, { ok: true, value })
|
|
60
|
+
|
|
61
|
+
/** 写一个失败 envelope。 */
|
|
62
|
+
const fail = (res, code, message, status = 200) => writeJson(res, status, { ok: false, error: { code, message } })
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 读取有界的 JSON 请求体。
|
|
66
|
+
* @param req - HTTP 请求对象。
|
|
67
|
+
* @returns 解析后的对象;为空、超限或非法 JSON 时返回 `undefined`。
|
|
68
|
+
*/
|
|
69
|
+
async function readJsonBody(req) {
|
|
70
|
+
const chunks = []
|
|
71
|
+
let size = 0
|
|
72
|
+
for await (const chunk of req) {
|
|
73
|
+
size += chunk.length
|
|
74
|
+
if (size > MAX_BODY_BYTES) {
|
|
75
|
+
req.destroy()
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
chunks.push(chunk)
|
|
79
|
+
}
|
|
80
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
81
|
+
if (text.trim().length === 0) return {}
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(text)
|
|
84
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : undefined
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 收紧一个文件的权限,尽力而为:Windows 用 icacls,其余平台用 chmod 0600。
|
|
92
|
+
* @param filePath - 目标文件。
|
|
93
|
+
* @returns 是否成功收紧。
|
|
94
|
+
*/
|
|
95
|
+
async function hardenFilePermissions(filePath) {
|
|
96
|
+
try {
|
|
97
|
+
if (process.platform !== 'win32') {
|
|
98
|
+
await chmod(filePath, 0o600)
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
const who = `${process.env.USERDOMAIN ?? ''}\\${process.env.USERNAME ?? ''}`.replace(/^\\/, '')
|
|
102
|
+
await new Promise((resolve, reject) => {
|
|
103
|
+
execFile('icacls', [filePath, '/inheritance:r', '/grant:r', `${who}:(R)`], { windowsHide: true }, (error) => {
|
|
104
|
+
if (error) reject(error)
|
|
105
|
+
else resolve()
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
return true
|
|
109
|
+
} catch {
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 恢复一个文件被 hardenFilePermissions 收紧过的权限:Windows 用 `icacls /reset`
|
|
116
|
+
* 回到继承 ACL,其余平台 chmod 回 0600。收紧成用户只读的副本连写带删都会被
|
|
117
|
+
* 拒绝(EPERM),覆盖写回或删除前必须先恢复。文件不存在或失败都安静地返回 false。
|
|
118
|
+
* @param filePath - 目标文件。
|
|
119
|
+
* @returns 是否成功恢复。
|
|
120
|
+
*/
|
|
121
|
+
export async function resetFilePermissions(filePath) {
|
|
122
|
+
try {
|
|
123
|
+
if (process.platform !== 'win32') {
|
|
124
|
+
await chmod(filePath, 0o600)
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
await new Promise((resolve, reject) => {
|
|
128
|
+
execFile('icacls', [filePath, '/reset'], { windowsHide: true }, (error) => {
|
|
129
|
+
if (error) reject(error)
|
|
130
|
+
else resolve()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
return true
|
|
134
|
+
} catch {
|
|
135
|
+
return false
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 插件版本。**故意不缓存**:这个字段的唯一用途就是让设置页显示「当前跑的是哪一版代码」,
|
|
141
|
+
* 一个滞后的版本号比没有更糟。package.json 很小,每次列主机时读一次的开销可以忽略。
|
|
142
|
+
* @returns 版本号,读取失败时为 `unknown`。
|
|
143
|
+
*/
|
|
144
|
+
async function pluginVersion() {
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version ?? 'unknown'
|
|
147
|
+
} catch {
|
|
148
|
+
return 'unknown'
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 把一条主机记录整理成设置界面需要的形状(地址与账号脱敏,永不返回口令)。
|
|
154
|
+
* @param record - 保险库中的主机记录。
|
|
155
|
+
* @returns 可安全送到浏览器的视图对象。
|
|
156
|
+
*/
|
|
157
|
+
function hostView(record) {
|
|
158
|
+
return {
|
|
159
|
+
alias: record.alias,
|
|
160
|
+
host: maskHost(record.host),
|
|
161
|
+
port: record.port,
|
|
162
|
+
username: maskUser(record.username),
|
|
163
|
+
authType: record.auth?.type ?? 'agent',
|
|
164
|
+
shell: record.shell ?? 'bash',
|
|
165
|
+
description: record.description ?? '',
|
|
166
|
+
tags: Array.isArray(record.tags) ? record.tags : [],
|
|
167
|
+
bindAddress: record.bindAddress ?? '',
|
|
168
|
+
credentialRef: record.credentialRef ?? '',
|
|
169
|
+
privateKeyPath: record.auth?.type === 'key' ? record.auth.privateKeyPath : '',
|
|
170
|
+
hasPassword: record.auth?.type === 'password' && typeof record.auth.password === 'string' && record.auth.password.length > 0,
|
|
171
|
+
hasSudoPassword: typeof record.sudoPassword === 'string' && record.sudoPassword.length > 0,
|
|
172
|
+
updatedAt: record.updatedAt ?? '',
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 用系统文件管理器打开一个目录。
|
|
178
|
+
*
|
|
179
|
+
* 只被内部以插件自己的 `vaultDir` 调用,**从不接受请求方传入的路径**——否则这就成了
|
|
180
|
+
* 一个「按需启动本机程序」的接口。
|
|
181
|
+
*
|
|
182
|
+
* @param target - 要打开的目录。
|
|
183
|
+
*/
|
|
184
|
+
function defaultOpenFolder(target) {
|
|
185
|
+
const [command, args] = process.platform === 'win32'
|
|
186
|
+
? ['explorer.exe', [target]]
|
|
187
|
+
: process.platform === 'darwin'
|
|
188
|
+
? ['open', [target]]
|
|
189
|
+
: ['xdg-open', [target]]
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
// 用 spawn 而不是 execFile:文件管理器是 GUI 程序,没有理由等它退出,
|
|
192
|
+
// 而 explorer.exe 又可能长期驻留——等下去只会把这条请求挂住。
|
|
193
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' })
|
|
194
|
+
child.once('error', reject)
|
|
195
|
+
child.once('spawn', () => {
|
|
196
|
+
child.unref()
|
|
197
|
+
resolve()
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 构造路由处理器。
|
|
204
|
+
*
|
|
205
|
+
* @param options - 依赖注入。
|
|
206
|
+
* @param options.vault - 保险库实例。
|
|
207
|
+
* @param options.vaultDir - 保险库目录(私钥副本落在其下的 keys/)。
|
|
208
|
+
* @param options.probe - 连接自检回调 `(alias, timeoutMs) => Promise<{ ok, detail, notes }>`。
|
|
209
|
+
* @param options.openFolder - 打开目录的实现,默认交给系统文件管理器;测试可替换。
|
|
210
|
+
* @param options.requestRejection - 可选:把请求交给宿主的 Connection 信任栅栏
|
|
211
|
+
* (Host/Origin 校验 + 浏览器认证),返回 401 / 403 / undefined。
|
|
212
|
+
* @param options.logger - 可选日志接口。
|
|
213
|
+
* @returns `(req, res) => Promise<void>` 形式的处理器。
|
|
214
|
+
*/
|
|
215
|
+
export function createVaultApi({ vault, vaultDir, probe, openFolder, requestRejection, logger }) {
|
|
216
|
+
/**
|
|
217
|
+
* 保存一条主机记录。
|
|
218
|
+
* @param body - 浏览器提交的表单数据。
|
|
219
|
+
* @returns 面向浏览器的结果。
|
|
220
|
+
*/
|
|
221
|
+
const saveHost = async (body) => {
|
|
222
|
+
const alias = typeof body.alias === 'string' ? body.alias.trim() : ''
|
|
223
|
+
if (alias.length === 0) return { error: t('api.aliasRequired') }
|
|
224
|
+
if (!/^[A-Za-z0-9._-]+$/.test(alias)) return { error: t('api.aliasInvalid') }
|
|
225
|
+
|
|
226
|
+
// 改名:编辑表单会带上它开始编辑时的旧别名。凭据、私钥路径、备注都随记录一起搬过去。
|
|
227
|
+
const renameFrom = typeof body.renameFrom === 'string' ? body.renameFrom.trim() : ''
|
|
228
|
+
const sourceAlias = renameFrom.length > 0 && renameFrom !== alias ? renameFrom : alias
|
|
229
|
+
const renaming = sourceAlias !== alias
|
|
230
|
+
const existing = await vault.get(sourceAlias)
|
|
231
|
+
if (renaming) {
|
|
232
|
+
if (existing === undefined) return { error: t('api.renameSourceMissing', { alias: sourceAlias }) }
|
|
233
|
+
if ((await vault.get(alias)) !== undefined) return { error: t('api.aliasTaken', { alias }) }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const host = typeof body.host === 'string' ? body.host.trim() : ''
|
|
237
|
+
const username = typeof body.username === 'string' ? body.username.trim() : ''
|
|
238
|
+
if (host.length === 0) return { error: t('api.hostRequired') }
|
|
239
|
+
if (username.length === 0) return { error: t('api.usernameRequired') }
|
|
240
|
+
|
|
241
|
+
const port = Number.isFinite(body.port) ? Number(body.port) : 22
|
|
242
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return { error: t('api.portInvalid') }
|
|
243
|
+
|
|
244
|
+
const authType = body.authType === 'key' ? 'key' : 'password'
|
|
245
|
+
const next = {
|
|
246
|
+
...(existing ?? {}),
|
|
247
|
+
alias,
|
|
248
|
+
host,
|
|
249
|
+
port,
|
|
250
|
+
username,
|
|
251
|
+
shell: typeof body.shell === 'string' && body.shell.trim().length > 0 ? body.shell.trim() : 'bash',
|
|
252
|
+
description: typeof body.description === 'string' ? body.description : '',
|
|
253
|
+
tags: Array.isArray(body.tags) ? body.tags.filter((tag) => typeof tag === 'string' && tag.length > 0) : [],
|
|
254
|
+
bindAddress: typeof body.bindAddress === 'string' ? body.bindAddress.trim() : '',
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (authType === 'key') {
|
|
258
|
+
let keyPath = typeof body.privateKeyPath === 'string' ? body.privateKeyPath.trim() : ''
|
|
259
|
+
const upload = body.privateKeyFile
|
|
260
|
+
if (upload !== undefined && upload !== null) {
|
|
261
|
+
if (typeof upload.base64 !== 'string' || upload.base64.length === 0) return { error: t('api.keyEmpty') }
|
|
262
|
+
if (upload.base64.length > MAX_BODY_BYTES) return { error: t('api.keyTooLarge') }
|
|
263
|
+
let bytes
|
|
264
|
+
try {
|
|
265
|
+
bytes = Buffer.from(upload.base64, 'base64')
|
|
266
|
+
} catch {
|
|
267
|
+
return { error: t('api.keyBadBase64') }
|
|
268
|
+
}
|
|
269
|
+
const text = bytes.toString('utf8')
|
|
270
|
+
const head = text.slice(0, 8192)
|
|
271
|
+
// 诊断顺序:公钥 → PuTTY → 根本不是密钥。越具体的结论越先给。
|
|
272
|
+
if (/^(ssh-rsa|ssh-ed25519|ssh-dss|ecdsa-sha2-|sk-ssh-|sk-ecdsa-)/m.test(head.split(/\r?\n/, 1)[0] ?? '')) {
|
|
273
|
+
return { error: t('api.keyPublic') }
|
|
274
|
+
}
|
|
275
|
+
if (/^PuTTY-User-Key-File-/m.test(head)) {
|
|
276
|
+
return { error: t('api.keyPpk') }
|
|
277
|
+
}
|
|
278
|
+
// 要求真正的私钥封装头。只搜 "PRIVATE KEY" 字样不够——任意文本里出现这几个词就能混过去。
|
|
279
|
+
if (!/^-----BEGIN (?:OPENSSH |RSA |DSA |EC |ENCRYPTED )?PRIVATE KEY-----/m.test(head)) {
|
|
280
|
+
return { error: t('api.keyNotPrivate') }
|
|
281
|
+
}
|
|
282
|
+
const keyDir = path.join(vaultDir, KEYS_DIR)
|
|
283
|
+
await mkdir(keyDir, { recursive: true })
|
|
284
|
+
const target = path.join(keyDir, `${alias.replace(/[^A-Za-z0-9._-]/g, '_')}_id`)
|
|
285
|
+
// 上一次上传留下的同名副本已被收紧成只读 ACL,直接覆盖会被拒;先恢复再写。
|
|
286
|
+
await resetFilePermissions(target)
|
|
287
|
+
await writeFile(target, bytes, { mode: 0o600 })
|
|
288
|
+
const hardened = await hardenFilePermissions(target)
|
|
289
|
+
keyPath = target
|
|
290
|
+
logger?.info?.(`ssh-vault: 已把上传的私钥保存到 ${target}(权限收紧:${hardened ? '是' : '否'})`)
|
|
291
|
+
}
|
|
292
|
+
if (keyPath.length === 0) return { error: t('api.keyRequired') }
|
|
293
|
+
next.auth = {
|
|
294
|
+
type: 'key',
|
|
295
|
+
privateKeyPath: keyPath,
|
|
296
|
+
...(typeof body.passphrase === 'string' && body.passphrase.length > 0 ? { passphrase: body.passphrase } : {}),
|
|
297
|
+
}
|
|
298
|
+
delete next.credentialRef
|
|
299
|
+
} else {
|
|
300
|
+
const password = typeof body.password === 'string' ? body.password : ''
|
|
301
|
+
if (password.length > 0) {
|
|
302
|
+
next.auth = { type: 'password', password }
|
|
303
|
+
delete next.credentialRef
|
|
304
|
+
} else if (existing?.auth?.type === 'password' && typeof existing.auth.password === 'string' && existing.auth.password.length > 0) {
|
|
305
|
+
// 留空表示沿用已保存的口令。
|
|
306
|
+
next.auth = { type: 'password', password: existing.auth.password }
|
|
307
|
+
} else {
|
|
308
|
+
return { error: t('api.passwordRequired') }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (typeof body.sudoPassword === 'string' && body.sudoPassword.length > 0) next.sudoPassword = body.sudoPassword
|
|
313
|
+
if (typeof body.credentialRef === 'string' && body.credentialRef.trim().length > 0) next.credentialRef = body.credentialRef.trim()
|
|
314
|
+
|
|
315
|
+
await vault.put(next)
|
|
316
|
+
// 先写新记录再删旧的:即使删除失败,最坏也只是多出一条副本,不会丢配置。
|
|
317
|
+
if (renaming) await vault.remove(sourceAlias)
|
|
318
|
+
return { value: { alias, created: existing === undefined, renamedFrom: renaming ? sourceAlias : undefined } }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 分发一次 API 调用。
|
|
323
|
+
* @param action - 路由末段。
|
|
324
|
+
* @param body - 已解析的请求体。
|
|
325
|
+
* @returns `{ value }` 或 `{ error }`。
|
|
326
|
+
*/
|
|
327
|
+
const dispatch = async (action, body) => {
|
|
328
|
+
if (action === 'list') {
|
|
329
|
+
const hosts = await vault.list()
|
|
330
|
+
return { value: { hosts: hosts.map(hostView), keyMode: vault.keyMode ?? 'unknown', vaultDir, version: await pluginVersion() } }
|
|
331
|
+
}
|
|
332
|
+
if (action === 'save') return saveHost(body)
|
|
333
|
+
if (action === 'remove') {
|
|
334
|
+
const alias = typeof body.alias === 'string' ? body.alias.trim() : ''
|
|
335
|
+
if (alias.length === 0) return { error: t('api.removeAliasRequired') }
|
|
336
|
+
const removed = await vault.remove(alias)
|
|
337
|
+
return removed ? { value: { alias } } : { error: t('api.hostNotFound', { alias }) }
|
|
338
|
+
}
|
|
339
|
+
if (action === 'test') {
|
|
340
|
+
const alias = typeof body.alias === 'string' ? body.alias.trim() : ''
|
|
341
|
+
if (alias.length === 0) return { error: t('api.removeAliasRequired') }
|
|
342
|
+
const record = await vault.get(alias)
|
|
343
|
+
if (record === undefined) return { error: t('api.hostNotFound', { alias }) }
|
|
344
|
+
const result = await probe(alias, SETTINGS_PROBE_TIMEOUT_MS)
|
|
345
|
+
return { value: result }
|
|
346
|
+
}
|
|
347
|
+
if (action === 'openVault') {
|
|
348
|
+
// 路径来自插件自身,不接受请求体里的任何路径。
|
|
349
|
+
try {
|
|
350
|
+
await (openFolder ?? defaultOpenFolder)(vaultDir)
|
|
351
|
+
return { value: { opened: true, path: vaultDir } }
|
|
352
|
+
} catch (error) {
|
|
353
|
+
return { error: t('api.openFolderFailed', { message: error instanceof Error ? error.message : String(error) }) }
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (action === 'reveal') {
|
|
357
|
+
const alias = typeof body.alias === 'string' ? body.alias.trim() : ''
|
|
358
|
+
const record = await vault.get(alias)
|
|
359
|
+
if (record === undefined) return { error: t('api.hostNotFound', { alias }) }
|
|
360
|
+
return { value: { alias, host: record.host, port: record.port, username: record.username } }
|
|
361
|
+
}
|
|
362
|
+
return { error: t('api.unknownAction', { action }) }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 一次 HTTP 请求的处理入口。
|
|
367
|
+
* @param req - HTTP 请求。
|
|
368
|
+
* @param res - HTTP 响应。
|
|
369
|
+
*/
|
|
370
|
+
return async function handle(req, res) {
|
|
371
|
+
try {
|
|
372
|
+
// 信任栅栏先于一切:这条路由能读写凭据与私钥,未认证/跨站请求不该有机会
|
|
373
|
+
// 读请求体、改进程内语言、或探测动作是否存在。宿主 Connection 的
|
|
374
|
+
// requestRejection 负责 Host/Origin 校验与浏览器登录 cookie 认证,
|
|
375
|
+
// 与 /api 及 open-in-app 等内置路由使用同一套判定。
|
|
376
|
+
const rejection = typeof requestRejection === 'function' ? requestRejection(req) : undefined
|
|
377
|
+
if (rejection !== undefined) {
|
|
378
|
+
res.writeHead(rejection, {
|
|
379
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
380
|
+
'cache-control': 'no-store',
|
|
381
|
+
'x-content-type-options': 'nosniff',
|
|
382
|
+
})
|
|
383
|
+
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
// 最后一道语言信号:没有显式偏好时,浏览器的 Accept-Language 与 DSH 未设置偏好时
|
|
387
|
+
// 派生出的界面语言一致。显式偏好优先级更高,`learnLocaleFromHeader` 会忽略它。
|
|
388
|
+
try {
|
|
389
|
+
learnLocaleFromHeader(req?.headers?.['accept-language'])
|
|
390
|
+
} catch {
|
|
391
|
+
// 头缺失或形状异常都不影响请求本身。
|
|
392
|
+
}
|
|
393
|
+
if (req.method !== 'POST') {
|
|
394
|
+
fail(res, 'method-not-allowed', t('api.methodPostOnly'), 405)
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
398
|
+
const rest = url.pathname.startsWith(API_PREFIX) ? url.pathname.slice(API_PREFIX.length) : ''
|
|
399
|
+
const action = rest.startsWith('/') ? rest.slice(1) : rest
|
|
400
|
+
if (action.length === 0 || action.includes('/')) {
|
|
401
|
+
fail(res, 'not-found', t('api.routeUnknown'), 404)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
const body = await readJsonBody(req)
|
|
405
|
+
if (body === undefined) {
|
|
406
|
+
fail(res, 'bad-request', t('api.bodyInvalid'), 400)
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
const outcome = await dispatch(action, body)
|
|
410
|
+
if (outcome.error !== undefined) fail(res, 'request-failed', outcome.error)
|
|
411
|
+
else ok(res, outcome.value)
|
|
412
|
+
} catch (error) {
|
|
413
|
+
logger?.warn?.(`ssh-vault: settings request failed: ${error instanceof Error ? error.stack ?? error.message : String(error)}`)
|
|
414
|
+
fail(res, 'internal', error instanceof Error ? error.message : String(error), 500)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|