Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
285 lines (252 loc) Β· 9.91 KB
/
Copy pathserver.js
File metadata and controls
285 lines (252 loc) Β· 9.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
// === NgheoAuto Server β Entry Point ===
const logger = require('./src/logger'); // MUST be first β intercepts console.* globally
const express = require('express');
const http = require('http');
const crypto = require('crypto');
const { WebSocketServer } = require('ws');
const { PORT } = require('./src/config');
const { init, startAutoRescan } = require('./src/detector');
const { setupRoutes } = require('./src/routes');
const { setupWebSocket, startPolling } = require('./src/cache');
const helmet = require('helmet');
const { rateLimit, ipKeyGenerator } = require('express-rate-limit');
const morgan = require('morgan');
const rfs = require('rotating-file-stream');
const fs = require('fs');
const path = require('path');
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
// Security Headers - Helmet.js
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:', 'blob:'],
connectSrc: ["'self'", "wss:", "ws:"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
frameguard: { action: 'deny' },
noSniff: true,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
// Trust Cloudflare proxy - Express will parse X-Forwarded-For automatically
app.set('trust proxy', true);
// Network Hardening: Verify Cloudflare headers (optional debug logging)
if (process.env.VERIFY_CF_HEADERS === 'true') {
app.use((req, res, next) => {
console.log('[SECURITY CHECK] Headers:', {
'cf-connecting-ip': req.headers['cf-connecting-ip'],
'cf-ray': req.headers['cf-ray'],
'x-forwarded-for': req.headers['x-forwarded-for'],
'direct-ip': req.socket.remoteAddress,
'express-ip': req.ip,
});
next();
});
}
// Create logs directory with restricted permissions
const logDir = path.join(__dirname, 'logs');
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { mode: 0o750 }); // rwxr-x--- (owner + group only)
}
// Redaction function for sensitive data
function redactSensitive(str) {
if (!str) return str;
// Redact auth keys (16+ hex chars)
str = str.replace(/\b[a-f0-9]{16,}\b/gi, '[REDACTED-KEY]');
// Redact JWT tokens
str = str.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[REDACTED-JWT]');
// Redact email addresses
str = str.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[REDACTED-EMAIL]');
return str;
}
// Rotating log stream with retention policy
const securityLogStream = rfs.createStream('security.log', {
interval: '1d', // Rotate daily
maxFiles: 30, // Keep 30 days of logs
maxSize: '100M', // Rotate if file exceeds 100MB
compress: 'gzip', // Compress rotated logs
path: logDir,
});
// Custom morgan format with redaction
morgan.token('redacted-url', (req) => redactSensitive(req.originalUrl));
morgan.token('redacted-referrer', (req) => redactSensitive(req.get('referrer')));
// HTTP request logging with redaction
app.use(morgan(
':remote-addr - :remote-user [:date[clf]] ":method :redacted-url HTTP/:http-version" :status :res[content-length] ":redacted-referrer" ":user-agent"',
{ stream: securityLogStream }
));
// Security event tracking
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const logData = {
timestamp: new Date().toISOString(),
ip: req.ip,
method: req.method,
url: redactSensitive(req.originalUrl),
status: res.statusCode,
duration,
userAgent: req.get('user-agent'),
cfRay: req.headers['cf-ray'],
};
// Alert on security events (console only, not to file to avoid duplication)
if (res.statusCode === 401 || res.statusCode === 403) {
console.warn('π Auth failure:', JSON.stringify(logData));
}
if (res.statusCode === 429) {
console.warn('β οΈ Rate limit hit:', JSON.stringify(logData));
}
if (res.statusCode >= 500) {
console.error('β Server error:', JSON.stringify(logData));
}
});
next();
});
// Rate Limiting
const generalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10000,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' },
keyGenerator: (req) => req.headers['cf-connecting-ip'] || ipKeyGenerator(req),
});
const strictLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10000,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many authentication attempts, please try again later.' },
keyGenerator: (req) => req.headers['cf-connecting-ip'] || ipKeyGenerator(req),
skipSuccessfulRequests: true,
});
// Apply general limiter to all API routes
app.use('/api/', generalLimiter);
// Middleware - Apply 10mb limit for image upload endpoints BEFORE default 1mb
app.use('/api/media/save', express.json({ limit: '10mb' }));
app.use('/api/cascade/send', express.json({ limit: '10mb' }));
app.use('/api/cascade/submit', express.json({ limit: '10mb' }));
// Default 1mb limit for all other endpoints
app.use(express.json({ limit: '1mb' }));
// CORS β allow frontend on any port/origin to call backend API
app.use((req, res, next) => {
const origin = req.headers.origin || '*';
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Headers', 'Content-Type, X-Auth-Key, Authorization, Accept, Origin, X-Requested-With');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Max-Age', '86400'); // Cache preflight for 24h
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
// Disable browser caching for API routes β always return 200 with fresh data
app.use('/api', (req, res, next) => {
res.set('Cache-Control', 'no-store');
next();
});
// Public route β must be registered BEFORE auth middleware
app.get('/api/ws-url', (req, res) => {
res.json({ wsPort: Number(process.env.PORT || 3500) });
});
// Auth middleware β only active when AUTH_KEY env var is set
const isNoAuth = process.argv.includes('--no-auth');
const AUTH_KEY = isNoAuth ? '' : (process.env.AUTH_KEY || '');
if (AUTH_KEY) {
console.log(` π Auth enabled (key length: ${AUTH_KEY.length})`);
app.use('/api', (req, res, next) => {
// Skip auth for public endpoints
if (req.path === '/ws-url' || req.path === '/status') {
return next();
}
// Localhost bypass only if explicitly enabled (disabled by default for security)
const ip = req.ip || req.socket.remoteAddress || '';
const isLocal = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
const allowLocalBypass = process.env.ALLOW_LOCALHOST_BYPASS === 'true';
if (isLocal && allowLocalBypass) return next();
const key = req.headers['x-auth-key'] || req.query.auth_key;
// Timing-safe comparison to prevent timing attacks
if (!key || key.length !== AUTH_KEY.length) {
return res.status(401).json({ error: 'Unauthorized β invalid or missing auth key' });
}
try {
const keyBuffer = Buffer.from(key);
const authBuffer = Buffer.from(AUTH_KEY);
if (!crypto.timingSafeEqual(keyBuffer, authBuffer)) {
return res.status(401).json({ error: 'Unauthorized β invalid or missing auth key' });
}
} catch (e) {
return res.status(401).json({ error: 'Unauthorized β invalid or missing auth key' });
}
next();
});
// Apply strict rate limiter to sensitive operations
app.use('/api/settings', strictLimiter);
app.use('/api/launch-ide', strictLimiter);
app.use('/api/kill-ide', strictLimiter);
} else {
console.log(' β οΈ No AUTH_KEY set β API is open (safe for local dev)');
}
// Routes & WebSocket
setupRoutes(app);
setupWebSocket(wss);
// Connect logger β broadcast app_log events to Live Logs viewers
logger.connect(require('./src/ws').broadcastToGlobal);
// Reverse proxy: forward non-API requests to Next.js frontend
// This allows cloudflared to point EVERYTHING at the backend port,
// so WebSocket connections work through the tunnel.
const FE_PORT = process.env.FRONTEND_PORT || (Number(PORT) + 1);
app.use((req, res) => {
const target = `http://localhost:${FE_PORT}${req.originalUrl}`;
const proxyReq = http.request(target, {
method: req.method,
headers: { ...req.headers, host: `localhost:${FE_PORT}` },
}, (proxyRes) => {
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
proxyRes.pipe(res);
});
proxyReq.on('error', () => {
res.status(502).send('Frontend not available');
});
req.pipe(proxyReq);
});
// Start
server.listen(PORT, async () => {
console.log(`\n π¬ NghαΊΉo Remote (API) running at http://localhost:${PORT}\n`);
// Initialize Web Push VAPID keys
try {
const { initVapid } = require('./src/push-service');
initVapid();
} catch (e) {
console.warn(' β οΈ Push service init failed:', e.message);
}
await init(() => startPolling());
const { startResourceMonitor } = require('./src/resource-monitor');
startResourceMonitor();
startAutoRescan();
// Auto-start Agent Bridge if configured in settings.json
const { getSettings } = require('./src/config');
const bridgeCfg = getSettings().agentBridge || {};
if (bridgeCfg.autoStart && bridgeCfg.discordBotToken && bridgeCfg.discordChannelId) {
console.log(' π€ Auto-starting Agent Bridge...');
const bridge = require('./src/agent-bridge');
bridge.startBridge(bridgeCfg).then(status => {
console.log(` π€ Bridge ACTIVE β cascade: ${status.cascadeIdShort}`);
}).catch(e => {
console.error(' β Bridge auto-start failed:', e.message);
});
}
});