@imenam/self-register-proxy 1.0.43 → 1.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -472,10 +472,18 @@ Le proxy charge automatiquement le fichier `.env` s'il existe (voir `.env.exampl
472
472
  |----------|--------|-------------|
473
473
  | `PROXY_NAME` | `Proxy Server` | Nom affiché sur la page d'accueil |
474
474
  | `PROXY_PORT` | `3000` | Port d'écoute du proxy |
475
+ | `PROXY_HOST` | *(toutes les interfaces)* | Adresse d'écoute. `127.0.0.1` confine le proxy — dashboard et terminal compris — à la machine locale. Voir [Sécurité](#sécurité). |
476
+ | `PROXY_INTERNAL_PORT_START` | `4000` | Premier port attribuable aux applications enregistrées |
477
+ | `PROXY_INTERNAL_PORT_END` | `5000` | Dernier port attribuable |
478
+ | `PROXY_ADMIN_PASSWORD` | *(aucun)* | Mot de passe du dashboard. **Sans lui, le dashboard, son terminal et les routes shell ne sont pas protégés.** |
479
+ | `PROXY_SESSION_SECRET` | *(généré)* | Clé de signature des sessions. **Obligatoire** dès que `PROXY_ADMIN_PASSWORD` est défini, sinon le proxy sort en `exit(1)`. |
475
480
  | `HEALTH_CHECK_INTERVAL` | `30000` | Intervalle du health check polling (ms) |
476
481
  | `HEALTH_CHECK_TIMEOUT` | `5000` | Timeout pour chaque health check (ms) |
477
482
  | `HEALTH_CHECK_GRACE_PERIOD` | `60000` | Période de grâce pour les nouveaux serveurs (ms) |
478
483
  | `PROXY_CONFIG_DIR` | `.` | Dossier de stockage des configurations (meta-configs.json) |
484
+ | `PROXY_TERMINAL_CWD` | `$HOME` / `%USERPROFILE%` | Répertoire de travail du terminal en ligne |
485
+ | `PROXY_TERMINAL_COMMAND` | *(shell par défaut)* | Commande lancée par le terminal en ligne |
486
+ | `PROXY_OPENCODE_CLEAN_UP` | *(aucune)* | Commande exécutée par `POST /proxy/cleanup` |
479
487
 
480
488
  ### Variables pour les applications clientes
481
489
 
@@ -484,18 +492,69 @@ Le proxy charge automatiquement le fichier `.env` s'il existe (voir `.env.exampl
484
492
  | `PROXY_URL` | `http://localhost:3000` | URL du proxy (pour les apps) |
485
493
  | `APP_PATH` | `/example` | Chemin de l'application |
486
494
  | `APP_NAME` | `Example App` | Nom de l'application |
495
+ | `APP_GROUP` | *(aucun)* | Groupe d'affichage dans le dashboard. Absent : l'application apparaît sous « Non groupées ». |
487
496
 
488
- ## Plage de ports
497
+ ## Sécurité
489
498
 
490
- Par défaut, le proxy attribue des ports entre **4000** et **5000** (1000 ports disponibles).
499
+ **Le dashboard n'est pas qu'une liste de liens.** Il sert un terminal interactif
500
+ (`/proxy/terminal`, un vrai PTY sur la machine) et deux routes qui exécutent du
501
+ shell : `POST /proxy/kill-port` et `POST /proxy/cleanup`. Elles sont derrière
502
+ `requireAuth` — **qui laisse tout passer quand `PROXY_ADMIN_PASSWORD` n'est pas
503
+ défini** (rétrocompatibilité historique). Le `verifyClient` du WebSocket du
504
+ terminal a la même porte ouverte.
491
505
 
492
- Pour modifier cette plage, éditez `proxy.js` :
506
+ Le proxy écoute par défaut sur **toutes les interfaces**. Sans mot de passe, il
507
+ offre donc un shell sans identifiants à quiconque atteint son port depuis le
508
+ réseau. Deux postures saines, à choisir explicitement :
493
509
 
