dynamic-self-register-proxy 1.0.16 → 1.0.18
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/package.json +5 -2
- package/proxy.js +597 -17
- package/terminal-server.js +154 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dynamic-self-register-proxy",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.18",
|
|
4
4
|
"description": "Dynamic reverse proxy with self-registration API - applications can register themselves and receive an automatically assigned port",
|
|
5
5
|
"main": "proxy-client.js",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"proxy.js",
|
|
17
17
|
"proxy-client.js",
|
|
18
18
|
"logger.js",
|
|
19
|
+
"terminal-server.js",
|
|
19
20
|
"README.md",
|
|
20
21
|
"LICENSE"
|
|
21
22
|
],
|
|
@@ -55,6 +56,8 @@
|
|
|
55
56
|
"dependencies": {
|
|
56
57
|
"dotenv": "^17.2.3",
|
|
57
58
|
"express": "^5.2.1",
|
|
58
|
-
"http-proxy-middleware": "^3.0.5"
|
|
59
|
+
"http-proxy-middleware": "^3.0.5",
|
|
60
|
+
"node-pty": "^1.1.0",
|
|
61
|
+
"ws": "^8.19.0"
|
|
59
62
|
}
|
|
60
63
|
}
|
package/proxy.js
CHANGED
|
@@ -4,6 +4,7 @@ const express = require('express');
|
|
|
4
4
|
const { createProxyMiddleware } = require('http-proxy-middleware');
|
|
5
5
|
const { setupLogging } = require('./logger');
|
|
6
6
|
const { execSync } = require('child_process');
|
|
7
|
+
const crypto = require('crypto');
|
|
7
8
|
const path = require('path');
|
|
8
9
|
|
|
9
10
|
// Chargement du fichier .env depuis le répertoire du script
|
|
@@ -14,6 +15,7 @@ setupLogging('PROXY');
|
|
|
14
15
|
|
|
15
16
|
const app = express();
|
|
16
17
|
app.use(express.json());
|
|
18
|
+
app.use(express.urlencoded({ extended: false }));
|
|
17
19
|
|
|
18
20
|
// ============================================
|
|
19
21
|
// CONFIGURATION
|
|
@@ -26,6 +28,14 @@ const HEALTH_CHECK_INTERVAL = process.env.HEALTH_CHECK_INTERVAL || 30000; // 30
|
|
|
26
28
|
const HEALTH_CHECK_TIMEOUT = process.env.HEALTH_CHECK_TIMEOUT || 5000; // 5 secondes timeout
|
|
27
29
|
const HEALTH_CHECK_GRACE_PERIOD = process.env.HEALTH_CHECK_GRACE_PERIOD || 60000; // 60 secondes de grâce pour les nouveaux serveurs
|
|
28
30
|
|
|
31
|
+
// Authentification
|
|
32
|
+
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
|
33
|
+
const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
|
|
34
|
+
|
|
35
|
+
// Terminal en ligne
|
|
36
|
+
const TERMINAL_CWD = process.env.TERMINAL_CWD || process.env.HOME || process.env.USERPROFILE;
|
|
37
|
+
const TERMINAL_COMMAND = process.env.TERMINAL_COMMAND || '';
|
|
38
|
+
|
|
29
39
|
// ============================================
|
|
30
40
|
// REGISTRY - Stockage des routes en mémoire
|
|
31
41
|
// ============================================
|
|
@@ -257,6 +267,333 @@ function startHealthCheckPolling() {
|
|
|
257
267
|
setInterval(performHealthChecks, HEALTH_CHECK_INTERVAL);
|
|
258
268
|
}
|
|
259
269
|
|
|
270
|
+
// ============================================
|
|
271
|
+
// AUTHENTIFICATION — Sessions & Middlewares
|
|
272
|
+
// ============================================
|
|
273
|
+
|
|
274
|
+
/** Stockage des sessions actives en mémoire */
|
|
275
|
+
const activeSessions = new Set();
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Parse manuellement le header Cookie (évite cookie-parser)
|
|
279
|
+
* @param {import('express').Request} req
|
|
280
|
+
* @returns {Object<string, string>}
|
|
281
|
+
*/
|
|
282
|
+
function parseCookies(req) {
|
|
283
|
+
const header = req.headers.cookie || '';
|
|
284
|
+
const cookies = {};
|
|
285
|
+
header.split(';').forEach(pair => {
|
|
286
|
+
const [name, ...rest] = pair.trim().split('=');
|
|
287
|
+
if (name) cookies[name.trim()] = decodeURIComponent(rest.join('=').trim());
|
|
288
|
+
});
|
|
289
|
+
return cookies;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Signe un token avec HMAC-SHA256
|
|
294
|
+
* @param {string} token
|
|
295
|
+
* @returns {string} token.signature
|
|
296
|
+
*/
|
|
297
|
+
function signToken(token) {
|
|
298
|
+
const signature = crypto.createHmac('sha256', SESSION_SECRET).update(token).digest('hex');
|
|
299
|
+
return `${token}.${signature}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Vérifie et extrait un token signé
|
|
304
|
+
* @param {string} signedValue - Format: token.signature
|
|
305
|
+
* @returns {string|null} Le token si la signature est valide, null sinon
|
|
306
|
+
*/
|
|
307
|
+
function verifySignedToken(signedValue) {
|
|
308
|
+
if (!signedValue || !signedValue.includes('.')) return null;
|
|
309
|
+
const lastDot = signedValue.lastIndexOf('.');
|
|
310
|
+
const token = signedValue.slice(0, lastDot);
|
|
311
|
+
const signature = signedValue.slice(lastDot + 1);
|
|
312
|
+
const expected = crypto.createHmac('sha256', SESSION_SECRET).update(token).digest('hex');
|
|
313
|
+
if (signature.length !== expected.length) return null;
|
|
314
|
+
try {
|
|
315
|
+
if (crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) {
|
|
316
|
+
return token;
|
|
317
|
+
}
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Comparaison sécurisée de deux chaînes (protection timing attack)
|
|
326
|
+
* @param {string} a
|
|
327
|
+
* @param {string} b
|
|
328
|
+
* @returns {boolean}
|
|
329
|
+
*/
|
|
330
|
+
function safeEqual(a, b) {
|
|
331
|
+
if (!a || !b) return false;
|
|
332
|
+
const bufA = Buffer.from(String(a));
|
|
333
|
+
const bufB = Buffer.from(String(b));
|
|
334
|
+
if (bufA.length !== bufB.length) return false;
|
|
335
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Crée une nouvelle session et la stocke
|
|
340
|
+
* @returns {string} Le token de session
|
|
341
|
+
*/
|
|
342
|
+
function createSession() {
|
|
343
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
344
|
+
activeSessions.add(token);
|
|
345
|
+
return token;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Détruit une session
|
|
350
|
+
* @param {string} token
|
|
351
|
+
*/
|
|
352
|
+
function destroySession(token) {
|
|
353
|
+
activeSessions.delete(token);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Vérifie si une session est valide
|
|
358
|
+
* @param {string} token
|
|
359
|
+
* @returns {boolean}
|
|
360
|
+
*/
|
|
361
|
+
function isValidSession(token) {
|
|
362
|
+
return token && activeSessions.has(token);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Écrit le cookie de session signé dans la réponse
|
|
367
|
+
* @param {import('express').Response} res
|
|
368
|
+
* @param {string} token
|
|
369
|
+
*/
|
|
370
|
+
function setSessionCookie(res, token) {
|
|
371
|
+
const signed = signToken(token);
|
|
372
|
+
res.setHeader('Set-Cookie', `proxy_session=${encodeURIComponent(signed)}; HttpOnly; SameSite=Strict; Path=/`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Lit et vérifie le cookie de session depuis la requête
|
|
377
|
+
* @param {import('express').Request} req
|
|
378
|
+
* @returns {string|null} Le token si valide, null sinon
|
|
379
|
+
*/
|
|
380
|
+
function getSessionFromCookie(req) {
|
|
381
|
+
const cookies = parseCookies(req);
|
|
382
|
+
const signedValue = cookies['proxy_session'];
|
|
383
|
+
if (!signedValue) return null;
|
|
384
|
+
const token = verifySignedToken(signedValue);
|
|
385
|
+
if (!token) return null;
|
|
386
|
+
return isValidSession(token) ? token : null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Supprime le cookie de session
|
|
391
|
+
* @param {import('express').Response} res
|
|
392
|
+
*/
|
|
393
|
+
function clearSessionCookie(res) {
|
|
394
|
+
res.setHeader('Set-Cookie', 'proxy_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Middleware : restreint l'accès aux requêtes provenant de localhost uniquement.
|
|
399
|
+
* Sécurisé car :
|
|
400
|
+
* - TCP handshake empêche le spoofing d'IP loopback depuis l'extérieur
|
|
401
|
+
* - trust proxy n'est pas activé → req.ip = IP réelle du socket
|
|
402
|
+
* - Pas de CORS configuré → DNS rebinding bloqué (preflight échoue pour JSON)
|
|
403
|
+
*/
|
|
404
|
+
function requireLocalOnly(req, res, next) {
|
|
405
|
+
const ip = req.ip || req.socket.remoteAddress;
|
|
406
|
+
const localAddresses = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];
|
|
407
|
+
|
|
408
|
+
if (!localAddresses.includes(ip)) {
|
|
409
|
+
return res.status(403).json({ success: false, error: 'Local access only' });
|
|
410
|
+
}
|
|
411
|
+
next();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Middleware : exige une session admin valide
|
|
416
|
+
* Si ADMIN_PASSWORD n'est pas configuré, la route reste ouverte (rétrocompatibilité)
|
|
417
|
+
*/
|
|
418
|
+
function requireAuth(req, res, next) {
|
|
419
|
+
if (!ADMIN_PASSWORD) return next(); // Rétrocompatibilité
|
|
420
|
+
|
|
421
|
+
const sessionToken = getSessionFromCookie(req);
|
|
422
|
+
if (sessionToken) return next();
|
|
423
|
+
|
|
424
|
+
// Pas de session valide → distinguer HTML vs JSON
|
|
425
|
+
const acceptHeader = req.get('Accept') || '';
|
|
426
|
+
if (acceptHeader.includes('text/html') || !acceptHeader.includes('application/json')) {
|
|
427
|
+
return res.redirect('/proxy/login');
|
|
428
|
+
}
|
|
429
|
+
return res.status(401).json({ success: false, error: 'Authentication required' });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ============================================
|
|
433
|
+
// ROUTES D'AUTHENTIFICATION
|
|
434
|
+
// ============================================
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* GET /proxy/login
|
|
438
|
+
* Page de connexion administrateur
|
|
439
|
+
*/
|
|
440
|
+
app.get('/proxy/login', (req, res) => {
|
|
441
|
+
const error = req.query.error === '1';
|
|
442
|
+
|
|
443
|
+
const html = `
|
|
444
|
+
<!DOCTYPE html>
|
|
445
|
+
<html lang="fr">
|
|
446
|
+
<head>
|
|
447
|
+
<meta charset="UTF-8">
|
|
448
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
449
|
+
<title>${escapeHtml(PROXY_NAME)} - Connexion</title>
|
|
450
|
+
<style>
|
|
451
|
+
* {
|
|
452
|
+
margin: 0;
|
|
453
|
+
padding: 0;
|
|
454
|
+
box-sizing: border-box;
|
|
455
|
+
}
|
|
456
|
+
body {
|
|
457
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
458
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
459
|
+
min-height: 100vh;
|
|
460
|
+
color: #e4e4e7;
|
|
461
|
+
display: flex;
|
|
462
|
+
align-items: center;
|
|
463
|
+
justify-content: center;
|
|
464
|
+
padding: 1.5rem;
|
|
465
|
+
}
|
|
466
|
+
.login-container {
|
|
467
|
+
width: 100%;
|
|
468
|
+
max-width: 380px;
|
|
469
|
+
text-align: center;
|
|
470
|
+
}
|
|
471
|
+
h1 {
|
|
472
|
+
font-size: 2rem;
|
|
473
|
+
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
|
474
|
+
-webkit-background-clip: text;
|
|
475
|
+
-webkit-text-fill-color: transparent;
|
|
476
|
+
background-clip: text;
|
|
477
|
+
margin-bottom: 0.25rem;
|
|
478
|
+
}
|
|
479
|
+
.subtitle {
|
|
480
|
+
color: #a1a1aa;
|
|
481
|
+
font-size: 0.95rem;
|
|
482
|
+
margin-bottom: 1.5rem;
|
|
483
|
+
}
|
|
484
|
+
.login-box {
|
|
485
|
+
background: rgba(255, 255, 255, 0.05);
|
|
486
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
487
|
+
border-radius: 12px;
|
|
488
|
+
padding: 1.5rem;
|
|
489
|
+
}
|
|
490
|
+
.form-group {
|
|
491
|
+
margin-bottom: 1rem;
|
|
492
|
+
text-align: left;
|
|
493
|
+
}
|
|
494
|
+
.form-group label {
|
|
495
|
+
font-size: 0.75rem;
|
|
496
|
+
color: #a1a1aa;
|
|
497
|
+
margin-bottom: 0.25rem;
|
|
498
|
+
display: block;
|
|
499
|
+
}
|
|
500
|
+
.form-group input {
|
|
501
|
+
background: rgba(0, 0, 0, 0.3);
|
|
502
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
503
|
+
padding: 0.6rem 0.75rem;
|
|
504
|
+
border-radius: 6px;
|
|
505
|
+
color: white;
|
|
506
|
+
font-family: inherit;
|
|
507
|
+
font-size: 0.9rem;
|
|
508
|
+
width: 100%;
|
|
509
|
+
}
|
|
510
|
+
.form-group input:focus {
|
|
511
|
+
outline: none;
|
|
512
|
+
border-color: #8b5cf6;
|
|
513
|
+
background: rgba(0, 0, 0, 0.4);
|
|
514
|
+
}
|
|
515
|
+
.submit-btn {
|
|
516
|
+
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
|
517
|
+
color: white;
|
|
518
|
+
border: none;
|
|
519
|
+
padding: 0.6rem 1.25rem;
|
|
520
|
+
border-radius: 6px;
|
|
521
|
+
font-weight: 600;
|
|
522
|
+
font-size: 0.85rem;
|
|
523
|
+
cursor: pointer;
|
|
524
|
+
transition: opacity 0.2s;
|
|
525
|
+
width: 100%;
|
|
526
|
+
}
|
|
527
|
+
.submit-btn:hover {
|
|
528
|
+
opacity: 0.9;
|
|
529
|
+
}
|
|
530
|
+
.error-msg {
|
|
531
|
+
background: rgba(239, 68, 68, 0.15);
|
|
532
|
+
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
533
|
+
color: #fca5a5;
|
|
534
|
+
padding: 0.5rem 0.75rem;
|
|
535
|
+
border-radius: 6px;
|
|
536
|
+
font-size: 0.8rem;
|
|
537
|
+
margin-bottom: 1rem;
|
|
538
|
+
}
|
|
539
|
+
@media (max-width: 600px) {
|
|
540
|
+
.login-container {
|
|
541
|
+
max-width: none;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
</style>
|
|
545
|
+
</head>
|
|
546
|
+
<body>
|
|
547
|
+
<div class="login-container">
|
|
548
|
+
<h1>${escapeHtml(PROXY_NAME)}</h1>
|
|
549
|
+
<p class="subtitle">Connexion administrateur</p>
|
|
550
|
+
<div class="login-box">
|
|
551
|
+
<form method="POST" action="/proxy/login">
|
|
552
|
+
${error ? '<div class="error-msg">Mot de passe incorrect</div>' : ''}
|
|
553
|
+
<div class="form-group">
|
|
554
|
+
<label for="password">Mot de passe</label>
|
|
555
|
+
<input type="password" id="password" name="password" required autofocus>
|
|
556
|
+
</div>
|
|
557
|
+
<button type="submit" class="submit-btn">Connexion</button>
|
|
558
|
+
</form>
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
</body>
|
|
562
|
+
</html>
|
|
563
|
+
`.trim();
|
|
564
|
+
|
|
565
|
+
res.type('html').send(html);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* POST /proxy/login
|
|
570
|
+
* Traitement du formulaire de connexion
|
|
571
|
+
*/
|
|
572
|
+
app.post('/proxy/login', (req, res) => {
|
|
573
|
+
const { password } = req.body;
|
|
574
|
+
|
|
575
|
+
if (ADMIN_PASSWORD && safeEqual(password, ADMIN_PASSWORD)) {
|
|
576
|
+
const token = createSession();
|
|
577
|
+
setSessionCookie(res, token);
|
|
578
|
+
return res.redirect('/');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
res.redirect('/proxy/login?error=1');
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* POST /proxy/logout
|
|
586
|
+
* Déconnexion — détruit la session et redirige vers /proxy/login
|
|
587
|
+
*/
|
|
588
|
+
app.post('/proxy/logout', (req, res) => {
|
|
589
|
+
const sessionToken = getSessionFromCookie(req);
|
|
590
|
+
if (sessionToken) {
|
|
591
|
+
destroySession(sessionToken);
|
|
592
|
+
}
|
|
593
|
+
clearSessionCookie(res);
|
|
594
|
+
res.redirect('/proxy/login');
|
|
595
|
+
});
|
|
596
|
+
|
|
260
597
|
// ============================================
|
|
261
598
|
// API D'ENREGISTREMENT
|
|
262
599
|
// ============================================
|
|
@@ -269,7 +606,7 @@ function startHealthCheckPolling() {
|
|
|
269
606
|
*
|
|
270
607
|
* Utilise un mutex pour éviter les race conditions lors d'inscriptions simultanées
|
|
271
608
|
*/
|
|
272
|
-
app.post('/proxy/register', async (req, res) => {
|
|
609
|
+
app.post('/proxy/register', requireLocalOnly, async (req, res) => {
|
|
273
610
|
// Validation préliminaire (avant d'acquérir le mutex)
|
|
274
611
|
const { path, name, port: requestedPort, target: requestedTarget, healthCheck } = req.body;
|
|
275
612
|
|
|
@@ -392,7 +729,7 @@ app.post('/proxy/register', async (req, res) => {
|
|
|
392
729
|
*
|
|
393
730
|
* Utilise un mutex pour éviter les conflits avec les inscriptions simultanées
|
|
394
731
|
*/
|
|
395
|
-
app.delete('/proxy/unregister', async (req, res) => {
|
|
732
|
+
app.delete('/proxy/unregister', requireLocalOnly, async (req, res) => {
|
|
396
733
|
const { path } = req.body;
|
|
397
734
|
|
|
398
735
|
if (!path) {
|
|
@@ -443,7 +780,7 @@ app.delete('/proxy/unregister', async (req, res) => {
|
|
|
443
780
|
* GET /proxy/routes
|
|
444
781
|
* Liste toutes les routes enregistrées
|
|
445
782
|
*/
|
|
446
|
-
app.get('/proxy/routes', (req, res) => {
|
|
783
|
+
app.get('/proxy/routes', requireAuth, (req, res) => {
|
|
447
784
|
const routes = [];
|
|
448
785
|
registry.routes.forEach((value, path) => {
|
|
449
786
|
routes.push({
|
|
@@ -469,7 +806,7 @@ app.get('/proxy/routes', (req, res) => {
|
|
|
469
806
|
* Déclenche manuellement un health check pour une route spécifique
|
|
470
807
|
* Body: { path: "/myapp" }
|
|
471
808
|
*/
|
|
472
|
-
app.post('/proxy/check', async (req, res) => {
|
|
809
|
+
app.post('/proxy/check', requireAuth, async (req, res) => {
|
|
473
810
|
const { path } = req.body;
|
|
474
811
|
|
|
475
812
|
if (!path) {
|
|
@@ -510,6 +847,162 @@ app.post('/proxy/check', async (req, res) => {
|
|
|
510
847
|
});
|
|
511
848
|
});
|
|
512
849
|
|
|
850
|
+
/**
|
|
851
|
+
* GET /proxy/terminal
|
|
852
|
+
* Page du terminal en ligne (xterm.js + WebSocket)
|
|
853
|
+
*/
|
|
854
|
+
app.get('/proxy/terminal', requireAuth, (req, res) => {
|
|
855
|
+
const html = `
|
|
856
|
+
<!DOCTYPE html>
|
|
857
|
+
<html lang="fr">
|
|
858
|
+
<head>
|
|
859
|
+
<meta charset="UTF-8">
|
|
860
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
861
|
+
<title>${escapeHtml(PROXY_NAME)} - Agent</title>
|
|
862
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css">
|
|
863
|
+
<style>
|
|
864
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
865
|
+
html, body { height: 100%; overflow: hidden; background: #1e1e1e; }
|
|
866
|
+
.terminal-header {
|
|
867
|
+
display: flex;
|
|
868
|
+
align-items: center;
|
|
869
|
+
gap: 1rem;
|
|
870
|
+
padding: 0.5rem 1rem;
|
|
871
|
+
background: #1a1a2e;
|
|
872
|
+
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
873
|
+
color: #e4e4e7;
|
|
874
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
875
|
+
font-size: 0.85rem;
|
|
876
|
+
}
|
|
877
|
+
.back-link {
|
|
878
|
+
color: #8b5cf6;
|
|
879
|
+
text-decoration: none;
|
|
880
|
+
font-weight: 500;
|
|
881
|
+
}
|
|
882
|
+
.back-link:hover {
|
|
883
|
+
text-decoration: underline;
|
|
884
|
+
}
|
|
885
|
+
.terminal-title {
|
|
886
|
+
font-weight: 600;
|
|
887
|
+
color: #f4f4f5;
|
|
888
|
+
}
|
|
889
|
+
.terminal-command {
|
|
890
|
+
color: #10b981;
|
|
891
|
+
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
892
|
+
font-size: 0.75rem;
|
|
893
|
+
margin-left: auto;
|
|
894
|
+
overflow: hidden;
|
|
895
|
+
text-overflow: ellipsis;
|
|
896
|
+
white-space: nowrap;
|
|
897
|
+
}
|
|
898
|
+
#terminal-container {
|
|
899
|
+
height: calc(100vh - 41px);
|
|
900
|
+
}
|
|
901
|
+
</style>
|
|
902
|
+
</head>
|
|
903
|
+
<body>
|
|
904
|
+
<div class="terminal-header">
|
|
905
|
+
<a href="/" class="back-link">← Dashboard</a>
|
|
906
|
+
<span class="terminal-title">Agent</span>
|
|
907
|
+
${TERMINAL_COMMAND ? '<span class="terminal-command">' + escapeHtml(TERMINAL_COMMAND) + '</span>' : ''}
|
|
908
|
+
</div>
|
|
909
|
+
<div id="terminal-container"></div>
|
|
910
|
+
|
|
911
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.min.js"></script>
|
|
912
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.min.js"></script>
|
|
913
|
+
<script>
|
|
914
|
+
class BrowserTerminal {
|
|
915
|
+
constructor(container, options = {}) {
|
|
916
|
+
this.container =
|
|
917
|
+
typeof container === 'string' ? document.querySelector(container) : container;
|
|
918
|
+
this.options = {
|
|
919
|
+
fitOnResize: true,
|
|
920
|
+
...options,
|
|
921
|
+
terminalOptions: {
|
|
922
|
+
cursorBlink: true,
|
|
923
|
+
theme: {
|
|
924
|
+
background: '#1e1e1e',
|
|
925
|
+
foreground: '#d4d4d4',
|
|
926
|
+
},
|
|
927
|
+
...options.terminalOptions,
|
|
928
|
+
},
|
|
929
|
+
};
|
|
930
|
+
this.terminal = new Terminal(this.options.terminalOptions);
|
|
931
|
+
this.fitAddon = new FitAddon.FitAddon();
|
|
932
|
+
this.ws = null;
|
|
933
|
+
this._resizeHandler = null;
|
|
934
|
+
this._init();
|
|
935
|
+
}
|
|
936
|
+
_init() {
|
|
937
|
+
this.terminal.loadAddon(this.fitAddon);
|
|
938
|
+
this.terminal.open(this.container);
|
|
939
|
+
this.fitAddon.fit();
|
|
940
|
+
this._bindClipboard();
|
|
941
|
+
this._connect();
|
|
942
|
+
this._bindResize();
|
|
943
|
+
}
|
|
944
|
+
_bindClipboard() {
|
|
945
|
+
// Laisser le navigateur gérer Ctrl+V (coller) et Ctrl+Shift+C (copier)
|
|
946
|
+
// Par défaut xterm intercepte ces touches et envoie des caractères de contrôle
|
|
947
|
+
this.terminal.attachCustomKeyEventHandler((e) => {
|
|
948
|
+
if (e.type !== 'keydown') return true;
|
|
949
|
+
if (e.ctrlKey && e.key === 'v') return false;
|
|
950
|
+
if (e.ctrlKey && e.shiftKey && e.key === 'C') return false;
|
|
951
|
+
return true;
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
_connect() {
|
|
955
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
956
|
+
const path = this.options.wsPath || '/terminal';
|
|
957
|
+
const url = this.options.wsUrl || protocol + '//' + location.host + path;
|
|
958
|
+
this.ws = new WebSocket(url);
|
|
959
|
+
this.ws.onopen = () => { this._sendResize(); };
|
|
960
|
+
this.ws.onmessage = (event) => { this.terminal.write(event.data); };
|
|
961
|
+
this.terminal.onData((data) => {
|
|
962
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
963
|
+
this.ws.send(data);
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
_bindResize() {
|
|
968
|
+
if (!this.options.fitOnResize) return;
|
|
969
|
+
this._resizeHandler = () => {
|
|
970
|
+
this.fitAddon.fit();
|
|
971
|
+
this._sendResize();
|
|
972
|
+
};
|
|
973
|
+
window.addEventListener('resize', this._resizeHandler);
|
|
974
|
+
}
|
|
975
|
+
_sendResize() {
|
|
976
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
977
|
+
this.ws.send(JSON.stringify({
|
|
978
|
+
type: 'resize',
|
|
979
|
+
cols: this.terminal.cols,
|
|
980
|
+
rows: this.terminal.rows,
|
|
981
|
+
}));
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
fit() {
|
|
985
|
+
this.fitAddon.fit();
|
|
986
|
+
this._sendResize();
|
|
987
|
+
}
|
|
988
|
+
dispose() {
|
|
989
|
+
if (this._resizeHandler) window.removeEventListener('resize', this._resizeHandler);
|
|
990
|
+
if (this.ws) this.ws.close();
|
|
991
|
+
this.terminal.dispose();
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const term = new BrowserTerminal('#terminal-container', {
|
|
996
|
+
wsPath: '/terminal-ws',
|
|
997
|
+
});
|
|
998
|
+
</script>
|
|
999
|
+
</body>
|
|
1000
|
+
</html>
|
|
1001
|
+
`.trim();
|
|
1002
|
+
|
|
1003
|
+
res.type('html').send(html);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
513
1006
|
/**
|
|
514
1007
|
* GET /proxy/health
|
|
515
1008
|
* Health check du proxy
|
|
@@ -529,7 +1022,7 @@ app.get('/proxy/health', (req, res) => {
|
|
|
529
1022
|
* Page d'accueil listant tous les serveurs disponibles
|
|
530
1023
|
* Retourne du JSON si le client l'accepte (API), sinon du HTML (navigateur)
|
|
531
1024
|
*/
|
|
532
|
-
app.get('/', (req, res) => {
|
|
1025
|
+
app.get('/', requireAuth, (req, res) => {
|
|
533
1026
|
const routes = [];
|
|
534
1027
|
registry.routes.forEach((value, path) => {
|
|
535
1028
|
routes.push({
|
|
@@ -626,8 +1119,33 @@ app.get('/', (req, res) => {
|
|
|
626
1119
|
/* Toolbar */
|
|
627
1120
|
.toolbar {
|
|
628
1121
|
display: flex;
|
|
629
|
-
justify-content:
|
|
1122
|
+
justify-content: space-between;
|
|
1123
|
+
align-items: center;
|
|
630
1124
|
margin-bottom: 0.75rem;
|
|
1125
|
+
gap: 0.5rem;
|
|
1126
|
+
}
|
|
1127
|
+
.toolbar-right {
|
|
1128
|
+
display: flex;
|
|
1129
|
+
gap: 0.5rem;
|
|
1130
|
+
align-items: center;
|
|
1131
|
+
}
|
|
1132
|
+
.agent-btn {
|
|
1133
|
+
background: linear-gradient(90deg, #10b981, #059669);
|
|
1134
|
+
color: white;
|
|
1135
|
+
border: none;
|
|
1136
|
+
padding: 0.5rem 1rem;
|
|
1137
|
+
border-radius: 6px;
|
|
1138
|
+
font-weight: 600;
|
|
1139
|
+
font-size: 0.8rem;
|
|
1140
|
+
cursor: pointer;
|
|
1141
|
+
transition: opacity 0.2s;
|
|
1142
|
+
display: inline-flex;
|
|
1143
|
+
align-items: center;
|
|
1144
|
+
gap: 0.4rem;
|
|
1145
|
+
white-space: nowrap;
|
|
1146
|
+
}
|
|
1147
|
+
.agent-btn:hover {
|
|
1148
|
+
opacity: 0.9;
|
|
631
1149
|
}
|
|
632
1150
|
.add-btn {
|
|
633
1151
|
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
|
@@ -884,6 +1402,13 @@ app.get('/', (req, res) => {
|
|
|
884
1402
|
}
|
|
885
1403
|
/* Responsive */
|
|
886
1404
|
@media (max-width: 600px) {
|
|
1405
|
+
.toolbar {
|
|
1406
|
+
flex-direction: column;
|
|
1407
|
+
}
|
|
1408
|
+
.toolbar-right {
|
|
1409
|
+
width: 100%;
|
|
1410
|
+
justify-content: flex-end;
|
|
1411
|
+
}
|
|
887
1412
|
.service-card {
|
|
888
1413
|
flex-direction: column;
|
|
889
1414
|
align-items: flex-start;
|
|
@@ -918,7 +1443,11 @@ app.get('/', (req, res) => {
|
|
|
918
1443
|
</header>
|
|
919
1444
|
|
|
920
1445
|
<div class="toolbar">
|
|
921
|
-
<button class="
|
|
1446
|
+
<button class="agent-btn" onclick="openAgent()">▶ Lancer Agent</button>
|
|
1447
|
+
<div class="toolbar-right">
|
|
1448
|
+
<button class="add-btn" onclick="openModal()">+ Ajouter</button>
|
|
1449
|
+
${ADMIN_PASSWORD ? '<button class="action-btn" onclick="logout()">Déconnexion</button>' : ''}
|
|
1450
|
+
</div>
|
|
922
1451
|
</div>
|
|
923
1452
|
|
|
924
1453
|
${routes.length > 0 ? `
|
|
@@ -1051,6 +1580,18 @@ app.get('/', (req, res) => {
|
|
|
1051
1580
|
}
|
|
1052
1581
|
});
|
|
1053
1582
|
|
|
1583
|
+
function openAgent() {
|
|
1584
|
+
window.open('/proxy/terminal', '_blank');
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function logout() {
|
|
1588
|
+
const form = document.createElement('form');
|
|
1589
|
+
form.method = 'POST';
|
|
1590
|
+
form.action = '/proxy/logout';
|
|
1591
|
+
document.body.appendChild(form);
|
|
1592
|
+
form.submit();
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1054
1595
|
async function checkHealth(path, btn) {
|
|
1055
1596
|
if (btn.classList.contains('loading')) return;
|
|
1056
1597
|
|
|
@@ -1215,23 +1756,26 @@ app.use((req, res, next) => {
|
|
|
1215
1756
|
});
|
|
1216
1757
|
}
|
|
1217
1758
|
|
|
1218
|
-
//
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1759
|
+
// Vérification de l'authentification avant de proxifier
|
|
1760
|
+
requireAuth(req, res, () => {
|
|
1761
|
+
// Stocke les informations de la route dans l'objet req pour le middleware persistant
|
|
1762
|
+
const target = route.target || 'http://localhost';
|
|
1763
|
+
req.proxyTarget = `${target}:${route.port}`;
|
|
1764
|
+
|
|
1765
|
+
// Trouve le path de la route pour le pathRewrite
|
|
1766
|
+
req.proxyRoutePath = [...registry.routes.entries()]
|
|
1767
|
+
.find(([, v]) => v === route)?.[0];
|
|
1225
1768
|
|
|
1226
|
-
|
|
1227
|
-
|
|
1769
|
+
// Délègue au middleware de proxy persistant
|
|
1770
|
+
persistentProxyMiddleware(req, res, next);
|
|
1771
|
+
});
|
|
1228
1772
|
});
|
|
1229
1773
|
|
|
1230
1774
|
// ============================================
|
|
1231
1775
|
// DÉMARRAGE DU SERVEUR
|
|
1232
1776
|
// ============================================
|
|
1233
1777
|
|
|
1234
|
-
app.listen(PROXY_PORT, () => {
|
|
1778
|
+
const server = app.listen(PROXY_PORT, () => {
|
|
1235
1779
|
console.log('='.repeat(50));
|
|
1236
1780
|
console.log(`🚀 ${PROXY_NAME}`);
|
|
1237
1781
|
console.log('='.repeat(50));
|
|
@@ -1240,15 +1784,51 @@ app.listen(PROXY_PORT, () => {
|
|
|
1240
1784
|
console.log(`Health check interval: ${HEALTH_CHECK_INTERVAL}ms`);
|
|
1241
1785
|
console.log(`Health check grace period: ${HEALTH_CHECK_GRACE_PERIOD}ms`);
|
|
1242
1786
|
console.log('');
|
|
1787
|
+
|
|
1788
|
+
// Warnings d'authentification
|
|
1789
|
+
if (!ADMIN_PASSWORD) {
|
|
1790
|
+
console.log('⚠️ WARNING: ADMIN_PASSWORD is not set — web interface is unprotected');
|
|
1791
|
+
} else {
|
|
1792
|
+
console.log('🔒 Web interface protected by admin password');
|
|
1793
|
+
}
|
|
1794
|
+
console.log('🔒 API routes restricted to localhost only');
|
|
1795
|
+
if (!process.env.SESSION_SECRET) {
|
|
1796
|
+
console.log('⚠️ WARNING: SESSION_SECRET is not set — using random secret (sessions lost on restart)');
|
|
1797
|
+
}
|
|
1798
|
+
console.log('');
|
|
1799
|
+
|
|
1243
1800
|
console.log('API Endpoints:');
|
|
1244
1801
|
console.log(' POST /proxy/register - Register a new route');
|
|
1245
1802
|
console.log(' DELETE /proxy/unregister - Remove a route');
|
|
1246
1803
|
console.log(' GET /proxy/routes - List all routes');
|
|
1247
1804
|
console.log(' GET /proxy/health - Health check');
|
|
1805
|
+
console.log(' GET /proxy/login - Admin login page');
|
|
1806
|
+
console.log(' GET /proxy/terminal - Agent terminal');
|
|
1248
1807
|
console.log('');
|
|
1249
1808
|
console.log('Note: Registered servers must implement GET /proxy/health');
|
|
1250
1809
|
console.log(' returning status 200 to stay registered.');
|
|
1251
1810
|
console.log('='.repeat(50));
|
|
1811
|
+
|
|
1812
|
+
// Initialisation du terminal en ligne
|
|
1813
|
+
const { TerminalServer } = require('./terminal-server');
|
|
1814
|
+
new TerminalServer(server, {
|
|
1815
|
+
path: '/terminal-ws',
|
|
1816
|
+
command: TERMINAL_COMMAND,
|
|
1817
|
+
ptyOptions: {
|
|
1818
|
+
cwd: TERMINAL_CWD,
|
|
1819
|
+
},
|
|
1820
|
+
wsOptions: {
|
|
1821
|
+
verifyClient: (info) => {
|
|
1822
|
+
if (!ADMIN_PASSWORD) return true;
|
|
1823
|
+
const cookies = parseCookies(info.req);
|
|
1824
|
+
const signedValue = cookies['proxy_session'];
|
|
1825
|
+
if (!signedValue) return false;
|
|
1826
|
+
const token = verifySignedToken(signedValue);
|
|
1827
|
+
return token ? isValidSession(token) : false;
|
|
1828
|
+
},
|
|
1829
|
+
},
|
|
1830
|
+
});
|
|
1831
|
+
console.log(`🖥️ Agent terminal at /proxy/terminal (cwd: ${TERMINAL_CWD}, command: ${TERMINAL_COMMAND || 'none'})`);
|
|
1252
1832
|
|
|
1253
1833
|
// Démarre le polling de health check
|
|
1254
1834
|
startHealthCheckPolling();
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
const os = require('os');
|
|
2
|
+
const { execSync } = require('child_process');
|
|
3
|
+
const pty = require('node-pty');
|
|
4
|
+
const { WebSocketServer } = require('ws');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SHELL = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
|
|
7
|
+
|
|
8
|
+
class TerminalServer {
|
|
9
|
+
/**
|
|
10
|
+
* @param {import('http').Server} httpServer
|
|
11
|
+
* @param {object} [options]
|
|
12
|
+
* @param {string} [options.shell] - Shell to spawn (default: auto-detected)
|
|
13
|
+
* @param {string[]} [options.shellArgs] - Shell arguments (default: [])
|
|
14
|
+
* @param {object} [options.ptyOptions] - node-pty spawn options overrides
|
|
15
|
+
* @param {number} [options.ptyOptions.cols] - Initial columns (default: 80)
|
|
16
|
+
* @param {number} [options.ptyOptions.rows] - Initial rows (default: 24)
|
|
17
|
+
* @param {string} [options.ptyOptions.cwd] - Working directory (default: user home)
|
|
18
|
+
* @param {object} [options.ptyOptions.env] - Environment variables (default: process.env)
|
|
19
|
+
* @param {string} [options.command] - Command to execute automatically after spawn
|
|
20
|
+
* @param {string} [options.path] - WebSocket endpoint path (default: '/terminal')
|
|
21
|
+
* @param {object} [options.wsOptions] - WebSocketServer options overrides
|
|
22
|
+
*/
|
|
23
|
+
constructor(httpServer, options = {}) {
|
|
24
|
+
this.shell = options.shell || DEFAULT_SHELL;
|
|
25
|
+
this.shellArgs = options.shellArgs || [];
|
|
26
|
+
this.command = options.command || '';
|
|
27
|
+
this.ptyOptions = {
|
|
28
|
+
name: 'xterm-color',
|
|
29
|
+
cols: 80,
|
|
30
|
+
rows: 24,
|
|
31
|
+
cwd: process.env.HOME || process.env.USERPROFILE,
|
|
32
|
+
env: process.env,
|
|
33
|
+
...options.ptyOptions,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
this.connections = new Set();
|
|
37
|
+
|
|
38
|
+
this.wss = new WebSocketServer({
|
|
39
|
+
server: httpServer,
|
|
40
|
+
path: options.path || '/terminal',
|
|
41
|
+
...options.wsOptions,
|
|
42
|
+
});
|
|
43
|
+
this.wss.on('connection', (ws, req) => this._handleConnection(ws, req));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_handleConnection(ws, req) {
|
|
47
|
+
const cwd = this.ptyOptions.cwd;
|
|
48
|
+
|
|
49
|
+
let ptyProcess;
|
|
50
|
+
try {
|
|
51
|
+
ptyProcess = pty.spawn(this.shell, this.shellArgs, {
|
|
52
|
+
...this.ptyOptions,
|
|
53
|
+
cwd,
|
|
54
|
+
});
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error(`[TERMINAL] Failed to spawn PTY (cwd: ${cwd}):`, err.message);
|
|
57
|
+
ws.close(1011, 'Failed to spawn terminal');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const connection = { ws, ptyProcess };
|
|
62
|
+
this.connections.add(connection);
|
|
63
|
+
|
|
64
|
+
ptyProcess.onData((data) => {
|
|
65
|
+
try {
|
|
66
|
+
ws.send(data);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// WebSocket already closed
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
ws.on('message', (data) => {
|
|
73
|
+
const message = data.toString();
|
|
74
|
+
try {
|
|
75
|
+
const msg = JSON.parse(message);
|
|
76
|
+
if (msg.type === 'resize') {
|
|
77
|
+
ptyProcess.resize(msg.cols, msg.rows);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
81
|
+
// Not JSON, treat as terminal input
|
|
82
|
+
}
|
|
83
|
+
ptyProcess.write(message);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
ws.on('close', () => {
|
|
87
|
+
this._gracefulKill(ptyProcess);
|
|
88
|
+
this.connections.delete(connection);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Exécuter automatiquement la commande configurée
|
|
92
|
+
if (this.command) {
|
|
93
|
+
// Petit délai pour laisser le shell s'initialiser
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
ptyProcess.write(this.command + '\r');
|
|
96
|
+
console.log(`[TERMINAL] Auto-executed command: ${this.command}`);
|
|
97
|
+
}, 500);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Arrêt gracieux : envoie Ctrl+C deux fois pour interrompre proprement
|
|
103
|
+
* le programme en cours, attend qu'il se termine, puis tue le shell.
|
|
104
|
+
* @param {import('node-pty').IPty} ptyProcess
|
|
105
|
+
*/
|
|
106
|
+
_gracefulKill(ptyProcess) {
|
|
107
|
+
const pid = ptyProcess.pid;
|
|
108
|
+
console.log(`[TERMINAL] Graceful shutdown (PID: ${pid})...`);
|
|
109
|
+
|
|
110
|
+
// 1er Ctrl+C
|
|
111
|
+
try { ptyProcess.write('\x03'); } catch (_) { /* ignore */ }
|
|
112
|
+
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
// 2e Ctrl+C
|
|
115
|
+
try { ptyProcess.write('\x03'); } catch (_) { /* ignore */ }
|
|
116
|
+
|
|
117
|
+
setTimeout(() => {
|
|
118
|
+
// Tuer tout l'arbre de processus
|
|
119
|
+
this._killProcessTree(pid);
|
|
120
|
+
}, 1000);
|
|
121
|
+
}, 500);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Tue l'arbre de processus par PID.
|
|
126
|
+
* Sur Windows : taskkill /F /T /PID
|
|
127
|
+
* Sur Linux/macOS : kill du groupe de processus
|
|
128
|
+
* @param {number} pid
|
|
129
|
+
*/
|
|
130
|
+
_killProcessTree(pid) {
|
|
131
|
+
try {
|
|
132
|
+
if (os.platform() === 'win32') {
|
|
133
|
+
execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' });
|
|
134
|
+
} else {
|
|
135
|
+
process.kill(-pid, 'SIGKILL');
|
|
136
|
+
}
|
|
137
|
+
console.log(`[TERMINAL] Killed process tree (PID: ${pid})`);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
// Le processus est peut-être déjà terminé
|
|
140
|
+
console.log(`[TERMINAL] Process tree already exited (PID: ${pid})`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
dispose() {
|
|
145
|
+
for (const { ws, ptyProcess } of this.connections) {
|
|
146
|
+
this._gracefulKill(ptyProcess);
|
|
147
|
+
ws.close();
|
|
148
|
+
}
|
|
149
|
+
this.connections.clear();
|
|
150
|
+
this.wss.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { TerminalServer };
|