dsh-remote 0.8.39 → 0.8.41
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/README.en.md +3 -7
- package/README.md +3 -7
- package/lib/client.js +12 -227
- package/lib/index.js +0 -217
- package/lib/paths.js +10 -2
- package/lib/web-attach.js +12 -0
- package/package.json +2 -2
- package/lib/archive.js +0 -680
- package/lib/backup.js +0 -519
- package/lib/routes-backup.js +0 -313
package/lib/routes-backup.js
DELETED
|
@@ -1,313 +0,0 @@
|
|
|
1
|
-
// Settings-panel routes for workspace compressed backup / restore.
|
|
2
|
-
//
|
|
3
|
-
// Kept in its own module (like lib/routes-fs.js) so the route surface stays
|
|
4
|
-
// readable and the whole thing is unit-testable with an injected binding — the
|
|
5
|
-
// production wiring in lib/index.js supplies the real SSH pool, audit log and
|
|
6
|
-
// configuration.
|
|
7
|
-
//
|
|
8
|
-
// Scope: this is the machine/session-scoped JSON API behind Settings → 备份.
|
|
9
|
-
// The model-facing equivalents are the rw_backup / rw_backup_list / rw_restore
|
|
10
|
-
// tools, which resolve their own session binding and share the same lib/backup.js
|
|
11
|
-
// implementation — one behaviour, two entrances.
|
|
12
|
-
import {
|
|
13
|
-
createBackup, listBackups, verifyBackup, deleteBackup, restoreBackup,
|
|
14
|
-
pullBackup, pushBackup, listLocalBackups, deleteLocalBackup, verifyLocalBackup,
|
|
15
|
-
resolveRemoteHome,
|
|
16
|
-
} from './backup.js'
|
|
17
|
-
import { parseExcludes, resolveBackupDir, isSafeArchiveName, formatBytes, DEFAULT_MAX_TRANSFER_BYTES } from './archive.js'
|
|
18
|
-
import { describeCreate } from './backup.js'
|
|
19
|
-
|
|
20
|
-
/** Default cap on a cross-machine archive copy; the archive itself is uncapped. */
|
|
21
|
-
export { DEFAULT_MAX_TRANSFER_BYTES }
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* @param {object} deps
|
|
25
|
-
* @param {Function} deps.sendJson - (res, status, body)
|
|
26
|
-
* @param {Function} deps.readBody - (req) => Promise<string>
|
|
27
|
-
* @param {object} deps.config - the resolved plugin config
|
|
28
|
-
* @param {Function} deps.audit - (op, detail, code, target)
|
|
29
|
-
* @param {Function} deps.resolveRequestBinding - (req, body) => binding (see routes-fs)
|
|
30
|
-
* @param {Function} deps.resolveMachineBinding - (machineId) => binding for an explicit machine
|
|
31
|
-
* @param {Function} deps.localRootFor - (binding) => local directory for pulled archives
|
|
32
|
-
* @returns {Array<object>} route definitions
|
|
33
|
-
*/
|
|
34
|
-
export function createBackupRoutes({
|
|
35
|
-
sendJson, readBody, config, audit, resolveRequestBinding, resolveMachineBinding, localRootFor,
|
|
36
|
-
}) {
|
|
37
|
-
const maxTransfer = Number(config.maxBackupTransferBytes) > 0
|
|
38
|
-
? Number(config.maxBackupTransferBytes)
|
|
39
|
-
: DEFAULT_MAX_TRANSFER_BYTES
|
|
40
|
-
|
|
41
|
-
/** The remote backup directory for a binding (config override → $HOME/.dsh-remote/backups). */
|
|
42
|
-
const backupDirFor = async (b) => {
|
|
43
|
-
const override = String(config.backupDir || '').trim()
|
|
44
|
-
if (override) return resolveBackupDir('', override)
|
|
45
|
-
// Resolved remotely so a Windows/posix home is reported by the host that
|
|
46
|
-
// actually owns it, never guessed from the local machine.
|
|
47
|
-
const home = await resolveRemoteHome(b.pool)
|
|
48
|
-
return resolveBackupDir(home, '')
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const bindOrReply = async (req, res, body = {}) => {
|
|
52
|
-
try {
|
|
53
|
-
// An explicit machineId targets that machine (the settings page lets the
|
|
54
|
-
// operator act on a machine that is not the current one); otherwise the
|
|
55
|
-
// session-scoped binding applies, with the active machine as the documented
|
|
56
|
-
// fallback for the machine-scoped settings view.
|
|
57
|
-
if (body && body.machineId && typeof resolveMachineBinding === 'function') {
|
|
58
|
-
return await resolveMachineBinding(String(body.machineId))
|
|
59
|
-
}
|
|
60
|
-
return await resolveRequestBinding(req, body)
|
|
61
|
-
} catch (err) {
|
|
62
|
-
sendJson(res, (err && err.httpStatus) || 500, { ok: false, error: String((err && err.message) || err) })
|
|
63
|
-
return null
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Everything the panel needs to render: remote archives + local copies. */
|
|
68
|
-
const collect = async (b) => {
|
|
69
|
-
let dir = ''
|
|
70
|
-
let remote = { exists: false, dir: '', entries: [], error: '' }
|
|
71
|
-
try {
|
|
72
|
-
dir = await backupDirFor(b)
|
|
73
|
-
if (dir) remote = await listBackups(b.pool, { dir })
|
|
74
|
-
} catch (err) {
|
|
75
|
-
remote = { exists: false, dir, entries: [], error: String((err && err.message) || err) }
|
|
76
|
-
}
|
|
77
|
-
const localDir = localRootFor(b)
|
|
78
|
-
const local = listLocalBackups(localDir)
|
|
79
|
-
return {
|
|
80
|
-
workspace: b.ws || '',
|
|
81
|
-
host: b.host,
|
|
82
|
-
username: b.username,
|
|
83
|
-
port: b.port,
|
|
84
|
-
backupDir: dir,
|
|
85
|
-
exists: !!remote.exists,
|
|
86
|
-
error: remote.error || '',
|
|
87
|
-
entries: (remote.entries || []).map((e) => ({ ...e, source: 'remote' })),
|
|
88
|
-
local: {
|
|
89
|
-
dir: localDir,
|
|
90
|
-
exists: !!local.exists,
|
|
91
|
-
entries: local.entries.map((e) => ({
|
|
92
|
-
...e,
|
|
93
|
-
source: 'local',
|
|
94
|
-
// The local digest is recomputed lazily on verify; reporting the
|
|
95
|
-
// recorded one here would look like verification that never ran.
|
|
96
|
-
})),
|
|
97
|
-
},
|
|
98
|
-
maxTransferBytes: maxTransfer,
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return [
|
|
103
|
-
{
|
|
104
|
-
kind: 'exact',
|
|
105
|
-
path: '/dsh-remote/backup',
|
|
106
|
-
handler: async (req, res) => {
|
|
107
|
-
// ── list ────────────────────────────────────────────────────────────
|
|
108
|
-
if (req.method === 'GET') {
|
|
109
|
-
const q = new URL(req.url, 'http://localhost').searchParams
|
|
110
|
-
const hint = {
|
|
111
|
-
sessionId: q.get('sessionId') ? decodeURIComponent(q.get('sessionId')) : '',
|
|
112
|
-
local: q.get('local') ? decodeURIComponent(q.get('local')) : '',
|
|
113
|
-
machineId: q.get('machineId') ? decodeURIComponent(q.get('machineId')) : '',
|
|
114
|
-
}
|
|
115
|
-
const b = await bindOrReply(req, res, hint)
|
|
116
|
-
if (!b) return
|
|
117
|
-
try {
|
|
118
|
-
return sendJson(res, 200, { ok: true, ...(await collect(b)) })
|
|
119
|
-
} catch (err) {
|
|
120
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
124
|
-
|
|
125
|
-
// ── actions ─────────────────────────────────────────────────────────
|
|
126
|
-
let body = {}
|
|
127
|
-
try {
|
|
128
|
-
body = JSON.parse((await readBody(req)) || '{}')
|
|
129
|
-
} catch {
|
|
130
|
-
return sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' })
|
|
131
|
-
}
|
|
132
|
-
const action = String(body.action || '')
|
|
133
|
-
const b = await bindOrReply(req, res, body)
|
|
134
|
-
if (!b) return
|
|
135
|
-
|
|
136
|
-
try {
|
|
137
|
-
if (action === 'create') {
|
|
138
|
-
const parsed = parseExcludes(body.excludes)
|
|
139
|
-
// The UI may target a subdirectory of the workspace; it must stay
|
|
140
|
-
// inside it, otherwise "backup" could archive the whole home dir.
|
|
141
|
-
let dir = b.ws || ''
|
|
142
|
-
const requested = String(body.path || '').trim()
|
|
143
|
-
if (requested && b.ws && requested !== b.ws
|
|
144
|
-
&& !requested.startsWith(b.ws.replace(/\/+$/, '') + '/')) {
|
|
145
|
-
return sendJson(res, 400, { ok: false, error: '只允许备份当前工作区目录(或其子目录)' })
|
|
146
|
-
}
|
|
147
|
-
if (requested) dir = requested
|
|
148
|
-
if (!dir) return sendJson(res, 400, { ok: false, error: '没有远程工作区可备份——先选择一个远程工作区' })
|
|
149
|
-
const dest = ['remote', 'local', 'both'].includes(String(body.dest)) ? String(body.dest) : 'remote'
|
|
150
|
-
const backupDir = await backupDirFor(b)
|
|
151
|
-
if (!backupDir) return sendJson(res, 400, { ok: false, error: '无法确定远端 $HOME,请配置 backupDir' })
|
|
152
|
-
const result = await createBackup(b.pool, {
|
|
153
|
-
dir,
|
|
154
|
-
backupDir,
|
|
155
|
-
label: String(body.label || 'manual'),
|
|
156
|
-
excludes: parsed.patterns,
|
|
157
|
-
meta: { username: b.username, host: b.host, port: b.port },
|
|
158
|
-
})
|
|
159
|
-
if (!result.ok) {
|
|
160
|
-
audit('backup', `backup ${dir} failed: ${result.error}`, 1, b)
|
|
161
|
-
return sendJson(res, 500, { ok: false, error: result.error, droppedExcludes: parsed.dropped })
|
|
162
|
-
}
|
|
163
|
-
audit('backup', `backup ${dir} → ${result.archive}`, 0, b)
|
|
164
|
-
let pulled = null
|
|
165
|
-
let pullError = ''
|
|
166
|
-
if (dest === 'local' || dest === 'both') {
|
|
167
|
-
pulled = await pullBackup(b.pool, { archive: result.archive, localDir: localRootFor(b), maxTransferBytes: maxTransfer })
|
|
168
|
-
if (!pulled.ok) pullError = pulled.error
|
|
169
|
-
else audit('backup', `pull ${result.name} → ${pulled.localPath}`, 0, b)
|
|
170
|
-
}
|
|
171
|
-
// Local-only means the remote copy is a staging step: drop it so the
|
|
172
|
-
// "dest: local" promise is honoured (and say so if removal failed).
|
|
173
|
-
let remoteRemoved = null
|
|
174
|
-
if (dest === 'local' && result.ok) {
|
|
175
|
-
const del = await deleteBackup(b.pool, { archive: result.archive })
|
|
176
|
-
remoteRemoved = !!del.ok
|
|
177
|
-
if (del.ok) audit('backup', `removed remote staging copy ${result.name}`, 0, b)
|
|
178
|
-
}
|
|
179
|
-
return sendJson(res, 200, {
|
|
180
|
-
ok: true,
|
|
181
|
-
created: {
|
|
182
|
-
name: result.name,
|
|
183
|
-
archive: result.archive,
|
|
184
|
-
bytes: result.bytes,
|
|
185
|
-
members: result.members,
|
|
186
|
-
sha256: result.sha256,
|
|
187
|
-
verified: result.verified,
|
|
188
|
-
dest,
|
|
189
|
-
},
|
|
190
|
-
summary: describeCreate(result, { dest }),
|
|
191
|
-
localPath: pulled && pulled.ok ? pulled.localPath : '',
|
|
192
|
-
pullError,
|
|
193
|
-
remoteRemoved,
|
|
194
|
-
droppedExcludes: parsed.dropped,
|
|
195
|
-
...(await collect(b)),
|
|
196
|
-
})
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (action === 'restore') {
|
|
200
|
-
// Restoring is destructive by nature, so the UI must confirm it and
|
|
201
|
-
// the route enforces that the confirmation is explicit.
|
|
202
|
-
if (body.confirm !== true) return sendJson(res, 400, { ok: false, error: '恢复需要显式确认(confirm: true)' })
|
|
203
|
-
const file = String(body.file || '')
|
|
204
|
-
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
205
|
-
const mode = body.mode === 'merge' ? 'merge' : 'replace'
|
|
206
|
-
const target = String(body.target || '').trim() || b.ws || ''
|
|
207
|
-
if (!target || target === '/') return sendJson(res, 400, { ok: false, error: '没有恢复目标目录' })
|
|
208
|
-
const where = body.where === 'local' ? 'local' : 'remote'
|
|
209
|
-
let archive = ''
|
|
210
|
-
let uploaded = null
|
|
211
|
-
if (where === 'local') {
|
|
212
|
-
// A pulled copy is restored by pushing it back first: the extract
|
|
213
|
-
// itself must happen where the workspace lives.
|
|
214
|
-
const localDir = localRootFor(b)
|
|
215
|
-
const up = await pushBackup(b.pool, {
|
|
216
|
-
localPath: `${localDir.replace(/\/+$/, '')}/${file}`,
|
|
217
|
-
remoteDir: await backupDirFor(b),
|
|
218
|
-
maxTransferBytes: maxTransfer,
|
|
219
|
-
})
|
|
220
|
-
if (!up.ok) return sendJson(res, 500, { ok: false, error: up.error })
|
|
221
|
-
uploaded = up.remotePath
|
|
222
|
-
archive = up.remotePath
|
|
223
|
-
} else {
|
|
224
|
-
archive = `${(await backupDirFor(b)).replace(/\/+$/, '')}/${file}`
|
|
225
|
-
}
|
|
226
|
-
const r = await restoreBackup(b.pool, { archive, target, mode })
|
|
227
|
-
audit('restore', `${mode} restore ${file} → ${target}`, r.ok ? 0 : 1, b)
|
|
228
|
-
if (!r.ok) return sendJson(res, 500, { ok: false, error: r.error, mode, target, uploaded })
|
|
229
|
-
return sendJson(res, 200, {
|
|
230
|
-
ok: true,
|
|
231
|
-
restored: { file, mode: r.mode, target, members: r.members, from: where },
|
|
232
|
-
uploaded,
|
|
233
|
-
text: `已${r.mode === 'merge' ? '合并' : '替换'}恢复 ${file} → ${target}(${r.members} 个文件)`,
|
|
234
|
-
...(await collect(b)),
|
|
235
|
-
})
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (action === 'delete') {
|
|
239
|
-
const file = String(body.file || '')
|
|
240
|
-
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
241
|
-
const where = body.where === 'local' ? 'local' : 'remote'
|
|
242
|
-
let result
|
|
243
|
-
if (where === 'local') {
|
|
244
|
-
result = deleteLocalBackup(localRootFor(b), file)
|
|
245
|
-
} else {
|
|
246
|
-
result = await deleteBackup(b.pool, { archive: `${(await backupDirFor(b)).replace(/\/+$/, '')}/${file}` })
|
|
247
|
-
}
|
|
248
|
-
audit('backup-delete', `delete ${where} backup ${file}`, result.ok ? 0 : 1, b)
|
|
249
|
-
if (!result.ok) return sendJson(res, 500, { ok: false, error: result.error })
|
|
250
|
-
return sendJson(res, 200, { ok: true, ...(await collect(b)) })
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (action === 'verify') {
|
|
254
|
-
const file = String(body.file || '')
|
|
255
|
-
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
256
|
-
const where = body.where === 'local' ? 'local' : 'remote'
|
|
257
|
-
const result = where === 'local'
|
|
258
|
-
? verifyLocalBackup(localRootFor(b), file)
|
|
259
|
-
: await verifyBackup(b.pool, { archive: `${(await backupDirFor(b)).replace(/\/+$/, '')}/${file}` })
|
|
260
|
-
return sendJson(res, 200, {
|
|
261
|
-
ok: !!result.ok,
|
|
262
|
-
verified: where === 'local' ? result.matches !== false : !!result.verified,
|
|
263
|
-
bytes: result.bytes,
|
|
264
|
-
sha256: result.sha256,
|
|
265
|
-
members: result.members,
|
|
266
|
-
expectedSha256: result.expectedSha256,
|
|
267
|
-
matches: result.matches ?? null,
|
|
268
|
-
error: result.error || '',
|
|
269
|
-
})
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
if (action === 'download') {
|
|
273
|
-
const file = String(body.file || '')
|
|
274
|
-
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
275
|
-
const pulled = await pullBackup(b.pool, {
|
|
276
|
-
archive: `${(await backupDirFor(b)).replace(/\/+$/, '')}/${file}`,
|
|
277
|
-
localDir: localRootFor(b),
|
|
278
|
-
maxTransferBytes: maxTransfer,
|
|
279
|
-
})
|
|
280
|
-
if (!pulled.ok) return sendJson(res, 500, { ok: false, error: pulled.error })
|
|
281
|
-
audit('backup-download', `pull ${file} → ${pulled.localPath}`, 0, b)
|
|
282
|
-
return sendJson(res, 200, {
|
|
283
|
-
ok: true,
|
|
284
|
-
localPath: pulled.localPath,
|
|
285
|
-
bytes: pulled.bytes,
|
|
286
|
-
text: `已下载到本机:${pulled.localPath}(${formatBytes(pulled.bytes)})`,
|
|
287
|
-
...(await collect(b)),
|
|
288
|
-
})
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (action === 'upload') {
|
|
292
|
-
// Push a locally held archive back to the remote (the inverse of
|
|
293
|
-
// download), which is the prerequisite for restoring a local copy
|
|
294
|
-
// on a fresh machine.
|
|
295
|
-
const file = String(body.file || '')
|
|
296
|
-
if (!isSafeArchiveName(file)) return sendJson(res, 400, { ok: false, error: '无效的备份文件名' })
|
|
297
|
-
const localDir = localRootFor(b)
|
|
298
|
-
const dir = await backupDirFor(b)
|
|
299
|
-
if (!dir) return sendJson(res, 400, { ok: false, error: '无法确定远端备份目录' })
|
|
300
|
-
const up = await pushBackup(b.pool, { localPath: `${localDir.replace(/\/+$/, '')}/${file}`, remoteDir: dir, maxTransferBytes: maxTransfer })
|
|
301
|
-
if (!up.ok) return sendJson(res, 500, { ok: false, error: up.error })
|
|
302
|
-
audit('backup-upload', `push ${file} → ${up.remotePath}`, 0, b)
|
|
303
|
-
return sendJson(res, 200, { ok: true, remotePath: up.remotePath, bytes: up.bytes, ...(await collect(b)) })
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
return sendJson(res, 400, { ok: false, error: `unknown action: ${action || '(none)'}` })
|
|
307
|
-
} catch (err) {
|
|
308
|
-
return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
|
|
309
|
-
}
|
|
310
|
-
},
|
|
311
|
-
},
|
|
312
|
-
]
|
|
313
|
-
}
|