@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/proxy.js ADDED
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+
3
+ const path = require('path');
4
+ require('dotenv').config({ path: path.join(__dirname, '.env') });
5
+
6
+ const express = require('express');
7
+ const helmet = require('helmet');
8
+ const rateLimit = require('express-rate-limit');
9
+ const { setupLogging } = require('./logger');
10
+
11
+ setupLogging('PROXY');
12
+
13
+ // Config — doit être importé après dotenv
14
+ const {
15
+ PROXY_PORT,
16
+ PROXY_NAME,
17
+ INTERNAL_PORT_START,
18
+ INTERNAL_PORT_END,
19
+ HEALTH_CHECK_INTERVAL,
20
+ HEALTH_CHECK_GRACE_PERIOD,
21
+ ADMIN_PASSWORD,
22
+ TERMINAL_CWD,
23
+ TERMINAL_COMMAND,
24
+ } = require('./config');
25
+
26
+ if (ADMIN_PASSWORD && !process.env.PROXY_SESSION_SECRET) {
27
+ console.error('FATAL: PROXY_SESSION_SECRET must be set when ADMIN_PASSWORD is configured.');
28
+ console.error('Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
29
+ process.exit(1);
30
+ }
31
+
32
+ // Services
33
+ const { startHealthCheckPolling } = require('./services/healthCheck');
34
+ const { routingMiddleware } = require('./services/proxyRouter');
35
+ const { parseCookies, verifySignedToken, isValidSession } = require('./services/auth');
36
+
37
+ // Routes
38
+ const registrationRoutes = require('./routes/registration');
39
+ const metaConfigRoutes = require('./routes/metaConfig');
40
+ const terminalRoutes = require('./routes/terminal');
41
+ const dashboardRoutes = require('./routes/dashboard');
42
+
43
+ const app = express();
44
+
45
+ app.use(helmet({
46
+ contentSecurityPolicy: {
47
+ directives: {
48
+ defaultSrc: ["'self'"],
49
+ scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.tailwindcss.com", "https://cdn.jsdelivr.net"],
50
+ scriptSrcAttr: ["'unsafe-inline'"],
51
+ styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"],
52
+ connectSrc: ["'self'", "ws:", "wss:"],
53
+ imgSrc: ["'self'", "data:", "https://i.ytimg.com"],
54
+ },
55
+ },
56
+ hsts: ADMIN_PASSWORD ? { maxAge: 31536000, includeSubDomains: true } : false,
57
+ referrerPolicy: { policy: 'same-origin' },
58
+ }));
59
+
60
+ const isLocalRequest = (req) => {
61
+ const ip = req.ip || req.connection?.remoteAddress || '';
62
+ return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
63
+ };
64
+
65
+ const loginLimiter = rateLimit({
66
+ windowMs: 15 * 60 * 1000,
67
+ max: 10,
68
+ message: { success: false, error: 'Too many login attempts, try again later' },
69
+ standardHeaders: true,
70
+ legacyHeaders: false,
71
+ skip: isLocalRequest,
72
+ });
73
+ app.use('/proxy/login', loginLimiter);
74
+
75
+ const globalLimiter = rateLimit({
76
+ windowMs: 60 * 1000,
77
+ max: 300,
78
+ standardHeaders: true,
79
+ legacyHeaders: false,
80
+ skip: isLocalRequest,
81
+ });
82
+ app.use(globalLimiter);
83
+
84
+ app.use(express.json({ limit: '1mb' }));
85
+ app.use(express.urlencoded({ limit: '1mb', extended: false }));
86
+
87
+ // Routes (ordre important : spécifiques avant le catch-all)
88
+ app.use(dashboardRoutes);
89
+ app.use(registrationRoutes);
90
+ app.use(metaConfigRoutes);
91
+ app.use(terminalRoutes);
92
+
93
+ // Middleware proxy (catch-all, doit être en dernier)
94
+ app.use(routingMiddleware);
95
+
96
+ // ============================================
97
+ // DÉMARRAGE DU SERVEUR
98
+ // ============================================
99
+
100
+ const server = app.listen(PROXY_PORT, () => {
101
+ console.log('='.repeat(50));
102
+ console.log(`🚀 ${PROXY_NAME}`);
103
+ console.log('='.repeat(50));
104
+ console.log(`Listening on port ${PROXY_PORT}`);
105
+ console.log(`Internal ports range: ${INTERNAL_PORT_START}-${INTERNAL_PORT_END}`);
106
+ console.log(`Health check interval: ${HEALTH_CHECK_INTERVAL}ms`);
107
+ console.log(`Health check grace period: ${HEALTH_CHECK_GRACE_PERIOD}ms`);
108
+ console.log('');
109
+
110
+ if (!ADMIN_PASSWORD) {
111
+ console.log('⚠️ WARNING: ADMIN_PASSWORD is not set — web interface is unprotected');
112
+ } else {
113
+ console.log('🔒 Web interface protected by admin password');
114
+ console.log('🔒 Session secret configured');
115
+ }
116
+ console.log('🔒 API routes restricted to localhost only');
117
+ console.log('');
118
+
119
+ console.log('API Endpoints:');
120
+ console.log(' POST /proxy/register-auto - Register a new route (auto port)');
121
+ console.log(' POST /proxy/register-manual - Register a new route (manual port)');
122
+ console.log(' DELETE /proxy/unregister - Remove a route');
123
+ console.log(' GET /proxy/routes - List all routes');
124
+ console.log(' GET /proxy/health - Health check');
125
+ console.log(' GET /proxy/login - Admin login page');
126
+ console.log(' GET /proxy/terminal - Agent terminal');
127
+ console.log('');
128
+ console.log('Note: Registered servers must implement GET /proxy/health');
129
+ console.log(' returning status 200 to stay registered.');
130
+ console.log('='.repeat(50));
131
+
132
+ // Terminal en ligne
133
+ const { TerminalServer } = require('./terminal-server');
134
+ new TerminalServer(server, {
135
+ path: '/terminal-ws',
136
+ command: TERMINAL_COMMAND,
137
+ ptyOptions: {
138
+ cwd: TERMINAL_CWD,
139
+ },
140
+ wsOptions: {
141
+ verifyClient: (info) => {
142
+ if (ADMIN_PASSWORD) {
143
+ const origin = info.origin || info.req.headers.origin;
144
+ const host = info.req.headers.host;
145
+ if (origin) {
146
+ try {
147
+ const originHost = new URL(origin).host;
148
+ if (originHost !== host) return false;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
154
+ const cookies = parseCookies(info.req);
155
+ const signedValue = cookies['proxy_session'];
156
+ if (!signedValue) return false;
157
+ const token = verifySignedToken(signedValue);
158
+ return token ? isValidSession(token) : false;
159
+ }
160
+ return true;
161
+ },
162
+ },
163
+ });
164
+ console.log(`🖥️ Agent terminal at /proxy/terminal (cwd: ${TERMINAL_CWD}, command: ${TERMINAL_COMMAND || 'none'})`);
165
+
166
+ startHealthCheckPolling();
167
+ });
168
+
169
+ module.exports = { app };
@@ -0,0 +1,152 @@
1
+ const { Router } = require('express');
2
+ const { exec } = require('child_process');
3
+ const { requireAuth } = require('../services/auth');
4
+ const { safeEqual, createSession, setSessionCookie, destroySession, clearSessionCookie, getSessionFromCookie } = require('../services/auth');
5
+ const { registry } = require('../services/registry');
6
+ const { renderLogin } = require('../views/login');
7
+ const { renderDashboard } = require('../views/dashboard');
8
+ const { PROXY_NAME, PROXY_PORT, ADMIN_PASSWORD, INTERNAL_PORT_START, INTERNAL_PORT_END, OPENCODE_CLEAN_UP, TERMINAL_CWD } = require('../config');
9
+
10
+ const router = Router();
11
+
12
+ /**
13
+ * GET /proxy/health
14
+ * Health check du proxy — info minimale sans auth, complète avec auth
15
+ */
16
+ router.get('/proxy/health', (req, res) => {
17
+ if (!ADMIN_PASSWORD || getSessionFromCookie(req)) {
18
+ return res.json({
19
+ name: PROXY_NAME,
20
+ status: 'healthy',
21
+ uptime: process.uptime(),
22
+ registeredRoutes: registry.routes.size,
23
+ usedPorts: registry.usedPorts.size
24
+ });
25
+ }
26
+ res.json({ status: 'healthy' });
27
+ });
28
+
29
+ /**
30
+ * GET /proxy/login
31
+ * Page de connexion administrateur
32
+ */
33
+ router.get('/proxy/login', (req, res) => {
34
+ const error = req.query.error === '1';
35
+ res.type('html').send(renderLogin(PROXY_NAME, error));
36
+ });
37
+
38
+ /**
39
+ * POST /proxy/login
40
+ * Traitement du formulaire de connexion
41
+ */
42
+ router.post('/proxy/login', (req, res) => {
43
+ const { password } = req.body;
44
+
45
+ if (ADMIN_PASSWORD && safeEqual(password, ADMIN_PASSWORD)) {
46
+ const token = createSession();
47
+ setSessionCookie(res, token);
48
+ return res.redirect('/');
49
+ }
50
+
51
+ res.redirect('/proxy/login?error=1');
52
+ });
53
+
54
+ /**
55
+ * POST /proxy/logout
56
+ * Déconnexion — détruit la session et redirige vers /proxy/login
57
+ */
58
+ router.post('/proxy/logout', (req, res) => {
59
+ const sessionToken = getSessionFromCookie(req);
60
+ if (sessionToken) {
61
+ destroySession(sessionToken);
62
+ }
63
+ clearSessionCookie(res);
64
+ res.redirect('/proxy/login');
65
+ });
66
+
67
+ /**
68
+ * GET /
69
+ * Dashboard — retourne HTML ou JSON selon l'en-tête Accept
70
+ */
71
+ router.get('/', requireAuth, (req, res) => {
72
+ const routes = [];
73
+ registry.routes.forEach((value, path) => {
74
+ routes.push({
75
+ path,
76
+ port: value.port,
77
+ target: value.target || 'http://localhost',
78
+ name: value.name,
79
+ healthCheck: value.healthCheck,
80
+ registeredAt: value.registeredAt,
81
+ fullTarget: `${value.target || 'http://localhost'}:${value.port}`
82
+ });
83
+ });
84
+
85
+ routes.sort((a, b) => a.name.localeCompare(b.name));
86
+
87
+ const acceptHeader = req.get('Accept') || '';
88
+ const wantsJson = acceptHeader.includes('application/json') ||
89
+ (acceptHeader.includes('*/*') && !acceptHeader.includes('text/html'));
90
+
91
+ if (wantsJson) {
92
+ return res.json({
93
+ name: PROXY_NAME,
94
+ status: 'healthy',
95
+ uptime: process.uptime(),
96
+ count: routes.length,
97
+ routes,
98
+ availablePorts: (INTERNAL_PORT_END - INTERNAL_PORT_START) - registry.usedPorts.size
99
+ });
100
+ }
101
+
102
+ const availablePorts = (INTERNAL_PORT_END - INTERNAL_PORT_START) - registry.usedPorts.size;
103
+ const html = renderDashboard(routes, availablePorts, PROXY_NAME, !!ADMIN_PASSWORD, OPENCODE_CLEAN_UP);
104
+ res.type('html').send(html);
105
+ });
106
+
107
+ /**
108
+ * POST /proxy/cleanup
109
+ * Exécute la commande de nettoyage des processus (OPENCODE_CLEAN_UP)
110
+ */
111
+ router.post('/proxy/cleanup', requireAuth, (req, res) => {
112
+ if (!OPENCODE_CLEAN_UP) {
113
+ return res.status(400).json({ error: 'OPENCODE_CLEAN_UP non configuré' });
114
+ }
115
+ console.log(`[CLEANUP] CWD: ${TERMINAL_CWD}`);
116
+ console.log(`[CLEANUP] Executing: ${OPENCODE_CLEAN_UP}`);
117
+ exec(OPENCODE_CLEAN_UP, { shell: true, cwd: TERMINAL_CWD }, (err, stdout, stderr) => {
118
+ if (stdout) console.log(`[CLEANUP] stdout: ${stdout.trim()}`);
119
+ if (stderr) console.log(`[CLEANUP] stderr: ${stderr.trim()}`);
120
+ if (err) {
121
+ console.log(`[CLEANUP] Error: code=${err.code}, signal=${err.signal}, message=${err.message}`);
122
+ } else {
123
+ console.log('[CLEANUP] Done (exit code: 0)');
124
+ }
125
+ });
126
+ res.json({ ok: true });
127
+ });
128
+
129
+ /**
130
+ * POST /proxy/kill-port
131
+ * Tue le process écoutant sur un port donné (cross-platform)
132
+ */
133
+ router.post('/proxy/kill-port', requireAuth, (req, res) => {
134
+ const port = parseInt(req.body.port, 10);
135
+ if (!port || port < 1 || port > 65535) {
136
+ return res.status(400).json({ error: 'Port invalide' });
137
+ }
138
+ const cmd = process.platform === 'win32'
139
+ ? `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Where-Object { $_.OwningProcess -ne 0 } | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }`
140
+ : `fuser -k ${port}/tcp`;
141
+ const options = process.platform === 'win32'
142
+ ? { shell: 'powershell.exe' }
143
+ : { shell: true };
144
+ exec(cmd, options, (err, stdout, stderr) => {
145
+ if (stdout) console.log(`[KILL-PORT] stdout: ${stdout.trim()}`);
146
+ if (stderr) console.log(`[KILL-PORT] stderr: ${stderr.trim()}`);
147
+ if (err) console.log(`[KILL-PORT] Error: ${err.message}`);
148
+ });
149
+ res.json({ ok: true });
150
+ });
151
+
152
+ module.exports = router;
@@ -0,0 +1,180 @@
1
+ const { Router } = require('express');
2
+ const metaConfig = require('../meta-config');
3
+ const { requireAuth } = require('../services/auth');
4
+ const { registry } = require('../services/registry');
5
+ const { renderMetaConfigs } = require('../views/metaConfigs');
6
+ const { PROXY_NAME } = require('../config');
7
+
8
+ const router = Router();
9
+
10
+ /**
11
+ * GET /proxy/mcp-configurations
12
+ * Découvre les configurations disponibles auprès de tous les MCPs enregistrés
13
+ */
14
+ router.get('/proxy/mcp-configurations', requireAuth, async (req, res) => {
15
+ try {
16
+ const results = await metaConfig.discoverConfigurations(registry.routes);
17
+ const compatible = results.filter(r => r.configurations !== null);
18
+ const incompatible = results.filter(r => r.configurations === null);
19
+
20
+ res.json({ success: true, compatible: compatible.length, incompatible: incompatible.length, results });
21
+ } catch (error) {
22
+ console.error('[META-CONFIG] Discovery error:', error.message);
23
+ res.status(500).json({ success: false, error: error.message });
24
+ }
25
+ });
26
+
27
+ /**
28
+ * GET /proxy/meta-configs
29
+ * Liste toutes les méta-configurations sauvegardées
30
+ */
31
+ router.get('/proxy/meta-configs', requireAuth, (req, res) => {
32
+ const data = metaConfig.getAll();
33
+ res.json({ success: true, metaConfigs: data.metaConfigs, activeMetaConfig: data.activeMetaConfig });
34
+ });
35
+
36
+ /**
37
+ * POST /proxy/meta-configs
38
+ * Crée une nouvelle méta-configuration
39
+ */
40
+ router.post('/proxy/meta-configs', requireAuth, (req, res) => {
41
+ const { id, name, description, mappings } = req.body;
42
+
43
+ if (!id || !name) {
44
+ return res.status(400).json({ success: false, error: 'id and name are required' });
45
+ }
46
+
47
+ try {
48
+ const created = metaConfig.create(id, name, description, mappings);
49
+ console.log(`[META-CONFIG] Created: ${id}`);
50
+ res.json({ success: true, metaConfig: { id, ...created } });
51
+ } catch (error) {
52
+ const status = error.message.includes('already exists') ? 409 : 500;
53
+ res.status(status).json({ success: false, error: error.message });
54
+ }
55
+ });
56
+
57
+ /**
58
+ * PUT /proxy/meta-configs/:id
59
+ * Met à jour une méta-configuration existante
60
+ */
61
+ router.put('/proxy/meta-configs/:id', requireAuth, (req, res) => {
62
+ const { id } = req.params;
63
+ const { name, description, mappings } = req.body;
64
+
65
+ try {
66
+ const updated = metaConfig.update(id, { name, description, mappings });
67
+ console.log(`[META-CONFIG] Updated: ${id}`);
68
+ res.json({ success: true, metaConfig: { id, ...updated } });
69
+ } catch (error) {
70
+ const status = error.message.includes('not found') ? 404 : 500;
71
+ res.status(status).json({ success: false, error: error.message });
72
+ }
73
+ });
74
+
75
+ /**
76
+ * DELETE /proxy/meta-configs/:id
77
+ * Supprime une méta-configuration
78
+ */
79
+ router.delete('/proxy/meta-configs/:id', requireAuth, (req, res) => {
80
+ const { id } = req.params;
81
+
82
+ try {
83
+ metaConfig.remove(id);
84
+ console.log(`[META-CONFIG] Deleted: ${id}`);
85
+ res.json({ success: true, deleted: id });
86
+ } catch (error) {
87
+ const status = error.message.includes('not found') ? 404 : 500;
88
+ res.status(status).json({ success: false, error: error.message });
89
+ }
90
+ });
91
+
92
+ /**
93
+ * POST /proxy/meta-configs/:id/apply
94
+ * Applique une méta-configuration
95
+ */
96
+ router.post('/proxy/meta-configs/:id/apply', requireAuth, async (req, res) => {
97
+ const { id } = req.params;
98
+
99
+ try {
100
+ const result = await metaConfig.applyMetaConfig(id, registry.routes);
101
+ console.log(`[META-CONFIG] Applied "${id}": ${result.success ? 'all OK' : 'some failures'}`);
102
+ res.json({ success: result.success, warnings: result.warnings, metaConfigId: id, results: result.results });
103
+ } catch (error) {
104
+ const status = error.message.includes('not found') ? 404 : 500;
105
+ res.status(status).json({ success: false, error: error.message });
106
+ }
107
+ });
108
+
109
+ /**
110
+ * GET /proxy/meta-configs/active/sync
111
+ * Vérifie si les configurations actives des MCPs correspondent à la méta-config active
112
+ */
113
+ router.get('/proxy/meta-configs/active/sync', requireAuth, async (req, res) => {
114
+ const activeId = metaConfig.getActive();
115
+ console.log(`[META-CONFIG SYNC] activeId = ${JSON.stringify(activeId)}`);
116
+
117
+ if (!activeId) {
118
+ return res.json({ synced: true, activeMetaConfigId: null });
119
+ }
120
+
121
+ const activeCfg = metaConfig.getById(activeId);
122
+ console.log(`[META-CONFIG SYNC] mappings = ${JSON.stringify(activeCfg?.mappings)}`);
123
+ if (!activeCfg) {
124
+ return res.json({ synced: true, activeMetaConfigId: null });
125
+ }
126
+
127
+ try {
128
+ const results = await metaConfig.discoverConfigurations(registry.routes);
129
+ console.log(`[META-CONFIG SYNC] discoverConfigurations results:`);
130
+ results.forEach(r => {
131
+ if (r.configurations) {
132
+ const active = r.configurations.find(c => c.active);
133
+ console.log(` ${r.path} → configs: [${r.configurations.map(c => `${c.id}${c.active ? '*' : ''}`).join(', ')}] — active: ${JSON.stringify(active?.id ?? null)}`);
134
+ } else {
135
+ console.log(` ${r.path} → error: ${r.error}`);
136
+ }
137
+ });
138
+
139
+ const conflicts = [];
140
+
141
+ for (const [mcpPath, expectedConfigId] of Object.entries(activeCfg.mappings)) {
142
+ const mcpResult = results.find(r => r.path === mcpPath);
143
+ if (!mcpResult) {
144
+ console.log(` [META-CONFIG SYNC] ${mcpPath} → MCP non trouvé dans les résultats, ignoré`);
145
+ continue;
146
+ }
147
+ if (!mcpResult.configurations) {
148
+ console.log(` [META-CONFIG SYNC] ${mcpPath} → pas de configurations (erreur: ${mcpResult.error}), ignoré`);
149
+ continue;
150
+ }
151
+
152
+ const activeConfig = mcpResult.configurations.find(c => c.active);
153
+ const actualConfigId = activeConfig ? activeConfig.id : null;
154
+
155
+ console.log(` [META-CONFIG SYNC] ${mcpPath} → attendu: ${JSON.stringify(expectedConfigId)} | actuel: ${JSON.stringify(actualConfigId)} | match: ${actualConfigId === expectedConfigId}`);
156
+
157
+ if (actualConfigId !== expectedConfigId) {
158
+ conflicts.push({ path: mcpPath, expected: expectedConfigId, actual: actualConfigId });
159
+ }
160
+ }
161
+
162
+ console.log(`[META-CONFIG SYNC] synced=${conflicts.length === 0}, conflicts=${JSON.stringify(conflicts)}`);
163
+ res.json({ synced: conflicts.length === 0, activeMetaConfigId: activeId, conflicts });
164
+ } catch (error) {
165
+ console.error('[META-CONFIG SYNC] Erreur:', error.message);
166
+ res.status(500).json({ success: false, error: error.message });
167
+ }
168
+ });
169
+
170
+ /**
171
+ * GET /proxy/meta-configs-ui
172
+ * Interface de gestion des méta-configurations
173
+ */
174
+ router.get('/proxy/meta-configs-ui', requireAuth, (req, res) => {
175
+ const data = metaConfig.getAll();
176
+ const html = renderMetaConfigs(PROXY_NAME, data.metaConfigs, data.activeMetaConfig);
177
+ res.type('html').send(html);
178
+ });
179
+
180
+ module.exports = router;