494
- ```javascript
495
- const INTERNAL_PORT_START = 4000;
496
- const INTERNAL_PORT_END = 5000;
510
+ ```bash
511
+ # Poste local : rien n'est exposé hors de la machine.
512
+ PROXY_HOST=127.0.0.1
513
+
514
+ # Accès distant / tunnel : le dashboard est protégé.
515
+ PROXY_ADMIN_PASSWORD=...
516
+ PROXY_SESSION_SECRET=... # 32 octets hex ; obligatoire avec le mot de passe
497
517
  ```
498
518
 
519
+ Le proxy affiche au démarrage l'adresse à laquelle il se lie et avertit
520
+ bruyamment quand ces deux protections manquent toutes les deux.
521
+
522
+ **HSTS.** Jusqu'à la 1.0.43, un en-tête `Strict-Transport-Security` était émis dès
523
+ qu'un mot de passe était défini, même sur des réponses servies en clair : le
524
+ navigateur forçait ensuite `https://` sur cette origine pour un an, et le proxy —
525
+ qui ne sert pas de TLS — répondait `ERR_SSL_PROTOCOL_ERROR`. Depuis la 1.0.44,
526
+ HSTS n'est posé que sur une requête réellement en HTTPS (`req.secure`, ou un
527
+ `X-Forwarded-Proto: https` de terminaison TLS). Le cookie de session suit la même
528
+ règle pour son attribut `Secure`, qui empêchait sinon toute connexion sur un
529
+ domaine servi en clair. Si votre navigateur a mémorisé l'ordre, purgez l'origine
530
+ dans `chrome://net-internals/#hsts`.
531
+
532
+ ## Plage de ports
533
+
534
+ Par défaut, le proxy attribue des ports entre **4000** et **5000** (1000 ports
535
+ disponibles). Depuis la 1.0.44 la plage se règle par variables d'environnement,
536
+ sans toucher au code :
537
+
538
+ ```bash
539
+ PROXY_INTERNAL_PORT_START=5200
540
+ PROXY_INTERNAL_PORT_END=5400
541
+ ```
542
+
543
+ Une valeur invalide, ou une plage inversée, est ignorée au profit des valeurs par
544
+ défaut, avec un avertissement au démarrage.
545
+
546
+ **Faites-le si deux proxys tournent sur la même machine.** Chaque proxy gère sa
547
+ plage pour lui seul et ignore l'existence de l'autre : avec la même plage des deux
548
+ côtés, le second peut attribuer à une application un port déjà occupé par une
549
+ application du premier. Sa sonde de santé interroge alors le service de l'autre,
550
+ qui répond — la route ne meurt jamais et les requêtes partent au mauvais endroit.
551
+
552
+ > **Correctif 1.0.44 — sonde de disponibilité.** Elle se contentait d'essayer de
553
+ > se lier au port. Sous Windows, lier `127.0.0.1:<port>` **réussit** alors qu'un
554
+ > autre process écoute déjà sur `0.0.0.0:<port>` : un port occupé était déclaré
555
+ > libre, puis attribué. La sonde tente désormais aussi une **connexion**, ce qui
556
+ > donne la même réponse sur tous les systèmes.
557
+
499
558
  ## Fonctionnalités
500
559
 
501
560
  - **Attribution sécurisée des ports** : utilisation d'un mutex pour éviter les conflits lors d'enregistrements simultanés
@@ -514,9 +573,16 @@ Le format des logs inclut la date, l'heure, le PID et le tag `[PROXY]`.
514
573
 
515
574
  ## Limitations
516
575
 
517
- - Les routes sont stockées en mémoire (perdues au redémarrage du proxy)
576
+ - Les routes sont stockées en mémoire (perdues au redémarrage du proxy), et **les
577
+ applications ne se réenregistrent pas d'elles-mêmes** : après un redémarrage du
578
+ proxy, il faut relancer chaque application
518
579
  - Pas de persistance des enregistrements
