dsh-server-connector 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +573 -456
- package/lib/audit.js +14 -3
- package/lib/client.js +1716 -1376
- package/lib/policy.js +167 -6
- package/lib/registry.js +59 -0
- package/lib/routes.js +1527 -1272
- package/lib/tools.js +42 -10
- package/package.json +3 -2
package/lib/routes.js
CHANGED
|
@@ -1,1272 +1,1527 @@
|
|
|
1
|
-
// dsh-server-connector — HTTP routes for the Client half (design doc §8.5).
|
|
2
|
-
//
|
|
3
|
-
// Eleven endpoints read and write the same units the tools use:
|
|
4
|
-
//
|
|
5
|
-
// GET /server-connector/servers list (no secrets)
|
|
6
|
-
// POST /server-connector/servers create
|
|
7
|
-
// PUT /server-connector/servers/:id update (off / 停用 go to the pending queue)
|
|
8
|
-
// DELETE /server-connector/servers/:id delete *request* → pending queue
|
|
9
|
-
// POST /server-connector/test connection test (outside a turn, no policy gate)
|
|
10
|
-
// POST /server-connector/connect/:id open the pooled connection
|
|
11
|
-
// POST /server-connector/disconnect/:id close it
|
|
12
|
-
// GET /server-connector/audit?limit=20 audit tail
|
|
13
|
-
// GET /server-connector/pending two-step queue
|
|
14
|
-
// POST /server-connector/pending/:id/confirm
|
|
15
|
-
// POST /server-connector/pending/:id/cancel
|
|
16
|
-
//
|
|
17
|
-
// Two rules shape this module:
|
|
18
|
-
//
|
|
19
|
-
// 1. **The panel has no turn**, so `ctx.approval` is unavailable (§11). Every
|
|
20
|
-
// destructive operation therefore goes through the pending queue (§8.3):
|
|
21
|
-
// nothing here deletes a server, sets `hostKeyMode: off` or disables a rule
|
|
22
|
-
// directly. The handlers *request*; only `pending.confirm` applies.
|
|
23
|
-
// 2. **The response never carries a secret** (§6.3). A stored password is a
|
|
24
|
-
// credential reference the panel cannot read; the panel only learns whether
|
|
25
|
-
// one is configured. A password arrives write-only on create/update and goes
|
|
26
|
-
// straight to `ctx.credentials`.
|
|
27
|
-
//
|
|
28
|
-
// The HTTP layer is split from the handlers so the contract is testable without
|
|
29
|
-
// a socket: {@link createApi} is a pure dispatcher over `{method, path, query,
|
|
30
|
-
// body}` and {@link createHandler} is the thin node:http adapter.
|
|
31
|
-
|
|
32
|
-
import { stat } from 'node:fs/promises'
|
|
33
|
-
|
|
34
|
-
import { AuditError } from './audit.js'
|
|
35
|
-
import { PendingError } from './pending.js'
|
|
36
|
-
import { PoolError } from './pool.js'
|
|
37
|
-
import { RULES, RULES_BY_ID, effectiveDecision } from './policy.js'
|
|
38
|
-
import { DECISION_VALUES, RegistryError } from './registry.js'
|
|
39
|
-
import { CONNECT_PROBE, parseProbe } from './tools.js'
|
|
40
|
-
|
|
41
|
-
/** Mount path of every route below (doc §8.5). */
|
|
42
|
-
export const API_PREFIX = '/server-connector'
|
|
43
|
-
|
|
44
|
-
/** Largest request body the panel may send; the forms are small JSON objects. */
|
|
45
|
-
export const MAX_BODY_BYTES = 64 * 1024
|
|
46
|
-
|
|
47
|
-
/** Audit tail ceiling; the panel asks for 20, the API never reads more than this. */
|
|
48
|
-
export const MAX_AUDIT_LIMIT = 200
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The eleven endpoints of doc §8.5, in table order.
|
|
52
|
-
*
|
|
53
|
-
* `path` is relative to {@link API_PREFIX}; `:name` segments are parameters.
|
|
54
|
-
* Tests and the handler's 405 response read this table, so it is the single
|
|
55
|
-
* declaration of the surface.
|
|
56
|
-
* @type {ReadonlyArray<{method: string, path: string, op: string}>}
|
|
57
|
-
*/
|
|
58
|
-
export const API_ROUTES = Object.freeze([
|
|
59
|
-
Object.freeze({ method: 'GET', path: '/servers', op: 'listServers' }),
|
|
60
|
-
Object.freeze({ method: 'POST', path: '/servers', op: 'createServer' }),
|
|
61
|
-
Object.freeze({ method: 'PUT', path: '/servers/:id', op: 'updateServer' }),
|
|
62
|
-
Object.freeze({ method: 'DELETE', path: '/servers/:id', op: 'requestDelete' }),
|
|
63
|
-
Object.freeze({ method: 'POST', path: '/test', op: 'testConnection' }),
|
|
64
|
-
Object.freeze({ method: 'POST', path: '/connect/:id', op: 'connectServer' }),
|
|
65
|
-
Object.freeze({ method: 'POST', path: '/disconnect/:id', op: 'disconnectServer' }),
|
|
66
|
-
Object.freeze({ method: 'GET', path: '/audit', op: 'listAudit' }),
|
|
67
|
-
Object.freeze({ method: 'GET', path: '/pending', op: 'listPending' }),
|
|
68
|
-
Object.freeze({ method: 'POST', path: '/pending/:id/confirm', op: 'confirmPending' }),
|
|
69
|
-
Object.freeze({ method: 'POST', path: '/pending/:id/cancel', op: 'cancelPending' }),
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
this.
|
|
92
|
-
/** @type {
|
|
93
|
-
this.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
throw new HttpError(400, 'INVALID_SERVER', `${field} must be
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* `
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* @
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
*
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
return
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
*
|
|
287
|
-
* @
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* @
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
if (error instanceof
|
|
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
|
-
const
|
|
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
|
-
try {
|
|
503
|
-
|
|
504
|
-
} catch {
|
|
505
|
-
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
return
|
|
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
|
-
|
|
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
|
-
const
|
|
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
|
-
const
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
if (
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
/** @type {string
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
})
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
const
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
const
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1
|
+
// dsh-server-connector — HTTP routes for the Client half (design doc §8.5).
|
|
2
|
+
//
|
|
3
|
+
// Eleven endpoints read and write the same units the tools use:
|
|
4
|
+
//
|
|
5
|
+
// GET /server-connector/servers list (no secrets)
|
|
6
|
+
// POST /server-connector/servers create
|
|
7
|
+
// PUT /server-connector/servers/:id update (off / 停用 go to the pending queue)
|
|
8
|
+
// DELETE /server-connector/servers/:id delete *request* → pending queue
|
|
9
|
+
// POST /server-connector/test connection test (outside a turn, no policy gate)
|
|
10
|
+
// POST /server-connector/connect/:id open the pooled connection
|
|
11
|
+
// POST /server-connector/disconnect/:id close it
|
|
12
|
+
// GET /server-connector/audit?limit=20 audit tail
|
|
13
|
+
// GET /server-connector/pending two-step queue
|
|
14
|
+
// POST /server-connector/pending/:id/confirm
|
|
15
|
+
// POST /server-connector/pending/:id/cancel
|
|
16
|
+
//
|
|
17
|
+
// Two rules shape this module:
|
|
18
|
+
//
|
|
19
|
+
// 1. **The panel has no turn**, so `ctx.approval` is unavailable (§11). Every
|
|
20
|
+
// destructive operation therefore goes through the pending queue (§8.3):
|
|
21
|
+
// nothing here deletes a server, sets `hostKeyMode: off` or disables a rule
|
|
22
|
+
// directly. The handlers *request*; only `pending.confirm` applies.
|
|
23
|
+
// 2. **The response never carries a secret** (§6.3). A stored password is a
|
|
24
|
+
// credential reference the panel cannot read; the panel only learns whether
|
|
25
|
+
// one is configured. A password arrives write-only on create/update and goes
|
|
26
|
+
// straight to `ctx.credentials`.
|
|
27
|
+
//
|
|
28
|
+
// The HTTP layer is split from the handlers so the contract is testable without
|
|
29
|
+
// a socket: {@link createApi} is a pure dispatcher over `{method, path, query,
|
|
30
|
+
// body}` and {@link createHandler} is the thin node:http adapter.
|
|
31
|
+
|
|
32
|
+
import { stat } from 'node:fs/promises'
|
|
33
|
+
|
|
34
|
+
import { AuditError } from './audit.js'
|
|
35
|
+
import { PendingError } from './pending.js'
|
|
36
|
+
import { PoolError } from './pool.js'
|
|
37
|
+
import { MAX_CUSTOM_RULES, RULES, RULES_BY_ID, RuleError, check as checkPolicy, effectiveDecision, validateCustomRule } from './policy.js'
|
|
38
|
+
import { DECISION_VALUES, RegistryError } from './registry.js'
|
|
39
|
+
import { CONNECT_PROBE, parseProbe } from './tools.js'
|
|
40
|
+
|
|
41
|
+
/** Mount path of every route below (doc §8.5). */
|
|
42
|
+
export const API_PREFIX = '/server-connector'
|
|
43
|
+
|
|
44
|
+
/** Largest request body the panel may send; the forms are small JSON objects. */
|
|
45
|
+
export const MAX_BODY_BYTES = 64 * 1024
|
|
46
|
+
|
|
47
|
+
/** Audit tail ceiling; the panel asks for 20, the API never reads more than this. */
|
|
48
|
+
export const MAX_AUDIT_LIMIT = 200
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The eleven endpoints of doc §8.5, in table order.
|
|
52
|
+
*
|
|
53
|
+
* `path` is relative to {@link API_PREFIX}; `:name` segments are parameters.
|
|
54
|
+
* Tests and the handler's 405 response read this table, so it is the single
|
|
55
|
+
* declaration of the surface.
|
|
56
|
+
* @type {ReadonlyArray<{method: string, path: string, op: string}>}
|
|
57
|
+
*/
|
|
58
|
+
export const API_ROUTES = Object.freeze([
|
|
59
|
+
Object.freeze({ method: 'GET', path: '/servers', op: 'listServers' }),
|
|
60
|
+
Object.freeze({ method: 'POST', path: '/servers', op: 'createServer' }),
|
|
61
|
+
Object.freeze({ method: 'PUT', path: '/servers/:id', op: 'updateServer' }),
|
|
62
|
+
Object.freeze({ method: 'DELETE', path: '/servers/:id', op: 'requestDelete' }),
|
|
63
|
+
Object.freeze({ method: 'POST', path: '/test', op: 'testConnection' }),
|
|
64
|
+
Object.freeze({ method: 'POST', path: '/connect/:id', op: 'connectServer' }),
|
|
65
|
+
Object.freeze({ method: 'POST', path: '/disconnect/:id', op: 'disconnectServer' }),
|
|
66
|
+
Object.freeze({ method: 'GET', path: '/audit', op: 'listAudit' }),
|
|
67
|
+
Object.freeze({ method: 'GET', path: '/pending', op: 'listPending' }),
|
|
68
|
+
Object.freeze({ method: 'POST', path: '/pending/:id/confirm', op: 'confirmPending' }),
|
|
69
|
+
Object.freeze({ method: 'POST', path: '/pending/:id/cancel', op: 'cancelPending' }),
|
|
70
|
+
Object.freeze({ method: 'POST', path: '/servers/:id/rules', op: 'createRule' }),
|
|
71
|
+
Object.freeze({ method: 'PUT', path: '/servers/:id/rules/:ruleId', op: 'updateRule' }),
|
|
72
|
+
Object.freeze({ method: 'DELETE', path: '/servers/:id/rules/:ruleId', op: 'deleteRule' }),
|
|
73
|
+
Object.freeze({ method: 'POST', path: '/servers/:id/rules/preview', op: 'previewRule' }),
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A handler failure carrying the status the browser should show.
|
|
78
|
+
*
|
|
79
|
+
* Codes are stable strings the panel may branch on; `message` is written for a
|
|
80
|
+
* person and is safe to display (it never contains a secret).
|
|
81
|
+
*/
|
|
82
|
+
export class HttpError extends Error {
|
|
83
|
+
/**
|
|
84
|
+
* @param {number} status - HTTP status code.
|
|
85
|
+
* @param {string} code - stable machine-readable code, e.g. `INVALID_SERVER`.
|
|
86
|
+
* @param {string} message - displayable message.
|
|
87
|
+
* @param {Record<string, unknown>} [details] - extra fields for the panel.
|
|
88
|
+
*/
|
|
89
|
+
constructor(status, code, message, details = {}) {
|
|
90
|
+
super(message)
|
|
91
|
+
this.name = 'HttpError'
|
|
92
|
+
/** @type {number} */
|
|
93
|
+
this.status = status
|
|
94
|
+
/** @type {string} */
|
|
95
|
+
this.code = code
|
|
96
|
+
/** @type {Record<string, unknown>} */
|
|
97
|
+
this.details = details
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
102
|
+
function isPlainObject(value) {
|
|
103
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {unknown} value
|
|
108
|
+
* @param {string} field
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
function requiredString(value, field) {
|
|
112
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
113
|
+
throw new HttpError(400, 'INVALID_SERVER', `${field} must be a non-empty string.`)
|
|
114
|
+
}
|
|
115
|
+
return value.trim()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {unknown} value
|
|
120
|
+
* @param {string} field
|
|
121
|
+
* @returns {string} an absolute POSIX path, as doc §8.4 requires.
|
|
122
|
+
*/
|
|
123
|
+
function absolutePath(value, field) {
|
|
124
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
125
|
+
throw new HttpError(400, 'INVALID_SERVER', `${field} must be a non-empty absolute path.`)
|
|
126
|
+
}
|
|
127
|
+
const path = value.trim()
|
|
128
|
+
if (!path.startsWith('/')) {
|
|
129
|
+
throw new HttpError(400, 'INVALID_SERVER', `${field} must be an absolute path.`)
|
|
130
|
+
}
|
|
131
|
+
return path
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A path on *this* machine, which is not necessarily a POSIX one.
|
|
136
|
+
*
|
|
137
|
+
* `auth.privateKeyPath` points at a local file the Host reads itself, so on
|
|
138
|
+
* Windows it is an ordinary drive-letter (`C:\Users\me\.ssh\id_ed25519`) or
|
|
139
|
+
* UNC (`\\server\share\key`) path. Requiring a leading `/` here rejected every
|
|
140
|
+
* Windows path the panel could offer, which left key auth unusable on Windows
|
|
141
|
+
* even though the Host-side file check accepted the very same path.
|
|
142
|
+
*
|
|
143
|
+
* `defaultWorkDir` deliberately keeps using {@link absolutePath}: it is a path
|
|
144
|
+
* on the *remote* host, which is POSIX by contract.
|
|
145
|
+
* @param {unknown} value
|
|
146
|
+
* @param {string} field
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
function localAbsolutePath(value, field) {
|
|
150
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
151
|
+
throw new HttpError(400, 'INVALID_SERVER', `${field} must be a non-empty absolute path.`)
|
|
152
|
+
}
|
|
153
|
+
const path = value.trim()
|
|
154
|
+
if (path.startsWith('/')) return path
|
|
155
|
+
if (/^[A-Za-z]:[\\/]/.test(path)) return path
|
|
156
|
+
if (path.startsWith('\\\\')) return path
|
|
157
|
+
throw new HttpError(400, 'INVALID_SERVER', `${field} must be an absolute path.`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The three auth kinds of doc §3.1. @type {ReadonlyArray<string>} */
|
|
161
|
+
const AUTH_KINDS = Object.freeze(['key', 'password', 'agent'])
|
|
162
|
+
|
|
163
|
+
/** The three host-key modes of doc §6.4. @type {ReadonlyArray<string>} */
|
|
164
|
+
const HOST_KEY_MODES = Object.freeze(['accept-new', 'verify', 'off'])
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Validate the public `auth` object of a create/update body (§3.1, §8.4).
|
|
168
|
+
*
|
|
169
|
+
* A password never appears here: it arrives in the separate write-only
|
|
170
|
+
* `password` field and is stored through `ctx.credentials` (doc §6.3).
|
|
171
|
+
* @param {unknown} value
|
|
172
|
+
* @returns {{kind:'key', privateKeyPath:string}|{kind:'password'}|{kind:'agent'}}
|
|
173
|
+
*/
|
|
174
|
+
function normalizeAuthBody(value) {
|
|
175
|
+
if (value === undefined) return { kind: 'agent' }
|
|
176
|
+
if (!isPlainObject(value)) throw new HttpError(400, 'INVALID_SERVER', 'auth must be an object.')
|
|
177
|
+
const kind = value.kind
|
|
178
|
+
if (kind === 'key') {
|
|
179
|
+
for (const key of Object.keys(value)) {
|
|
180
|
+
if (key !== 'kind' && key !== 'privateKeyPath') {
|
|
181
|
+
throw new HttpError(400, 'INVALID_SERVER', `Unknown auth field "${key}".`)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { kind: 'key', privateKeyPath: localAbsolutePath(value.privateKeyPath, 'auth.privateKeyPath') }
|
|
185
|
+
}
|
|
186
|
+
if (kind === 'password') {
|
|
187
|
+
for (const key of Object.keys(value)) {
|
|
188
|
+
if (key !== 'kind') throw new HttpError(400, 'INVALID_SERVER', `Unknown auth field "${key}".`)
|
|
189
|
+
}
|
|
190
|
+
return { kind: 'password' }
|
|
191
|
+
}
|
|
192
|
+
if (kind === 'agent') {
|
|
193
|
+
for (const key of Object.keys(value)) {
|
|
194
|
+
if (key !== 'kind') throw new HttpError(400, 'INVALID_SERVER', `Unknown auth field "${key}".`)
|
|
195
|
+
}
|
|
196
|
+
return { kind: 'agent' }
|
|
197
|
+
}
|
|
198
|
+
throw new HttpError(400, 'INVALID_SERVER', `auth.kind must be one of ${AUTH_KINDS.join(', ')}.`)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The server fields both mutations share, spelled once.
|
|
203
|
+
*
|
|
204
|
+
* The write-only credential is deliberately not in this list: it is a request
|
|
205
|
+
* field, not a record field (`servers.json` never holds it, §6.3), so each
|
|
206
|
+
* operation adds it where it applies.
|
|
207
|
+
*/
|
|
208
|
+
const SHARED_BODY_FIELDS = Object.freeze(['name', 'host', 'port', 'username', 'auth', 'hostKeyMode', 'defaultWorkDir', 'policy', 'enabled'])
|
|
209
|
+
|
|
210
|
+
/** Fields a create body may carry; everything else is rejected by name. */
|
|
211
|
+
const CREATE_BODY_FIELDS = new Set([...SHARED_BODY_FIELDS, 'password', 'disableRules'])
|
|
212
|
+
|
|
213
|
+
/** Fields an update body may carry. `disabledRules` is listed only to explain its refusal. */
|
|
214
|
+
const UPDATE_BODY_FIELDS = new Set([...SHARED_BODY_FIELDS, 'password', 'enableRules', 'disableRules', 'disabledRules'])
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Reject a body key the panel has no business sending.
|
|
218
|
+
*
|
|
219
|
+
* This is where a plaintext secret would arrive by mistake (`auth.passphrase`,
|
|
220
|
+
* a top-level `secret`): silently ignoring it would let the panel believe it was
|
|
221
|
+
* stored, so an unknown key is a 400 instead.
|
|
222
|
+
* @param {Record<string, unknown>} body
|
|
223
|
+
* @param {ReadonlySet<string>} allowed
|
|
224
|
+
* @returns {void}
|
|
225
|
+
*/
|
|
226
|
+
function assertKnownBodyFields(body, allowed) {
|
|
227
|
+
for (const key of Object.keys(body)) {
|
|
228
|
+
if (allowed.has(key)) continue
|
|
229
|
+
throw new HttpError(400, 'INVALID_SERVER', `Unknown field "${key}" in the request body.`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The decisions a rule may take from the panel (§4.2.3, §8.4).
|
|
235
|
+
*
|
|
236
|
+
* A built-in `block` rule may be lowered to `confirm` but never straight to
|
|
237
|
+
* `allow`; a `confirm` rule may be relaxed to `allow`, tightened to `block`, or
|
|
238
|
+
* switched off through the pending queue.
|
|
239
|
+
* @param {{defaultDecision: string}} rule
|
|
240
|
+
* @returns {string[]}
|
|
241
|
+
*/
|
|
242
|
+
export function allowedDecisions(rule) {
|
|
243
|
+
return rule.defaultDecision === 'block' ? ['confirm', 'block'] : ['allow', 'confirm', 'block']
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Validate one `policy` map from the panel.
|
|
248
|
+
*
|
|
249
|
+
* Unknown rule ids, non-decision values and a `block → allow` jump are rejected
|
|
250
|
+
* here rather than silently coerced by the engine: the panel must never store a
|
|
251
|
+
* setting it cannot honour.
|
|
252
|
+
*
|
|
253
|
+
* `customRules` widens the accepted id set to include this server's own rules
|
|
254
|
+
* (doc §4.4.4): a custom rule can be tightened or loosened through `policy`
|
|
255
|
+
* exactly like a built-in one, and the same `block → allow` ban applies.
|
|
256
|
+
* @param {unknown} value
|
|
257
|
+
* @param {ReadonlyArray<import('./policy.js').CustomRule>} [customRules]
|
|
258
|
+
* @returns {Record<string, 'allow'|'confirm'|'block'>}
|
|
259
|
+
*/
|
|
260
|
+
function normalizePolicyBody(value, customRules = []) {
|
|
261
|
+
if (!isPlainObject(value)) throw new HttpError(400, 'INVALID_POLICY', 'policy must be an object.')
|
|
262
|
+
const custom = new Map(customRules.map((rule) => [rule.id, rule]))
|
|
263
|
+
/** @type {Record<string, 'allow'|'confirm'|'block'>} */
|
|
264
|
+
const policy = {}
|
|
265
|
+
for (const [ruleId, decision] of Object.entries(value)) {
|
|
266
|
+
const rule = RULES_BY_ID.get(ruleId) ?? custom.get(ruleId)
|
|
267
|
+
if (rule === undefined) {
|
|
268
|
+
throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule "${ruleId}".`)
|
|
269
|
+
}
|
|
270
|
+
if (decision !== 'allow' && decision !== 'confirm' && decision !== 'block') {
|
|
271
|
+
throw new HttpError(400, 'INVALID_POLICY', `policy.${ruleId} must be one of ${DECISION_VALUES.join(', ')}.`)
|
|
272
|
+
}
|
|
273
|
+
if (!allowedDecisions(/** @type {{defaultDecision: string}} */ (rule)).includes(decision)) {
|
|
274
|
+
throw new HttpError(
|
|
275
|
+
400,
|
|
276
|
+
'INVALID_POLICY',
|
|
277
|
+
`policy.${ruleId}: a "block" rule can be lowered to "confirm" but not to "allow".`,
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
policy[ruleId] = decision
|
|
281
|
+
}
|
|
282
|
+
return policy
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Resolve one built-in rule id or fail with 400.
|
|
287
|
+
* @param {string} ruleId
|
|
288
|
+
* @returns {import('./policy.js').Rule}
|
|
289
|
+
*/
|
|
290
|
+
function ruleOf(ruleId) {
|
|
291
|
+
const rule = RULES_BY_ID.get(ruleId)
|
|
292
|
+
if (rule === undefined) throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule "${ruleId}".`)
|
|
293
|
+
return rule
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {unknown} ruleId
|
|
298
|
+
* @returns {boolean}
|
|
299
|
+
*/
|
|
300
|
+
function isKnownRule(ruleId) {
|
|
301
|
+
return typeof ruleId === 'string' && RULES_BY_ID.has(ruleId)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Pull the sample commands out of a custom-rule body (doc §4.4.3).
|
|
306
|
+
*
|
|
307
|
+
* Samples are optional everywhere except the preview endpoint. They are the
|
|
308
|
+
* cheap guard against "I wrote a regex that never fires": at least one sample
|
|
309
|
+
* must match before the rule is stored.
|
|
310
|
+
* @param {Record<string, unknown>} body
|
|
311
|
+
* @returns {string[]}
|
|
312
|
+
*/
|
|
313
|
+
function samplesOf(body) {
|
|
314
|
+
if (!Array.isArray(body.samples)) return []
|
|
315
|
+
return body.samples.filter((item) => typeof item === 'string' && item.trim() !== '').map((item) => item.trim())
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The public catalog the panel renders one row per rule from (§8.4).
|
|
320
|
+
* @returns {Array<Record<string, unknown>>}
|
|
321
|
+
*/
|
|
322
|
+
export function ruleCatalog() {
|
|
323
|
+
return RULES.map((rule) => ({
|
|
324
|
+
id: rule.id,
|
|
325
|
+
label: rule.label,
|
|
326
|
+
description: rule.description,
|
|
327
|
+
defaultDecision: rule.defaultDecision,
|
|
328
|
+
allowedDecisions: allowedDecisions(rule),
|
|
329
|
+
}))
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Per-rule state of one server (§8.4 "高危策略" table).
|
|
334
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
335
|
+
* @returns {Record<string, {configured: string|null, decision: string|null, disabled: boolean}>}
|
|
336
|
+
*/
|
|
337
|
+
function policyStateOf(server) {
|
|
338
|
+
/** @type {Record<string, {configured: string|null, decision: string|null, disabled: boolean}>} */
|
|
339
|
+
const state = {}
|
|
340
|
+
for (const rule of RULES) {
|
|
341
|
+
const disabled = server.disabledRules.includes(rule.id)
|
|
342
|
+
const configured = server.policy[rule.id]
|
|
343
|
+
state[rule.id] = {
|
|
344
|
+
configured: configured ?? null,
|
|
345
|
+
decision: disabled ? null : effectiveDecision(rule, server.policy),
|
|
346
|
+
disabled,
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return state
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Public view of one server: everything the panel needs, no secret, and
|
|
354
|
+
* `passwordConfigured` instead of the credential reference (§6.3).
|
|
355
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
356
|
+
* @param {{connected?: boolean, passwordConfigured?: boolean|null}} [extra]
|
|
357
|
+
* @returns {Record<string, unknown>}
|
|
358
|
+
*/
|
|
359
|
+
export function presentServer(server, extra = {}) {
|
|
360
|
+
return {
|
|
361
|
+
id: server.id,
|
|
362
|
+
name: server.name,
|
|
363
|
+
host: server.host,
|
|
364
|
+
port: server.port,
|
|
365
|
+
username: server.username,
|
|
366
|
+
auth:
|
|
367
|
+
server.auth.kind === 'key'
|
|
368
|
+
? { kind: 'key', privateKeyPath: server.auth.privateKeyPath }
|
|
369
|
+
: { kind: server.auth.kind },
|
|
370
|
+
hostKeyMode: server.hostKeyMode,
|
|
371
|
+
defaultWorkDir: server.defaultWorkDir,
|
|
372
|
+
enabled: server.enabled !== false,
|
|
373
|
+
policy: server.policy,
|
|
374
|
+
disabledRules: server.disabledRules,
|
|
375
|
+
customRules: server.customRules ?? [],
|
|
376
|
+
createdAt: server.createdAt,
|
|
377
|
+
lastConnectedAt: server.lastConnectedAt,
|
|
378
|
+
connected: extra.connected === true,
|
|
379
|
+
passwordConfigured: extra.passwordConfigured ?? null,
|
|
380
|
+
policyState: policyStateOf(server),
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Read the host-key mode of a body, defaulting to the current value.
|
|
386
|
+
* @param {unknown} value
|
|
387
|
+
* @returns {'accept-new'|'verify'|'off'}
|
|
388
|
+
*/
|
|
389
|
+
function hostKeyModeOf(value) {
|
|
390
|
+
if (value === undefined) return /** @type {any} */ (undefined)
|
|
391
|
+
if (typeof value !== 'string' || !HOST_KEY_MODES.includes(value)) {
|
|
392
|
+
throw new HttpError(400, 'INVALID_SERVER', `hostKeyMode must be one of ${HOST_KEY_MODES.join(', ')}.`)
|
|
393
|
+
}
|
|
394
|
+
return /** @type {'accept-new'|'verify'|'off'} */ (value)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* @param {unknown} value
|
|
399
|
+
* @returns {string|null}
|
|
400
|
+
*/
|
|
401
|
+
function workDirOf(value) {
|
|
402
|
+
if (value === undefined || value === null || value === '') return null
|
|
403
|
+
return absolutePath(value, 'defaultWorkDir')
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Map a domain error to an HTTP failure (§5.5 codes stay visible to the panel).
|
|
408
|
+
* @param {unknown} error
|
|
409
|
+
* @returns {HttpError}
|
|
410
|
+
*/
|
|
411
|
+
export function toHttpError(error) {
|
|
412
|
+
if (error instanceof HttpError) return error
|
|
413
|
+
if (error instanceof RegistryError) {
|
|
414
|
+
const status =
|
|
415
|
+
error.code === 'UNKNOWN_SERVER'
|
|
416
|
+
? 404
|
|
417
|
+
: error.code === 'DUPLICATE_NAME' || error.code === 'AMBIGUOUS_SERVER'
|
|
418
|
+
? 409
|
|
419
|
+
: error.code === 'INVALID_REGISTRY'
|
|
420
|
+
? 500
|
|
421
|
+
: 400
|
|
422
|
+
return new HttpError(status, error.code, error.message)
|
|
423
|
+
}
|
|
424
|
+
if (error instanceof PendingError) {
|
|
425
|
+
const status =
|
|
426
|
+
error.code === 'UNKNOWN_PENDING'
|
|
427
|
+
? 404
|
|
428
|
+
: error.code === 'EXPIRED_PENDING'
|
|
429
|
+
? 410
|
|
430
|
+
: error.code === 'PENDING_UNAVAILABLE'
|
|
431
|
+
? 503
|
|
432
|
+
: error.code === 'APPLY_FAILED'
|
|
433
|
+
? 502
|
|
434
|
+
: 400
|
|
435
|
+
return new HttpError(status, error.code, error.message)
|
|
436
|
+
}
|
|
437
|
+
if (error instanceof PoolError) {
|
|
438
|
+
const status =
|
|
439
|
+
error.code === 'HOST_KEY_UNKNOWN' || error.code === 'NO_CREDENTIAL'
|
|
440
|
+
? 409
|
|
441
|
+
: error.code === 'TIMEOUT'
|
|
442
|
+
? 504
|
|
443
|
+
: error.code === 'INVALID_SERVER'
|
|
444
|
+
? 400
|
|
445
|
+
: 502
|
|
446
|
+
return new HttpError(status, error.code, error.message, {
|
|
447
|
+
...(error.host === undefined ? {} : { host: error.host }),
|
|
448
|
+
...(error.serverId === undefined ? {} : { serverId: error.serverId }),
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
if (error instanceof AuditError) return new HttpError(500, error.code, error.message)
|
|
452
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
453
|
+
return new HttpError(500, 'INTERNAL', message)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Build the route handlers over the plugin's units.
|
|
458
|
+
*
|
|
459
|
+
* Everything is injected, so this module knows nothing about cordis: the tests
|
|
460
|
+
* build the same object with a real `Registry`, `Pool`, `AuditLog` and
|
|
461
|
+
* `PendingQueue` over temporary files (doc §2.2).
|
|
462
|
+
* @param {{
|
|
463
|
+
* registry: import('./registry.js').Registry,
|
|
464
|
+
* pool: {connect: Function, exec: Function, disconnect: Function, connectedServerIds: Function},
|
|
465
|
+
* audit?: import('./audit.js').AuditLog|null,
|
|
466
|
+
* pending?: import('./pending.js').PendingQueue|null,
|
|
467
|
+
* getCredentials?: () => ({describe?: Function, set?: Function, unset?: Function}|null|undefined),
|
|
468
|
+
* config?: Partial<import('./index.js').PluginConfig>,
|
|
469
|
+
* onChange?: () => (void|Promise<void>)|null,
|
|
470
|
+
* logger?: {info?: Function, warn?: Function, error?: Function}|null,
|
|
471
|
+
* }} options
|
|
472
|
+
* @returns {{dispatch: (request: {method: string, path: string, query?: Record<string, string>, body?: unknown}) => Promise<{status: number, body: unknown}>, describe: () => ReadonlyArray<{method: string, path: string}>}}
|
|
473
|
+
*/
|
|
474
|
+
export function createApi(options) {
|
|
475
|
+
if (options?.registry === undefined || options.registry === null) throw new TypeError('createApi requires a registry.')
|
|
476
|
+
if (options?.pool === undefined || options.pool === null) throw new TypeError('createApi requires a pool.')
|
|
477
|
+
const { registry, pool, audit = null, pending = null, logger = null } = options
|
|
478
|
+
const config = options.config ?? {}
|
|
479
|
+
const onChange = typeof options.onChange === 'function' ? options.onChange : null
|
|
480
|
+
const getCredentials = typeof options.getCredentials === 'function' ? options.getCredentials : () => null
|
|
481
|
+
const probeTimeoutMs = Math.min(Number(config.commandTimeoutMs ?? 60000), 15000)
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Report one non-fatal failure without changing the request outcome.
|
|
485
|
+
* @param {string} message - printf-style format.
|
|
486
|
+
* @param {...unknown} args
|
|
487
|
+
* @returns {void}
|
|
488
|
+
*/
|
|
489
|
+
const warn = (message, ...args) => {
|
|
490
|
+
const method = logger === null ? undefined : logger.warn
|
|
491
|
+
if (typeof method !== 'function') return
|
|
492
|
+
method.call(
|
|
493
|
+
logger,
|
|
494
|
+
message,
|
|
495
|
+
...args.map((value) => (value instanceof Error ? value.message : String(value))),
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Refresh the system-prompt cache after a mutation (§9.4). */
|
|
500
|
+
const notify = async () => {
|
|
501
|
+
if (onChange === null) return
|
|
502
|
+
try {
|
|
503
|
+
await onChange()
|
|
504
|
+
} catch (error) {
|
|
505
|
+
warn('dsh-server-connector: could not refresh the server list after a panel change: %s', error)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Write one panel-originated audit event (doc §3.4).
|
|
511
|
+
*
|
|
512
|
+
* The pending applier records its own events when a queued change is
|
|
513
|
+
* confirmed; the enable switch is applied immediately, so it records here.
|
|
514
|
+
* @param {'server-enabled'|'server-disabled'} event
|
|
515
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
516
|
+
* @returns {Promise<void>}
|
|
517
|
+
*/
|
|
518
|
+
const recordPanelEvent = async (event, server) => {
|
|
519
|
+
if (audit === null) return
|
|
520
|
+
try {
|
|
521
|
+
await audit.append({
|
|
522
|
+
event,
|
|
523
|
+
serverId: server.id,
|
|
524
|
+
serverName: server.name,
|
|
525
|
+
detail: event === 'server-disabled' ? 'server disabled (hidden from srv_list)' : 'server enabled',
|
|
526
|
+
})
|
|
527
|
+
} catch (error) {
|
|
528
|
+
warn('dsh-server-connector: could not write the %s audit event: %s', event, String(error))
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Whether a password is stored for this server (`null` = cannot tell).
|
|
534
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
535
|
+
* @returns {Promise<boolean|null>}
|
|
536
|
+
*/
|
|
537
|
+
const passwordConfigured = async (server) => {
|
|
538
|
+
if (server.auth.kind !== 'password') return null
|
|
539
|
+
const credentials = getCredentials()
|
|
540
|
+
if (credentials === null || credentials === undefined || typeof credentials.describe !== 'function') return null
|
|
541
|
+
try {
|
|
542
|
+
const info = await credentials.describe(server.auth.credentialRef)
|
|
543
|
+
return info?.configured === true
|
|
544
|
+
} catch (error) {
|
|
545
|
+
warn('dsh-server-connector: could not describe the credential of %s: %s', server.id, error)
|
|
546
|
+
return null
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** @returns {Set<string>} ids with a live pooled connection */
|
|
551
|
+
const connectedIds = () => {
|
|
552
|
+
try {
|
|
553
|
+
return new Set(/** @type {string[]} */ (pool.connectedServerIds()))
|
|
554
|
+
} catch {
|
|
555
|
+
return new Set()
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Load one server or fail with 404.
|
|
561
|
+
* @param {string} id
|
|
562
|
+
* @returns {Promise<import('./registry.js').ServerRecord>}
|
|
563
|
+
*/
|
|
564
|
+
const requireServer = async (id) => {
|
|
565
|
+
const server = await registry.get(id)
|
|
566
|
+
if (server === null) {
|
|
567
|
+
throw new HttpError(404, 'UNKNOWN_SERVER', `No server "${id}"; it may have been deleted.`)
|
|
568
|
+
}
|
|
569
|
+
return server
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Require the pending queue where an operation needs it.
|
|
574
|
+
* @returns {import('./pending.js').PendingQueue}
|
|
575
|
+
*/
|
|
576
|
+
const requirePending = () => {
|
|
577
|
+
if (pending === null) {
|
|
578
|
+
throw new HttpError(503, 'PENDING_UNAVAILABLE', 'This deployment has no pending queue, so dangerous operations are unavailable.')
|
|
579
|
+
}
|
|
580
|
+
return pending
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Write a password to the credential service (doc §6.3). The record only ever
|
|
585
|
+
* holds the reference.
|
|
586
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
587
|
+
* @param {unknown} value - the write-only body field; validated here.
|
|
588
|
+
* @returns {Promise<void>}
|
|
589
|
+
*/
|
|
590
|
+
const storePassword = async (server, value) => {
|
|
591
|
+
if (server.auth.kind !== 'password') {
|
|
592
|
+
throw new HttpError(400, 'INVALID_SERVER', 'password is only accepted for auth.kind "password".')
|
|
593
|
+
}
|
|
594
|
+
const credentials = getCredentials()
|
|
595
|
+
if (credentials === null || credentials === undefined || typeof credentials.set !== 'function') {
|
|
596
|
+
throw new HttpError(
|
|
597
|
+
503,
|
|
598
|
+
'CREDENTIALS_UNAVAILABLE',
|
|
599
|
+
'This deployment has no credential service, so a password cannot be stored. Use private-key or agent authentication.',
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
if (typeof value !== 'string' || value === '') {
|
|
603
|
+
throw new HttpError(400, 'INVALID_SERVER', 'password must be a non-empty string.')
|
|
604
|
+
}
|
|
605
|
+
await credentials.set(server.auth.credentialRef, value)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Validate a key path on this machine (§8.4 "绝对路径、文件存在").
|
|
610
|
+
* @param {string} path
|
|
611
|
+
* @returns {Promise<void>}
|
|
612
|
+
*/
|
|
613
|
+
const assertKeyFile = async (path) => {
|
|
614
|
+
try {
|
|
615
|
+
const info = await stat(path)
|
|
616
|
+
if (!info.isFile()) {
|
|
617
|
+
throw new HttpError(400, 'INVALID_SERVER', `auth.privateKeyPath ${path} is not a regular file.`)
|
|
618
|
+
}
|
|
619
|
+
} catch (error) {
|
|
620
|
+
if (error instanceof HttpError) throw error
|
|
621
|
+
throw new HttpError(400, 'INVALID_SERVER', `auth.privateKeyPath ${path} does not exist on this machine.`)
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Queue one dangerous operation and report it (§8.3).
|
|
627
|
+
* @param {import('./pending.js').PendingRequestInput} input
|
|
628
|
+
* @returns {Promise<import('./pending.js').PendingRecord>}
|
|
629
|
+
*/
|
|
630
|
+
const enqueue = async (input) => {
|
|
631
|
+
const queue = requirePending()
|
|
632
|
+
const { record } = await queue.request(input)
|
|
633
|
+
return record
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ------------------------------------------------------------- handlers --
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* GET /servers — every server, the rule catalog, the public config and the
|
|
640
|
+
* pending count, so the panel and the settings page need one round trip.
|
|
641
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
642
|
+
*/
|
|
643
|
+
const listServers = async () => {
|
|
644
|
+
const servers = await registry.list()
|
|
645
|
+
const connected = connectedIds()
|
|
646
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
647
|
+
const present = []
|
|
648
|
+
for (const server of servers) {
|
|
649
|
+
present.push(
|
|
650
|
+
presentServer(server, { connected: connected.has(server.id), passwordConfigured: await passwordConfigured(server) }),
|
|
651
|
+
)
|
|
652
|
+
}
|
|
653
|
+
/** @type {unknown[]} */
|
|
654
|
+
let queued = []
|
|
655
|
+
if (pending !== null) {
|
|
656
|
+
try {
|
|
657
|
+
queued = await pending.list()
|
|
658
|
+
} catch (error) {
|
|
659
|
+
warn('dsh-server-connector: could not read pending.json: %s', error)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return {
|
|
663
|
+
status: 200,
|
|
664
|
+
body: {
|
|
665
|
+
servers: present,
|
|
666
|
+
rules: ruleCatalog(),
|
|
667
|
+
config: {
|
|
668
|
+
connectTimeoutMs: config.connectTimeoutMs ?? 15000,
|
|
669
|
+
commandTimeoutMs: config.commandTimeoutMs ?? 60000,
|
|
670
|
+
syncTimeoutMs: config.syncTimeoutMs ?? 20000,
|
|
671
|
+
maxOutputChars: config.maxOutputChars ?? 200000,
|
|
672
|
+
idleTimeoutMs: config.idleTimeoutMs ?? 600000,
|
|
673
|
+
auditEnabled: config.auditEnabled ?? true,
|
|
674
|
+
auditMaxBytes: config.auditMaxBytes ?? 0,
|
|
675
|
+
pendingTtlHours: config.pendingTtlHours ?? 24,
|
|
676
|
+
maxListedServers: config.maxListedServers ?? 8,
|
|
677
|
+
},
|
|
678
|
+
hostKeyModes: [...HOST_KEY_MODES],
|
|
679
|
+
decisions: [...DECISION_VALUES],
|
|
680
|
+
pendingCount: Array.isArray(queued) ? queued.length : 0,
|
|
681
|
+
},
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* POST /servers — create.
|
|
687
|
+
*
|
|
688
|
+
* `hostKeyMode: "off"` obeys the same two-step rule as an update (§8.4/§8.3):
|
|
689
|
+
* the record is created with `accept-new` and an `disable-hostkey` request is
|
|
690
|
+
* queued, so choosing "off" never takes effect in the same click that creates
|
|
691
|
+
* the server.
|
|
692
|
+
* @param {unknown} body
|
|
693
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
694
|
+
*/
|
|
695
|
+
const createServer = async (body) => {
|
|
696
|
+
if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_SERVER', 'Request body must be a JSON object.')
|
|
697
|
+
assertKnownBodyFields(body, CREATE_BODY_FIELDS)
|
|
698
|
+
const auth = normalizeAuthBody(body.auth)
|
|
699
|
+
if (auth.kind !== 'password' && body.password !== undefined) {
|
|
700
|
+
throw new HttpError(
|
|
701
|
+
400,
|
|
702
|
+
'INVALID_SERVER',
|
|
703
|
+
'A password is only accepted for auth.kind "password"; the record never stores a plaintext secret.',
|
|
704
|
+
)
|
|
705
|
+
}
|
|
706
|
+
const requestedMode = hostKeyModeOf(body.hostKeyMode) ?? 'accept-new'
|
|
707
|
+
if (auth.kind === 'key') await assertKeyFile(auth.privateKeyPath)
|
|
708
|
+
if (auth.kind === 'password' && body.password === undefined) {
|
|
709
|
+
throw new HttpError(400, 'INVALID_SERVER', 'A password server needs a password on create.')
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** @type {Record<string, unknown>} */
|
|
713
|
+
const input = {
|
|
714
|
+
name: requiredString(body.name, 'name'),
|
|
715
|
+
host: requiredString(body.host, 'host'),
|
|
716
|
+
username: requiredString(body.username, 'username'),
|
|
717
|
+
auth,
|
|
718
|
+
// §8.3: "off" is never the value a record starts with; it is requested.
|
|
719
|
+
hostKeyMode: requestedMode === 'off' ? 'accept-new' : requestedMode,
|
|
720
|
+
defaultWorkDir: workDirOf(body.defaultWorkDir),
|
|
721
|
+
policy: body.policy === undefined ? {} : normalizePolicyBody(body.policy),
|
|
722
|
+
}
|
|
723
|
+
if (body.port !== undefined) input.port = body.port
|
|
724
|
+
const server = await registry.create(input)
|
|
725
|
+
try {
|
|
726
|
+
if (auth.kind === 'password') await storePassword(server, body.password)
|
|
727
|
+
} catch (error) {
|
|
728
|
+
// The record without its password would be unusable; undo the create.
|
|
729
|
+
await registry.remove(server.id).catch((cleanup) => {
|
|
730
|
+
warn('dsh-server-connector: could not roll back server %s: %s', server.id, cleanup)
|
|
731
|
+
})
|
|
732
|
+
throw error
|
|
733
|
+
}
|
|
734
|
+
/** @type {import('./pending.js').PendingRecord[]} */
|
|
735
|
+
const queued = []
|
|
736
|
+
if (requestedMode === 'off') queued.push(await enqueue({ kind: 'disable-hostkey', serverId: server.id }))
|
|
737
|
+
if (body.disableRules !== undefined) {
|
|
738
|
+
if (!Array.isArray(body.disableRules)) {
|
|
739
|
+
throw new HttpError(400, 'INVALID_POLICY', 'disableRules must be an array of rule ids.')
|
|
740
|
+
}
|
|
741
|
+
for (const ruleId of body.disableRules) {
|
|
742
|
+
if (!isKnownRule(ruleId)) {
|
|
743
|
+
throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule ${JSON.stringify(ruleId)}.`)
|
|
744
|
+
}
|
|
745
|
+
queued.push(await enqueue({ kind: 'disable-rule', serverId: server.id, ruleId }))
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
await notify()
|
|
749
|
+
return {
|
|
750
|
+
status: queued.length > 0 ? 202 : 201,
|
|
751
|
+
body: {
|
|
752
|
+
server: presentServer(server, { passwordConfigured: await passwordConfigured(server) }),
|
|
753
|
+
pending: queued,
|
|
754
|
+
applied: queued.length === 0,
|
|
755
|
+
...(queued.length === 0
|
|
756
|
+
? {}
|
|
757
|
+
: { note: 'The server was created with host-key verification on; switching it off is queued and needs confirmation.' }),
|
|
758
|
+
},
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* PUT /servers/:id — update.
|
|
764
|
+
*
|
|
765
|
+
* `hostKeyMode: "off"`, `disableRules` and rule *enabling* have different
|
|
766
|
+
* lifetimes: the first two go to the pending queue (§8.3) and are **not**
|
|
767
|
+
* applied, re-enabling a rule is safe and applies immediately.
|
|
768
|
+
* @param {string} id
|
|
769
|
+
* @param {unknown} body
|
|
770
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
771
|
+
*/
|
|
772
|
+
const updateServer = async (id, body) => {
|
|
773
|
+
if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_SERVER', 'Request body must be a JSON object.')
|
|
774
|
+
assertKnownBodyFields(body, UPDATE_BODY_FIELDS)
|
|
775
|
+
if (body.disabledRules !== undefined) {
|
|
776
|
+
throw new HttpError(
|
|
777
|
+
400,
|
|
778
|
+
'INVALID_PENDING',
|
|
779
|
+
'disabledRules cannot be written directly; switch a rule off with "disableRules" (pending) or on with "enableRules".',
|
|
780
|
+
)
|
|
781
|
+
}
|
|
782
|
+
const existing = await requireServer(id)
|
|
783
|
+
/** @type {Record<string, unknown>} */
|
|
784
|
+
const patch = {}
|
|
785
|
+
|
|
786
|
+
for (const field of ['name', 'host', 'username']) {
|
|
787
|
+
if (body[field] !== undefined) patch[field] = requiredString(body[field], field)
|
|
788
|
+
}
|
|
789
|
+
if (body.port !== undefined) patch.port = body.port
|
|
790
|
+
if (body.defaultWorkDir !== undefined) patch.defaultWorkDir = workDirOf(body.defaultWorkDir)
|
|
791
|
+
if (body.policy !== undefined) patch.policy = normalizePolicyBody(body.policy, existing.customRules)
|
|
792
|
+
|
|
793
|
+
// The enable switch (doc §8.5). Disabling while a connection is open is
|
|
794
|
+
// refused rather than silently cut: the operator asked for "no more use of
|
|
795
|
+
// this server", not "kill whatever is running on it".
|
|
796
|
+
let toggledEnabled = false
|
|
797
|
+
if (body.enabled !== undefined) {
|
|
798
|
+
if (typeof body.enabled !== 'boolean') {
|
|
799
|
+
throw new HttpError(400, 'INVALID_SERVER', 'enabled must be a boolean.')
|
|
800
|
+
}
|
|
801
|
+
if (body.enabled === false && connectedIds().has(existing.id)) {
|
|
802
|
+
throw new HttpError(
|
|
803
|
+
409,
|
|
804
|
+
'SERVER_CONNECTED',
|
|
805
|
+
'this server has an open connection; disconnect it before disabling.',
|
|
806
|
+
)
|
|
807
|
+
}
|
|
808
|
+
patch.enabled = body.enabled
|
|
809
|
+
toggledEnabled = body.enabled !== (existing.enabled !== false)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** @type {{kind:'key', privateKeyPath:string}|{kind:'password'}|{kind:'agent'}|undefined} */
|
|
813
|
+
let nextAuth
|
|
814
|
+
if (body.auth !== undefined) {
|
|
815
|
+
nextAuth = normalizeAuthBody(body.auth)
|
|
816
|
+
if (nextAuth.kind === 'key') await assertKeyFile(nextAuth.privateKeyPath)
|
|
817
|
+
patch.auth = nextAuth
|
|
818
|
+
} else if (body.password !== undefined) {
|
|
819
|
+
if (existing.auth.kind !== 'password') {
|
|
820
|
+
throw new HttpError(400, 'INVALID_SERVER', 'password is only accepted for auth.kind "password".')
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const requested = hostKeyModeOf(body.hostKeyMode)
|
|
825
|
+
/** @type {import('./pending.js').PendingRecord[]} */
|
|
826
|
+
const queued = []
|
|
827
|
+
/** @type {string} */
|
|
828
|
+
let note = ''
|
|
829
|
+
if (requested !== undefined && requested !== existing.hostKeyMode) {
|
|
830
|
+
if (requested === 'off') {
|
|
831
|
+
// §8.3: the server keeps connecting under its current policy.
|
|
832
|
+
queued.push(await enqueue({ kind: 'disable-hostkey', serverId: existing.id }))
|
|
833
|
+
note = 'hostKeyMode stays unchanged until the request is confirmed'
|
|
834
|
+
} else {
|
|
835
|
+
patch.hostKeyMode = requested
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** @type {string[]} */
|
|
840
|
+
const enableRules = []
|
|
841
|
+
if (body.enableRules !== undefined) {
|
|
842
|
+
if (!Array.isArray(body.enableRules)) {
|
|
843
|
+
throw new HttpError(400, 'INVALID_POLICY', 'enableRules must be an array of rule ids.')
|
|
844
|
+
}
|
|
845
|
+
for (const ruleId of body.enableRules) {
|
|
846
|
+
if (typeof ruleId !== 'string' || !isKnownRule(ruleId)) {
|
|
847
|
+
throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule ${JSON.stringify(ruleId)}.`)
|
|
848
|
+
}
|
|
849
|
+
if (existing.disabledRules.includes(ruleId)) enableRules.push(ruleId)
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
/** @type {string[]} */
|
|
853
|
+
const disableRules = []
|
|
854
|
+
if (body.disableRules !== undefined) {
|
|
855
|
+
if (!Array.isArray(body.disableRules)) {
|
|
856
|
+
throw new HttpError(400, 'INVALID_POLICY', 'disableRules must be an array of rule ids.')
|
|
857
|
+
}
|
|
858
|
+
for (const ruleId of body.disableRules) {
|
|
859
|
+
if (typeof ruleId !== 'string' || !isKnownRule(ruleId)) {
|
|
860
|
+
throw new HttpError(400, 'INVALID_POLICY', `Unknown high-risk rule ${JSON.stringify(ruleId)}.`)
|
|
861
|
+
}
|
|
862
|
+
// A rule the panel already disabled needs no second request; one being
|
|
863
|
+
// re-enabled in this same call would contradict itself.
|
|
864
|
+
if (existing.disabledRules.includes(ruleId) || enableRules.includes(ruleId)) continue
|
|
865
|
+
disableRules.push(ruleId)
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
for (const ruleId of disableRules) {
|
|
869
|
+
queued.push(await enqueue({ kind: 'disable-rule', serverId: existing.id, ruleId }))
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (enableRules.length > 0) {
|
|
873
|
+
patch.disabledRules = existing.disabledRules.filter((ruleId) => !enableRules.includes(ruleId))
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** @type {import('./registry.js').ServerRecord} */
|
|
877
|
+
let server = existing
|
|
878
|
+
if (Object.keys(patch).length > 0) server = await registry.update(existing.id, patch)
|
|
879
|
+
|
|
880
|
+
if (body.password !== undefined) await storePassword(server, body.password)
|
|
881
|
+
|
|
882
|
+
if (existing.auth.kind === 'password' && server.auth.kind !== 'password') {
|
|
883
|
+
const credentials = getCredentials()
|
|
884
|
+
if (credentials !== null && credentials !== undefined && typeof credentials.unset === 'function') {
|
|
885
|
+
try {
|
|
886
|
+
await credentials.unset(existing.auth.credentialRef)
|
|
887
|
+
} catch (error) {
|
|
888
|
+
warn('dsh-server-connector: could not remove the credential of %s: %s', existing.id, error)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (toggledEnabled) {
|
|
894
|
+
await recordPanelEvent(server.enabled === false ? 'server-disabled' : 'server-enabled', server)
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
await notify()
|
|
898
|
+
const connected = connectedIds()
|
|
899
|
+
return {
|
|
900
|
+
status: queued.length > 0 ? 202 : 200,
|
|
901
|
+
body: {
|
|
902
|
+
server: presentServer(server, {
|
|
903
|
+
connected: connected.has(server.id),
|
|
904
|
+
passwordConfigured: await passwordConfigured(server),
|
|
905
|
+
}),
|
|
906
|
+
pending: queued,
|
|
907
|
+
applied: queued.length === 0,
|
|
908
|
+
...(note === '' ? {} : { note }),
|
|
909
|
+
},
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* DELETE /servers/:id — a delete *request* (§8.3). The server stays present
|
|
915
|
+
* and usable; the snapshot keeps the pending row self-describing.
|
|
916
|
+
* @param {string} id
|
|
917
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
918
|
+
*/
|
|
919
|
+
const requestDelete = async (id) => {
|
|
920
|
+
const server = await requireServer(id)
|
|
921
|
+
const record = await enqueue({ kind: 'delete-server', serverId: server.id, snapshot: server })
|
|
922
|
+
await notify()
|
|
923
|
+
return {
|
|
924
|
+
status: 202,
|
|
925
|
+
body: {
|
|
926
|
+
pending: record,
|
|
927
|
+
server: presentServer(server, { connected: connectedIds().has(server.id) }),
|
|
928
|
+
note: 'The server stays configured until the deletion is confirmed in Settings.',
|
|
929
|
+
},
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// --------------------------------------------------- custom rules (§4.4) --
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Turn a `RuleError` into a 400 with a stable code.
|
|
937
|
+
* @param {unknown} error
|
|
938
|
+
* @returns {never}
|
|
939
|
+
*/
|
|
940
|
+
const rethrowRuleError = (error) => {
|
|
941
|
+
if (error instanceof RuleError) throw new HttpError(400, error.code, error.message)
|
|
942
|
+
throw error
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* The next free `custom-N` id for this server (doc §4.4.1: the panel never
|
|
947
|
+
* chooses the id, so it cannot collide with a built-in or go stale).
|
|
948
|
+
* @param {ReadonlyArray<import('./policy.js').CustomRule>} rules
|
|
949
|
+
* @returns {string}
|
|
950
|
+
*/
|
|
951
|
+
const nextRuleId = (rules) => {
|
|
952
|
+
const taken = new Set(rules.map((rule) => rule.id))
|
|
953
|
+
let n = 1
|
|
954
|
+
while (taken.has(`custom-${n}`)) n += 1
|
|
955
|
+
return `custom-${n}`
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Add one custom rule to a server (doc §4.4.2). Validation happens before the
|
|
960
|
+
* write, so a rule that cannot be honoured is never stored.
|
|
961
|
+
* @param {string} id - server id
|
|
962
|
+
* @param {unknown} body
|
|
963
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
964
|
+
*/
|
|
965
|
+
const createRule = async (id, body) => {
|
|
966
|
+
const server = await requireServer(id)
|
|
967
|
+
if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_RULE', 'a custom rule must be an object.')
|
|
968
|
+
const existing = server.customRules ?? []
|
|
969
|
+
if (existing.length >= MAX_CUSTOM_RULES) {
|
|
970
|
+
throw new HttpError(400, 'INVALID_RULE', `a server may carry at most ${MAX_CUSTOM_RULES} custom rules.`)
|
|
971
|
+
}
|
|
972
|
+
/** @type {import('./policy.js').CustomRule} */
|
|
973
|
+
let rule
|
|
974
|
+
try {
|
|
975
|
+
rule = validateCustomRule(
|
|
976
|
+
{ ...body, id: nextRuleId(existing) },
|
|
977
|
+
{ takenIds: existing.map((item) => item.id), samples: samplesOf(body) },
|
|
978
|
+
)
|
|
979
|
+
} catch (error) {
|
|
980
|
+
return rethrowRuleError(error)
|
|
981
|
+
}
|
|
982
|
+
const updated = await registry.update(server.id, { customRules: [...existing, rule] })
|
|
983
|
+
await notify()
|
|
984
|
+
return {
|
|
985
|
+
status: 201,
|
|
986
|
+
body: {
|
|
987
|
+
rule,
|
|
988
|
+
server: presentServer(updated, { connected: connectedIds().has(updated.id) }),
|
|
989
|
+
},
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Replace one custom rule's mutable fields. The id is immutable: `policy` and
|
|
995
|
+
* `disabledRules` refer to it.
|
|
996
|
+
* @param {string} id - server id
|
|
997
|
+
* @param {string} ruleId
|
|
998
|
+
* @param {unknown} body
|
|
999
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
1000
|
+
*/
|
|
1001
|
+
const updateRule = async (id, ruleId, body) => {
|
|
1002
|
+
const server = await requireServer(id)
|
|
1003
|
+
if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_RULE', 'a custom rule must be an object.')
|
|
1004
|
+
const existing = server.customRules ?? []
|
|
1005
|
+
const current = existing.find((rule) => rule.id === ruleId)
|
|
1006
|
+
if (current === undefined) throw new HttpError(404, 'RULE_NOT_FOUND', `no custom rule "${ruleId}" on this server.`)
|
|
1007
|
+
/** @type {import('./policy.js').CustomRule} */
|
|
1008
|
+
let rule
|
|
1009
|
+
try {
|
|
1010
|
+
rule = validateCustomRule(
|
|
1011
|
+
{ ...current, ...body, id: current.id },
|
|
1012
|
+
{ takenIds: existing.map((item) => item.id).filter((item) => item !== current.id), samples: samplesOf(body) },
|
|
1013
|
+
)
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
return rethrowRuleError(error)
|
|
1016
|
+
}
|
|
1017
|
+
const updated = await registry.update(server.id, {
|
|
1018
|
+
customRules: existing.map((item) => (item.id === ruleId ? rule : item)),
|
|
1019
|
+
})
|
|
1020
|
+
await notify()
|
|
1021
|
+
return {
|
|
1022
|
+
status: 200,
|
|
1023
|
+
body: {
|
|
1024
|
+
rule,
|
|
1025
|
+
server: presentServer(updated, { connected: connectedIds().has(updated.id) }),
|
|
1026
|
+
},
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Remove one custom rule. Deleting a rule only tightens the surface back to the
|
|
1032
|
+
* built-in default, so — like adding one — it needs no two-step confirmation;
|
|
1033
|
+
* the loosening path is "disable a rule", which still goes through the queue.
|
|
1034
|
+
* @param {string} id - server id
|
|
1035
|
+
* @param {string} ruleId
|
|
1036
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
1037
|
+
*/
|
|
1038
|
+
const deleteRule = async (id, ruleId) => {
|
|
1039
|
+
const server = await requireServer(id)
|
|
1040
|
+
const existing = server.customRules ?? []
|
|
1041
|
+
if (!existing.some((rule) => rule.id === ruleId)) {
|
|
1042
|
+
throw new HttpError(404, 'RULE_NOT_FOUND', `no custom rule "${ruleId}" on this server.`)
|
|
1043
|
+
}
|
|
1044
|
+
// Drop the id from `policy` and `disabledRules` too, so neither keeps a key
|
|
1045
|
+
// that no longer resolves to anything.
|
|
1046
|
+
const policy = { ...server.policy }
|
|
1047
|
+
delete policy[ruleId]
|
|
1048
|
+
const updated = await registry.update(server.id, {
|
|
1049
|
+
customRules: existing.filter((rule) => rule.id !== ruleId),
|
|
1050
|
+
policy,
|
|
1051
|
+
disabledRules: (server.disabledRules ?? []).filter((item) => item !== ruleId),
|
|
1052
|
+
})
|
|
1053
|
+
await notify()
|
|
1054
|
+
return {
|
|
1055
|
+
status: 200,
|
|
1056
|
+
body: { server: presentServer(updated, { connected: connectedIds().has(updated.id) }) },
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Dry-run a candidate pattern against sample commands without writing anything
|
|
1062
|
+
* (doc §4.4.3). It calls the same `check()` the executor calls, so a preview
|
|
1063
|
+
* cannot disagree with a real execution.
|
|
1064
|
+
* @param {string} id - server id
|
|
1065
|
+
* @param {unknown} body
|
|
1066
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
1067
|
+
*/
|
|
1068
|
+
const previewRule = async (id, body) => {
|
|
1069
|
+
const server = await requireServer(id)
|
|
1070
|
+
if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_RULE', 'preview needs an object body.')
|
|
1071
|
+
const samples = samplesOf(body)
|
|
1072
|
+
if (samples.length === 0) {
|
|
1073
|
+
throw new HttpError(400, 'INVALID_RULE', 'preview needs at least one sample command.')
|
|
1074
|
+
}
|
|
1075
|
+
// A preview compiles the rule but skips the "must match a sample" check —
|
|
1076
|
+
// that check is exactly what the caller is here to find out.
|
|
1077
|
+
/** @type {import('./policy.js').CustomRule} */
|
|
1078
|
+
let rule
|
|
1079
|
+
try {
|
|
1080
|
+
rule = validateCustomRule(
|
|
1081
|
+
{ ...body, id: 'preview', defaultDecision: body.defaultDecision ?? 'confirm', label: body.label ?? 'preview' },
|
|
1082
|
+
{ takenIds: [] },
|
|
1083
|
+
)
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
return rethrowRuleError(error)
|
|
1086
|
+
}
|
|
1087
|
+
const probe = { ...server, customRules: [...(server.customRules ?? []).filter((item) => item.id !== 'preview'), rule] }
|
|
1088
|
+
const results = samples.map((sample) => {
|
|
1089
|
+
const verdict = checkPolicy(sample, probe)
|
|
1090
|
+
return {
|
|
1091
|
+
sample,
|
|
1092
|
+
matched: verdict.matches.some((match) => match.id === 'preview'),
|
|
1093
|
+
decision: verdict.decision,
|
|
1094
|
+
matchedRule: verdict.matchedRule === null ? null : verdict.matchedRule.id,
|
|
1095
|
+
}
|
|
1096
|
+
})
|
|
1097
|
+
return {
|
|
1098
|
+
status: 200,
|
|
1099
|
+
body: {
|
|
1100
|
+
rule: { label: rule.label, pattern: rule.pattern, defaultDecision: rule.defaultDecision },
|
|
1101
|
+
results,
|
|
1102
|
+
anyMatched: results.some((item) => item.matched),
|
|
1103
|
+
},
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* A connection probe: connect, run the §5.1 platform/shell/sudo probe, report.
|
|
1109
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
1110
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
1111
|
+
*/
|
|
1112
|
+
const probe = async (server) => {
|
|
1113
|
+
const startedAt = Date.now()
|
|
1114
|
+
await pool.connect(server)
|
|
1115
|
+
const result = await pool.exec(server, { command: CONNECT_PROBE, timeoutMs: probeTimeoutMs })
|
|
1116
|
+
return { ...parseProbe(String(result.stdout ?? '')), durationMs: Date.now() - startedAt }
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* POST /test — test one saved server. This is outside a turn, so no
|
|
1121
|
+
* `srv_exec` policy gate applies (doc §8.5).
|
|
1122
|
+
* @param {unknown} body
|
|
1123
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
1124
|
+
*/
|
|
1125
|
+
const testConnection = async (body) => {
|
|
1126
|
+
if (!isPlainObject(body)) throw new HttpError(400, 'INVALID_SERVER', 'Request body must be a JSON object.')
|
|
1127
|
+
const id = requiredString(body.id, 'id')
|
|
1128
|
+
const server = await requireServer(id)
|
|
1129
|
+
const outcome = await probe(server)
|
|
1130
|
+
try {
|
|
1131
|
+
await registry.update(server.id, { lastConnectedAt: new Date().toISOString() })
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
warn('dsh-server-connector: could not record lastConnectedAt of %s: %s', server.id, error)
|
|
1134
|
+
}
|
|
1135
|
+
await notify()
|
|
1136
|
+
return { status: 200, body: { ok: true, serverId: server.id, ...outcome } }
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* POST /connect/:id — open and keep the pooled connection.
|
|
1141
|
+
* @param {string} id
|
|
1142
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
1143
|
+
*/
|
|
1144
|
+
const connectServer = async (id) => {
|
|
1145
|
+
const server = await requireServer(id)
|
|
1146
|
+
const outcome = await probe(server)
|
|
1147
|
+
try {
|
|
1148
|
+
await registry.update(server.id, { lastConnectedAt: new Date().toISOString() })
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
warn('dsh-server-connector: could not record lastConnectedAt of %s: %s', server.id, error)
|
|
1151
|
+
}
|
|
1152
|
+
await notify()
|
|
1153
|
+
return {
|
|
1154
|
+
status: 200,
|
|
1155
|
+
body: {
|
|
1156
|
+
serverId: server.id,
|
|
1157
|
+
connected: true,
|
|
1158
|
+
probe: outcome,
|
|
1159
|
+
server: presentServer(server, { connected: true, passwordConfigured: await passwordConfigured(server) }),
|
|
1160
|
+
},
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* POST /disconnect/:id
|
|
1166
|
+
* @param {string} id
|
|
1167
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
1168
|
+
*/
|
|
1169
|
+
const disconnectServer = async (id) => {
|
|
1170
|
+
const server = await requireServer(id)
|
|
1171
|
+
await pool.disconnect(server.id)
|
|
1172
|
+
return { status: 200, body: { serverId: server.id, connected: false } }
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* GET /audit?limit=20[&serverId=…] — the newest records, newest first, so the
|
|
1177
|
+
* panel can render the detail page's last-20 list without sorting.
|
|
1178
|
+
* @param {Record<string, string>} query
|
|
1179
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
1180
|
+
*/
|
|
1181
|
+
const listAudit = async (query) => {
|
|
1182
|
+
if (audit === null) return { status: 200, body: { records: [] } }
|
|
1183
|
+
const rawLimit = query.limit === undefined ? 20 : Number(query.limit)
|
|
1184
|
+
if (!Number.isSafeInteger(rawLimit) || rawLimit <= 0) {
|
|
1185
|
+
throw new HttpError(400, 'INVALID_ARGUMENT', 'limit must be a positive integer.')
|
|
1186
|
+
}
|
|
1187
|
+
const limit = Math.min(rawLimit, MAX_AUDIT_LIMIT)
|
|
1188
|
+
const serverId = query.serverId
|
|
1189
|
+
// Filtering happens here rather than in the log: the tail is bounded and the
|
|
1190
|
+
// panel only ever asks for a handful of records.
|
|
1191
|
+
const records = await audit.tail(serverId === undefined ? limit : MAX_AUDIT_LIMIT)
|
|
1192
|
+
const filtered = serverId === undefined ? records : records.filter((record) => record.serverId === serverId)
|
|
1193
|
+
return { status: 200, body: { records: filtered.slice(-limit).reverse() } }
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* The pending queue, enriched with the server name each row targets. A
|
|
1198
|
+
* `delete-server` snapshot is reduced to its display fields: the row's job is
|
|
1199
|
+
* to say *what* would be deleted, not to publish the record.
|
|
1200
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
1201
|
+
*/
|
|
1202
|
+
const listPending = async () => {
|
|
1203
|
+
if (pending === null) return { status: 200, body: { pending: [] } }
|
|
1204
|
+
const records = await pending.list()
|
|
1205
|
+
const servers = await registry.list()
|
|
1206
|
+
/** @type {Map<string, import('./registry.js').ServerRecord>} */
|
|
1207
|
+
const byId = new Map(servers.map((server) => [server.id, server]))
|
|
1208
|
+
return {
|
|
1209
|
+
status: 200,
|
|
1210
|
+
body: {
|
|
1211
|
+
pending: records.map((record) => {
|
|
1212
|
+
const live = byId.get(record.serverId)
|
|
1213
|
+
const snapshot = isPlainObject(record.snapshot) ? record.snapshot : null
|
|
1214
|
+
const name =
|
|
1215
|
+
typeof snapshot?.name === 'string'
|
|
1216
|
+
? snapshot.name
|
|
1217
|
+
: live === undefined
|
|
1218
|
+
? record.serverId
|
|
1219
|
+
: live.name
|
|
1220
|
+
/** @type {Record<string, unknown>} */
|
|
1221
|
+
const target = { present: live !== undefined }
|
|
1222
|
+
if (typeof snapshot?.host === 'string') target.host = snapshot.host
|
|
1223
|
+
else if (live !== undefined) target.host = live.host
|
|
1224
|
+
if (typeof snapshot?.port === 'number') target.port = snapshot.port
|
|
1225
|
+
else if (live !== undefined) target.port = live.port
|
|
1226
|
+
if (typeof snapshot?.username === 'string') target.username = snapshot.username
|
|
1227
|
+
else if (live !== undefined) target.username = live.username
|
|
1228
|
+
return { ...record, serverName: name, target }
|
|
1229
|
+
}),
|
|
1230
|
+
},
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* POST /pending/:id/confirm — apply one queued operation (§8.3).
|
|
1236
|
+
* @param {string} id
|
|
1237
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
1238
|
+
*/
|
|
1239
|
+
const confirmPending = async (id) => {
|
|
1240
|
+
const queue = requirePending()
|
|
1241
|
+
const outcome = await queue.confirm(id)
|
|
1242
|
+
await notify()
|
|
1243
|
+
return { status: 200, body: { status: outcome.status, record: outcome.record, result: outcome.result } }
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* POST /pending/:id/cancel — drop the request, leaving the target unchanged.
|
|
1248
|
+
* @param {string} id
|
|
1249
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
1250
|
+
*/
|
|
1251
|
+
const cancelPending = async (id) => {
|
|
1252
|
+
const queue = requirePending()
|
|
1253
|
+
const outcome = await queue.cancel(id)
|
|
1254
|
+
await notify()
|
|
1255
|
+
return { status: 200, body: { status: outcome.status, record: outcome.record } }
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Run one request.
|
|
1260
|
+
* @param {{method: string, path: string, query?: Record<string, string>, body?: unknown}} request
|
|
1261
|
+
* `path` is relative to {@link API_PREFIX} (`/servers/srv-01`).
|
|
1262
|
+
* @returns {Promise<{status: number, body: unknown}>}
|
|
1263
|
+
*/
|
|
1264
|
+
const dispatch = async (request) => {
|
|
1265
|
+
const method = String(request.method ?? 'GET').toUpperCase()
|
|
1266
|
+
const segments = String(request.path ?? '/')
|
|
1267
|
+
.split('/')
|
|
1268
|
+
.filter((segment) => segment !== '')
|
|
1269
|
+
const query = request.query ?? {}
|
|
1270
|
+
|
|
1271
|
+
// A path is matched first, then its method: a known path with an unexpected
|
|
1272
|
+
// method is a caller bug (405), a path nothing declares is a missing endpoint
|
|
1273
|
+
// (404). Both passes run over the same table, so the surface stays declared
|
|
1274
|
+
// in exactly one place.
|
|
1275
|
+
/** @type {typeof API_ROUTES[number]|null} */
|
|
1276
|
+
let matched = null
|
|
1277
|
+
/** @type {Record<string, string>|null} */
|
|
1278
|
+
let matchedParams = null
|
|
1279
|
+
/** @type {string[]} */
|
|
1280
|
+
const methods = []
|
|
1281
|
+
for (const route of API_ROUTES) {
|
|
1282
|
+
const params = matchRoute(route.path, segments)
|
|
1283
|
+
if (params === null) continue
|
|
1284
|
+
methods.push(route.method)
|
|
1285
|
+
if (route.method !== method || matched !== null) continue
|
|
1286
|
+
matched = route
|
|
1287
|
+
matchedParams = params
|
|
1288
|
+
}
|
|
1289
|
+
if (matched === null) {
|
|
1290
|
+
if (methods.length === 0) {
|
|
1291
|
+
throw new HttpError(404, 'NOT_FOUND', `No route for ${method} ${API_PREFIX}${request.path}.`)
|
|
1292
|
+
}
|
|
1293
|
+
const allow = methods.join(', ')
|
|
1294
|
+
throw new HttpError(405, 'METHOD_NOT_ALLOWED', `${request.path} only supports ${allow}.`, { allow })
|
|
1295
|
+
}
|
|
1296
|
+
/** @type {Record<string, string>} */
|
|
1297
|
+
const params = matchedParams ?? {}
|
|
1298
|
+
const id = /** @type {string} */ (params.id)
|
|
1299
|
+
switch (matched.op) {
|
|
1300
|
+
case 'listServers':
|
|
1301
|
+
return listServers()
|
|
1302
|
+
case 'createServer':
|
|
1303
|
+
return createServer(request.body)
|
|
1304
|
+
case 'updateServer':
|
|
1305
|
+
return updateServer(id, request.body)
|
|
1306
|
+
case 'requestDelete':
|
|
1307
|
+
return requestDelete(id)
|
|
1308
|
+
case 'testConnection':
|
|
1309
|
+
return testConnection(request.body)
|
|
1310
|
+
case 'connectServer':
|
|
1311
|
+
return connectServer(id)
|
|
1312
|
+
case 'disconnectServer':
|
|
1313
|
+
return disconnectServer(id)
|
|
1314
|
+
case 'listAudit':
|
|
1315
|
+
return listAudit(query)
|
|
1316
|
+
case 'listPending':
|
|
1317
|
+
return listPending()
|
|
1318
|
+
case 'confirmPending':
|
|
1319
|
+
return confirmPending(id)
|
|
1320
|
+
case 'cancelPending':
|
|
1321
|
+
return cancelPending(id)
|
|
1322
|
+
case 'createRule':
|
|
1323
|
+
return createRule(id, request.body)
|
|
1324
|
+
case 'updateRule':
|
|
1325
|
+
return updateRule(id, /** @type {string} */ (params.ruleId), request.body)
|
|
1326
|
+
case 'deleteRule':
|
|
1327
|
+
return deleteRule(id, /** @type {string} */ (params.ruleId))
|
|
1328
|
+
case 'previewRule':
|
|
1329
|
+
return previewRule(id, request.body)
|
|
1330
|
+
default:
|
|
1331
|
+
throw new HttpError(500, 'INTERNAL', `Unhandled route operation ${String(matched.op)}.`)
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
return {
|
|
1336
|
+
dispatch,
|
|
1337
|
+
describe: () => API_ROUTES.map(({ method, path }) => ({ method, path: `${API_PREFIX}${path}` })),
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* Match one route template against decoded path segments.
|
|
1343
|
+
* @param {string} template - e.g. `/pending/:id/confirm`
|
|
1344
|
+
* @param {string[]} segments - segments relative to the mount path.
|
|
1345
|
+
* @returns {Record<string, string>|null} the parameters, or null when it does not match.
|
|
1346
|
+
*/
|
|
1347
|
+
function matchRoute(template, segments) {
|
|
1348
|
+
const parts = template.split('/').filter((part) => part !== '')
|
|
1349
|
+
if (parts.length !== segments.length) return null
|
|
1350
|
+
/** @type {Record<string, string>} */
|
|
1351
|
+
const params = {}
|
|
1352
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
1353
|
+
const part = parts[index]
|
|
1354
|
+
const segment = segments[index]
|
|
1355
|
+
if (part === undefined || segment === undefined) return null
|
|
1356
|
+
if (part.startsWith(':')) {
|
|
1357
|
+
if (segment === '') return null
|
|
1358
|
+
params[part.slice(1)] = segment
|
|
1359
|
+
continue
|
|
1360
|
+
}
|
|
1361
|
+
if (part !== segment) return null
|
|
1362
|
+
}
|
|
1363
|
+
return params
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* Collect one bounded JSON body.
|
|
1368
|
+
*
|
|
1369
|
+
* An absent body is `undefined` (the POST endpoints that take no arguments);
|
|
1370
|
+
* anything past the ceiling is reported as 413 and the stream is drained so the
|
|
1371
|
+
* socket does not stall.
|
|
1372
|
+
* @param {import('node:http').IncomingMessage} req
|
|
1373
|
+
* @param {number} [limit]
|
|
1374
|
+
* @returns {Promise<unknown>}
|
|
1375
|
+
*/
|
|
1376
|
+
export async function readJsonBody(req, limit = MAX_BODY_BYTES) {
|
|
1377
|
+
/** @type {Buffer[]} */
|
|
1378
|
+
const chunks = []
|
|
1379
|
+
let size = 0
|
|
1380
|
+
for await (const chunk of req) {
|
|
1381
|
+
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
|
|
1382
|
+
size += buffer.byteLength
|
|
1383
|
+
if (size > limit) {
|
|
1384
|
+
req.resume()
|
|
1385
|
+
throw new HttpError(413, 'BODY_TOO_LARGE', `The request body exceeds ${String(limit)} bytes.`)
|
|
1386
|
+
}
|
|
1387
|
+
chunks.push(buffer)
|
|
1388
|
+
}
|
|
1389
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
1390
|
+
if (text === '') return undefined
|
|
1391
|
+
try {
|
|
1392
|
+
return JSON.parse(text)
|
|
1393
|
+
} catch {
|
|
1394
|
+
throw new HttpError(400, 'INVALID_JSON', 'The request body is not valid JSON.')
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* Send one JSON response. `no-store` because every payload is live state.
|
|
1400
|
+
* @param {import('node:http').ServerResponse} res
|
|
1401
|
+
* @param {number} status
|
|
1402
|
+
* @param {unknown} body
|
|
1403
|
+
* @param {Record<string, string>} [headers]
|
|
1404
|
+
* @returns {void}
|
|
1405
|
+
*/
|
|
1406
|
+
function sendJson(res, status, body, headers = {}) {
|
|
1407
|
+
res.statusCode = status
|
|
1408
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
1409
|
+
res.setHeader('cache-control', 'no-store')
|
|
1410
|
+
for (const [name, value] of Object.entries(headers)) res.setHeader(name, value)
|
|
1411
|
+
res.end(JSON.stringify(body ?? null))
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* The node:http adapter: parse, dispatch, serialise.
|
|
1416
|
+
*
|
|
1417
|
+
* `rejected` is the composition's own request fence (`connection.requestRejection`
|
|
1418
|
+
* in the shipped Web app). The routes live on the same trust boundary as the
|
|
1419
|
+
* GUI (doc §8.5), so a deployment that has the connection service refuses an
|
|
1420
|
+
* unauthenticated or untrusted request here rather than in each handler.
|
|
1421
|
+
* @param {ReturnType<typeof createApi>} api
|
|
1422
|
+
* @param {{rejected?: (req: import('node:http').IncomingMessage) => number|undefined, logger?: {warn?: Function}|null}} [options]
|
|
1423
|
+
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise<void>}
|
|
1424
|
+
*/
|
|
1425
|
+
export function createHandler(api, options = {}) {
|
|
1426
|
+
const rejected = typeof options.rejected === 'function' ? options.rejected : () => undefined
|
|
1427
|
+
const logger = options.logger ?? null
|
|
1428
|
+
return async (req, res) => {
|
|
1429
|
+
const rejection = rejected(req)
|
|
1430
|
+
if (rejection !== undefined) {
|
|
1431
|
+
res.statusCode = rejection
|
|
1432
|
+
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
1433
|
+
return
|
|
1434
|
+
}
|
|
1435
|
+
/** @type {URL} */
|
|
1436
|
+
let url
|
|
1437
|
+
try {
|
|
1438
|
+
url = new URL(String(req.url ?? '/'), 'http://server-connector.invalid')
|
|
1439
|
+
} catch {
|
|
1440
|
+
sendJson(res, 400, { error: { code: 'INVALID_URL', message: 'The request URL could not be parsed.' } })
|
|
1441
|
+
return
|
|
1442
|
+
}
|
|
1443
|
+
if (url.pathname !== API_PREFIX && !url.pathname.startsWith(`${API_PREFIX}/`)) {
|
|
1444
|
+
sendJson(res, 404, { error: { code: 'NOT_FOUND', message: `No route for ${url.pathname}.` } })
|
|
1445
|
+
return
|
|
1446
|
+
}
|
|
1447
|
+
// Decode each segment: the panel encodes ids, and a route parameter is the
|
|
1448
|
+
// decoded id it addresses (`srv-01` today, but the adapter stays generic).
|
|
1449
|
+
let relative = url.pathname.slice(API_PREFIX.length)
|
|
1450
|
+
if (relative === '') relative = '/'
|
|
1451
|
+
try {
|
|
1452
|
+
relative = relative
|
|
1453
|
+
.split('/')
|
|
1454
|
+
.map((segment) => (segment === '' ? segment : decodeURIComponent(segment)))
|
|
1455
|
+
.join('/')
|
|
1456
|
+
} catch {
|
|
1457
|
+
sendJson(res, 400, { error: { code: 'INVALID_URL', message: 'The request path is not valid percent-encoding.' } })
|
|
1458
|
+
return
|
|
1459
|
+
}
|
|
1460
|
+
/** @type {Record<string, string>} */
|
|
1461
|
+
const query = {}
|
|
1462
|
+
for (const [name, value] of url.searchParams) query[name] = value
|
|
1463
|
+
const method = String(req.method ?? 'GET').toUpperCase()
|
|
1464
|
+
try {
|
|
1465
|
+
const body = method === 'GET' || method === 'HEAD' ? undefined : await readJsonBody(req)
|
|
1466
|
+
const outcome = await api.dispatch({
|
|
1467
|
+
method,
|
|
1468
|
+
path: relative,
|
|
1469
|
+
query,
|
|
1470
|
+
body,
|
|
1471
|
+
})
|
|
1472
|
+
sendJson(res, outcome.status, outcome.body)
|
|
1473
|
+
} catch (error) {
|
|
1474
|
+
const failure = toHttpError(error)
|
|
1475
|
+
if (failure.status >= 500 && logger !== null && typeof logger.warn === 'function') {
|
|
1476
|
+
logger.warn('dsh-server-connector: %s %s failed: %s', method, relative, failure.message)
|
|
1477
|
+
}
|
|
1478
|
+
/** @type {Record<string, string>} */
|
|
1479
|
+
const headers = {}
|
|
1480
|
+
if (typeof failure.details.allow === 'string') headers.allow = failure.details.allow
|
|
1481
|
+
sendJson(
|
|
1482
|
+
res,
|
|
1483
|
+
failure.status,
|
|
1484
|
+
{
|
|
1485
|
+
error: {
|
|
1486
|
+
code: failure.code,
|
|
1487
|
+
message: failure.message,
|
|
1488
|
+
...(Object.keys(failure.details).length === 0 ? {} : { details: failure.details }),
|
|
1489
|
+
},
|
|
1490
|
+
},
|
|
1491
|
+
headers,
|
|
1492
|
+
)
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* Register the routes on the Web carrier (§8.5).
|
|
1499
|
+
*
|
|
1500
|
+
* One prefix route serves all eleven endpoints: `webServer.register` patterns
|
|
1501
|
+
* are exact or prefix-only, and a single prefix keeps method dispatch, 405 and
|
|
1502
|
+
* 404 in one place.
|
|
1503
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - context that carries `webServer`.
|
|
1504
|
+
* @param {Parameters<typeof createApi>[0]} options
|
|
1505
|
+
* @returns {{api: ReturnType<typeof createApi>, dispose: () => void}} the dispatcher and the route disposer.
|
|
1506
|
+
*/
|
|
1507
|
+
export function registerRoutes(ctx, options) {
|
|
1508
|
+
const api = createApi(options)
|
|
1509
|
+
const handler = createHandler(api, {
|
|
1510
|
+
// Read the fence per request: the connection service may register after
|
|
1511
|
+
// this plugin, and a plugin reload can replace it.
|
|
1512
|
+
rejected: (req) => {
|
|
1513
|
+
const connection = /** @type {any} */ (ctx.get('connection'))
|
|
1514
|
+
if (connection === null || connection === undefined) return undefined
|
|
1515
|
+
if (typeof connection.requestRejection !== 'function') return undefined
|
|
1516
|
+
return connection.requestRejection(req)
|
|
1517
|
+
},
|
|
1518
|
+
logger: ctx.logger ?? null,
|
|
1519
|
+
})
|
|
1520
|
+
const webServer = /** @type {any} */ (ctx).webServer
|
|
1521
|
+
const dispose = webServer.register({
|
|
1522
|
+
kind: 'prefix',
|
|
1523
|
+
path: API_PREFIX,
|
|
1524
|
+
handler,
|
|
1525
|
+
})
|
|
1526
|
+
return { api, dispose }
|
|
1527
|
+
}
|