dsh-server-connector 0.2.2 → 0.3.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 +88 -0
- package/LICENSE +0 -0
- package/README.md +49 -7
- package/cordis.patch.yml +6 -6
- package/icon.svg +15 -15
- package/lib/audit.js +469 -455
- package/lib/client.js +2389 -1720
- package/lib/config.js +89 -0
- package/lib/index.js +316 -266
- package/lib/known-hosts.js +377 -377
- package/lib/pending.js +766 -753
- package/lib/policy.js +590 -590
- package/lib/pool.js +1496 -1464
- package/lib/registry.js +968 -968
- package/lib/routes.js +14 -13
- package/lib/tools.js +1367 -1357
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +105 -105
package/lib/pending.js
CHANGED
|
@@ -1,753 +1,766 @@
|
|
|
1
|
-
// dsh-server-connector — two-step confirmation queue for dangerous operations
|
|
2
|
-
// (design doc §3.3, §8.3).
|
|
3
|
-
//
|
|
4
|
-
// A dangerous panel action (delete a server, switch host-key verification off,
|
|
5
|
-
// disable a high-risk rule) is *requested* first and applied later, after the
|
|
6
|
-
// user comes back to the settings page and sees the full context. This module
|
|
7
|
-
// owns that queue and nothing else:
|
|
8
|
-
//
|
|
9
|
-
// $DSH_HOME/server-connector/pending.json
|
|
10
|
-
// { "version": 1, "pending": [{
|
|
11
|
-
// "id": "pend-01",
|
|
12
|
-
// "kind": "delete-server" | "disable-hostkey" | "disable-rule",
|
|
13
|
-
// "serverId": "srv-01",
|
|
14
|
-
// "ruleId": "rm-recursive", // disable-rule only
|
|
15
|
-
// "requestedAt": "2026-11-04T09:20:11Z",
|
|
16
|
-
// "expiresAt": "2026-11-05T09:20:11Z", // requestedAt + pendingTtlHours
|
|
17
|
-
// "snapshot": { /* delete-server: the complete record */ }
|
|
18
|
-
// }] }
|
|
19
|
-
//
|
|
20
|
-
// State machine (§8.3):
|
|
21
|
-
//
|
|
22
|
-
// 无记录 ──request()──► pending ──confirm()──► applied (执行 + 审计留痕)
|
|
23
|
-
// └──cancel() / 24h 过期──► discarded
|
|
24
|
-
//
|
|
25
|
-
// `request()` performs no side effect at all: the server keeps working, the
|
|
26
|
-
// rule keeps blocking, host-key verification keeps its mode. Only `confirm()`
|
|
27
|
-
// runs the operation, and it does so through an injected applier
|
|
28
|
-
// ({@link createPendingApplier} is the production one) so this module stays a
|
|
29
|
-
// durable queue rather than a second place that mutates `servers.json`
|
|
30
|
-
// (doc §2.2: "pending:危险操作的两段式队列,不执行任何副作用").
|
|
31
|
-
//
|
|
32
|
-
// Guarantees, in the same spirit as the registry (§3.1):
|
|
33
|
-
// - a missing or empty file is an empty queue; a read never creates the file;
|
|
34
|
-
// - a malformed document is reported (`INVALID_PENDING`), never rewritten;
|
|
35
|
-
// - every mutation is an atomic replace and is serialised per file across the
|
|
36
|
-
// instances of this process;
|
|
37
|
-
// - an expired record is discarded on load, on every read and on every write
|
|
38
|
-
// (doc §8.3: "进程启动时与每次读写时各检查一次"), and an expired record can
|
|
39
|
-
// never be confirmed — the user has to request it again.
|
|
40
|
-
|
|
41
|
-
import { randomBytes } from 'node:crypto'
|
|
42
|
-
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
43
|
-
import { dirname, join, resolve } from 'node:path'
|
|
44
|
-
|
|
45
|
-
import { connectorDirectory } from './registry.js'
|
|
46
|
-
|
|
47
|
-
/** File name of the queue (doc §3.5). */
|
|
48
|
-
export const PENDING_FILE_NAME = 'pending.json'
|
|
49
|
-
|
|
50
|
-
/** Document version of `pending.json`. */
|
|
51
|
-
export const PENDING_VERSION = 1
|
|
52
|
-
|
|
53
|
-
/** The three dangerous operations the queue knows (doc §8.3). */
|
|
54
|
-
export const PENDING_KINDS = Object.freeze(['delete-server', 'disable-hostkey', 'disable-rule'])
|
|
55
|
-
|
|
56
|
-
/** Default lifetime of a pending record (doc §10 `pendingTtlHours`). */
|
|
57
|
-
export const DEFAULT_PENDING_TTL_HOURS = 24
|
|
58
|
-
|
|
59
|
-
const HOUR_MS = 60 * 60 * 1000
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* One write promise per queue file, so every PendingQueue instance in this
|
|
63
|
-
* process serialises its mutations against the same document (the registry and
|
|
64
|
-
* the known-hosts store use the same pattern).
|
|
65
|
-
* @type {Map<string, Promise<void>>}
|
|
66
|
-
*/
|
|
67
|
-
const MUTATION_QUEUES = new Map()
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Machine-readable queue failure. `INVALID_PENDING` marks a malformed call or
|
|
71
|
-
* document, `UNKNOWN_PENDING` a record that is not in the queue,
|
|
72
|
-
* `EXPIRED_PENDING` a record whose 24 h window closed, `PENDING_UNAVAILABLE` a
|
|
73
|
-
* queue built without an applier, and `APPLY_FAILED` an applier that threw (the
|
|
74
|
-
* record is kept, so the user can retry).
|
|
75
|
-
* @typedef {'INVALID_PENDING'|'UNKNOWN_PENDING'|'EXPIRED_PENDING'|'PENDING_UNAVAILABLE'|'APPLY_FAILED'} PendingErrorCode
|
|
76
|
-
*/
|
|
77
|
-
export class PendingError extends Error {
|
|
78
|
-
/**
|
|
79
|
-
* @param {PendingErrorCode} code
|
|
80
|
-
* @param {string} message
|
|
81
|
-
* @param {{cause?: unknown, record?: PendingRecord}} [details]
|
|
82
|
-
*/
|
|
83
|
-
constructor(code, message, details = {}) {
|
|
84
|
-
super(message)
|
|
85
|
-
this.name = 'PendingError'
|
|
86
|
-
/** @type {PendingErrorCode} */
|
|
87
|
-
this.code = code
|
|
88
|
-
if (details.cause !== undefined) this.cause = details.cause
|
|
89
|
-
if (details.record !== undefined) this.record = details.record
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* The three dangerous operations (doc §8.3).
|
|
95
|
-
* @typedef {'delete-server'|'disable-hostkey'|'disable-rule'} PendingKind
|
|
96
|
-
*/
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* One queued request, exactly as persisted (doc §3.3).
|
|
100
|
-
* @typedef {object} PendingRecord
|
|
101
|
-
* @property {string} id - `pend-01`, `pend-02`, …; assigned by the queue.
|
|
102
|
-
* @property {PendingKind} kind
|
|
103
|
-
* @property {string} serverId
|
|
104
|
-
* @property {string} [ruleId] - present only for `disable-rule`.
|
|
105
|
-
* @property {string} requestedAt - ISO-8601.
|
|
106
|
-
* @property {string} expiresAt - requestedAt + pendingTtlHours, ISO-8601.
|
|
107
|
-
* @property {Record<string, unknown>} [snapshot] - `delete-server` keeps the
|
|
108
|
-
* complete server record here so a cancelled request leaves no trace, and a
|
|
109
|
-
* confirmed one can still delete the credentials of an already-gone record.
|
|
110
|
-
*/
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The persisted document.
|
|
114
|
-
* @typedef {object} PendingDocument
|
|
115
|
-
* @property {number} version
|
|
116
|
-
* @property {PendingRecord[]} pending
|
|
117
|
-
*/
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* What `request()` accepts.
|
|
121
|
-
* @typedef {object} PendingRequestInput
|
|
122
|
-
* @property {PendingKind} kind
|
|
123
|
-
* @property {string} serverId
|
|
124
|
-
* @property {string} [ruleId] - required for `disable-rule`.
|
|
125
|
-
* @property {Record<string, unknown>} [snapshot] - optional, `delete-server` uses it.
|
|
126
|
-
*/
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* What `confirm()` returns. `status` is the §8.3 state the record reached.
|
|
130
|
-
* @typedef {{record: PendingRecord, status: 'applied', result: unknown}} PendingApplied
|
|
131
|
-
* @typedef {{record: PendingRecord, status: 'discarded'}} PendingDiscarded
|
|
132
|
-
*/
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Default location of `pending.json` (doc §3.5).
|
|
136
|
-
* @param {NodeJS.ProcessEnv} [env]
|
|
137
|
-
* @returns {string}
|
|
138
|
-
*/
|
|
139
|
-
export function defaultPendingFile(env = process.env) {
|
|
140
|
-
return join(connectorDirectory(env), PENDING_FILE_NAME)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* @param {unknown} value
|
|
145
|
-
* @returns {value is Record<string, unknown>}
|
|
146
|
-
*/
|
|
147
|
-
function isPlainObject(value) {
|
|
148
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* @param {unknown} error
|
|
153
|
-
* @param {string} code
|
|
154
|
-
* @returns {boolean}
|
|
155
|
-
*/
|
|
156
|
-
function isErrno(error, code) {
|
|
157
|
-
return isPlainObject(error) && error.code === code
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* @param {unknown} value
|
|
162
|
-
* @returns {value is PendingKind}
|
|
163
|
-
*/
|
|
164
|
-
function isPendingKind(value) {
|
|
165
|
-
return value === 'delete-server' || value === 'disable-hostkey' || value === 'disable-rule'
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* @param {unknown} value
|
|
170
|
-
* @param {string} label
|
|
171
|
-
* @param {PendingError['code']} code
|
|
172
|
-
* @returns {string}
|
|
173
|
-
*/
|
|
174
|
-
function requiredString(value, label, code = 'INVALID_PENDING') {
|
|
175
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
176
|
-
throw new PendingError(code, `${label} must be a non-empty string.`)
|
|
177
|
-
}
|
|
178
|
-
return value.trim()
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* @param {unknown} value
|
|
183
|
-
* @param {string} label
|
|
184
|
-
* @returns {string}
|
|
185
|
-
*/
|
|
186
|
-
function requiredTimestamp(value, label) {
|
|
187
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
188
|
-
throw new PendingError('INVALID_PENDING', `${label} must be an ISO-8601 timestamp.`)
|
|
189
|
-
}
|
|
190
|
-
const parsed = new Date(value)
|
|
191
|
-
if (Number.isNaN(parsed.getTime())) {
|
|
192
|
-
throw new PendingError('INVALID_PENDING', `${label} must be an ISO-8601 timestamp.`)
|
|
193
|
-
}
|
|
194
|
-
return parsed.toISOString()
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Validate one stored record. Load, request and the document check all go
|
|
199
|
-
* through this function, so a stored record and a freshly requested one have
|
|
200
|
-
* exactly the same shape.
|
|
201
|
-
* @param {unknown} raw
|
|
202
|
-
* @param {string} label
|
|
203
|
-
* @returns {PendingRecord}
|
|
204
|
-
*/
|
|
205
|
-
function normalizeRecord(raw, label) {
|
|
206
|
-
if (!isPlainObject(raw)) throw new PendingError('INVALID_PENDING', `${label} must be an object.`)
|
|
207
|
-
const id = requiredString(raw.id, `${label}.id`)
|
|
208
|
-
if (!/^pend-\d{2,}$/.test(id)) {
|
|
209
|
-
throw new PendingError('INVALID_PENDING', `${label}.id must look like "pend-01".`)
|
|
210
|
-
}
|
|
211
|
-
if (!isPendingKind(raw.kind)) {
|
|
212
|
-
throw new PendingError('INVALID_PENDING', `${label}.kind must be one of ${PENDING_KINDS.join(', ')}.`)
|
|
213
|
-
}
|
|
214
|
-
const kind = raw.kind
|
|
215
|
-
const serverId = requiredString(raw.serverId, `${label}.serverId`)
|
|
216
|
-
const requestedAt = requiredTimestamp(raw.requestedAt, `${label}.requestedAt`)
|
|
217
|
-
const expiresAt = requiredTimestamp(raw.expiresAt, `${label}.expiresAt`)
|
|
218
|
-
if (new Date(expiresAt).getTime() < new Date(requestedAt).getTime()) {
|
|
219
|
-
throw new PendingError('INVALID_PENDING', `${label}.expiresAt must not precede requestedAt.`)
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** @type {PendingRecord} */
|
|
223
|
-
const record = { id, kind, serverId, requestedAt, expiresAt }
|
|
224
|
-
if (kind === 'disable-rule') {
|
|
225
|
-
record.ruleId = requiredString(raw.ruleId, `${label}.ruleId`)
|
|
226
|
-
} else if (raw.ruleId !== undefined) {
|
|
227
|
-
throw new PendingError('INVALID_PENDING', `${label}.ruleId is only valid for kind "disable-rule".`)
|
|
228
|
-
}
|
|
229
|
-
if (raw.snapshot !== undefined) {
|
|
230
|
-
if (!isPlainObject(raw.snapshot)) {
|
|
231
|
-
throw new PendingError('INVALID_PENDING', `${label}.snapshot must be an object.`)
|
|
232
|
-
}
|
|
233
|
-
record.snapshot = raw.snapshot
|
|
234
|
-
}
|
|
235
|
-
return record
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Allocate the next `pend-NN` id: a numeric suffix one past the highest in the
|
|
240
|
-
* document, so a cancelled record's id is never reused by accident.
|
|
241
|
-
* @param {PendingRecord[]} records
|
|
242
|
-
* @returns {string}
|
|
243
|
-
*/
|
|
244
|
-
function nextPendingId(records) {
|
|
245
|
-
let highest = 0
|
|
246
|
-
for (const record of records) {
|
|
247
|
-
const match = /^pend-(\d+)$/.exec(record.id)
|
|
248
|
-
if (match !== null) highest = Math.max(highest, Number(match[1]))
|
|
249
|
-
}
|
|
250
|
-
return `pend-${String(highest + 1).padStart(2, '0')}`
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Drop the records whose window closed (doc §8.3). The caller persists the
|
|
255
|
-
* document when it mutated it.
|
|
256
|
-
* @param {PendingDocument} document
|
|
257
|
-
* @param {number} nowMs
|
|
258
|
-
* @returns {PendingRecord[]} the discarded records, oldest first.
|
|
259
|
-
*/
|
|
260
|
-
function dropExpired(document, nowMs) {
|
|
261
|
-
const removed = []
|
|
262
|
-
const kept = []
|
|
263
|
-
for (const record of document.pending) {
|
|
264
|
-
if (new Date(record.expiresAt).getTime() <= nowMs) removed.push(record)
|
|
265
|
-
else kept.push(record)
|
|
266
|
-
}
|
|
267
|
-
document.pending = kept
|
|
268
|
-
return removed
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Deep copy a record so callers cannot mutate this instance's state.
|
|
273
|
-
* @template T
|
|
274
|
-
* @param {T} value
|
|
275
|
-
* @returns {T}
|
|
276
|
-
*/
|
|
277
|
-
function clone(value) {
|
|
278
|
-
return structuredClone(value)
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* The durable two-step queue (doc §3.3, §8.3).
|
|
283
|
-
*/
|
|
284
|
-
export class PendingQueue {
|
|
285
|
-
/** @type {string} */
|
|
286
|
-
#file
|
|
287
|
-
/** @type {number} */
|
|
288
|
-
#ttlHours
|
|
289
|
-
/** @type {() => Date} */
|
|
290
|
-
#now
|
|
291
|
-
/** @type {((record: PendingRecord) => Promise<unknown>) | null} */
|
|
292
|
-
#apply
|
|
293
|
-
/** @type {{info?: Function, warn?: Function, error?: Function}|null} */
|
|
294
|
-
#logger
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* @param {{
|
|
298
|
-
* file?: string,
|
|
299
|
-
* ttlHours?: number,
|
|
300
|
-
* now?: () => Date,
|
|
301
|
-
* apply?: (record: PendingRecord) => Promise<unknown>,
|
|
302
|
-
* logger?: {info?: Function, warn?: Function, error?: Function} | null,
|
|
303
|
-
* }} [options]
|
|
304
|
-
* `file` defaults to `$DSH_HOME/server-connector/pending.json`; `now` is
|
|
305
|
-
* injectable so expiry is testable; `apply` is the side-effecting half and
|
|
306
|
-
* is required only by `confirm()`.
|
|
307
|
-
*/
|
|
308
|
-
constructor(options = {}) {
|
|
309
|
-
this.#file = options.file ?? defaultPendingFile()
|
|
310
|
-
const ttlHours = options.ttlHours ?? DEFAULT_PENDING_TTL_HOURS
|
|
311
|
-
if (!(typeof ttlHours === 'number' && Number.isFinite(ttlHours) && ttlHours > 0)) {
|
|
312
|
-
throw new PendingError('INVALID_PENDING', 'ttlHours must be a positive number.')
|
|
313
|
-
}
|
|
314
|
-
this.#ttlHours = ttlHours
|
|
315
|
-
this.#now = options.now ?? (() => new Date())
|
|
316
|
-
this.#apply = options.apply ?? null
|
|
317
|
-
this.#logger = options.logger ?? null
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** Absolute path of the queue file this instance reads and writes. */
|
|
321
|
-
get file() {
|
|
322
|
-
return this.#file
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/** Lifetime of a record, in hours (`pendingTtlHours`). */
|
|
326
|
-
get ttlHours() {
|
|
327
|
-
return this.#ttlHours
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/** Whether this queue can apply a confirmed record. */
|
|
331
|
-
get applicable() {
|
|
332
|
-
return this.#apply !== null
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
*
|
|
350
|
-
* @
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
return record
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
'
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
)
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
* @param {
|
|
680
|
-
* @param {string
|
|
681
|
-
* @param {
|
|
682
|
-
* @returns {
|
|
683
|
-
*/
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
}
|
|
699
|
-
return summary
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
await registry.
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1
|
+
// dsh-server-connector — two-step confirmation queue for dangerous operations
|
|
2
|
+
// (design doc §3.3, §8.3).
|
|
3
|
+
//
|
|
4
|
+
// A dangerous panel action (delete a server, switch host-key verification off,
|
|
5
|
+
// disable a high-risk rule) is *requested* first and applied later, after the
|
|
6
|
+
// user comes back to the settings page and sees the full context. This module
|
|
7
|
+
// owns that queue and nothing else:
|
|
8
|
+
//
|
|
9
|
+
// $DSH_HOME/server-connector/pending.json
|
|
10
|
+
// { "version": 1, "pending": [{
|
|
11
|
+
// "id": "pend-01",
|
|
12
|
+
// "kind": "delete-server" | "disable-hostkey" | "disable-rule",
|
|
13
|
+
// "serverId": "srv-01",
|
|
14
|
+
// "ruleId": "rm-recursive", // disable-rule only
|
|
15
|
+
// "requestedAt": "2026-11-04T09:20:11Z",
|
|
16
|
+
// "expiresAt": "2026-11-05T09:20:11Z", // requestedAt + pendingTtlHours
|
|
17
|
+
// "snapshot": { /* delete-server: the complete record */ }
|
|
18
|
+
// }] }
|
|
19
|
+
//
|
|
20
|
+
// State machine (§8.3):
|
|
21
|
+
//
|
|
22
|
+
// 无记录 ──request()──► pending ──confirm()──► applied (执行 + 审计留痕)
|
|
23
|
+
// └──cancel() / 24h 过期──► discarded
|
|
24
|
+
//
|
|
25
|
+
// `request()` performs no side effect at all: the server keeps working, the
|
|
26
|
+
// rule keeps blocking, host-key verification keeps its mode. Only `confirm()`
|
|
27
|
+
// runs the operation, and it does so through an injected applier
|
|
28
|
+
// ({@link createPendingApplier} is the production one) so this module stays a
|
|
29
|
+
// durable queue rather than a second place that mutates `servers.json`
|
|
30
|
+
// (doc §2.2: "pending:危险操作的两段式队列,不执行任何副作用").
|
|
31
|
+
//
|
|
32
|
+
// Guarantees, in the same spirit as the registry (§3.1):
|
|
33
|
+
// - a missing or empty file is an empty queue; a read never creates the file;
|
|
34
|
+
// - a malformed document is reported (`INVALID_PENDING`), never rewritten;
|
|
35
|
+
// - every mutation is an atomic replace and is serialised per file across the
|
|
36
|
+
// instances of this process;
|
|
37
|
+
// - an expired record is discarded on load, on every read and on every write
|
|
38
|
+
// (doc §8.3: "进程启动时与每次读写时各检查一次"), and an expired record can
|
|
39
|
+
// never be confirmed — the user has to request it again.
|
|
40
|
+
|
|
41
|
+
import { randomBytes } from 'node:crypto'
|
|
42
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
43
|
+
import { dirname, join, resolve } from 'node:path'
|
|
44
|
+
|
|
45
|
+
import { connectorDirectory } from './registry.js'
|
|
46
|
+
|
|
47
|
+
/** File name of the queue (doc §3.5). */
|
|
48
|
+
export const PENDING_FILE_NAME = 'pending.json'
|
|
49
|
+
|
|
50
|
+
/** Document version of `pending.json`. */
|
|
51
|
+
export const PENDING_VERSION = 1
|
|
52
|
+
|
|
53
|
+
/** The three dangerous operations the queue knows (doc §8.3). */
|
|
54
|
+
export const PENDING_KINDS = Object.freeze(['delete-server', 'disable-hostkey', 'disable-rule'])
|
|
55
|
+
|
|
56
|
+
/** Default lifetime of a pending record (doc §10 `pendingTtlHours`). */
|
|
57
|
+
export const DEFAULT_PENDING_TTL_HOURS = 24
|
|
58
|
+
|
|
59
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One write promise per queue file, so every PendingQueue instance in this
|
|
63
|
+
* process serialises its mutations against the same document (the registry and
|
|
64
|
+
* the known-hosts store use the same pattern).
|
|
65
|
+
* @type {Map<string, Promise<void>>}
|
|
66
|
+
*/
|
|
67
|
+
const MUTATION_QUEUES = new Map()
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Machine-readable queue failure. `INVALID_PENDING` marks a malformed call or
|
|
71
|
+
* document, `UNKNOWN_PENDING` a record that is not in the queue,
|
|
72
|
+
* `EXPIRED_PENDING` a record whose 24 h window closed, `PENDING_UNAVAILABLE` a
|
|
73
|
+
* queue built without an applier, and `APPLY_FAILED` an applier that threw (the
|
|
74
|
+
* record is kept, so the user can retry).
|
|
75
|
+
* @typedef {'INVALID_PENDING'|'UNKNOWN_PENDING'|'EXPIRED_PENDING'|'PENDING_UNAVAILABLE'|'APPLY_FAILED'} PendingErrorCode
|
|
76
|
+
*/
|
|
77
|
+
export class PendingError extends Error {
|
|
78
|
+
/**
|
|
79
|
+
* @param {PendingErrorCode} code
|
|
80
|
+
* @param {string} message
|
|
81
|
+
* @param {{cause?: unknown, record?: PendingRecord}} [details]
|
|
82
|
+
*/
|
|
83
|
+
constructor(code, message, details = {}) {
|
|
84
|
+
super(message)
|
|
85
|
+
this.name = 'PendingError'
|
|
86
|
+
/** @type {PendingErrorCode} */
|
|
87
|
+
this.code = code
|
|
88
|
+
if (details.cause !== undefined) this.cause = details.cause
|
|
89
|
+
if (details.record !== undefined) this.record = details.record
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The three dangerous operations (doc §8.3).
|
|
95
|
+
* @typedef {'delete-server'|'disable-hostkey'|'disable-rule'} PendingKind
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One queued request, exactly as persisted (doc §3.3).
|
|
100
|
+
* @typedef {object} PendingRecord
|
|
101
|
+
* @property {string} id - `pend-01`, `pend-02`, …; assigned by the queue.
|
|
102
|
+
* @property {PendingKind} kind
|
|
103
|
+
* @property {string} serverId
|
|
104
|
+
* @property {string} [ruleId] - present only for `disable-rule`.
|
|
105
|
+
* @property {string} requestedAt - ISO-8601.
|
|
106
|
+
* @property {string} expiresAt - requestedAt + pendingTtlHours, ISO-8601.
|
|
107
|
+
* @property {Record<string, unknown>} [snapshot] - `delete-server` keeps the
|
|
108
|
+
* complete server record here so a cancelled request leaves no trace, and a
|
|
109
|
+
* confirmed one can still delete the credentials of an already-gone record.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The persisted document.
|
|
114
|
+
* @typedef {object} PendingDocument
|
|
115
|
+
* @property {number} version
|
|
116
|
+
* @property {PendingRecord[]} pending
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What `request()` accepts.
|
|
121
|
+
* @typedef {object} PendingRequestInput
|
|
122
|
+
* @property {PendingKind} kind
|
|
123
|
+
* @property {string} serverId
|
|
124
|
+
* @property {string} [ruleId] - required for `disable-rule`.
|
|
125
|
+
* @property {Record<string, unknown>} [snapshot] - optional, `delete-server` uses it.
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What `confirm()` returns. `status` is the §8.3 state the record reached.
|
|
130
|
+
* @typedef {{record: PendingRecord, status: 'applied', result: unknown}} PendingApplied
|
|
131
|
+
* @typedef {{record: PendingRecord, status: 'discarded'}} PendingDiscarded
|
|
132
|
+
*/
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Default location of `pending.json` (doc §3.5).
|
|
136
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
export function defaultPendingFile(env = process.env) {
|
|
140
|
+
return join(connectorDirectory(env), PENDING_FILE_NAME)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {unknown} value
|
|
145
|
+
* @returns {value is Record<string, unknown>}
|
|
146
|
+
*/
|
|
147
|
+
function isPlainObject(value) {
|
|
148
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {unknown} error
|
|
153
|
+
* @param {string} code
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
function isErrno(error, code) {
|
|
157
|
+
return isPlainObject(error) && error.code === code
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {unknown} value
|
|
162
|
+
* @returns {value is PendingKind}
|
|
163
|
+
*/
|
|
164
|
+
function isPendingKind(value) {
|
|
165
|
+
return value === 'delete-server' || value === 'disable-hostkey' || value === 'disable-rule'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {unknown} value
|
|
170
|
+
* @param {string} label
|
|
171
|
+
* @param {PendingError['code']} code
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
function requiredString(value, label, code = 'INVALID_PENDING') {
|
|
175
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
176
|
+
throw new PendingError(code, `${label} must be a non-empty string.`)
|
|
177
|
+
}
|
|
178
|
+
return value.trim()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @param {unknown} value
|
|
183
|
+
* @param {string} label
|
|
184
|
+
* @returns {string}
|
|
185
|
+
*/
|
|
186
|
+
function requiredTimestamp(value, label) {
|
|
187
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
188
|
+
throw new PendingError('INVALID_PENDING', `${label} must be an ISO-8601 timestamp.`)
|
|
189
|
+
}
|
|
190
|
+
const parsed = new Date(value)
|
|
191
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
192
|
+
throw new PendingError('INVALID_PENDING', `${label} must be an ISO-8601 timestamp.`)
|
|
193
|
+
}
|
|
194
|
+
return parsed.toISOString()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Validate one stored record. Load, request and the document check all go
|
|
199
|
+
* through this function, so a stored record and a freshly requested one have
|
|
200
|
+
* exactly the same shape.
|
|
201
|
+
* @param {unknown} raw
|
|
202
|
+
* @param {string} label
|
|
203
|
+
* @returns {PendingRecord}
|
|
204
|
+
*/
|
|
205
|
+
function normalizeRecord(raw, label) {
|
|
206
|
+
if (!isPlainObject(raw)) throw new PendingError('INVALID_PENDING', `${label} must be an object.`)
|
|
207
|
+
const id = requiredString(raw.id, `${label}.id`)
|
|
208
|
+
if (!/^pend-\d{2,}$/.test(id)) {
|
|
209
|
+
throw new PendingError('INVALID_PENDING', `${label}.id must look like "pend-01".`)
|
|
210
|
+
}
|
|
211
|
+
if (!isPendingKind(raw.kind)) {
|
|
212
|
+
throw new PendingError('INVALID_PENDING', `${label}.kind must be one of ${PENDING_KINDS.join(', ')}.`)
|
|
213
|
+
}
|
|
214
|
+
const kind = raw.kind
|
|
215
|
+
const serverId = requiredString(raw.serverId, `${label}.serverId`)
|
|
216
|
+
const requestedAt = requiredTimestamp(raw.requestedAt, `${label}.requestedAt`)
|
|
217
|
+
const expiresAt = requiredTimestamp(raw.expiresAt, `${label}.expiresAt`)
|
|
218
|
+
if (new Date(expiresAt).getTime() < new Date(requestedAt).getTime()) {
|
|
219
|
+
throw new PendingError('INVALID_PENDING', `${label}.expiresAt must not precede requestedAt.`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @type {PendingRecord} */
|
|
223
|
+
const record = { id, kind, serverId, requestedAt, expiresAt }
|
|
224
|
+
if (kind === 'disable-rule') {
|
|
225
|
+
record.ruleId = requiredString(raw.ruleId, `${label}.ruleId`)
|
|
226
|
+
} else if (raw.ruleId !== undefined) {
|
|
227
|
+
throw new PendingError('INVALID_PENDING', `${label}.ruleId is only valid for kind "disable-rule".`)
|
|
228
|
+
}
|
|
229
|
+
if (raw.snapshot !== undefined) {
|
|
230
|
+
if (!isPlainObject(raw.snapshot)) {
|
|
231
|
+
throw new PendingError('INVALID_PENDING', `${label}.snapshot must be an object.`)
|
|
232
|
+
}
|
|
233
|
+
record.snapshot = raw.snapshot
|
|
234
|
+
}
|
|
235
|
+
return record
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Allocate the next `pend-NN` id: a numeric suffix one past the highest in the
|
|
240
|
+
* document, so a cancelled record's id is never reused by accident.
|
|
241
|
+
* @param {PendingRecord[]} records
|
|
242
|
+
* @returns {string}
|
|
243
|
+
*/
|
|
244
|
+
function nextPendingId(records) {
|
|
245
|
+
let highest = 0
|
|
246
|
+
for (const record of records) {
|
|
247
|
+
const match = /^pend-(\d+)$/.exec(record.id)
|
|
248
|
+
if (match !== null) highest = Math.max(highest, Number(match[1]))
|
|
249
|
+
}
|
|
250
|
+
return `pend-${String(highest + 1).padStart(2, '0')}`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Drop the records whose window closed (doc §8.3). The caller persists the
|
|
255
|
+
* document when it mutated it.
|
|
256
|
+
* @param {PendingDocument} document
|
|
257
|
+
* @param {number} nowMs
|
|
258
|
+
* @returns {PendingRecord[]} the discarded records, oldest first.
|
|
259
|
+
*/
|
|
260
|
+
function dropExpired(document, nowMs) {
|
|
261
|
+
const removed = []
|
|
262
|
+
const kept = []
|
|
263
|
+
for (const record of document.pending) {
|
|
264
|
+
if (new Date(record.expiresAt).getTime() <= nowMs) removed.push(record)
|
|
265
|
+
else kept.push(record)
|
|
266
|
+
}
|
|
267
|
+
document.pending = kept
|
|
268
|
+
return removed
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Deep copy a record so callers cannot mutate this instance's state.
|
|
273
|
+
* @template T
|
|
274
|
+
* @param {T} value
|
|
275
|
+
* @returns {T}
|
|
276
|
+
*/
|
|
277
|
+
function clone(value) {
|
|
278
|
+
return structuredClone(value)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The durable two-step queue (doc §3.3, §8.3).
|
|
283
|
+
*/
|
|
284
|
+
export class PendingQueue {
|
|
285
|
+
/** @type {string} */
|
|
286
|
+
#file
|
|
287
|
+
/** @type {number} */
|
|
288
|
+
#ttlHours
|
|
289
|
+
/** @type {() => Date} */
|
|
290
|
+
#now
|
|
291
|
+
/** @type {((record: PendingRecord) => Promise<unknown>) | null} */
|
|
292
|
+
#apply
|
|
293
|
+
/** @type {{info?: Function, warn?: Function, error?: Function}|null} */
|
|
294
|
+
#logger
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {{
|
|
298
|
+
* file?: string,
|
|
299
|
+
* ttlHours?: number,
|
|
300
|
+
* now?: () => Date,
|
|
301
|
+
* apply?: (record: PendingRecord) => Promise<unknown>,
|
|
302
|
+
* logger?: {info?: Function, warn?: Function, error?: Function} | null,
|
|
303
|
+
* }} [options]
|
|
304
|
+
* `file` defaults to `$DSH_HOME/server-connector/pending.json`; `now` is
|
|
305
|
+
* injectable so expiry is testable; `apply` is the side-effecting half and
|
|
306
|
+
* is required only by `confirm()`.
|
|
307
|
+
*/
|
|
308
|
+
constructor(options = {}) {
|
|
309
|
+
this.#file = options.file ?? defaultPendingFile()
|
|
310
|
+
const ttlHours = options.ttlHours ?? DEFAULT_PENDING_TTL_HOURS
|
|
311
|
+
if (!(typeof ttlHours === 'number' && Number.isFinite(ttlHours) && ttlHours > 0)) {
|
|
312
|
+
throw new PendingError('INVALID_PENDING', 'ttlHours must be a positive number.')
|
|
313
|
+
}
|
|
314
|
+
this.#ttlHours = ttlHours
|
|
315
|
+
this.#now = options.now ?? (() => new Date())
|
|
316
|
+
this.#apply = options.apply ?? null
|
|
317
|
+
this.#logger = options.logger ?? null
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Absolute path of the queue file this instance reads and writes. */
|
|
321
|
+
get file() {
|
|
322
|
+
return this.#file
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Lifetime of a record, in hours (`pendingTtlHours`). */
|
|
326
|
+
get ttlHours() {
|
|
327
|
+
return this.#ttlHours
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Whether this queue can apply a confirmed record. */
|
|
331
|
+
get applicable() {
|
|
332
|
+
return this.#apply !== null
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Apply an edited TTL to this queue (doc §10, live settings).
|
|
337
|
+
*
|
|
338
|
+
* The TTL is read when a request is created (`expiresAt`), so a new value
|
|
339
|
+
* applies to the next request; records already queued keep the deadline they
|
|
340
|
+
* were promised, which is why this does not rewrite the file.
|
|
341
|
+
* @param {number} ttlHours
|
|
342
|
+
* @returns {void}
|
|
343
|
+
*/
|
|
344
|
+
configure(ttlHours) {
|
|
345
|
+
if (typeof ttlHours === 'number' && Number.isFinite(ttlHours) && ttlHours > 0) this.#ttlHours = ttlHours
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Install the side-effecting half (the tools/HTTP layer builds it with
|
|
350
|
+
* {@link createPendingApplier}). A queue without one can list, request,
|
|
351
|
+
* cancel and expire — everything a read-only panel needs — but not confirm.
|
|
352
|
+
* @param {(record: PendingRecord) => Promise<unknown>} apply
|
|
353
|
+
* @returns {void}
|
|
354
|
+
*/
|
|
355
|
+
setApplier(apply) {
|
|
356
|
+
if (typeof apply !== 'function') throw new PendingError('INVALID_PENDING', 'apply must be a function.')
|
|
357
|
+
this.#apply = apply
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Every live record, oldest request first. Expired records are discarded
|
|
362
|
+
* (and the file rewritten when that removed anything).
|
|
363
|
+
* @returns {Promise<PendingRecord[]>}
|
|
364
|
+
*/
|
|
365
|
+
async list() {
|
|
366
|
+
const { document, removed } = await this.#load()
|
|
367
|
+
if (removed.length > 0) await this.#save(document)
|
|
368
|
+
return document.pending.map((record) => clone(record))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* One record by id, or null. An expired record is discarded and reported as
|
|
373
|
+
* null, never returned.
|
|
374
|
+
* @param {string} id
|
|
375
|
+
* @returns {Promise<PendingRecord|null>}
|
|
376
|
+
*/
|
|
377
|
+
async get(id) {
|
|
378
|
+
const { document, removed } = await this.#load()
|
|
379
|
+
if (removed.length > 0) await this.#save(document)
|
|
380
|
+
const record = document.pending.find((entry) => entry.id === id)
|
|
381
|
+
return record === undefined ? null : clone(record)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Queue one dangerous operation. **No side effect happens here** (doc §8.3):
|
|
386
|
+
* the server keeps working, the rule keeps blocking, the host-key mode keeps
|
|
387
|
+
* its value. Requesting the same operation again while it is pending returns
|
|
388
|
+
* the existing record (`created: false`) instead of stacking duplicates.
|
|
389
|
+
* @param {PendingRequestInput} input
|
|
390
|
+
* @returns {Promise<{record: PendingRecord, created: boolean}>}
|
|
391
|
+
* @throws {PendingError} `INVALID_PENDING`.
|
|
392
|
+
*/
|
|
393
|
+
async request(input) {
|
|
394
|
+
if (!isPlainObject(input)) throw new PendingError('INVALID_PENDING', 'request input must be an object.')
|
|
395
|
+
if (!isPendingKind(input.kind)) {
|
|
396
|
+
throw new PendingError('INVALID_PENDING', `kind must be one of ${PENDING_KINDS.join(', ')}.`)
|
|
397
|
+
}
|
|
398
|
+
const serverId = requiredString(input.serverId, 'serverId')
|
|
399
|
+
/** @type {string|undefined} */
|
|
400
|
+
let ruleId
|
|
401
|
+
if (input.kind === 'disable-rule') ruleId = requiredString(input.ruleId, 'ruleId')
|
|
402
|
+
else if (input.ruleId !== undefined) {
|
|
403
|
+
throw new PendingError('INVALID_PENDING', 'ruleId is only valid for kind "disable-rule".')
|
|
404
|
+
}
|
|
405
|
+
if (input.snapshot !== undefined && !isPlainObject(input.snapshot)) {
|
|
406
|
+
throw new PendingError('INVALID_PENDING', 'snapshot must be an object.')
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const now = this.#now()
|
|
410
|
+
const requestedAt = now.toISOString()
|
|
411
|
+
const expiresAt = new Date(now.getTime() + this.#ttlHours * HOUR_MS).toISOString()
|
|
412
|
+
return this.#mutate((document) => {
|
|
413
|
+
const existing = document.pending.find(
|
|
414
|
+
(record) => record.kind === input.kind && record.serverId === serverId && record.ruleId === ruleId,
|
|
415
|
+
)
|
|
416
|
+
if (existing !== undefined) return { record: clone(existing), created: false }
|
|
417
|
+
/** @type {PendingRecord} */
|
|
418
|
+
const record = {
|
|
419
|
+
id: nextPendingId(document.pending),
|
|
420
|
+
kind: input.kind,
|
|
421
|
+
serverId,
|
|
422
|
+
requestedAt,
|
|
423
|
+
expiresAt,
|
|
424
|
+
}
|
|
425
|
+
if (ruleId !== undefined) record.ruleId = ruleId
|
|
426
|
+
if (input.snapshot !== undefined) record.snapshot = clone(input.snapshot)
|
|
427
|
+
document.pending.push(record)
|
|
428
|
+
return { record: clone(record), created: true }
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Apply one queued operation: run the injected applier (which performs the
|
|
434
|
+
* real deletion / mode change / rule disable and writes the audit event),
|
|
435
|
+
* then remove the record. A throwing applier keeps the record so the user can
|
|
436
|
+
* retry.
|
|
437
|
+
* @param {string} id
|
|
438
|
+
* @returns {Promise<PendingApplied>}
|
|
439
|
+
* @throws {PendingError} `UNKNOWN_PENDING`, `EXPIRED_PENDING`,
|
|
440
|
+
* `PENDING_UNAVAILABLE` or `APPLY_FAILED`.
|
|
441
|
+
*/
|
|
442
|
+
async confirm(id) {
|
|
443
|
+
const apply = this.#apply
|
|
444
|
+
if (apply === null) {
|
|
445
|
+
throw new PendingError(
|
|
446
|
+
'PENDING_UNAVAILABLE',
|
|
447
|
+
'This pending queue has no applier; confirmations cannot be executed in this composition.',
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
// The applier runs *outside* the mutation queue: it talks to the registry,
|
|
451
|
+
// the pool and the credentials service, and holding the queue lock across
|
|
452
|
+
// those awaits would block every other queue operation. The record is
|
|
453
|
+
// re-checked and removed in a second, short mutation.
|
|
454
|
+
const record = await this.#requireLive(id)
|
|
455
|
+
let result
|
|
456
|
+
try {
|
|
457
|
+
result = await apply(clone(record))
|
|
458
|
+
} catch (error) {
|
|
459
|
+
throw new PendingError(
|
|
460
|
+
'APPLY_FAILED',
|
|
461
|
+
`Applying ${record.kind} for ${record.serverId} failed: ${error instanceof Error ? error.message : String(error)}. The request stays pending; fix the cause and confirm again.`,
|
|
462
|
+
{ cause: error, record: clone(record) },
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
await this.#mutate((document) => {
|
|
466
|
+
const index = document.pending.findIndex((entry) => entry.id === id)
|
|
467
|
+
// Another confirm/cancel may have won the race while the applier ran.
|
|
468
|
+
if (index !== -1) document.pending.splice(index, 1)
|
|
469
|
+
})
|
|
470
|
+
return { record, status: 'applied', result }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Drop one queued request. The target keeps its current state — cancelling
|
|
475
|
+
* never reverts anything, because nothing happened yet.
|
|
476
|
+
* @param {string} id
|
|
477
|
+
* @returns {Promise<PendingDiscarded>}
|
|
478
|
+
* @throws {PendingError} `UNKNOWN_PENDING`.
|
|
479
|
+
*/
|
|
480
|
+
async cancel(id) {
|
|
481
|
+
const discarded = await this.#mutate((document) => {
|
|
482
|
+
const index = document.pending.findIndex((entry) => entry.id === id)
|
|
483
|
+
if (index === -1) {
|
|
484
|
+
throw new PendingError('UNKNOWN_PENDING', `No pending record "${id}"; it may have been confirmed, cancelled or expired already.`)
|
|
485
|
+
}
|
|
486
|
+
const [record] = document.pending.splice(index, 1)
|
|
487
|
+
return record
|
|
488
|
+
})
|
|
489
|
+
return { record: clone(/** @type {PendingRecord} */ (discarded)), status: 'discarded' }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Discard every record whose 24 h window closed (doc §8.3). Called once at
|
|
494
|
+
* startup and implicitly by every read/write, so the panel never shows a
|
|
495
|
+
* stale row.
|
|
496
|
+
* @returns {Promise<PendingRecord[]>} the discarded records.
|
|
497
|
+
*/
|
|
498
|
+
async expire() {
|
|
499
|
+
/** @type {PendingRecord[]} */
|
|
500
|
+
let discarded = []
|
|
501
|
+
await this.#mutate((document, removed) => {
|
|
502
|
+
// `#mutate` already dropped the expired records while loading; this write
|
|
503
|
+
// persists that removal (and is a harmless no-op when nothing expired).
|
|
504
|
+
void document
|
|
505
|
+
discarded = removed
|
|
506
|
+
})
|
|
507
|
+
return discarded.map((record) => clone(record))
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Load the document, dropping expired records in memory and reporting them so
|
|
512
|
+
* a read can persist the removal.
|
|
513
|
+
* @returns {Promise<{document: PendingDocument, removed: PendingRecord[]}>}
|
|
514
|
+
*/
|
|
515
|
+
async #load() {
|
|
516
|
+
/** @type {string} */
|
|
517
|
+
let text
|
|
518
|
+
try {
|
|
519
|
+
text = await readFile(this.#file, 'utf8')
|
|
520
|
+
} catch (error) {
|
|
521
|
+
if (isErrno(error, 'ENOENT')) return { document: { version: PENDING_VERSION, pending: [] }, removed: [] }
|
|
522
|
+
throw error
|
|
523
|
+
}
|
|
524
|
+
if (text.trim() === '') return { document: { version: PENDING_VERSION, pending: [] }, removed: [] }
|
|
525
|
+
|
|
526
|
+
/** @type {unknown} */
|
|
527
|
+
let parsed
|
|
528
|
+
try {
|
|
529
|
+
parsed = JSON.parse(text)
|
|
530
|
+
} catch {
|
|
531
|
+
throw new PendingError('INVALID_PENDING', `${this.#file} is not valid JSON; fix or remove it.`)
|
|
532
|
+
}
|
|
533
|
+
if (!isPlainObject(parsed)) {
|
|
534
|
+
throw new PendingError('INVALID_PENDING', `${this.#file} must contain a JSON object.`)
|
|
535
|
+
}
|
|
536
|
+
if (parsed.version !== PENDING_VERSION) {
|
|
537
|
+
throw new PendingError(
|
|
538
|
+
'INVALID_PENDING',
|
|
539
|
+
`${this.#file} has unsupported version ${String(parsed.version)}; expected ${PENDING_VERSION}.`,
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
if (!Array.isArray(parsed.pending)) {
|
|
543
|
+
throw new PendingError('INVALID_PENDING', `${this.#file} must contain a "pending" array.`)
|
|
544
|
+
}
|
|
545
|
+
/** @type {PendingRecord[]} */
|
|
546
|
+
const pending = []
|
|
547
|
+
const seen = new Set()
|
|
548
|
+
for (let index = 0; index < parsed.pending.length; index += 1) {
|
|
549
|
+
const record = normalizeRecord(parsed.pending[index], `pending[${index}]`)
|
|
550
|
+
if (seen.has(record.id)) {
|
|
551
|
+
throw new PendingError('INVALID_PENDING', `${this.#file}: duplicate pending id "${record.id}".`)
|
|
552
|
+
}
|
|
553
|
+
seen.add(record.id)
|
|
554
|
+
pending.push(record)
|
|
555
|
+
}
|
|
556
|
+
/** @type {PendingDocument} */
|
|
557
|
+
const document = { version: PENDING_VERSION, pending }
|
|
558
|
+
const removed = dropExpired(document, this.#now().getTime())
|
|
559
|
+
return { document, removed }
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Commit the document atomically: write a sibling temp file, then rename it
|
|
564
|
+
* over the queue, so a reader sees either the old or the new complete file.
|
|
565
|
+
* @param {PendingDocument} document
|
|
566
|
+
* @returns {Promise<void>}
|
|
567
|
+
*/
|
|
568
|
+
async #save(document) {
|
|
569
|
+
await mkdir(dirname(this.#file), { recursive: true, mode: 0o700 })
|
|
570
|
+
const temporary = `${this.#file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
|
|
571
|
+
try {
|
|
572
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
573
|
+
await rename(temporary, this.#file)
|
|
574
|
+
} catch (error) {
|
|
575
|
+
await unlink(temporary).catch(() => {})
|
|
576
|
+
throw error
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Run one read-modify-write after every earlier mutation of the same file
|
|
582
|
+
* finished, in this process and across instances. Expired records are
|
|
583
|
+
* discarded before the callback sees the document.
|
|
584
|
+
* @template T
|
|
585
|
+
* @param {(document: PendingDocument, removed: PendingRecord[]) => T} mutate
|
|
586
|
+
* @returns {Promise<T>}
|
|
587
|
+
*/
|
|
588
|
+
async #mutate(mutate) {
|
|
589
|
+
const key = resolve(this.#file)
|
|
590
|
+
const previous = MUTATION_QUEUES.get(key) ?? Promise.resolve()
|
|
591
|
+
const operation = previous.then(async () => {
|
|
592
|
+
const { document, removed } = await this.#load()
|
|
593
|
+
for (const record of removed) {
|
|
594
|
+
this.#log('info', 'dsh-server-connector: discarded expired pending %s (%s)', record.id, record.kind)
|
|
595
|
+
}
|
|
596
|
+
const result = mutate(document, removed)
|
|
597
|
+
await this.#save(document)
|
|
598
|
+
return result
|
|
599
|
+
})
|
|
600
|
+
const settled = operation.then(
|
|
601
|
+
() => undefined,
|
|
602
|
+
() => undefined,
|
|
603
|
+
)
|
|
604
|
+
MUTATION_QUEUES.set(key, settled)
|
|
605
|
+
void settled.then(() => {
|
|
606
|
+
if (MUTATION_QUEUES.get(key) === settled) MUTATION_QUEUES.delete(key)
|
|
607
|
+
})
|
|
608
|
+
return operation
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Read one record that is still pending and not expired, without mutating.
|
|
613
|
+
* @param {string} id
|
|
614
|
+
* @returns {Promise<PendingRecord>}
|
|
615
|
+
*/
|
|
616
|
+
async #requireLive(id) {
|
|
617
|
+
const { document, removed } = await this.#load()
|
|
618
|
+
if (removed.length > 0) await this.#save(document)
|
|
619
|
+
const record = document.pending.find((entry) => entry.id === id)
|
|
620
|
+
if (record === undefined) {
|
|
621
|
+
const expired = removed.find((entry) => entry.id === id)
|
|
622
|
+
if (expired !== undefined) {
|
|
623
|
+
throw new PendingError(
|
|
624
|
+
'EXPIRED_PENDING',
|
|
625
|
+
`Pending record "${id}" expired at ${expired.expiresAt} and was discarded; request the ${expired.kind} again if it is still wanted.`,
|
|
626
|
+
{ record: clone(expired) },
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
throw new PendingError(
|
|
630
|
+
'UNKNOWN_PENDING',
|
|
631
|
+
`No pending record "${id}"; it may have been confirmed, cancelled or expired already.`,
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
return clone(record)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* @param {string} level
|
|
639
|
+
* @param {string} message
|
|
640
|
+
* @param {...unknown} args
|
|
641
|
+
* @returns {void}
|
|
642
|
+
*/
|
|
643
|
+
#log(level, message, ...args) {
|
|
644
|
+
const logger = this.#logger
|
|
645
|
+
if (logger === null || logger === undefined) return
|
|
646
|
+
const write = logger[/** @type {'info'|'warn'|'error'} */ (level)] ?? logger.info
|
|
647
|
+
if (typeof write === 'function') write.call(logger, message, ...args)
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The production applier: the side-effecting half of `confirm()` (doc §8.3).
|
|
653
|
+
*
|
|
654
|
+
* - `delete-server` → remove the record from `servers.json`, close the
|
|
655
|
+
* pool connection, drop the stored password, and write
|
|
656
|
+
* a `server-deleted` audit event;
|
|
657
|
+
* - `disable-hostkey` → set `hostKeyMode: "off"` and write `hostkey-disabled`;
|
|
658
|
+
* - `disable-rule` → add the rule id to `disabledRules` and write
|
|
659
|
+
* `rule-disabled`.
|
|
660
|
+
*
|
|
661
|
+
* Everything is injected, so the queue module itself never imports the
|
|
662
|
+
* registry, the pool or the audit log (doc §2.2).
|
|
663
|
+
* @param {{
|
|
664
|
+
* registry: import('./registry.js').Registry,
|
|
665
|
+
* pool?: {disconnect(serverId: string): Promise<void>} | null,
|
|
666
|
+
* audit?: {append(entry: Record<string, unknown>): Promise<unknown>} | null,
|
|
667
|
+
* getCredentials?: () => ({unset?(ref: string): Promise<void>} | null | undefined),
|
|
668
|
+
* logger?: {info?: Function, warn?: Function, error?: Function} | null,
|
|
669
|
+
* }} options
|
|
670
|
+
* @returns {(record: PendingRecord) => Promise<{event: string, serverId: string, serverName: string|null, detail: string}>}
|
|
671
|
+
*/
|
|
672
|
+
export function createPendingApplier(options) {
|
|
673
|
+
const { registry, pool = null, audit = null, getCredentials, logger = null } = options
|
|
674
|
+
if (registry === undefined || registry === null) {
|
|
675
|
+
throw new PendingError('INVALID_PENDING', 'createPendingApplier requires a registry.')
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* @param {'info'|'warn'|'error'} level
|
|
680
|
+
* @param {string} message
|
|
681
|
+
* @param {...unknown} args
|
|
682
|
+
* @returns {void}
|
|
683
|
+
*/
|
|
684
|
+
const log = (level, message, ...args) => {
|
|
685
|
+
if (logger === null) return
|
|
686
|
+
const write = logger[level] ?? logger.info
|
|
687
|
+
if (typeof write === 'function') write.call(logger, message, ...args)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* @param {PendingRecord} record
|
|
692
|
+
* @param {string} event
|
|
693
|
+
* @param {string|null} serverName
|
|
694
|
+
* @param {string} detail
|
|
695
|
+
* @returns {Promise<{event: string, serverId: string, serverName: string|null, detail: string}>}
|
|
696
|
+
*/
|
|
697
|
+
const recordEvent = async (record, event, serverName, detail) => {
|
|
698
|
+
const summary = { event, serverId: record.serverId, serverName, detail }
|
|
699
|
+
if (audit === null) return summary
|
|
700
|
+
try {
|
|
701
|
+
await audit.append({
|
|
702
|
+
event: /** @type {'server-deleted'|'hostkey-disabled'|'rule-disabled'} */ (event),
|
|
703
|
+
serverId: record.serverId,
|
|
704
|
+
...(serverName === null ? {} : { serverName }),
|
|
705
|
+
detail,
|
|
706
|
+
})
|
|
707
|
+
} catch (error) {
|
|
708
|
+
// The operation already committed; a broken audit file must not make the
|
|
709
|
+
// panel believe the deletion failed. Report it in the log instead.
|
|
710
|
+
log('error', 'dsh-server-connector: could not write the %s audit event: %s', event, String(error))
|
|
711
|
+
}
|
|
712
|
+
return summary
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return async function apply(record) {
|
|
716
|
+
if (record.kind === 'delete-server') {
|
|
717
|
+
/** @type {import('./registry.js').ServerRecord|null} */
|
|
718
|
+
const snapshot = /** @type {import('./registry.js').ServerRecord|null} */ (record.snapshot ?? null)
|
|
719
|
+
try {
|
|
720
|
+
await registry.remove(record.serverId)
|
|
721
|
+
} catch (error) {
|
|
722
|
+
// A record already gone is the desired end state; anything else is a
|
|
723
|
+
// real failure and must keep the pending row (confirm() rethrows).
|
|
724
|
+
if (!(error instanceof Error && /** @type {{code?: string}} */ (error).code === 'UNKNOWN_SERVER')) throw error
|
|
725
|
+
}
|
|
726
|
+
if (pool !== null) await pool.disconnect(record.serverId)
|
|
727
|
+
if (snapshot !== null && snapshot.auth !== undefined && snapshot.auth.kind === 'password') {
|
|
728
|
+
const credentials = getCredentials?.()
|
|
729
|
+
if (credentials !== null && credentials !== undefined) {
|
|
730
|
+
try {
|
|
731
|
+
await credentials.unset?.(snapshot.auth.credentialRef)
|
|
732
|
+
} catch (error) {
|
|
733
|
+
log('warn', 'dsh-server-connector: could not remove the credential of %s: %s', record.serverId, String(error))
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const serverName = snapshot === null ? null : String(snapshot.name)
|
|
738
|
+
return recordEvent(record, 'server-deleted', serverName, `server ${serverName ?? record.serverId} removed`)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (record.kind === 'disable-hostkey') {
|
|
742
|
+
const server = await registry.update(record.serverId, { hostKeyMode: 'off' })
|
|
743
|
+
return recordEvent(record, 'hostkey-disabled', server.name, 'hostKeyMode changed to off (host-key verification disabled)')
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (record.kind === 'disable-rule') {
|
|
747
|
+
const server = await registry.get(record.serverId)
|
|
748
|
+
if (server === null) {
|
|
749
|
+
// The record vanished between the request and the confirmation; the
|
|
750
|
+
// applier reports it with the registry's own code so confirm() keeps
|
|
751
|
+
// the pending row instead of silently discarding it.
|
|
752
|
+
throw Object.assign(new Error(`Unknown server "${record.serverId}"; request the rule change again.`), {
|
|
753
|
+
code: 'UNKNOWN_SERVER',
|
|
754
|
+
})
|
|
755
|
+
}
|
|
756
|
+
const ruleId = String(record.ruleId)
|
|
757
|
+
const disabledRules = server.disabledRules.includes(ruleId)
|
|
758
|
+
? server.disabledRules
|
|
759
|
+
: [...server.disabledRules, ruleId]
|
|
760
|
+
await registry.update(record.serverId, { disabledRules })
|
|
761
|
+
return recordEvent(record, 'rule-disabled', server.name, `high-risk rule ${ruleId} disabled`)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
throw new PendingError('INVALID_PENDING', `Unsupported pending kind "${String(record.kind)}".`)
|
|
765
|
+
}
|
|
766
|
+
}
|