519
- - Pas d'authentification sur l'API de registration
580
+ - `POST /proxy/register` et `DELETE /proxy/unregister` sont ouvertes sans mot de
581
+ passe, mais **restreintes à localhost**. Le dashboard et ses routes shell, eux,
582
+ ne sont protégés que par `PROXY_ADMIN_PASSWORD` — voir [Sécurité](#sécurité)
583
+ - `group` est un **libellé d'affichage**, pas un espace de noms : il ne dédoublonne
584
+ pas les chemins. Deux applications au même chemin restent en conflit (409), quel
585
+ que soit leur groupe
520
586
  - Les applications doivent implémenter `GET /proxy/health` pour ne pas être désenregistrées automatiquement
521
587
 
522
588
  ## Licence
package/config.js CHANGED
@@ -2,8 +2,45 @@ const crypto = require('crypto');
2
2
 
3
3
  const PROXY_PORT = process.env.PROXY_PORT || 3000;
4
4
  const PROXY_NAME = process.env.PROXY_NAME || 'Proxy Server';
5
- const INTERNAL_PORT_START = 4000;
6
- const INTERNAL_PORT_END = 5000;
5
+
6
+ /**
7
+ * Interface d'écoute. Non définie = comportement historique : Express écoute sur
8
+ * **toutes** les interfaces (0.0.0.0), le dashboard et son terminal PTY sont donc
9
+ * joignables depuis le réseau. `PROXY_HOST=127.0.0.1` restreint l'écoute à la
10
+ * boucle locale — c'est ce que pose un superviseur qui démarre un proxy sans
11
+ * `PROXY_ADMIN_PASSWORD`.
12
+ */
13
+ const PROXY_HOST = process.env.PROXY_HOST || undefined;
14
+
15
+ /**
16
+ * Plage des ports attribués aux applications enregistrées.
17
+ *
18
+ * Historiquement codée en dur à 4000-5000, ce qui rendait impossible de faire
19
+ * cohabiter deux proxys sur une même machine sans qu'ils se disputent les mêmes
20
+ * ports. Elle est désormais configurable ; **les valeurs par défaut ne changent
21
+ * pas**, et une valeur invalide est ignorée au profit du défaut.
22
+ */
23
+ function readPortBound(name, fallback) {
24
+ const raw = process.env[name];
25
+ if (raw === undefined || raw === '') return fallback;
26
+ const value = Number.parseInt(raw, 10);
27
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
28
+ console.warn(`[CONFIG] ${name}="${raw}" invalide — valeur par défaut ${fallback} conservée.`);
29
+ return fallback;
30
+ }
31
+ return value;
32
+ }
33
+
34
+ let INTERNAL_PORT_START = readPortBound('PROXY_INTERNAL_PORT_START', 4000);
35
+ let INTERNAL_PORT_END = readPortBound('PROXY_INTERNAL_PORT_END', 5000);
36
+ if (INTERNAL_PORT_START > INTERNAL_PORT_END) {
37
+ console.warn(
38
+ `[CONFIG] Plage de ports internes inversée (${INTERNAL_PORT_START}-${INTERNAL_PORT_END}) — ` +
39
+ 'retour aux valeurs par défaut 4000-5000.'
40
+ );
41
+ INTERNAL_PORT_START = 4000;
42
+ INTERNAL_PORT_END = 5000;
43
+ }
7
44
  const HEALTH_CHECK_INTERVAL = process.env.HEALTH_CHECK_INTERVAL || 30000;
8
45
  const HEALTH_CHECK_TIMEOUT = process.env.HEALTH_CHECK_TIMEOUT || 5000;
9
46
  const HEALTH_CHECK_GRACE_PERIOD = process.env.HEALTH_CHECK_GRACE_PERIOD || 60000;
