@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,99 @@
|
|
|
1
|
+
const { createProxyMiddleware } = require('http-proxy-middleware');
|
|
2
|
+
const { registry } = require('./registry');
|
|
3
|
+
const { findRouteForPath } = require('./portManager');
|
|
4
|
+
const { unregisterServer } = require('./healthCheck');
|
|
5
|
+
const { requireAuth } = require('./auth');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Échappe les caractères HTML pour éviter les injections XSS
|
|
9
|
+
*/
|
|
10
|
+
function escapeHtml(text) {
|
|
11
|
+
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
12
|
+
return String(text).replace(/[&<>"']/g, char => map[char]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Middleware de proxy persistant — créé une seule fois pour éviter les fuites mémoire
|
|
17
|
+
*/
|
|
18
|
+
const persistentProxyMiddleware = createProxyMiddleware({
|
|
19
|
+
target: 'http://localhost',
|
|
20
|
+
router: (req) => req.proxyTarget,
|
|
21
|
+
changeOrigin: true,
|
|
22
|
+
pathRewrite: (path, req) => {
|
|
23
|
+
const routePath = req.proxyRoutePath;
|
|
24
|
+
if (routePath && path.startsWith(routePath)) {
|
|
25
|
+
return path.slice(routePath.length) || '/';
|
|
26
|
+
}
|
|
27
|
+
return path;
|
|
28
|
+
},
|
|
29
|
+
on: {
|
|
30
|
+
proxyReq: (proxyReq, req) => {
|
|
31
|
+
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) {
|
|
32
|
+
const bodyData = JSON.stringify(req.body);
|
|
33
|
+
proxyReq.setHeader('Content-Type', 'application/json');
|
|
34
|
+
proxyReq.setHeader('Content-Length', Buffer.byteLength(bodyData));
|
|
35
|
+
proxyReq.write(bodyData);
|
|
36
|
+
}
|
|
37
|
+
console.log(`[PROXY] ${req.method} ${req.path} -> ${req.proxyTarget}`);
|
|
38
|
+
},
|
|
39
|
+
error: (err, req, res) => {
|
|
40
|
+
if (res.headersSent) return;
|
|
41
|
+
console.error(`[PROXY ERROR] ${req.path}:`, err.message);
|
|
42
|
+
|
|
43
|
+
if (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT') {
|
|
44
|
+
const route = findRouteForPath(req.path);
|
|
45
|
+
if (route) {
|
|
46
|
+
const routePath = [...registry.routes.entries()].find(([, v]) => v === route)?.[0];
|
|
47
|
+
if (routePath) {
|
|
48
|
+
unregisterServer(routePath, `connection error (${err.code})`).catch(() => {});
|
|
49
|
+
console.log(`[PROXY] Auto-unregistered ${routePath} due to connection error`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
res.status(502).json({ error: 'Proxy error' });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Middleware principal de routage vers le proxy
|
|
61
|
+
* Inclut le Smart Routing (Referer Fallback)
|
|
62
|
+
*/
|
|
63
|
+
function routingMiddleware(req, res, next) {
|
|
64
|
+
if (req.path.startsWith('/proxy/')) return next();
|
|
65
|
+
|
|
66
|
+
requireAuth(req, res, () => {
|
|
67
|
+
let route = findRouteForPath(req.path);
|
|
68
|
+
|
|
69
|
+
if (!route && req.get('Referer')) {
|
|
70
|
+
try {
|
|
71
|
+
const refererUrl = new URL(req.get('Referer'));
|
|
72
|
+
if (refererUrl.host === req.get('host')) {
|
|
73
|
+
const refererPath = refererUrl.pathname;
|
|
74
|
+
route = findRouteForPath(refererPath);
|
|
75
|
+
if (route) {
|
|
76
|
+
console.log(`[SMART ROUTE] Inferred ${route.name} from referer: ${refererPath}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// URL de referer invalide, on ignore
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!route) {
|
|
85
|
+
return res.status(404).json({
|
|
86
|
+
error: 'No route registered for this path',
|
|
87
|
+
path: req.path,
|
|
88
|
+
hint: 'Register a route with POST /proxy/register or use the UI'
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const target = route.target || 'http://localhost';
|
|
93
|
+
req.proxyTarget = `${target}:${route.port}`;
|
|
94
|
+
req.proxyRoutePath = [...registry.routes.entries()].find(([, v]) => v === route)?.[0];
|
|
95
|
+
persistentProxyMiddleware(req, res, next);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { persistentProxyMiddleware, routingMiddleware, escapeHtml };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { INTERNAL_PORT_START } = require('../config');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Simple mutex pour sérialiser les opérations critiques
|
|
5
|
+
* Évite les race conditions lors d'inscriptions simultanées
|
|
6
|
+
*/
|
|
7
|
+
class Mutex {
|
|
8
|
+
constructor() {
|
|
9
|
+
this._locked = false;
|
|
10
|
+
this._queue = [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async acquire() {
|
|
14
|
+
if (!this._locked) {
|
|
15
|
+
this._locked = true;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
return new Promise(resolve => {
|
|
19
|
+
this._queue.push(resolve);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
release() {
|
|
24
|
+
if (this._queue.length > 0) {
|
|
25
|
+
const next = this._queue.shift();
|
|
26
|
+
next();
|
|
27
|
+
} else {
|
|
28
|
+
this._locked = false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const registry = {
|
|
34
|
+
routes: new Map(), // path -> { port, target, name, healthCheck, registeredAt }
|
|
35
|
+
usedPorts: new Set(), // Ports déjà attribués
|
|
36
|
+
nextPort: INTERNAL_PORT_START
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const mutex = new Mutex();
|
|
40
|
+
|
|
41
|
+
module.exports = { registry, mutex };
|
|
@@ -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 };
|