portok 1.0.0
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/.dockerignore +10 -0
- package/Dockerfile +41 -0
- package/README.md +606 -0
- package/bench/baseline.bench.mjs +73 -0
- package/bench/connections.bench.mjs +70 -0
- package/bench/keepalive.bench.mjs +248 -0
- package/bench/latency.bench.mjs +47 -0
- package/bench/run.mjs +211 -0
- package/bench/switching.bench.mjs +96 -0
- package/bench/throughput.bench.mjs +44 -0
- package/bench/validate.mjs +260 -0
- package/docker-compose.yml +62 -0
- package/examples/api.env +30 -0
- package/examples/web.env +27 -0
- package/package.json +39 -0
- package/portok.mjs +793 -0
- package/portok@.service +62 -0
- package/portokd.mjs +793 -0
- package/test/cli.test.mjs +220 -0
- package/test/drain.test.mjs +249 -0
- package/test/helpers/mock-server.mjs +305 -0
- package/test/metrics.test.mjs +328 -0
- package/test/proxy.test.mjs +223 -0
- package/test/rollback.test.mjs +344 -0
- package/test/security.test.mjs +256 -0
- package/test/switching.test.mjs +261 -0
package/portokd.mjs
ADDED
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Portok Daemon - Zero-downtime deployment proxy
|
|
5
|
+
* Routes traffic through a stable port to internal app instances
|
|
6
|
+
* with health-gated switching, connection draining, and auto-rollback.
|
|
7
|
+
*
|
|
8
|
+
* Performance optimizations:
|
|
9
|
+
* - FAST_PATH mode for maximum throughput in benchmarks
|
|
10
|
+
* - Keep-alive agent for upstream connections (critical for throughput)
|
|
11
|
+
* - No URL parsing in hot path
|
|
12
|
+
* - Minimal event listeners per request
|
|
13
|
+
* - Connection header stripping for proper keep-alive upstream
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import http from 'node:http';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import httpProxy from 'http-proxy';
|
|
20
|
+
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Configuration
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
// Instance name for multi-instance deployments
|
|
26
|
+
const instanceName = process.env.INSTANCE_NAME || 'default';
|
|
27
|
+
|
|
28
|
+
// Derive default state file from instance name if not explicitly set
|
|
29
|
+
const defaultStateFile = process.env.STATE_FILE
|
|
30
|
+
|| (instanceName !== 'default' ? `/var/lib/portok/${instanceName}.json` : './portok-state.json');
|
|
31
|
+
|
|
32
|
+
const config = {
|
|
33
|
+
instanceName,
|
|
34
|
+
listenPort: parseInt(process.env.LISTEN_PORT || '3000', 10),
|
|
35
|
+
initialTargetPort: parseInt(process.env.INITIAL_TARGET_PORT || '0', 10),
|
|
36
|
+
stateFile: defaultStateFile,
|
|
37
|
+
healthPath: process.env.HEALTH_PATH || '/health',
|
|
38
|
+
healthTimeoutMs: parseInt(process.env.HEALTH_TIMEOUT_MS || '5000', 10),
|
|
39
|
+
drainMs: parseInt(process.env.DRAIN_MS || '30000', 10),
|
|
40
|
+
rollbackWindowMs: parseInt(process.env.ROLLBACK_WINDOW_MS || '60000', 10),
|
|
41
|
+
rollbackCheckEveryMs: parseInt(process.env.ROLLBACK_CHECK_EVERY_MS || '5000', 10),
|
|
42
|
+
rollbackFailThreshold: parseInt(process.env.ROLLBACK_FAIL_THRESHOLD || '3', 10),
|
|
43
|
+
adminToken: process.env.ADMIN_TOKEN || '',
|
|
44
|
+
adminAllowlist: (process.env.ADMIN_ALLOWLIST || '127.0.0.1,::1,::ffff:127.0.0.1').split(',').map(s => s.trim()),
|
|
45
|
+
adminUnixSocket: process.env.ADMIN_UNIX_SOCKET || '',
|
|
46
|
+
|
|
47
|
+
// Performance tuning: Upstream keep-alive settings
|
|
48
|
+
// CRITICAL: Without keep-alive, every request opens a new TCP connection
|
|
49
|
+
upstreamKeepAlive: process.env.UPSTREAM_KEEPALIVE !== '0',
|
|
50
|
+
upstreamMaxSockets: parseInt(process.env.UPSTREAM_MAX_SOCKETS || '1024', 10),
|
|
51
|
+
upstreamKeepAliveMsecs: parseInt(process.env.UPSTREAM_KEEPALIVE_MSECS || '1000', 10),
|
|
52
|
+
|
|
53
|
+
// Server timeout settings
|
|
54
|
+
serverKeepAliveTimeout: parseInt(process.env.SERVER_KEEPALIVE_TIMEOUT || '5000', 10),
|
|
55
|
+
serverHeadersTimeout: parseInt(process.env.SERVER_HEADERS_TIMEOUT || '6000', 10),
|
|
56
|
+
|
|
57
|
+
// Proxy settings
|
|
58
|
+
enableXfwd: process.env.ENABLE_XFWD !== '0', // Default ON for standard proxy behavior
|
|
59
|
+
|
|
60
|
+
// FAST_PATH mode: Maximum performance for benchmarks
|
|
61
|
+
// Disables: statusCounters, rolling RPS, lastProxyError capture
|
|
62
|
+
// Keeps: totalRequests, inflight, proxyErrors
|
|
63
|
+
fastPath: process.env.FAST_PATH === '1',
|
|
64
|
+
|
|
65
|
+
// Debug mode: Track upstream socket creation
|
|
66
|
+
debugUpstream: process.env.DEBUG_UPSTREAM === '1',
|
|
67
|
+
|
|
68
|
+
// Error handling
|
|
69
|
+
verboseErrors: process.env.VERBOSE_ERRORS === '1',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Logging helper - only for admin/startup, NEVER in hot path
|
|
73
|
+
function log(category, message) {
|
|
74
|
+
const prefix = config.instanceName !== 'default' ? `[${config.instanceName}]` : '';
|
|
75
|
+
console.log(`${prefix}[${category}] ${message}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function logError(category, message) {
|
|
79
|
+
const prefix = config.instanceName !== 'default' ? `[${config.instanceName}]` : '';
|
|
80
|
+
console.error(`${prefix}[${category}] ${message}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Validate required config
|
|
84
|
+
if (!config.initialTargetPort && !fs.existsSync(config.stateFile)) {
|
|
85
|
+
logError('config', 'INITIAL_TARGET_PORT is required when no state file exists');
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!config.adminToken) {
|
|
90
|
+
logError('config', 'ADMIN_TOKEN is required');
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// =============================================================================
|
|
95
|
+
// State Management
|
|
96
|
+
// =============================================================================
|
|
97
|
+
|
|
98
|
+
const state = {
|
|
99
|
+
activePort: config.initialTargetPort,
|
|
100
|
+
previousPort: null,
|
|
101
|
+
drainUntil: null,
|
|
102
|
+
lastSwitch: {
|
|
103
|
+
from: null,
|
|
104
|
+
to: null,
|
|
105
|
+
at: null,
|
|
106
|
+
reason: null,
|
|
107
|
+
id: null,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Load state from file if exists
|
|
112
|
+
function loadState() {
|
|
113
|
+
try {
|
|
114
|
+
if (fs.existsSync(config.stateFile)) {
|
|
115
|
+
const data = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
|
|
116
|
+
state.activePort = data.activePort || config.initialTargetPort;
|
|
117
|
+
state.previousPort = data.previousPort || null;
|
|
118
|
+
state.lastSwitch = data.lastSwitch || state.lastSwitch;
|
|
119
|
+
log('state', `Loaded state from ${config.stateFile}, activePort=${state.activePort}`);
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
logError('state', `Failed to load state file: ${err.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Save state to file atomically
|
|
127
|
+
function saveState() {
|
|
128
|
+
try {
|
|
129
|
+
const tempFile = `${config.stateFile}.tmp`;
|
|
130
|
+
const data = JSON.stringify({
|
|
131
|
+
activePort: state.activePort,
|
|
132
|
+
previousPort: state.previousPort,
|
|
133
|
+
lastSwitch: state.lastSwitch,
|
|
134
|
+
}, null, 2);
|
|
135
|
+
fs.writeFileSync(tempFile, data, 'utf-8');
|
|
136
|
+
fs.renameSync(tempFile, config.stateFile);
|
|
137
|
+
log('state', `Saved state to ${config.stateFile}`);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logError('state', `Failed to save state: ${err.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
loadState();
|
|
144
|
+
|
|
145
|
+
// =============================================================================
|
|
146
|
+
// Metrics
|
|
147
|
+
// =============================================================================
|
|
148
|
+
|
|
149
|
+
const metrics = {
|
|
150
|
+
startedAt: Date.now(),
|
|
151
|
+
inflight: 0,
|
|
152
|
+
inflightMax: 0,
|
|
153
|
+
totalRequests: 0,
|
|
154
|
+
totalProxyErrors: 0,
|
|
155
|
+
// These are only updated when NOT in FAST_PATH mode
|
|
156
|
+
statusCounters: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 },
|
|
157
|
+
requestTimestamps: [], // Only used when NOT in FAST_PATH
|
|
158
|
+
health: {
|
|
159
|
+
activePortOk: true,
|
|
160
|
+
lastCheckedAt: null,
|
|
161
|
+
consecutiveFails: 0,
|
|
162
|
+
},
|
|
163
|
+
lastProxyError: null,
|
|
164
|
+
// Debug: upstream socket tracking
|
|
165
|
+
upstreamSocketsCreated: 0,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Rolling RPS calculation - ONLY called from /__metrics endpoint, not hot path
|
|
169
|
+
function getRollingRps60() {
|
|
170
|
+
if (config.fastPath) return 0;
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const cutoff = now - 60000;
|
|
173
|
+
let count = 0;
|
|
174
|
+
for (let i = metrics.requestTimestamps.length - 1; i >= 0; i--) {
|
|
175
|
+
if (metrics.requestTimestamps[i] >= cutoff) count++;
|
|
176
|
+
else break;
|
|
177
|
+
}
|
|
178
|
+
return Math.round((count / 60) * 100) / 100;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Record request timestamp - uses push only, cleanup done periodically
|
|
182
|
+
function recordRequest() {
|
|
183
|
+
metrics.requestTimestamps.push(Date.now());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Periodic cleanup of old timestamps (every 10 seconds)
|
|
187
|
+
if (!config.fastPath) {
|
|
188
|
+
setInterval(() => {
|
|
189
|
+
const cutoff = Date.now() - 60000;
|
|
190
|
+
// Find first index >= cutoff using binary-ish scan from start
|
|
191
|
+
let removeCount = 0;
|
|
192
|
+
while (removeCount < metrics.requestTimestamps.length &&
|
|
193
|
+
metrics.requestTimestamps[removeCount] < cutoff) {
|
|
194
|
+
removeCount++;
|
|
195
|
+
}
|
|
196
|
+
if (removeCount > 0) {
|
|
197
|
+
metrics.requestTimestamps.splice(0, removeCount);
|
|
198
|
+
}
|
|
199
|
+
}, 10000);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function incrementStatusCounter(statusCode) {
|
|
203
|
+
if (statusCode >= 200 && statusCode < 300) metrics.statusCounters['2xx']++;
|
|
204
|
+
else if (statusCode >= 300 && statusCode < 400) metrics.statusCounters['3xx']++;
|
|
205
|
+
else if (statusCode >= 400 && statusCode < 500) metrics.statusCounters['4xx']++;
|
|
206
|
+
else if (statusCode >= 500) metrics.statusCounters['5xx']++;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// =============================================================================
|
|
210
|
+
// Socket Tracking for Connection Draining
|
|
211
|
+
// =============================================================================
|
|
212
|
+
|
|
213
|
+
// Map<Socket, number> - just store the port number for speed
|
|
214
|
+
const socketPortMap = new Map();
|
|
215
|
+
|
|
216
|
+
function getSocketPort(socket) {
|
|
217
|
+
const port = socketPortMap.get(socket);
|
|
218
|
+
return port !== undefined ? port : state.activePort;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// =============================================================================
|
|
222
|
+
// Upstream Keep-Alive Agent
|
|
223
|
+
// =============================================================================
|
|
224
|
+
|
|
225
|
+
// CRITICAL for performance: Shared HTTP agent for upstream connections
|
|
226
|
+
const upstreamAgent = new http.Agent({
|
|
227
|
+
keepAlive: config.upstreamKeepAlive,
|
|
228
|
+
maxSockets: config.upstreamMaxSockets,
|
|
229
|
+
keepAliveMsecs: config.upstreamKeepAliveMsecs,
|
|
230
|
+
scheduling: 'fifo', // Better for keep-alive reuse
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Debug: Track socket creation
|
|
234
|
+
if (config.debugUpstream) {
|
|
235
|
+
const originalCreateConnection = upstreamAgent.createConnection.bind(upstreamAgent);
|
|
236
|
+
upstreamAgent.createConnection = function(options, callback) {
|
|
237
|
+
metrics.upstreamSocketsCreated++;
|
|
238
|
+
return originalCreateConnection(options, callback);
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// =============================================================================
|
|
243
|
+
// Health Check
|
|
244
|
+
// =============================================================================
|
|
245
|
+
|
|
246
|
+
async function checkHealth(port, timeout = config.healthTimeoutMs) {
|
|
247
|
+
return new Promise((resolve) => {
|
|
248
|
+
const req = http.request({
|
|
249
|
+
hostname: '127.0.0.1',
|
|
250
|
+
port,
|
|
251
|
+
path: config.healthPath,
|
|
252
|
+
method: 'GET',
|
|
253
|
+
timeout,
|
|
254
|
+
}, (res) => {
|
|
255
|
+
// Consume response to free socket
|
|
256
|
+
res.resume();
|
|
257
|
+
resolve(res.statusCode >= 200 && res.statusCode < 300);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
req.on('error', () => resolve(false));
|
|
261
|
+
req.on('timeout', () => {
|
|
262
|
+
req.destroy();
|
|
263
|
+
resolve(false);
|
|
264
|
+
});
|
|
265
|
+
req.end();
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// =============================================================================
|
|
270
|
+
// Auto Rollback Monitor
|
|
271
|
+
// =============================================================================
|
|
272
|
+
|
|
273
|
+
let rollbackInterval = null;
|
|
274
|
+
|
|
275
|
+
function startRollbackMonitor(newPort, previousPort, isRollback = false) {
|
|
276
|
+
if (isRollback) {
|
|
277
|
+
log('rollback', 'Skipping rollback monitor for rollback operation');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (rollbackInterval) {
|
|
282
|
+
clearInterval(rollbackInterval);
|
|
283
|
+
rollbackInterval = null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let consecutiveFails = 0;
|
|
287
|
+
const endTime = Date.now() + config.rollbackWindowMs;
|
|
288
|
+
|
|
289
|
+
log('rollback', `Starting monitor for port ${newPort}, window=${config.rollbackWindowMs}ms`);
|
|
290
|
+
|
|
291
|
+
rollbackInterval = setInterval(async () => {
|
|
292
|
+
if (Date.now() > endTime) {
|
|
293
|
+
log('rollback', 'Monitor window expired, port is stable');
|
|
294
|
+
clearInterval(rollbackInterval);
|
|
295
|
+
rollbackInterval = null;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (state.activePort !== newPort) {
|
|
300
|
+
log('rollback', 'Port changed externally, stopping monitor');
|
|
301
|
+
clearInterval(rollbackInterval);
|
|
302
|
+
rollbackInterval = null;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const healthy = await checkHealth(newPort);
|
|
307
|
+
metrics.health.lastCheckedAt = Date.now();
|
|
308
|
+
|
|
309
|
+
if (!healthy) {
|
|
310
|
+
consecutiveFails++;
|
|
311
|
+
metrics.health.consecutiveFails = consecutiveFails;
|
|
312
|
+
metrics.health.activePortOk = false;
|
|
313
|
+
log('rollback', `Health check failed (${consecutiveFails}/${config.rollbackFailThreshold})`);
|
|
314
|
+
|
|
315
|
+
if (consecutiveFails >= config.rollbackFailThreshold) {
|
|
316
|
+
log('rollback', `Threshold reached, rolling back to port ${previousPort}`);
|
|
317
|
+
clearInterval(rollbackInterval);
|
|
318
|
+
rollbackInterval = null;
|
|
319
|
+
await performSwitch(previousPort, 'auto-rollback', true);
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
consecutiveFails = 0;
|
|
323
|
+
metrics.health.consecutiveFails = 0;
|
|
324
|
+
metrics.health.activePortOk = true;
|
|
325
|
+
}
|
|
326
|
+
}, config.rollbackCheckEveryMs);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// =============================================================================
|
|
330
|
+
// Switching Logic
|
|
331
|
+
// =============================================================================
|
|
332
|
+
|
|
333
|
+
async function performSwitch(newPort, reason = 'manual', isRollback = false) {
|
|
334
|
+
const previousPort = state.activePort;
|
|
335
|
+
|
|
336
|
+
state.previousPort = previousPort;
|
|
337
|
+
state.activePort = newPort;
|
|
338
|
+
state.drainUntil = Date.now() + config.drainMs;
|
|
339
|
+
state.lastSwitch = {
|
|
340
|
+
from: previousPort,
|
|
341
|
+
to: newPort,
|
|
342
|
+
at: Date.now(),
|
|
343
|
+
reason,
|
|
344
|
+
id: crypto.randomUUID(),
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
saveState();
|
|
348
|
+
|
|
349
|
+
log('switch', `Switched from port ${previousPort} to ${newPort} (reason: ${reason})`);
|
|
350
|
+
|
|
351
|
+
startRollbackMonitor(newPort, previousPort, isRollback);
|
|
352
|
+
|
|
353
|
+
setTimeout(() => {
|
|
354
|
+
if (state.drainUntil && state.drainUntil <= Date.now()) {
|
|
355
|
+
state.drainUntil = null;
|
|
356
|
+
log('drain', 'Drain period ended');
|
|
357
|
+
}
|
|
358
|
+
}, config.drainMs);
|
|
359
|
+
|
|
360
|
+
return state.lastSwitch;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// =============================================================================
|
|
364
|
+
// Security: Token Validation & Rate Limiting
|
|
365
|
+
// =============================================================================
|
|
366
|
+
|
|
367
|
+
function timingSafeEqual(a, b) {
|
|
368
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
369
|
+
const bufA = Buffer.from(a);
|
|
370
|
+
const bufB = Buffer.from(b);
|
|
371
|
+
if (bufA.length !== bufB.length) {
|
|
372
|
+
crypto.timingSafeEqual(bufA, bufA);
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function validateAdminToken(req) {
|
|
379
|
+
const token = req.headers['x-admin-token'];
|
|
380
|
+
return timingSafeEqual(token || '', config.adminToken);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function isAllowedIP(req) {
|
|
384
|
+
const remoteAddr = req.socket.remoteAddress || '';
|
|
385
|
+
return config.adminAllowlist.some(allowed => {
|
|
386
|
+
if (remoteAddr === `::ffff:${allowed}`) return true;
|
|
387
|
+
return remoteAddr === allowed;
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const rateLimitMap = new Map();
|
|
392
|
+
const RATE_LIMIT_MAX = parseInt(process.env.ADMIN_RATE_LIMIT || '10', 10);
|
|
393
|
+
const RATE_LIMIT_WINDOW = 60000;
|
|
394
|
+
|
|
395
|
+
function checkRateLimit(ip) {
|
|
396
|
+
const now = Date.now();
|
|
397
|
+
const cutoff = now - RATE_LIMIT_WINDOW;
|
|
398
|
+
|
|
399
|
+
let timestamps = rateLimitMap.get(ip) || [];
|
|
400
|
+
timestamps = timestamps.filter(t => t > cutoff);
|
|
401
|
+
|
|
402
|
+
if (timestamps.length >= RATE_LIMIT_MAX) {
|
|
403
|
+
rateLimitMap.set(ip, timestamps);
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
timestamps.push(now);
|
|
408
|
+
rateLimitMap.set(ip, timestamps);
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
setInterval(() => {
|
|
413
|
+
const now = Date.now();
|
|
414
|
+
const cutoff = now - RATE_LIMIT_WINDOW;
|
|
415
|
+
for (const [ip, timestamps] of rateLimitMap.entries()) {
|
|
416
|
+
const filtered = timestamps.filter(t => t > cutoff);
|
|
417
|
+
if (filtered.length === 0) {
|
|
418
|
+
rateLimitMap.delete(ip);
|
|
419
|
+
} else {
|
|
420
|
+
rateLimitMap.set(ip, filtered);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}, 60000);
|
|
424
|
+
|
|
425
|
+
// =============================================================================
|
|
426
|
+
// Admin Endpoints
|
|
427
|
+
// =============================================================================
|
|
428
|
+
|
|
429
|
+
function sendJSON(res, statusCode, data) {
|
|
430
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
431
|
+
res.end(JSON.stringify(data));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Extract pathname without URL object
|
|
435
|
+
function getPathname(url) {
|
|
436
|
+
const qIdx = url.indexOf('?');
|
|
437
|
+
return qIdx === -1 ? url : url.slice(0, qIdx);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Extract query param without URL parsing
|
|
441
|
+
function getQueryParam(url, param) {
|
|
442
|
+
const qIdx = url.indexOf('?');
|
|
443
|
+
if (qIdx === -1) return null;
|
|
444
|
+
const query = url.slice(qIdx + 1);
|
|
445
|
+
const regex = new RegExp(`(?:^|&)${param}=([^&]*)`);
|
|
446
|
+
const match = query.match(regex);
|
|
447
|
+
return match ? match[1] : null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function handleAdminRequest(req, res) {
|
|
451
|
+
const pathname = getPathname(req.url);
|
|
452
|
+
|
|
453
|
+
if (!isAllowedIP(req)) {
|
|
454
|
+
sendJSON(res, 403, { error: 'Forbidden: IP not allowed' });
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (!validateAdminToken(req)) {
|
|
459
|
+
sendJSON(res, 401, { error: 'Unauthorized: Invalid or missing x-admin-token' });
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const clientIP = req.socket.remoteAddress || 'unknown';
|
|
464
|
+
if (!checkRateLimit(clientIP)) {
|
|
465
|
+
sendJSON(res, 429, { error: 'Too Many Requests: Rate limit exceeded' });
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (pathname === '/__status' && req.method === 'GET') {
|
|
470
|
+
sendJSON(res, 200, {
|
|
471
|
+
instanceName: config.instanceName,
|
|
472
|
+
activePort: state.activePort,
|
|
473
|
+
drainUntil: state.drainUntil,
|
|
474
|
+
lastSwitch: state.lastSwitch,
|
|
475
|
+
});
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (pathname === '/__metrics' && req.method === 'GET') {
|
|
480
|
+
const metricsData = {
|
|
481
|
+
startedAt: new Date(metrics.startedAt).toISOString(),
|
|
482
|
+
inflight: metrics.inflight,
|
|
483
|
+
inflightMax: metrics.inflightMax,
|
|
484
|
+
totalRequests: metrics.totalRequests,
|
|
485
|
+
totalProxyErrors: metrics.totalProxyErrors,
|
|
486
|
+
statusCounters: metrics.statusCounters,
|
|
487
|
+
rollingRps60: getRollingRps60(),
|
|
488
|
+
health: {
|
|
489
|
+
...metrics.health,
|
|
490
|
+
lastCheckedAt: metrics.health.lastCheckedAt
|
|
491
|
+
? new Date(metrics.health.lastCheckedAt).toISOString()
|
|
492
|
+
: null,
|
|
493
|
+
},
|
|
494
|
+
lastProxyError: metrics.lastProxyError,
|
|
495
|
+
fastPath: config.fastPath,
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// Include upstream debug info if enabled
|
|
499
|
+
if (config.debugUpstream) {
|
|
500
|
+
metricsData.upstreamSocketsCreated = metrics.upstreamSocketsCreated;
|
|
501
|
+
metricsData.upstreamAgentSockets = {
|
|
502
|
+
freeSockets: Object.keys(upstreamAgent.freeSockets).length,
|
|
503
|
+
sockets: Object.keys(upstreamAgent.sockets).length,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
sendJSON(res, 200, metricsData);
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (pathname === '/__switch' && req.method === 'POST') {
|
|
512
|
+
const portStr = getQueryParam(req.url, 'port');
|
|
513
|
+
const port = parseInt(portStr, 10);
|
|
514
|
+
|
|
515
|
+
if (!port || port < 1 || port > 65535) {
|
|
516
|
+
sendJSON(res, 400, { error: 'Invalid port: must be 1-65535' });
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
log('switch', `Health checking port ${port}...`);
|
|
521
|
+
const healthy = await checkHealth(port);
|
|
522
|
+
|
|
523
|
+
if (!healthy) {
|
|
524
|
+
sendJSON(res, 409, {
|
|
525
|
+
error: 'Health check failed',
|
|
526
|
+
message: `Port ${port} did not respond with 2xx at ${config.healthPath}`,
|
|
527
|
+
});
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const result = await performSwitch(port, 'manual', false);
|
|
532
|
+
sendJSON(res, 200, {
|
|
533
|
+
success: true,
|
|
534
|
+
message: `Switched to port ${port}`,
|
|
535
|
+
switch: result,
|
|
536
|
+
});
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (pathname === '/__health' && req.method === 'GET') {
|
|
541
|
+
const healthy = await checkHealth(state.activePort);
|
|
542
|
+
metrics.health.activePortOk = healthy;
|
|
543
|
+
metrics.health.lastCheckedAt = Date.now();
|
|
544
|
+
|
|
545
|
+
if (healthy) {
|
|
546
|
+
sendJSON(res, 200, {
|
|
547
|
+
healthy: true,
|
|
548
|
+
activePort: state.activePort,
|
|
549
|
+
checkedAt: new Date(metrics.health.lastCheckedAt).toISOString(),
|
|
550
|
+
});
|
|
551
|
+
} else {
|
|
552
|
+
sendJSON(res, 503, {
|
|
553
|
+
healthy: false,
|
|
554
|
+
activePort: state.activePort,
|
|
555
|
+
checkedAt: new Date(metrics.health.lastCheckedAt).toISOString(),
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// =============================================================================
|
|
565
|
+
// Proxy Server
|
|
566
|
+
// =============================================================================
|
|
567
|
+
|
|
568
|
+
const proxy = httpProxy.createProxyServer({
|
|
569
|
+
ws: true,
|
|
570
|
+
xfwd: config.enableXfwd,
|
|
571
|
+
// These are critical for performance:
|
|
572
|
+
changeOrigin: false, // Not needed for localhost
|
|
573
|
+
selfHandleResponse: false, // Let http-proxy handle response
|
|
574
|
+
followRedirects: false, // Don't follow redirects
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// CRITICAL: Remove connection header to ensure upstream keep-alive works
|
|
578
|
+
// Without this, "connection: close" from client breaks upstream reuse
|
|
579
|
+
proxy.on('proxyReq', (proxyReq, req, res, options) => {
|
|
580
|
+
// Remove connection header - let agent manage keep-alive
|
|
581
|
+
proxyReq.removeHeader('connection');
|
|
582
|
+
// Ensure we don't forward hop-by-hop headers that break keep-alive
|
|
583
|
+
proxyReq.removeHeader('proxy-connection');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// Proxy error handler - minimal work in hot path
|
|
587
|
+
proxy.on('error', (err, req, res) => {
|
|
588
|
+
metrics.totalProxyErrors++;
|
|
589
|
+
|
|
590
|
+
// Only capture error details if NOT in FAST_PATH
|
|
591
|
+
if (!config.fastPath) {
|
|
592
|
+
metrics.lastProxyError = {
|
|
593
|
+
message: err.message,
|
|
594
|
+
timestamp: Date.now(),
|
|
595
|
+
targetPort: state.activePort,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Only log if verbose enabled
|
|
600
|
+
if (config.verboseErrors) {
|
|
601
|
+
logError('proxy', `${err.message}\n${err.stack}`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Fast 502 response
|
|
605
|
+
if (res && !res.headersSent && typeof res.writeHead === 'function') {
|
|
606
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
607
|
+
res.end('Bad Gateway');
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// Track response status codes - ONLY if NOT in FAST_PATH mode
|
|
612
|
+
if (!config.fastPath) {
|
|
613
|
+
proxy.on('proxyRes', (proxyRes) => {
|
|
614
|
+
incrementStatusCounter(proxyRes.statusCode);
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// =============================================================================
|
|
619
|
+
// HTTP Request Handler - ULTRA OPTIMIZED HOT PATH
|
|
620
|
+
// =============================================================================
|
|
621
|
+
|
|
622
|
+
// Pre-calculate admin prefix bytes for fastest check
|
|
623
|
+
const SLASH = 47; // '/'
|
|
624
|
+
const UNDERSCORE = 95; // '_'
|
|
625
|
+
|
|
626
|
+
function handleRequest(req, res) {
|
|
627
|
+
const url = req.url;
|
|
628
|
+
|
|
629
|
+
// Fast admin check: is url starting with '/__' ?
|
|
630
|
+
if (url && url.length > 2 &&
|
|
631
|
+
url.charCodeAt(0) === SLASH &&
|
|
632
|
+
url.charCodeAt(1) === UNDERSCORE &&
|
|
633
|
+
url.charCodeAt(2) === UNDERSCORE) {
|
|
634
|
+
// Admin path - async handling
|
|
635
|
+
handleAdminRequest(req, res).then(handled => {
|
|
636
|
+
if (!handled) {
|
|
637
|
+
proxyRequestFast(req, res);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Hot path: proxy immediately
|
|
644
|
+
proxyRequestFast(req, res);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Ultra-fast proxy function - minimal overhead
|
|
648
|
+
function proxyRequestFast(req, res) {
|
|
649
|
+
// Essential metrics only
|
|
650
|
+
metrics.totalRequests++;
|
|
651
|
+
metrics.inflight++;
|
|
652
|
+
|
|
653
|
+
// Track max inflight (cheap comparison)
|
|
654
|
+
if (metrics.inflight > metrics.inflightMax) {
|
|
655
|
+
metrics.inflightMax = metrics.inflight;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Rolling RPS tracking - ONLY if not FAST_PATH
|
|
659
|
+
if (!config.fastPath) {
|
|
660
|
+
recordRequest();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Get target port (for drain support)
|
|
664
|
+
const targetPort = getSocketPort(req.socket);
|
|
665
|
+
|
|
666
|
+
// Track inflight with protection against double-decrement
|
|
667
|
+
// Use a simple flag on the response object
|
|
668
|
+
res._inflightDecremented = false;
|
|
669
|
+
|
|
670
|
+
const onFinish = () => {
|
|
671
|
+
if (!res._inflightDecremented) {
|
|
672
|
+
res._inflightDecremented = true;
|
|
673
|
+
metrics.inflight--;
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// Use 'once' to auto-remove listener after first call
|
|
678
|
+
res.once('finish', onFinish);
|
|
679
|
+
res.once('close', onFinish);
|
|
680
|
+
|
|
681
|
+
// Proxy with keep-alive agent
|
|
682
|
+
proxy.web(req, res, {
|
|
683
|
+
target: { host: '127.0.0.1', port: targetPort },
|
|
684
|
+
agent: upstreamAgent,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// WebSocket upgrade handler
|
|
689
|
+
function handleUpgrade(req, socket, head) {
|
|
690
|
+
const targetPort = getSocketPort(socket);
|
|
691
|
+
|
|
692
|
+
// No logging in hot path
|
|
693
|
+
proxy.ws(req, socket, head, {
|
|
694
|
+
target: { host: '127.0.0.1', port: targetPort },
|
|
695
|
+
agent: upstreamAgent,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// =============================================================================
|
|
700
|
+
// HTTP Server
|
|
701
|
+
// =============================================================================
|
|
702
|
+
|
|
703
|
+
const server = http.createServer(handleRequest);
|
|
704
|
+
server.on('upgrade', handleUpgrade);
|
|
705
|
+
|
|
706
|
+
// Configure server for high concurrency
|
|
707
|
+
server.keepAliveTimeout = config.serverKeepAliveTimeout;
|
|
708
|
+
server.headersTimeout = config.serverHeadersTimeout;
|
|
709
|
+
server.maxHeadersCount = 100; // Reasonable limit
|
|
710
|
+
|
|
711
|
+
// Track socket connections for draining - minimal overhead
|
|
712
|
+
server.on('connection', (socket) => {
|
|
713
|
+
socketPortMap.set(socket, state.activePort);
|
|
714
|
+
socket.once('close', () => socketPortMap.delete(socket));
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// =============================================================================
|
|
718
|
+
// Server Startup
|
|
719
|
+
// =============================================================================
|
|
720
|
+
|
|
721
|
+
server.listen(config.listenPort, () => {
|
|
722
|
+
log('portokd', `Listening on port ${config.listenPort}`);
|
|
723
|
+
log('portokd', `Instance: ${config.instanceName}`);
|
|
724
|
+
log('portokd', `Proxying to 127.0.0.1:${state.activePort}`);
|
|
725
|
+
log('portokd', `Health path: ${config.healthPath}`);
|
|
726
|
+
log('portokd', `State file: ${config.stateFile}`);
|
|
727
|
+
log('portokd', `Admin endpoints: /__status, /__metrics, /__switch, /__health`);
|
|
728
|
+
log('portokd', `Keep-alive: ${config.upstreamKeepAlive ? 'enabled' : 'disabled'}, maxSockets=${config.upstreamMaxSockets}`);
|
|
729
|
+
if (config.fastPath) {
|
|
730
|
+
log('portokd', 'FAST_PATH mode: Minimal metrics for maximum throughput');
|
|
731
|
+
}
|
|
732
|
+
if (config.debugUpstream) {
|
|
733
|
+
log('portokd', 'DEBUG_UPSTREAM mode: Tracking upstream socket creation');
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// Optional: Admin Unix socket
|
|
738
|
+
if (config.adminUnixSocket) {
|
|
739
|
+
try {
|
|
740
|
+
fs.unlinkSync(config.adminUnixSocket);
|
|
741
|
+
} catch (e) {
|
|
742
|
+
// Ignore if doesn't exist
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const adminServer = http.createServer(async (req, res) => {
|
|
746
|
+
req.socket.remoteAddress = '127.0.0.1';
|
|
747
|
+
if (req.url && req.url.charCodeAt(0) === SLASH &&
|
|
748
|
+
req.url.charCodeAt(1) === UNDERSCORE &&
|
|
749
|
+
req.url.charCodeAt(2) === UNDERSCORE) {
|
|
750
|
+
await handleAdminRequest(req, res);
|
|
751
|
+
} else {
|
|
752
|
+
sendJSON(res, 404, { error: 'Not found' });
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
adminServer.listen(config.adminUnixSocket, () => {
|
|
757
|
+
log('portokd', `Admin socket listening on ${config.adminUnixSocket}`);
|
|
758
|
+
fs.chmodSync(config.adminUnixSocket, 0o600);
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Graceful shutdown
|
|
763
|
+
process.on('SIGTERM', () => {
|
|
764
|
+
log('portokd', 'Received SIGTERM, shutting down...');
|
|
765
|
+
upstreamAgent.destroy();
|
|
766
|
+
server.close(() => {
|
|
767
|
+
log('portokd', 'Server closed');
|
|
768
|
+
process.exit(0);
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
process.on('SIGINT', () => {
|
|
773
|
+
log('portokd', 'Received SIGINT, shutting down...');
|
|
774
|
+
upstreamAgent.destroy();
|
|
775
|
+
server.close(() => {
|
|
776
|
+
log('portokd', 'Server closed');
|
|
777
|
+
process.exit(0);
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// Export for testing
|
|
782
|
+
export {
|
|
783
|
+
config,
|
|
784
|
+
state,
|
|
785
|
+
metrics,
|
|
786
|
+
checkHealth,
|
|
787
|
+
performSwitch,
|
|
788
|
+
validateAdminToken,
|
|
789
|
+
isAllowedIP,
|
|
790
|
+
checkRateLimit,
|
|
791
|
+
server,
|
|
792
|
+
upstreamAgent,
|
|
793
|
+
};
|