@imenam/self-register-proxy 1.0.37
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/LICENSE +15 -0
- package/README.md +510 -0
- package/config.js +31 -0
- package/logger.js +88 -0
- package/meta-config.js +336 -0
- package/package.json +71 -0
- package/proxy-client.js +203 -0
- package/proxy.js +169 -0
- package/routes/dashboard.js +152 -0
- package/routes/metaConfig.js +180 -0
- package/routes/registration.js +227 -0
- package/routes/terminal.js +68 -0
- package/services/auth.js +151 -0
- package/services/healthCheck.js +95 -0
- package/services/portManager.js +82 -0
- package/services/proxyRouter.js +99 -0
- package/services/registry.js +41 -0
- package/terminal-server.js +154 -0
- package/views/dashboard.js +688 -0
- package/views/login.js +130 -0
- package/views/metaConfigs.js +643 -0
- package/views/terminal.html +1029 -0
- package/views/terminal.js +30 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
const { Router } = require('express');
|
|
2
|
+
const { requireLocalOnly, requireAuth } = require('../services/auth');
|
|
3
|
+
const { registry, mutex } = require('../services/registry');
|
|
4
|
+
const { getNextAvailablePort, releasePort, isLocalTarget, isPortFree, findRouteForPath } = require('../services/portManager');
|
|
5
|
+
const { checkServerHealth, unregisterServer } = require('../services/healthCheck');
|
|
6
|
+
const { INTERNAL_PORT_START, INTERNAL_PORT_END } = require('../config');
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Logique commune d'enregistrement (auto et manuel)
|
|
12
|
+
*/
|
|
13
|
+
async function registerRouteInternal({ path, name, port, target, healthCheck, autoAssignPort }) {
|
|
14
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
15
|
+
|
|
16
|
+
let finalTarget = target || 'http://localhost';
|
|
17
|
+
if (!finalTarget.startsWith('http://') && !finalTarget.startsWith('https://')) {
|
|
18
|
+
finalTarget = `http://${finalTarget}`;
|
|
19
|
+
}
|
|
20
|
+
if (finalTarget.endsWith('/')) {
|
|
21
|
+
finalTarget = finalTarget.slice(0, -1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await mutex.acquire();
|
|
25
|
+
|
|
26
|
+
let assignedPort = null;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
if (registry.routes.has(normalizedPath)) {
|
|
30
|
+
const existing = registry.routes.get(normalizedPath);
|
|
31
|
+
|
|
32
|
+
mutex.release();
|
|
33
|
+
const isAlive = await checkServerHealth(normalizedPath, existing);
|
|
34
|
+
await mutex.acquire();
|
|
35
|
+
|
|
36
|
+
if (!isAlive) {
|
|
37
|
+
console.log(`[REGISTER] Path ${normalizedPath} was occupied by a dead server. Overwriting...`);
|
|
38
|
+
registry.usedPorts.delete(existing.port);
|
|
39
|
+
registry.routes.delete(normalizedPath);
|
|
40
|
+
} else {
|
|
41
|
+
throw new Error('Path already registered');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!autoAssignPort) {
|
|
46
|
+
if (!port) throw new Error('Port is required for manual registration');
|
|
47
|
+
const manualPort = parseInt(port, 10);
|
|
48
|
+
|
|
49
|
+
if (registry.usedPorts.has(manualPort)) {
|
|
50
|
+
throw new Error(`Port ${manualPort} already in use`);
|
|
51
|
+
}
|
|
52
|
+
if (isLocalTarget(finalTarget) && !await isPortFree(manualPort)) {
|
|
53
|
+
throw new Error(`Port ${manualPort} is already in use by another process`);
|
|
54
|
+
}
|
|
55
|
+
assignedPort = manualPort;
|
|
56
|
+
registry.usedPorts.add(assignedPort);
|
|
57
|
+
} else {
|
|
58
|
+
assignedPort = await getNextAvailablePort(isLocalTarget(finalTarget));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
registry.routes.set(normalizedPath, {
|
|
62
|
+
port: assignedPort,
|
|
63
|
+
target: finalTarget,
|
|
64
|
+
name: name || normalizedPath,
|
|
65
|
+
healthCheck: healthCheck !== undefined ? healthCheck === true || healthCheck === 'true' : true,
|
|
66
|
+
registeredAt: new Date().toISOString()
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
console.log(`[REGISTER] ${normalizedPath} -> ${finalTarget}:${assignedPort} (${name || 'unnamed'}) [Health: ${healthCheck !== undefined ? healthCheck : 'default'}]`);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
success: true,
|
|
73
|
+
path: normalizedPath,
|
|
74
|
+
port: assignedPort,
|
|
75
|
+
target: finalTarget,
|
|
76
|
+
name: name || normalizedPath,
|
|
77
|
+
healthCheck: healthCheck !== undefined ? healthCheck === true || healthCheck === 'true' : true,
|
|
78
|
+
message: `Route registered. Target: ${finalTarget}:${assignedPort}`
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (assignedPort !== null) {
|
|
83
|
+
if (!registry.routes.has(normalizedPath) || registry.routes.get(normalizedPath).port !== assignedPort) {
|
|
84
|
+
releasePort(assignedPort);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw error;
|
|
88
|
+
} finally {
|
|
89
|
+
mutex.release();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* POST /proxy/register
|
|
95
|
+
* Enregistrement automatique (rétro-compatibilité)
|
|
96
|
+
*/
|
|
97
|
+
router.post('/proxy/register', requireLocalOnly, async (req, res) => {
|
|
98
|
+
const { path, name, target, healthCheck } = req.body;
|
|
99
|
+
|
|
100
|
+
if (!path) return res.status(400).json({ success: false, error: 'Path is required' });
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const result = await registerRouteInternal({ path, name, target, healthCheck, autoAssignPort: true });
|
|
104
|
+
res.json(result);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error('[REGISTER AUTO ERROR]', error.message);
|
|
107
|
+
const status = error.message.includes('already') ? 409 : 500;
|
|
108
|
+
res.status(status).json({ success: false, error: error.message });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* POST /proxy/register-manual
|
|
114
|
+
* Enregistrement manuel via l'interface graphique (port obligatoire)
|
|
115
|
+
*/
|
|
116
|
+
router.post('/proxy/register-manual', requireAuth, async (req, res) => {
|
|
117
|
+
const { path, name, port, target, healthCheck } = req.body;
|
|
118
|
+
|
|
119
|
+
if (!path) return res.status(400).json({ success: false, error: 'Path is required' });
|
|
120
|
+
if (!port) return res.status(400).json({ success: false, error: 'Port is required for manual registration' });
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const result = await registerRouteInternal({ path, name, port, target, healthCheck, autoAssignPort: false });
|
|
124
|
+
res.json(result);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error('[REGISTER MANUAL ERROR]', error.message);
|
|
127
|
+
const status = error.message.includes('already') ? 409 : 500;
|
|
128
|
+
res.status(status).json({ success: false, error: error.message });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* DELETE /proxy/unregister
|
|
134
|
+
* Supprime une route
|
|
135
|
+
*/
|
|
136
|
+
router.delete('/proxy/unregister', requireLocalOnly, async (req, res) => {
|
|
137
|
+
const { path } = req.body;
|
|
138
|
+
|
|
139
|
+
if (!path) {
|
|
140
|
+
return res.status(400).json({ success: false, error: 'Path is required' });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
144
|
+
|
|
145
|
+
await mutex.acquire();
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
if (!registry.routes.has(normalizedPath)) {
|
|
149
|
+
return res.status(404).json({ success: false, error: 'Path not found' });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const route = registry.routes.get(normalizedPath);
|
|
153
|
+
registry.routes.delete(normalizedPath);
|
|
154
|
+
registry.usedPorts.delete(route.port);
|
|
155
|
+
|
|
156
|
+
console.log(`[UNREGISTER] ${normalizedPath} (was on port ${route.port})`);
|
|
157
|
+
|
|
158
|
+
res.json({ success: true, path: normalizedPath, freedPort: route.port });
|
|
159
|
+
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error('[UNREGISTER ERROR]', error.message);
|
|
162
|
+
res.status(500).json({ success: false, error: error.message });
|
|
163
|
+
} finally {
|
|
164
|
+
mutex.release();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* GET /proxy/routes
|
|
170
|
+
* Liste toutes les routes enregistrées
|
|
171
|
+
*/
|
|
172
|
+
router.get('/proxy/routes', requireAuth, (req, res) => {
|
|
173
|
+
const routes = [];
|
|
174
|
+
registry.routes.forEach((value, path) => {
|
|
175
|
+
routes.push({
|
|
176
|
+
path,
|
|
177
|
+
port: value.port,
|
|
178
|
+
target: value.target || 'http://localhost',
|
|
179
|
+
name: value.name,
|
|
180
|
+
healthCheck: value.healthCheck,
|
|
181
|
+
registeredAt: value.registeredAt,
|
|
182
|
+
fullTarget: `${value.target || 'http://localhost'}:${value.port}`
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
res.json({
|
|
187
|
+
count: routes.length,
|
|
188
|
+
routes,
|
|
189
|
+
availablePorts: (INTERNAL_PORT_END - INTERNAL_PORT_START) - registry.usedPorts.size
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* POST /proxy/check
|
|
195
|
+
* Déclenche manuellement un health check pour une route spécifique
|
|
196
|
+
*/
|
|
197
|
+
router.post('/proxy/check', requireAuth, async (req, res) => {
|
|
198
|
+
const { path } = req.body;
|
|
199
|
+
|
|
200
|
+
if (!path) {
|
|
201
|
+
return res.status(400).json({ success: false, error: 'Path is required' });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
205
|
+
const route = registry.routes.get(normalizedPath);
|
|
206
|
+
|
|
207
|
+
if (!route) {
|
|
208
|
+
return res.status(404).json({ success: false, error: 'Path not found' });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log(`[MANUAL HEALTH CHECK] Checking ${normalizedPath}...`);
|
|
212
|
+
const isHealthy = await checkServerHealth(normalizedPath, route);
|
|
213
|
+
|
|
214
|
+
if (!isHealthy) {
|
|
215
|
+
await unregisterServer(normalizedPath);
|
|
216
|
+
return res.json({
|
|
217
|
+
success: true,
|
|
218
|
+
path: normalizedPath,
|
|
219
|
+
healthy: false,
|
|
220
|
+
message: 'Server was unhealthy and has been removed'
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
res.json({ success: true, path: normalizedPath, healthy: true, message: 'Server is healthy' });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
module.exports = router;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const { Router } = require('express');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { requireAuth } = require('../services/auth');
|
|
5
|
+
const { renderTerminal } = require('../views/terminal');
|
|
6
|
+
const { PROXY_NAME, TERMINAL_CWD, TERMINAL_COMMAND } = require('../config');
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
|
|
10
|
+
const IGNORED_DIRS = new Set(['.git', 'node_modules', '.next', 'dist', 'build', '__pycache__', '.cache', 'coverage']);
|
|
11
|
+
|
|
12
|
+
function listFilesRecursive(rootDir) {
|
|
13
|
+
const results = [];
|
|
14
|
+
const stack = [{ dir: rootDir, base: '', depth: 0 }];
|
|
15
|
+
while (stack.length) {
|
|
16
|
+
const { dir, base, depth } = stack.pop();
|
|
17
|
+
if (depth > 6) continue;
|
|
18
|
+
let entries;
|
|
19
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
if (entry.name.startsWith('.')) continue;
|
|
22
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
23
|
+
const rel = base ? base + '/' + entry.name : entry.name;
|
|
24
|
+
if (entry.isDirectory()) {
|
|
25
|
+
stack.push({ dir: path.join(dir, entry.name), base: rel, depth: depth + 1 });
|
|
26
|
+
} else {
|
|
27
|
+
results.push(rel);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return results;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fuzzyMatch(query, str) {
|
|
35
|
+
let qi = 0;
|
|
36
|
+
for (let i = 0; i < str.length && qi < query.length; i++) {
|
|
37
|
+
if (str[i] === query[qi]) qi++;
|
|
38
|
+
}
|
|
39
|
+
return qi === query.length;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* GET /proxy/files
|
|
44
|
+
* Liste récursive des fichiers de TERMINAL_CWD pour l'autocomplete @
|
|
45
|
+
*/
|
|
46
|
+
router.get('/proxy/files', requireAuth, (req, res) => {
|
|
47
|
+
const q = (req.query.q || '').toLowerCase();
|
|
48
|
+
try {
|
|
49
|
+
const all = listFilesRecursive(TERMINAL_CWD);
|
|
50
|
+
const filtered = q
|
|
51
|
+
? all.filter(f => fuzzyMatch(q, path.basename(f).toLowerCase())).slice(0, 20)
|
|
52
|
+
: all.slice(0, 20);
|
|
53
|
+
res.json(filtered);
|
|
54
|
+
} catch {
|
|
55
|
+
res.json([]);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* GET /proxy/terminal
|
|
61
|
+
* Page du terminal en ligne (xterm.js + WebSocket)
|
|
62
|
+
*/
|
|
63
|
+
router.get('/proxy/terminal', requireAuth, (req, res) => {
|
|
64
|
+
const html = renderTerminal(PROXY_NAME, TERMINAL_COMMAND);
|
|
65
|
+
res.type('html').send(html);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
module.exports = router;
|
package/services/auth.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// TODO: envisager un stockage Redis pour les sessions en production
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const { ADMIN_PASSWORD, SESSION_SECRET } = require('../config');
|
|
4
|
+
|
|
5
|
+
const SESSION_TTL = 24 * 60 * 60 * 1000; // 24h
|
|
6
|
+
const CLEANUP_INTERVAL = 60 * 60 * 1000; // 1h
|
|
7
|
+
|
|
8
|
+
/** Stockage des sessions actives en mémoire (token → createdAt timestamp) */
|
|
9
|
+
const activeSessions = new Map();
|
|
10
|
+
|
|
11
|
+
setInterval(() => {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
for (const [token, createdAt] of activeSessions) {
|
|
14
|
+
if (now - createdAt > SESSION_TTL) {
|
|
15
|
+
activeSessions.delete(token);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}, CLEANUP_INTERVAL);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse manuellement le header Cookie
|
|
22
|
+
*/
|
|
23
|
+
function parseCookies(req) {
|
|
24
|
+
const header = req.headers.cookie || '';
|
|
25
|
+
const cookies = {};
|
|
26
|
+
header.split(';').forEach(pair => {
|
|
27
|
+
const [name, ...rest] = pair.trim().split('=');
|
|
28
|
+
if (name) cookies[name.trim()] = decodeURIComponent(rest.join('=').trim());
|
|
29
|
+
});
|
|
30
|
+
return cookies;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Signe un token avec HMAC-SHA256
|
|
35
|
+
*/
|
|
36
|
+
function signToken(token) {
|
|
37
|
+
const signature = crypto.createHmac('sha256', SESSION_SECRET).update(token).digest('hex');
|
|
38
|
+
return `${token}.${signature}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Vérifie et extrait un token signé
|
|
43
|
+
*/
|
|
44
|
+
function verifySignedToken(signedValue) {
|
|
45
|
+
if (!signedValue || !signedValue.includes('.')) return null;
|
|
46
|
+
const lastDot = signedValue.lastIndexOf('.');
|
|
47
|
+
const token = signedValue.slice(0, lastDot);
|
|
48
|
+
const signature = signedValue.slice(lastDot + 1);
|
|
49
|
+
const expected = crypto.createHmac('sha256', SESSION_SECRET).update(token).digest('hex');
|
|
50
|
+
if (signature.length !== expected.length) return null;
|
|
51
|
+
try {
|
|
52
|
+
if (crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) {
|
|
53
|
+
return token;
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Comparaison sécurisée de deux chaînes (protection timing attack)
|
|
63
|
+
*/
|
|
64
|
+
function safeEqual(a, b) {
|
|
65
|
+
if (!a || !b) return false;
|
|
66
|
+
const bufA = Buffer.from(String(a));
|
|
67
|
+
const bufB = Buffer.from(String(b));
|
|
68
|
+
if (bufA.length !== bufB.length) return false;
|
|
69
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createSession() {
|
|
73
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
74
|
+
activeSessions.set(token, Date.now());
|
|
75
|
+
return token;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function destroySession(token) {
|
|
79
|
+
activeSessions.delete(token);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isValidSession(token) {
|
|
83
|
+
if (!token || !activeSessions.has(token)) return false;
|
|
84
|
+
const createdAt = activeSessions.get(token);
|
|
85
|
+
if (Date.now() - createdAt > SESSION_TTL) {
|
|
86
|
+
activeSessions.delete(token);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function setSessionCookie(res, token) {
|
|
93
|
+
const signed = signToken(token);
|
|
94
|
+
const secure = ADMIN_PASSWORD ? '; Secure' : '';
|
|
95
|
+
res.setHeader('Set-Cookie', `proxy_session=${encodeURIComponent(signed)}; HttpOnly; SameSite=Lax; Path=/${secure}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getSessionFromCookie(req) {
|
|
99
|
+
const cookies = parseCookies(req);
|
|
100
|
+
const signedValue = cookies['proxy_session'];
|
|
101
|
+
if (!signedValue) return null;
|
|
102
|
+
const token = verifySignedToken(signedValue);
|
|
103
|
+
if (!token) return null;
|
|
104
|
+
return isValidSession(token) ? token : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function clearSessionCookie(res) {
|
|
108
|
+
res.setHeader('Set-Cookie', 'proxy_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Middleware : restreint l'accès aux requêtes provenant de localhost uniquement
|
|
113
|
+
*/
|
|
114
|
+
function requireLocalOnly(req, res, next) {
|
|
115
|
+
const ip = req.ip || req.socket.remoteAddress;
|
|
116
|
+
const localAddresses = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];
|
|
117
|
+
if (!localAddresses.includes(ip)) {
|
|
118
|
+
return res.status(403).json({ success: false, error: 'Local access only' });
|
|
119
|
+
}
|
|
120
|
+
next();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Middleware : exige une session admin valide
|
|
125
|
+
* Si ADMIN_PASSWORD n'est pas configuré, la route reste ouverte (rétrocompatibilité)
|
|
126
|
+
*/
|
|
127
|
+
function requireAuth(req, res, next) {
|
|
128
|
+
if (!ADMIN_PASSWORD) return next();
|
|
129
|
+
const sessionToken = getSessionFromCookie(req);
|
|
130
|
+
if (sessionToken) return next();
|
|
131
|
+
const acceptHeader = req.get('Accept') || '';
|
|
132
|
+
if (acceptHeader.includes('text/html') || !acceptHeader.includes('application/json')) {
|
|
133
|
+
return res.redirect('/proxy/login');
|
|
134
|
+
}
|
|
135
|
+
return res.status(401).json({ success: false, error: 'Authentication required' });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
parseCookies,
|
|
140
|
+
signToken,
|
|
141
|
+
verifySignedToken,
|
|
142
|
+
safeEqual,
|
|
143
|
+
createSession,
|
|
144
|
+
destroySession,
|
|
145
|
+
isValidSession,
|
|
146
|
+
setSessionCookie,
|
|
147
|
+
getSessionFromCookie,
|
|
148
|
+
clearSessionCookie,
|
|
149
|
+
requireLocalOnly,
|
|
150
|
+
requireAuth,
|
|
151
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// NOTE: le timeout est appliqué globalement, pas par route individuelle
|
|
2
|
+
const { HEALTH_CHECK_INTERVAL, HEALTH_CHECK_TIMEOUT, HEALTH_CHECK_GRACE_PERIOD } = require('../config');
|
|
3
|
+
const { registry, mutex } = require('./registry');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Vérifie la santé d'un serveur enregistré
|
|
7
|
+
*/
|
|
8
|
+
async function checkServerHealth(path, route) {
|
|
9
|
+
const target = route.target || 'http://localhost';
|
|
10
|
+
const healthUrl = `${target}:${route.port}/proxy/health`;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
|
|
15
|
+
const response = await fetch(healthUrl, { method: 'GET', signal: controller.signal });
|
|
16
|
+
clearTimeout(timeoutId);
|
|
17
|
+
return response.status === 200;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Désenregistre un serveur via le mutex
|
|
25
|
+
*/
|
|
26
|
+
async function unregisterServer(path, reason = 'unhealthy') {
|
|
27
|
+
await mutex.acquire();
|
|
28
|
+
try {
|
|
29
|
+
const route = registry.routes.get(path);
|
|
30
|
+
if (route) {
|
|
31
|
+
registry.routes.delete(path);
|
|
32
|
+
registry.usedPorts.delete(route.port);
|
|
33
|
+
console.log(`[UNREGISTER] ${path} (was on port ${route.port}) - reason: ${reason}`);
|
|
34
|
+
}
|
|
35
|
+
} finally {
|
|
36
|
+
mutex.release();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Vérifie si un serveur est encore dans sa période de grâce
|
|
42
|
+
*/
|
|
43
|
+
function isInGracePeriod(route) {
|
|
44
|
+
const registeredAt = new Date(route.registeredAt).getTime();
|
|
45
|
+
return (Date.now() - registeredAt) < HEALTH_CHECK_GRACE_PERIOD;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Effectue un health check sur tous les serveurs enregistrés
|
|
50
|
+
*/
|
|
51
|
+
async function performHealthChecks() {
|
|
52
|
+
if (registry.routes.size === 0) return;
|
|
53
|
+
|
|
54
|
+
const checks = [];
|
|
55
|
+
let skippedCount = 0;
|
|
56
|
+
|
|
57
|
+
for (const [path, route] of registry.routes.entries()) {
|
|
58
|
+
if (route.healthCheck === false) continue;
|
|
59
|
+
if (isInGracePeriod(route)) { skippedCount++; continue; }
|
|
60
|
+
checks.push(
|
|
61
|
+
checkServerHealth(path, route).then(isHealthy => ({ path, route, isHealthy }))
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (checks.length === 0) {
|
|
66
|
+
if (skippedCount > 0) {
|
|
67
|
+
console.log(`[HEALTH CHECK] ${skippedCount} server(s) in grace period, skipping checks`);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(`[HEALTH CHECK] Checking ${checks.length} server(s)...${skippedCount > 0 ? ` (${skippedCount} in grace period)` : ''}`);
|
|
73
|
+
|
|
74
|
+
const results = await Promise.all(checks);
|
|
75
|
+
|
|
76
|
+
for (const { path, isHealthy } of results) {
|
|
77
|
+
if (!isHealthy) await unregisterServer(path, 'failed health check');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const healthyCount = results.filter(r => r.isHealthy).length;
|
|
81
|
+
const unhealthyCount = results.length - healthyCount;
|
|
82
|
+
if (unhealthyCount > 0) {
|
|
83
|
+
console.log(`[HEALTH CHECK] Complete: ${healthyCount} healthy, ${unhealthyCount} removed`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Démarre le polling de health check
|
|
89
|
+
*/
|
|
90
|
+
function startHealthCheckPolling() {
|
|
91
|
+
console.log(`[HEALTH CHECK] Starting polling every ${HEALTH_CHECK_INTERVAL}ms`);
|
|
92
|
+
setInterval(performHealthChecks, HEALTH_CHECK_INTERVAL);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { checkServerHealth, unregisterServer, isInGracePeriod, performHealthChecks, startHealthCheckPolling };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const net = require('net');
|
|
2
|
+
const { INTERNAL_PORT_START, INTERNAL_PORT_END } = require('../config');
|
|
3
|
+
const { registry } = require('./registry');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Vérifie si le target pointe vers la machine locale
|
|
7
|
+
*/
|
|
8
|
+
function isLocalTarget(target) {
|
|
9
|
+
try {
|
|
10
|
+
const { hostname } = new URL(target);
|
|
11
|
+
return ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname);
|
|
12
|
+
} catch {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Vérifie si un port est réellement libre au niveau OS
|
|
19
|
+
*/
|
|
20
|
+
function isPortFree(port) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const server = net.createServer();
|
|
23
|
+
server.once('error', () => resolve(false));
|
|
24
|
+
server.once('listening', () => {
|
|
25
|
+
server.close(() => resolve(true));
|
|
26
|
+
});
|
|
27
|
+
server.listen(port);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Trouve le prochain port disponible et le réserve atomiquement
|
|
33
|
+
* DOIT être appelé avec le mutex acquis
|
|
34
|
+
*/
|
|
35
|
+
async function getNextAvailablePort(checkSystem = true) {
|
|
36
|
+
while (true) {
|
|
37
|
+
while (registry.usedPorts.has(registry.nextPort)) {
|
|
38
|
+
registry.nextPort++;
|
|
39
|
+
if (registry.nextPort > INTERNAL_PORT_END) {
|
|
40
|
+
registry.nextPort = INTERNAL_PORT_START;
|
|
41
|
+
if (registry.usedPorts.size >= (INTERNAL_PORT_END - INTERNAL_PORT_START)) {
|
|
42
|
+
throw new Error('No available ports');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const port = registry.nextPort;
|
|
48
|
+
|
|
49
|
+
if (!checkSystem || await isPortFree(port)) {
|
|
50
|
+
registry.usedPorts.add(port);
|
|
51
|
+
registry.nextPort++;
|
|
52
|
+
return port;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(`[PORT] Port ${port} is in use by another process, skipping...`);
|
|
56
|
+
registry.usedPorts.add(port);
|
|
57
|
+
registry.nextPort++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Libère un port réservé (en cas d'échec d'enregistrement)
|
|
63
|
+
*/
|
|
64
|
+
function releasePort(port) {
|
|
65
|
+
registry.usedPorts.delete(port);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Trouve la route correspondant à un chemin de requête
|
|
70
|
+
* Tri par longueur décroissante pour matcher le plus spécifique d'abord
|
|
71
|
+
*/
|
|
72
|
+
function findRouteForPath(requestPath) {
|
|
73
|
+
const sortedPaths = [...registry.routes.keys()].sort((a, b) => b.length - a.length);
|
|
74
|
+
for (const routePath of sortedPaths) {
|
|
75
|
+
if (requestPath.startsWith(routePath)) {
|
|
76
|
+
return registry.routes.get(routePath);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { isLocalTarget, isPortFree, getNextAvailablePort, releasePort, findRouteForPath };
|