@@ -20,6 +57,7 @@ const MINIMAL_DASHBOARD = process.argv.includes('--minimal-dashboard');
20
57
  module.exports = {
21
58
  PROXY_PORT,
22
59
  PROXY_NAME,
60
+ PROXY_HOST,
23
61
  INTERNAL_PORT_START,
24
62
  INTERNAL_PORT_END,
25
63
  HEALTH_CHECK_INTERVAL,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/self-register-proxy",
3
- "version": "1.0.43",
3
+ "version": "1.0.45",
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": {
package/proxy.js CHANGED
@@ -14,6 +14,7 @@ setupLogging('PROXY');
14
14
  const {
15
15
  PROXY_PORT,
16
16
  PROXY_NAME,
17
+ PROXY_HOST,
17
18
  INTERNAL_PORT_START,
18
19
  INTERNAL_PORT_END,
19
20
  HEALTH_CHECK_INTERVAL,
@@ -42,6 +43,26 @@ const dashboardRoutes = require('./routes/dashboard');
42
43
 
43
44
  const app = express();
44
45
 
46
+ /**
47
+ * Vrai si la requête est arrivée en HTTPS — directement, ou via un terminaison
48
+ * TLS en amont qui l'annonce (`X-Forwarded-Proto`, posé par ngrok, nginx, etc.).
49
+ */
50
+ const isHttpsRequest = (req) =>
51
+ req.secure || String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https';
52
+
53
+ /**
54
+ * HSTS **uniquement sur une requête réellement servie en HTTPS**.
55
+ *
56
+ * Avant : HSTS était activé dès qu'un mot de passe était défini, quel que soit le
57
+ * protocole. Un `http://127.0.0.1:3000` renvoyait donc un `Strict-Transport-Security`
58
+ * que le navigateur mémorisait pour un an : toute visite suivante était forcée en
59
+ * `https://`, et le proxy — qui ne sert pas de TLS — répondait `ERR_SSL_PROTOCOL_ERROR`.
60
+ * Le même piège est franchement bloquant sur un domaine servi en clair.
61
+ *
62
+ * Un en-tête HSTS émis sur une réponse HTTP est de toute façon ignoré par les
63
+ * navigateurs conformes (RFC 6797 §7.2) : le restreindre à HTTPS ne retire donc
64
+ * aucune protection réelle aux déploiements sous TLS.
65
+ */
45
66
  app.use(helmet({
46
67
  contentSecurityPolicy: {
47
68
  directives: {
@@ -53,10 +74,16 @@ app.use(helmet({
53
74
  imgSrc: ["'self'", "data:", "https://i.ytimg.com"],
54
75
  },
55
76
  },
56
- hsts: ADMIN_PASSWORD ? { maxAge: 31536000, includeSubDomains: true } : false,
77
+ hsts: false,
57
78
  referrerPolicy: { policy: 'same-origin' },
58
79
  }));
59
80
 
81
+ const hstsMiddleware = helmet.hsts({ maxAge: 31536000, includeSubDomains: true });
82
+ app.use((req, res, next) => {
83
+ if (ADMIN_PASSWORD && isHttpsRequest(req)) return hstsMiddleware(req, res, next);
84
+ next();
85
+ });
86
+
60
87
  const isLocalRequest = (req) => {
61
88
  const ip = req.ip || req.connection?.remoteAddress || '';
62
89
  return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
@@ -97,11 +124,15 @@ app.use(routingMiddleware);
97
124
  // DÉMARRAGE DU SERVEUR
98
125
  // ============================================
99
126
 
100
- const server = app.listen(PROXY_PORT, () => {
127
+ // `PROXY_HOST` non défini = comportement historique (toutes les interfaces).
128
+ const listenArgs = PROXY_HOST ? [PROXY_PORT, PROXY_HOST] : [PROXY_PORT];
129
+
130
+ const server = app.listen(...listenArgs, () => {
101
131
  console.log('='.repeat(50));
102
132
  console.log(`🚀 ${PROXY_NAME}`);
103
133
  console.log('='.repeat(50));
104
134
  console.log(`Listening on port ${PROXY_PORT}`);
135
+ console.log(`Bind address: ${PROXY_HOST || '0.0.0.0 (toutes les interfaces)'}`);
105
136
  console.log(`Internal ports range: ${INTERNAL_PORT_START}-${INTERNAL_PORT_END}`);
106
137
  console.log(`Health check interval: ${HEALTH_CHECK_INTERVAL}ms`);
107
138
  console.log(`Health check grace period: ${HEALTH_CHECK_GRACE_PERIOD}ms`);
@@ -109,6 +140,12 @@ const server = app.listen(PROXY_PORT, () => {
109
140
 
110
141
  if (!ADMIN_PASSWORD) {
111
142
  console.log('⚠️ WARNING: ADMIN_PASSWORD is not set — web interface is unprotected');
143
+ console.log('⚠️ Le dashboard expose un terminal PTY et les routes kill-port / cleanup,');
144
+ console.log('⚠️ qui exécutent du shell sans authentification.');
145
+ if (!PROXY_HOST) {
146
+ console.log('⚠️ Écoute sur toutes les interfaces : cette surface est joignable depuis le réseau.');
147
+ console.log('⚠️ Posez PROXY_ADMIN_PASSWORD, ou PROXY_HOST=127.0.0.1 pour la boucle locale.');
148
+ }
112
149
  } else {
113
150
  console.log('🔒 Web interface protected by admin password');
114
151
  console.log('🔒 Session secret configured');
@@ -44,7 +44,7 @@ router.post('/proxy/login', (req, res) => {
44
44
 
45
45
  if (ADMIN_PASSWORD && safeEqual(password, ADMIN_PASSWORD)) {
46
46
  const token = createSession();
47
- setSessionCookie(res, token);
47
+ setSessionCookie(res, token, req);
48
48
  return res.redirect('/');
49
49
  }
50
50
 
package/services/auth.js CHANGED
@@ -89,9 +89,27 @@ function isValidSession(token) {
89
89
  return true;
90
90
  }
91
91
 
92
- function setSessionCookie(res, token) {
92
+ /**
93
+ * L'attribut `Secure` n'est posé que si la requête est **réellement** en HTTPS.
94
+ *
95
+ * Il l'était auparavant dès qu'un mot de passe était configuré : sur un domaine
96
+ * servi en clair, le navigateur refusait alors d'enregistrer le cookie et la
97
+ * connexion échouait en boucle, sans message. (En `http://localhost` le problème
98
+ * ne se voyait pas : les navigateurs y traitent l'origine comme sûre.)
99
+ *
100
+ * `req` est optionnel pour rester rétrocompatible avec les appelants existants,
101
+ * qui retrouvent alors l'ancien comportement.
102
+ */
103
+ function isSecureRequest(req) {
104
+ if (!req) return !!ADMIN_PASSWORD;
105
+ if (req.secure) return true;
106
+ const forwarded = String((req.headers && req.headers['x-forwarded-proto']) || '');
107
+ return forwarded.split(',')[0].trim() === 'https';
108
+ }
109
+
110
+ function setSessionCookie(res, token, req) {
93
111
  const signed = signToken(token);
94
- const secure = ADMIN_PASSWORD ? '; Secure' : '';
112
+ const secure = isSecureRequest(req) ? '; Secure' : '';
95
113
  res.setHeader('Set-Cookie', `proxy_session=${encodeURIComponent(signed)}; HttpOnly; SameSite=Lax; Path=/${secure}`);
96
114
  }
97
115
 
@@ -14,20 +14,67 @@ function isLocalTarget(target) {
14
14
  }
15
15
  }
16
16
 
17
+ /** Délai au-delà duquel une tentative de connexion locale est abandonnée. */
18
+ const CONNECT_PROBE_TIMEOUT = 300;
19
+
17
20
  /**
18
- * Vérifie si un port est réellement libre au niveau OS
21
+ * Vrai si l'OS nous laisse écouter sur ce port.
22
+ *
23
+ * Attention : ce test **ne suffit pas seul** (voir `isSomeoneListening`).
19
24
  */
20
- function isPortFree(port) {
25
+ function canBind(port, host) {
21
26
  return new Promise((resolve) => {
22
27
  const server = net.createServer();
23
28
  server.once('error', () => resolve(false));
24
29
  server.once('listening', () => {
25
30
  server.close(() => resolve(true));
26
31
  });
27
- server.listen(port);
32
+ if (host) server.listen(port, host);
33
+ else server.listen(port);
28
34
  });
29
35
  }
30
36
 
37
+ /**
38
+ * Vrai si **quelqu'un accepte déjà** des connexions sur `host:port`.
39
+ *
40
+ * Pourquoi cette seconde sonde : sous Windows, lier `127.0.0.1:<port>` **réussit**
41
+ * alors qu'un process tiers écoute déjà sur `0.0.0.0:<port>` — le bind d'une
42
+ * adresse précise n'entre pas en conflit avec un bind générique d'un autre
43
+ * process. Le test de bind seul déclarait donc libre un port occupé, et le proxy
44
+ * attribuait ce port à une application ; sa sonde de santé interrogeait ensuite
45
+ * le service de l'autre process, qui répondait, et la route ne mourait jamais.
46
+ * Sous Linux le même bind échoue en `EADDRINUSE` : le défaut était invisible
47
+ * hors Windows.
48
+ *
49
+ * Une tentative de connexion, elle, répond la même chose partout. Un timeout est
50
+ * traité comme « occupé » : en local, une connexion sans réponse n'est pas normale.
51
+ */
52
+ function isSomeoneListening(port, host = '127.0.0.1', timeoutMs = CONNECT_PROBE_TIMEOUT) {
53
+ return new Promise((resolve) => {
54
+ const socket = new net.Socket();
55
+ const done = (value) => {
56
+ socket.removeAllListeners();
57
+ socket.destroy();
58
+ resolve(value);
59
+ };
60
+ socket.setTimeout(timeoutMs);
61
+ socket.once('connect', () => done(true));
62
+ socket.once('timeout', () => done(true));
63
+ // ECONNREFUSED (personne n'écoute) et les autres erreurs : port utilisable.
64
+ socket.once('error', () => done(false));
65
+ socket.connect(port, host);
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Vérifie si un port est réellement libre au niveau OS : personne ne peut s'y
71
+ * lier **et** personne n'y répond. Les deux conditions sont nécessaires.
72
+ */
73
+ async function isPortFree(port) {
74
+ if (!(await canBind(port))) return false;
75
+ return !(await isSomeoneListening(port));
76
+ }
77
+
31
78
  /**
32
79
  * Trouve le prochain port disponible et le réserve atomiquement
33
80
  * DOIT être appelé avec le mutex acquis
@@ -79,4 +126,12 @@ function findRouteForPath(requestPath) {
79
126
  return null;
80
127
  }
81
128
 
82
- module.exports = { isLocalTarget, isPortFree, getNextAvailablePort, releasePort, findRouteForPath };
129
+ module.exports = {
130
+ isLocalTarget,
131
+ canBind,
132
+ isSomeoneListening,
133
+ isPortFree,
134
+ getNextAvailablePort,
135
+ releasePort,
136
+ findRouteForPath,
137
+ };