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/LICENSE +21 -0
- package/README.md +916 -0
- package/bin/get-port.js +128 -0
- package/bin/list-ports.js +68 -0
- package/bin/port-daddy.js +102 -0
- package/bin/release-port.js +103 -0
- package/bin/system-ports.js +102 -0
- package/completions/port-daddy.bash +89 -0
- package/config.json +26 -0
- package/install-daemon.js +133 -0
- package/package.json +68 -0
- package/public/favicon.png +0 -0
- package/public/index.html +225 -0
- package/server.js +592 -0
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
|
+
});
|