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.
Files changed (3) hide show
  1. package/package.json +5 -2
  2. package/proxy.js +597 -17
  3. 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.16",
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">&larr; 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: flex-end;
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="add-btn" onclick="openModal()">+ Ajouter</button>
1446
+ <button class="agent-btn" onclick="openAgent()">&#9654; 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
- // Stocke les informations de la route dans l'objet req pour le middleware persistant
1219
- const target = route.target || 'http://localhost';
1220
- req.proxyTarget = `${target}:${route.port}`;
1221
-
1222
- // Trouve le path de la route pour le pathRewrite
1223
- req.proxyRoutePath = [...registry.routes.entries()]
1224
- .find(([, v]) => v === route)?.[0];
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
- // Délègue au middleware de proxy persistant
1227
- persistentProxyMiddleware(req, res, next);
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 };