port-daddy 1.2.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/server.js ADDED
@@ -0,0 +1,592 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Port Daddy - Authoritative port assignment service
5
+ *
6
+ * Runs on localhost:9876 and manages port assignments for all dev servers
7
+ * across multiple AI agent sessions. Prevents port conflicts through atomic
8
+ * SQLite transactions and automatic cleanup of stale processes.
9
+ */
10
+
11
+ import express from 'express';
12
+ import Database from 'better-sqlite3';
13
+ import cors from 'cors';
14
+ import { fileURLToPath } from 'url';
15
+ import { dirname, join } from 'path';
16
+ import { spawnSync } from 'child_process';
17
+ import { readFileSync, existsSync } from 'fs';
18
+ import winston from 'winston';
19
+ import rateLimit from 'express-rate-limit';
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+
23
+ // =============================================================================
24
+ // CONFIGURATION
25
+ // =============================================================================
26
+
27
+ const configPath = join(__dirname, 'config.json');
28
+ const config = existsSync(configPath)
29
+ ? JSON.parse(readFileSync(configPath, 'utf8'))
30
+ : {
31
+ service: { port: 9876, host: 'localhost' },
32
+ ports: { range_start: 3100, range_end: 9999, reserved: [8080, 8000, 9876] },
33
+ cleanup: { interval_ms: 300000 },
34
+ logging: { level: 'info', file: 'port-daddy.log', error_file: 'port-daddy-error.log' },
35
+ security: { rate_limit: { window_ms: 60000, max_requests: 100 } }
36
+ };
37
+
38
+ const versionPath = join(__dirname, 'VERSION');
39
+ const VERSION = existsSync(versionPath)
40
+ ? readFileSync(versionPath, 'utf8').trim()
41
+ : '0.0.0-dev';
42
+
43
+ // =============================================================================
44
+ // INPUT VALIDATION (Security Fix #1)
45
+ // =============================================================================
46
+
47
+ const PROJECT_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
48
+ const PROJECT_NAME_MAX_LENGTH = 255;
49
+ const PID_MIN = 1;
50
+ const PID_MAX = 99999;
51
+
52
+ function validateProjectName(project) {
53
+ if (!project || typeof project !== 'string') {
54
+ return { valid: false, error: 'project name must be a non-empty string' };
55
+ }
56
+ if (project.length > PROJECT_NAME_MAX_LENGTH) {
57
+ return { valid: false, error: `project name too long (max ${PROJECT_NAME_MAX_LENGTH} characters)` };
58
+ }
59
+ if (!PROJECT_NAME_REGEX.test(project)) {
60
+ return { valid: false, error: 'project name contains invalid characters (use alphanumeric, dash, underscore, dot)' };
61
+ }
62
+ return { valid: true };
63
+ }
64
+
65
+ function validatePid(pidValue) {
66
+ if (pidValue === undefined || pidValue === null) {
67
+ return { valid: true, pid: null };
68
+ }
69
+ const pid = parseInt(pidValue, 10);
70
+ if (isNaN(pid) || pid < PID_MIN || pid > PID_MAX) {
71
+ return { valid: false, error: `PID must be between ${PID_MIN} and ${PID_MAX}` };
72
+ }
73
+ return { valid: true, pid };
74
+ }
75
+
76
+ function validatePort(portValue) {
77
+ if (portValue === undefined || portValue === null) {
78
+ return { valid: true, port: null };
79
+ }
80
+ const port = parseInt(portValue, 10);
81
+ if (isNaN(port) || port < 1 || port > 65535) {
82
+ return { valid: false, error: 'port must be between 1 and 65535' };
83
+ }
84
+ return { valid: true, port };
85
+ }
86
+
87
+ function validatePreferredPort(portValue, rangeStart, rangeEnd, reservedPorts) {
88
+ const baseValidation = validatePort(portValue);
89
+ if (!baseValidation.valid) return baseValidation;
90
+ if (baseValidation.port === null) return { valid: true, port: null };
91
+
92
+ const port = baseValidation.port;
93
+ if (port < rangeStart || port > rangeEnd) {
94
+ return { valid: false, error: `preferred port must be in range ${rangeStart}-${rangeEnd}` };
95
+ }
96
+ if (reservedPorts.includes(port)) {
97
+ return { valid: false, error: 'preferred port is reserved and cannot be assigned' };
98
+ }
99
+ return { valid: true, port };
100
+ }
101
+
102
+ // =============================================================================
103
+ // LOGGING
104
+ // =============================================================================
105
+
106
+ const logger = winston.createLogger({
107
+ level: config.logging.level,
108
+ format: winston.format.combine(
109
+ winston.format.timestamp(),
110
+ winston.format.json()
111
+ ),
112
+ defaultMeta: { service: 'port-daddy', version: VERSION },
113
+ transports: [
114
+ new winston.transports.File({
115
+ filename: join(__dirname, config.logging.error_file),
116
+ level: 'error'
117
+ }),
118
+ new winston.transports.File({
119
+ filename: join(__dirname, config.logging.file)
120
+ })
121
+ ]
122
+ });
123
+
124
+ if (process.env.NODE_ENV !== 'production') {
125
+ logger.add(new winston.transports.Console({
126
+ format: winston.format.combine(
127
+ winston.format.colorize(),
128
+ winston.format.simple()
129
+ )
130
+ }));
131
+ }
132
+
133
+ // =============================================================================
134
+ // DATABASE
135
+ // =============================================================================
136
+
137
+ const DB_PATH = join(__dirname, 'port-registry.db');
138
+ const PORT = config.service.port;
139
+ const PORT_RANGE_START = config.ports.range_start;
140
+ const PORT_RANGE_END = config.ports.range_end;
141
+ const RESERVED_PORTS = config.ports.reserved;
142
+
143
+ const db = new Database(DB_PATH);
144
+ db.pragma('journal_mode = WAL');
145
+
146
+ db.exec(`
147
+ CREATE TABLE IF NOT EXISTS port_assignments (
148
+ port INTEGER PRIMARY KEY,
149
+ project TEXT NOT NULL,
150
+ pid INTEGER NOT NULL,
151
+ started INTEGER NOT NULL,
152
+ last_seen INTEGER NOT NULL
153
+ );
154
+ CREATE INDEX IF NOT EXISTS idx_project ON port_assignments(project);
155
+ CREATE INDEX IF NOT EXISTS idx_pid ON port_assignments(pid);
156
+ `);
157
+
158
+ // Prepared statements for better performance and safety
159
+ const stmts = {
160
+ getByProject: db.prepare('SELECT * FROM port_assignments WHERE project = ?'),
161
+ getByPort: db.prepare('SELECT * FROM port_assignments WHERE port = ?'),
162
+ insert: db.prepare('INSERT INTO port_assignments (port, project, pid, started, last_seen) VALUES (?, ?, ?, ?, ?)'),
163
+ updateLastSeen: db.prepare('UPDATE port_assignments SET last_seen = ? WHERE port = ?'),
164
+ deleteByPort: db.prepare('DELETE FROM port_assignments WHERE port = ?'),
165
+ deleteByProject: db.prepare('DELETE FROM port_assignments WHERE project = ?'),
166
+ getAllPorts: db.prepare('SELECT port FROM port_assignments'),
167
+ getAll: db.prepare('SELECT port, pid, project FROM port_assignments'),
168
+ getAllFull: db.prepare('SELECT port, project, pid, started, last_seen FROM port_assignments ORDER BY port'),
169
+ getPortProject: db.prepare('SELECT port, project FROM port_assignments'),
170
+ countAll: db.prepare('SELECT COUNT(*) as count FROM port_assignments')
171
+ };
172
+
173
+ // =============================================================================
174
+ // METRICS
175
+ // =============================================================================
176
+
177
+ const metrics = {
178
+ total_assignments: 0,
179
+ total_releases: 0,
180
+ total_cleanups: 0,
181
+ ports_freed_by_cleanup: 0,
182
+ validation_failures: 0,
183
+ race_condition_retries: 0,
184
+ errors: 0,
185
+ uptime_start: Date.now()
186
+ };
187
+
188
+ // =============================================================================
189
+ // EXPRESS APP
190
+ // =============================================================================
191
+
192
+ const app = express();
193
+
194
+ // Rate limiting - keyed by project or PID (Security Fix #5)
195
+ const limiter = rateLimit({
196
+ windowMs: config.security.rate_limit.window_ms,
197
+ max: config.security.rate_limit.max_requests,
198
+ keyGenerator: (req) => {
199
+ if (req.body && req.body.project && typeof req.body.project === 'string') {
200
+ return `project:${req.body.project.substring(0, 50)}`;
201
+ }
202
+ const pid = req.headers['x-pid'] || 'unknown';
203
+ return `pid:${pid}`;
204
+ },
205
+ skip: (req) => req.path === '/health' || req.path === '/version',
206
+ message: { error: 'Too many requests, please slow down' },
207
+ standardHeaders: true,
208
+ legacyHeaders: false
209
+ });
210
+
211
+ // CORS - localhost only
212
+ app.use(cors({
213
+ origin: ['http://localhost:9876', 'http://127.0.0.1:9876'],
214
+ credentials: true
215
+ }));
216
+
217
+ app.use(limiter);
218
+ app.use(express.json({ limit: '10kb' }));
219
+ app.use(express.static(join(__dirname, 'public')));
220
+
221
+ // Request logging
222
+ app.use((req, res, next) => {
223
+ const start = Date.now();
224
+ res.on('finish', () => {
225
+ logger.info('request', {
226
+ method: req.method,
227
+ path: req.path,
228
+ status: res.statusCode,
229
+ duration_ms: Date.now() - start
230
+ });
231
+ });
232
+ next();
233
+ });
234
+
235
+ // =============================================================================
236
+ // UTILITY FUNCTIONS (using spawnSync with arrays - safe from injection)
237
+ // =============================================================================
238
+
239
+ function isProcessAlive(pid) {
240
+ try {
241
+ const result = spawnSync('ps', ['-p', String(pid)], {
242
+ stdio: 'ignore',
243
+ timeout: 1000
244
+ });
245
+ return result.status === 0;
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ // Caching for system port scans (DoS prevention)
252
+ let systemPortsCache = { data: null, timestamp: 0 };
253
+ const SYSTEM_PORTS_CACHE_TTL = 2000;
254
+
255
+ function getSystemPorts() {
256
+ const now = Date.now();
257
+ if (systemPortsCache.data && (now - systemPortsCache.timestamp) < SYSTEM_PORTS_CACHE_TTL) {
258
+ return systemPortsCache.data;
259
+ }
260
+
261
+ try {
262
+ const result = spawnSync('lsof', ['-i', '-P', '-n', '-sTCP:LISTEN'], {
263
+ encoding: 'utf8',
264
+ timeout: 5000,
265
+ maxBuffer: 1024 * 1024
266
+ });
267
+
268
+ if (result.status !== 0 || !result.stdout) {
269
+ return systemPortsCache.data || [];
270
+ }
271
+
272
+ const lines = result.stdout.trim().split('\n').slice(1);
273
+ const ports = [];
274
+ const maxLines = 1000;
275
+
276
+ for (let i = 0; i < Math.min(lines.length, maxLines); i++) {
277
+ const parts = lines[i].split(/\s+/);
278
+ if (parts.length < 9) continue;
279
+ const command = parts[0];
280
+ const pid = parseInt(parts[1], 10);
281
+ const user = parts[2];
282
+ const name = parts[8];
283
+ const portMatch = name.match(/:(\d+)$/);
284
+ if (portMatch) {
285
+ ports.push({ port: parseInt(portMatch[1], 10), pid, command, user });
286
+ }
287
+ }
288
+
289
+ const seen = new Set();
290
+ const deduplicated = ports.filter(p => {
291
+ if (seen.has(p.port)) return false;
292
+ seen.add(p.port);
293
+ return true;
294
+ }).sort((a, b) => a.port - b.port);
295
+
296
+ systemPortsCache = { data: deduplicated, timestamp: now };
297
+ return deduplicated;
298
+ } catch (err) {
299
+ logger.error('system_port_scan_failed', { error: err.message });
300
+ return systemPortsCache.data || [];
301
+ }
302
+ }
303
+
304
+ function isPortInUseOnSystem(port) {
305
+ try {
306
+ const result = spawnSync('lsof', ['-i', `:${port}`, '-P', '-n', '-sTCP:LISTEN'], {
307
+ encoding: 'utf8',
308
+ timeout: 2000
309
+ });
310
+ return result.status === 0 && result.stdout && result.stdout.trim().length > 0;
311
+ } catch {
312
+ return false;
313
+ }
314
+ }
315
+
316
+ function cleanupStale() {
317
+ const entries = stmts.getAll.all();
318
+ const freed = [];
319
+ for (const entry of entries) {
320
+ if (!isProcessAlive(entry.pid)) {
321
+ stmts.deleteByPort.run(entry.port);
322
+ freed.push({ port: entry.port, project: entry.project });
323
+ }
324
+ }
325
+ if (freed.length > 0) {
326
+ metrics.total_cleanups++;
327
+ metrics.ports_freed_by_cleanup += freed.length;
328
+ logger.info('cleanup_completed', { freed_count: freed.length, freed_ports: freed });
329
+ }
330
+ return freed;
331
+ }
332
+
333
+ function findAvailablePort() {
334
+ const dbUsed = stmts.getAllPorts.all().map(r => r.port);
335
+ const systemPorts = getSystemPorts().map(p => p.port);
336
+ const usedSet = new Set([...dbUsed, ...systemPorts, ...RESERVED_PORTS]);
337
+
338
+ for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
339
+ if (!usedSet.has(port) && !isPortInUseOnSystem(port)) {
340
+ return port;
341
+ }
342
+ }
343
+ throw new Error('No available ports in range');
344
+ }
345
+
346
+ // Atomic port assignment with retry (race condition fix)
347
+ const assignPortTransaction = db.transaction((port, project, pid, now) => {
348
+ stmts.insert.run(port, project, pid, now, now);
349
+ return true;
350
+ });
351
+
352
+ function assignPortWithRetry(project, preferredPort, requestingPid, maxRetries = 3) {
353
+ const now = Date.now();
354
+
355
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
356
+ try {
357
+ let port = preferredPort || findAvailablePort();
358
+ const existing = stmts.getByPort.get(port);
359
+
360
+ if (existing) {
361
+ if (isProcessAlive(existing.pid)) {
362
+ if (preferredPort) { port = findAvailablePort(); preferredPort = null; }
363
+ continue;
364
+ } else {
365
+ stmts.deleteByPort.run(port);
366
+ }
367
+ }
368
+
369
+ if (isPortInUseOnSystem(port)) {
370
+ if (preferredPort) { port = findAvailablePort(); preferredPort = null; }
371
+ continue;
372
+ }
373
+
374
+ assignPortTransaction(port, project, requestingPid, now);
375
+ return { port, success: true, retries: attempt };
376
+
377
+ } catch (error) {
378
+ if (error.code === 'SQLITE_CONSTRAINT_UNIQUE' || error.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
379
+ metrics.race_condition_retries++;
380
+ logger.debug('port_assignment_retry', { project, attempt: attempt + 1 });
381
+ preferredPort = null;
382
+ continue;
383
+ }
384
+ throw error;
385
+ }
386
+ }
387
+ throw new Error('Failed to assign port after retries');
388
+ }
389
+
390
+ function formatUptime(seconds) {
391
+ const days = Math.floor(seconds / 86400);
392
+ const hours = Math.floor((seconds % 86400) / 3600);
393
+ const minutes = Math.floor((seconds % 3600) / 60);
394
+ if (days > 0) return `${days}d ${hours}h ${minutes}m`;
395
+ if (hours > 0) return `${hours}h ${minutes}m`;
396
+ return `${minutes}m`;
397
+ }
398
+
399
+ // =============================================================================
400
+ // API ENDPOINTS
401
+ // =============================================================================
402
+
403
+ app.get('/version', (req, res) => {
404
+ res.json({ version: VERSION, service: 'port-daddy', node_version: process.version });
405
+ });
406
+
407
+ app.get('/metrics', (req, res) => {
408
+ const uptime_seconds = Math.floor((Date.now() - metrics.uptime_start) / 1000);
409
+ res.json({
410
+ ...metrics,
411
+ active_ports: stmts.countAll.get().count,
412
+ uptime_seconds,
413
+ uptime_formatted: formatUptime(uptime_seconds)
414
+ });
415
+ });
416
+
417
+ app.post('/ports/request', (req, res) => {
418
+ try {
419
+ const { project, preferred } = req.body;
420
+
421
+ const projectValidation = validateProjectName(project);
422
+ if (!projectValidation.valid) {
423
+ metrics.validation_failures++;
424
+ logger.warn('validation_failed', { field: 'project', error: projectValidation.error });
425
+ return res.status(400).json({ error: projectValidation.error });
426
+ }
427
+
428
+ const pidValidation = validatePid(req.headers['x-pid']);
429
+ if (!pidValidation.valid) {
430
+ metrics.validation_failures++;
431
+ return res.status(400).json({ error: pidValidation.error });
432
+ }
433
+ const requestingPid = pidValidation.pid || process.pid;
434
+
435
+ const portValidation = validatePreferredPort(preferred, PORT_RANGE_START, PORT_RANGE_END, RESERVED_PORTS);
436
+ if (!portValidation.valid) {
437
+ metrics.validation_failures++;
438
+ return res.status(400).json({ error: portValidation.error });
439
+ }
440
+
441
+ const now = Date.now();
442
+ const existing = stmts.getByProject.get(project);
443
+
444
+ if (existing) {
445
+ if (isProcessAlive(existing.pid)) {
446
+ stmts.updateLastSeen.run(now, existing.port);
447
+ logger.info('port_renewed', { port: existing.port, project, pid: existing.pid });
448
+ return res.json({ port: existing.port, message: 'existing assignment renewed', existing: true });
449
+ } else {
450
+ stmts.deleteByPort.run(existing.port);
451
+ logger.info('stale_assignment_cleared', { port: existing.port, project, old_pid: existing.pid });
452
+ }
453
+ }
454
+
455
+ let portToTry = portValidation.port;
456
+ if (portToTry && isPortInUseOnSystem(portToTry)) {
457
+ logger.info('preferred_port_system_conflict', { port: portToTry, project });
458
+ portToTry = null;
459
+ }
460
+
461
+ const result = assignPortWithRetry(project, portToTry, requestingPid);
462
+ metrics.total_assignments++;
463
+ logger.info('port_assigned', { port: result.port, project, pid: requestingPid, preferred: !!portValidation.port && result.port === portValidation.port, retries: result.retries });
464
+
465
+ res.json({ port: result.port, message: portValidation.port && result.port === portValidation.port ? 'assigned preferred port' : 'port assigned successfully' });
466
+
467
+ } catch (error) {
468
+ metrics.errors++;
469
+ logger.error('port_request_failed', { error: error.message, project: req.body?.project ? 'provided' : 'missing' });
470
+ res.status(500).json({ error: error.message });
471
+ }
472
+ });
473
+
474
+ app.delete('/ports/release', (req, res) => {
475
+ try {
476
+ const { port, project } = req.body;
477
+
478
+ if (port !== undefined) {
479
+ const portValidation = validatePort(port);
480
+ if (!portValidation.valid) {
481
+ metrics.validation_failures++;
482
+ return res.status(400).json({ error: portValidation.error });
483
+ }
484
+ const existing = stmts.getByPort.get(portValidation.port);
485
+ stmts.deleteByPort.run(portValidation.port);
486
+ metrics.total_releases++;
487
+ logger.info('port_released', { port: portValidation.port, project: existing?.project });
488
+ res.json({ success: true, message: `released port ${portValidation.port}` });
489
+
490
+ } else if (project !== undefined) {
491
+ const projectValidation = validateProjectName(project);
492
+ if (!projectValidation.valid) {
493
+ metrics.validation_failures++;
494
+ return res.status(400).json({ error: projectValidation.error });
495
+ }
496
+ const result = stmts.deleteByProject.run(project);
497
+ metrics.total_releases += result.changes;
498
+ logger.info('ports_released_by_project', { project, count: result.changes });
499
+ res.json({ success: true, message: `released ${result.changes} port(s) for project ${project}` });
500
+
501
+ } else {
502
+ res.status(400).json({ error: 'port or project required' });
503
+ }
504
+ } catch (error) {
505
+ metrics.errors++;
506
+ logger.error('port_release_failed', { error: error.message });
507
+ res.status(500).json({ error: error.message });
508
+ }
509
+ });
510
+
511
+ app.get('/ports/active', (req, res) => {
512
+ try {
513
+ const entries = stmts.getAllFull.all();
514
+ const enhanced = entries.map(e => ({
515
+ ...e,
516
+ alive: isProcessAlive(e.pid),
517
+ age_minutes: Math.floor((Date.now() - e.started) / 60000),
518
+ started_at: new Date(e.started).toISOString(),
519
+ last_seen_at: new Date(e.last_seen).toISOString()
520
+ }));
521
+ res.json({ ports: enhanced, count: enhanced.length });
522
+ } catch (error) {
523
+ metrics.errors++;
524
+ logger.error('list_ports_failed', { error: error.message });
525
+ res.status(500).json({ error: error.message });
526
+ }
527
+ });
528
+
529
+ const systemPortsLimiter = rateLimit({ windowMs: 60000, max: 30, message: { error: 'System port scanning rate limited' } });
530
+
531
+ app.get('/ports/system', systemPortsLimiter, (req, res) => {
532
+ try {
533
+ const systemPorts = getSystemPorts();
534
+ const dbAssignments = stmts.getPortProject.all();
535
+ const dbMap = new Map(dbAssignments.map(a => [a.port, a.project]));
536
+
537
+ let filtered = systemPorts.map(p => ({ ...p, managed_by_port_daddy: dbMap.has(p.port), project: dbMap.get(p.port) || null }));
538
+
539
+ if (req.query.range_only === 'true') filtered = filtered.filter(p => p.port >= PORT_RANGE_START && p.port <= PORT_RANGE_END);
540
+ if (req.query.unmanaged_only === 'true') filtered = filtered.filter(p => !p.managed_by_port_daddy);
541
+
542
+ res.json({ ports: filtered, count: filtered.length, total_system_ports: systemPorts.length });
543
+ } catch (error) {
544
+ metrics.errors++;
545
+ logger.error('system_ports_failed', { error: error.message });
546
+ res.status(500).json({ error: error.message });
547
+ }
548
+ });
549
+
550
+ app.post('/ports/cleanup', (req, res) => {
551
+ try {
552
+ const freed = cleanupStale();
553
+ res.json({ freed, count: freed.length });
554
+ } catch (error) {
555
+ metrics.errors++;
556
+ logger.error('cleanup_failed', { error: error.message });
557
+ res.status(500).json({ error: error.message });
558
+ }
559
+ });
560
+
561
+ app.get('/health', (req, res) => {
562
+ res.json({ status: 'ok', version: VERSION, uptime_seconds: Math.floor(process.uptime()), active_ports: stmts.countAll.get().count, pid: process.pid });
563
+ });
564
+
565
+ // =============================================================================
566
+ // LIFECYCLE
567
+ // =============================================================================
568
+
569
+ setInterval(() => cleanupStale(), config.cleanup.interval_ms);
570
+
571
+ function shutdown(signal) {
572
+ logger.info('shutdown_initiated', { signal });
573
+ db.close();
574
+ process.exit(0);
575
+ }
576
+
577
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
578
+ process.on('SIGINT', () => shutdown('SIGINT'));
579
+
580
+ app.listen(PORT, config.service.host, () => {
581
+ logger.info('server_started', { port: PORT, host: config.service.host, db_path: DB_PATH, port_range: `${PORT_RANGE_START}-${PORT_RANGE_END}` });
582
+ console.log(`
583
+ Port Daddy v${VERSION}
584
+ ────────────────────────────────────
585
+ Service: http://${config.service.host}:${PORT}
586
+ Dashboard: http://${config.service.host}:${PORT}/
587
+ Database: ${DB_PATH}
588
+ Port range: ${PORT_RANGE_START}-${PORT_RANGE_END}
589
+ ────────────────────────────────────
590
+ Ready to assign ports!
591
+ `);
592
+ });