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/portok.mjs
ADDED
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Portok CLI - Command-line interface for portokd daemon
|
|
5
|
+
* Communicates with the daemon via HTTP to query status, metrics, and trigger switches.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// ANSI Colors (only if TTY)
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
const isTTY = process.stdout.isTTY;
|
|
13
|
+
|
|
14
|
+
const colors = {
|
|
15
|
+
reset: isTTY ? '\x1b[0m' : '',
|
|
16
|
+
bold: isTTY ? '\x1b[1m' : '',
|
|
17
|
+
dim: isTTY ? '\x1b[2m' : '',
|
|
18
|
+
red: isTTY ? '\x1b[31m' : '',
|
|
19
|
+
green: isTTY ? '\x1b[32m' : '',
|
|
20
|
+
yellow: isTTY ? '\x1b[33m' : '',
|
|
21
|
+
blue: isTTY ? '\x1b[34m' : '',
|
|
22
|
+
cyan: isTTY ? '\x1b[36m' : '',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Argument Parsing
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
function parseArgs(args) {
|
|
30
|
+
const result = {
|
|
31
|
+
command: null,
|
|
32
|
+
positional: [],
|
|
33
|
+
options: {},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < args.length; i++) {
|
|
37
|
+
const arg = args[i];
|
|
38
|
+
|
|
39
|
+
if (arg.startsWith('--')) {
|
|
40
|
+
const key = arg.slice(2);
|
|
41
|
+
const nextArg = args[i + 1];
|
|
42
|
+
|
|
43
|
+
// Check if it's a flag (no value) or has a value
|
|
44
|
+
if (!nextArg || nextArg.startsWith('--')) {
|
|
45
|
+
result.options[key] = true;
|
|
46
|
+
} else {
|
|
47
|
+
result.options[key] = nextArg;
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
} else if (!result.command) {
|
|
51
|
+
result.command = arg;
|
|
52
|
+
} else {
|
|
53
|
+
result.positional.push(arg);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// =============================================================================
|
|
61
|
+
// Env File Parser (for --instance support)
|
|
62
|
+
// =============================================================================
|
|
63
|
+
|
|
64
|
+
import fs from 'node:fs';
|
|
65
|
+
import path from 'node:path';
|
|
66
|
+
|
|
67
|
+
const ENV_FILE_DIR = '/etc/portok';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse an env file (KEY=VALUE format)
|
|
71
|
+
* Ignores comments (#) and blank lines
|
|
72
|
+
*/
|
|
73
|
+
function parseEnvFile(filePath) {
|
|
74
|
+
const result = {};
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
78
|
+
const lines = content.split('\n');
|
|
79
|
+
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
const trimmed = line.trim();
|
|
82
|
+
|
|
83
|
+
// Skip empty lines and comments
|
|
84
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
85
|
+
|
|
86
|
+
// Parse KEY=VALUE
|
|
87
|
+
const eqIndex = trimmed.indexOf('=');
|
|
88
|
+
if (eqIndex === -1) continue;
|
|
89
|
+
|
|
90
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
91
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
92
|
+
|
|
93
|
+
// Remove surrounding quotes if present
|
|
94
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
95
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
96
|
+
value = value.slice(1, -1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
result[key] = value;
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// File doesn't exist or can't be read
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve instance configuration from env file
|
|
111
|
+
*/
|
|
112
|
+
function resolveInstanceConfig(instanceName) {
|
|
113
|
+
const envFilePath = path.join(ENV_FILE_DIR, `${instanceName}.env`);
|
|
114
|
+
const envVars = parseEnvFile(envFilePath);
|
|
115
|
+
|
|
116
|
+
if (!envVars) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const listenPort = envVars.LISTEN_PORT;
|
|
121
|
+
if (!listenPort) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
url: `http://127.0.0.1:${listenPort}`,
|
|
127
|
+
token: envVars.ADMIN_TOKEN || null,
|
|
128
|
+
instanceName,
|
|
129
|
+
envFilePath,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// =============================================================================
|
|
134
|
+
// HTTP Client
|
|
135
|
+
// =============================================================================
|
|
136
|
+
|
|
137
|
+
async function request(url, options = {}) {
|
|
138
|
+
try {
|
|
139
|
+
const response = await fetch(url, {
|
|
140
|
+
...options,
|
|
141
|
+
headers: {
|
|
142
|
+
...options.headers,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const text = await response.text();
|
|
147
|
+
let data;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
data = JSON.parse(text);
|
|
151
|
+
} catch {
|
|
152
|
+
data = { raw: text };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
ok: response.ok,
|
|
157
|
+
status: response.status,
|
|
158
|
+
data,
|
|
159
|
+
};
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
status: 0,
|
|
164
|
+
data: { error: err.message },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// =============================================================================
|
|
170
|
+
// Commands
|
|
171
|
+
// =============================================================================
|
|
172
|
+
|
|
173
|
+
async function cmdStatus(baseUrl, token, options) {
|
|
174
|
+
const res = await request(`${baseUrl}/__status`, {
|
|
175
|
+
headers: { 'x-admin-token': token },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
console.error(`${colors.red}Error:${colors.reset} ${res.data.error || 'Failed to fetch status'}`);
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { instanceName, activePort, drainUntil, lastSwitch } = res.data;
|
|
184
|
+
|
|
185
|
+
if (options.json) {
|
|
186
|
+
console.log(JSON.stringify(res.data, null, 2));
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const title = instanceName && instanceName !== 'default'
|
|
191
|
+
? `Portok Status [${instanceName}]`
|
|
192
|
+
: 'Portok Status';
|
|
193
|
+
console.log(`${colors.bold}${title}${colors.reset}`);
|
|
194
|
+
console.log(`${'─'.repeat(40)}`);
|
|
195
|
+
if (instanceName && instanceName !== 'default') {
|
|
196
|
+
console.log(`${colors.dim}Instance:${colors.reset} ${instanceName}`);
|
|
197
|
+
}
|
|
198
|
+
console.log(`${colors.cyan}Active Port:${colors.reset} ${colors.bold}${activePort}${colors.reset}`);
|
|
199
|
+
|
|
200
|
+
if (drainUntil) {
|
|
201
|
+
const drainTime = new Date(drainUntil);
|
|
202
|
+
const remaining = Math.max(0, drainTime - Date.now());
|
|
203
|
+
console.log(`${colors.yellow}Drain Until:${colors.reset} ${drainUntil} (${Math.ceil(remaining / 1000)}s remaining)`);
|
|
204
|
+
} else {
|
|
205
|
+
console.log(`${colors.dim}Drain Until:${colors.reset} ${colors.dim}none${colors.reset}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (lastSwitch.at) {
|
|
209
|
+
console.log(`\n${colors.bold}Last Switch${colors.reset}`);
|
|
210
|
+
console.log(` From: ${lastSwitch.from}`);
|
|
211
|
+
console.log(` To: ${lastSwitch.to}`);
|
|
212
|
+
console.log(` At: ${lastSwitch.at}`);
|
|
213
|
+
console.log(` Reason: ${lastSwitch.reason}`);
|
|
214
|
+
console.log(` ID: ${colors.dim}${lastSwitch.id}${colors.reset}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function cmdMetrics(baseUrl, token, options) {
|
|
221
|
+
const res = await request(`${baseUrl}/__metrics`, {
|
|
222
|
+
headers: { 'x-admin-token': token },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (!res.ok) {
|
|
226
|
+
console.error(`${colors.red}Error:${colors.reset} ${res.data.error || 'Failed to fetch metrics'}`);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (options.json) {
|
|
231
|
+
console.log(JSON.stringify(res.data, null, 2));
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const m = res.data;
|
|
236
|
+
|
|
237
|
+
console.log(`${colors.bold}Portok Metrics${colors.reset}`);
|
|
238
|
+
console.log(`${'─'.repeat(50)}`);
|
|
239
|
+
|
|
240
|
+
console.log(`\n${colors.cyan}Traffic${colors.reset}`);
|
|
241
|
+
console.log(` Total Requests: ${m.totalRequests.toLocaleString()}`);
|
|
242
|
+
console.log(` Rolling RPS(60s): ${colors.bold}${m.rollingRps60}${colors.reset}`);
|
|
243
|
+
console.log(` Inflight: ${m.inflight} (max: ${m.inflightMax})`);
|
|
244
|
+
|
|
245
|
+
console.log(`\n${colors.cyan}Status Codes${colors.reset}`);
|
|
246
|
+
console.log(` 2xx: ${colors.green}${m.statusCounters['2xx']}${colors.reset}`);
|
|
247
|
+
console.log(` 3xx: ${colors.blue}${m.statusCounters['3xx']}${colors.reset}`);
|
|
248
|
+
console.log(` 4xx: ${colors.yellow}${m.statusCounters['4xx']}${colors.reset}`);
|
|
249
|
+
console.log(` 5xx: ${colors.red}${m.statusCounters['5xx']}${colors.reset}`);
|
|
250
|
+
|
|
251
|
+
console.log(`\n${colors.cyan}Errors${colors.reset}`);
|
|
252
|
+
console.log(` Proxy Errors: ${m.totalProxyErrors}`);
|
|
253
|
+
if (m.lastProxyError) {
|
|
254
|
+
console.log(` Last Error: ${m.lastProxyError.message} @ ${m.lastProxyError.timestamp}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
console.log(`\n${colors.cyan}Health${colors.reset}`);
|
|
258
|
+
const healthIcon = m.health.activePortOk ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
|
|
259
|
+
console.log(` Active Port OK: ${healthIcon}`);
|
|
260
|
+
console.log(` Last Checked: ${m.health.lastCheckedAt || 'never'}`);
|
|
261
|
+
console.log(` Consecutive Fails: ${m.health.consecutiveFails}`);
|
|
262
|
+
|
|
263
|
+
console.log(`\n${colors.dim}Started: ${m.startedAt}${colors.reset}`);
|
|
264
|
+
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function cmdSwitch(baseUrl, token, port, options) {
|
|
269
|
+
if (!port) {
|
|
270
|
+
console.error(`${colors.red}Error:${colors.reset} Port is required`);
|
|
271
|
+
console.error('Usage: portok switch <port>');
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const portNum = parseInt(port, 10);
|
|
276
|
+
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
|
277
|
+
console.error(`${colors.red}Error:${colors.reset} Invalid port: ${port}`);
|
|
278
|
+
return 1;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
console.log(`${colors.cyan}Switching to port ${portNum}...${colors.reset}`);
|
|
282
|
+
|
|
283
|
+
const res = await request(`${baseUrl}/__switch?port=${portNum}`, {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: { 'x-admin-token': token },
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (options.json) {
|
|
289
|
+
console.log(JSON.stringify(res.data, null, 2));
|
|
290
|
+
return res.ok ? 0 : 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!res.ok) {
|
|
294
|
+
console.error(`${colors.red}Switch failed:${colors.reset} ${res.data.error || res.data.message || 'Unknown error'}`);
|
|
295
|
+
if (res.status === 409) {
|
|
296
|
+
console.error(`${colors.yellow}Hint:${colors.reset} The target port failed the health check.`);
|
|
297
|
+
console.error(` Make sure your app is running and healthy on port ${portNum}.`);
|
|
298
|
+
}
|
|
299
|
+
return 1;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
console.log(`${colors.green}✓ Switch successful!${colors.reset}`);
|
|
303
|
+
console.log(` From: ${res.data.switch.from} → To: ${res.data.switch.to}`);
|
|
304
|
+
console.log(` Switch ID: ${colors.dim}${res.data.switch.id}${colors.reset}`);
|
|
305
|
+
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// =============================================================================
|
|
310
|
+
// Management Commands (init, add, list)
|
|
311
|
+
// =============================================================================
|
|
312
|
+
|
|
313
|
+
async function cmdInit(options) {
|
|
314
|
+
const configDir = ENV_FILE_DIR;
|
|
315
|
+
const stateDir = '/var/lib/portok';
|
|
316
|
+
|
|
317
|
+
console.log(`${colors.bold}Initializing Portok...${colors.reset}\n`);
|
|
318
|
+
|
|
319
|
+
// Check if running as root (needed for /etc and /var/lib)
|
|
320
|
+
const isRoot = process.getuid && process.getuid() === 0;
|
|
321
|
+
|
|
322
|
+
if (!isRoot && !options.force) {
|
|
323
|
+
console.log(`${colors.yellow}Warning:${colors.reset} This command typically requires root privileges.`);
|
|
324
|
+
console.log(`Run with sudo or use --force to attempt anyway.\n`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const results = [];
|
|
328
|
+
|
|
329
|
+
// Create config directory
|
|
330
|
+
try {
|
|
331
|
+
if (!fs.existsSync(configDir)) {
|
|
332
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o755 });
|
|
333
|
+
results.push({ path: configDir, status: 'created' });
|
|
334
|
+
} else {
|
|
335
|
+
results.push({ path: configDir, status: 'exists' });
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
results.push({ path: configDir, status: 'error', error: err.message });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Create state directory
|
|
342
|
+
try {
|
|
343
|
+
if (!fs.existsSync(stateDir)) {
|
|
344
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o755 });
|
|
345
|
+
results.push({ path: stateDir, status: 'created' });
|
|
346
|
+
} else {
|
|
347
|
+
results.push({ path: stateDir, status: 'exists' });
|
|
348
|
+
}
|
|
349
|
+
} catch (err) {
|
|
350
|
+
results.push({ path: stateDir, status: 'error', error: err.message });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (options.json) {
|
|
354
|
+
console.log(JSON.stringify({ success: true, results }, null, 2));
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Display results
|
|
359
|
+
for (const r of results) {
|
|
360
|
+
const icon = r.status === 'created' ? `${colors.green}✓${colors.reset}` :
|
|
361
|
+
r.status === 'exists' ? `${colors.dim}○${colors.reset}` :
|
|
362
|
+
`${colors.red}✗${colors.reset}`;
|
|
363
|
+
const status = r.status === 'created' ? 'Created' :
|
|
364
|
+
r.status === 'exists' ? 'Already exists' :
|
|
365
|
+
`Error: ${r.error}`;
|
|
366
|
+
console.log(` ${icon} ${r.path} - ${status}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const hasError = results.some(r => r.status === 'error');
|
|
370
|
+
if (hasError) {
|
|
371
|
+
console.log(`\n${colors.red}Some directories could not be created.${colors.reset}`);
|
|
372
|
+
console.log('Try running with: sudo portok init');
|
|
373
|
+
return 1;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
console.log(`\n${colors.green}Portok initialized successfully!${colors.reset}`);
|
|
377
|
+
console.log(`\nNext steps:`);
|
|
378
|
+
console.log(` 1. Create a service: ${colors.cyan}portok add <name>${colors.reset}`);
|
|
379
|
+
console.log(` 2. Or manually create: ${colors.dim}${configDir}/<name>.env${colors.reset}`);
|
|
380
|
+
|
|
381
|
+
return 0;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function generateToken(length = 32) {
|
|
385
|
+
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
386
|
+
let token = '';
|
|
387
|
+
const randomBytes = require('crypto').randomBytes(length);
|
|
388
|
+
for (let i = 0; i < length; i++) {
|
|
389
|
+
token += chars[randomBytes[i] % chars.length];
|
|
390
|
+
}
|
|
391
|
+
return token;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function cmdAdd(name, options) {
|
|
395
|
+
if (!name) {
|
|
396
|
+
console.error(`${colors.red}Error:${colors.reset} Service name is required`);
|
|
397
|
+
console.error('Usage: portok add <name> [--port <listen_port>] [--target <target_port>]');
|
|
398
|
+
return 1;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Validate name (alphanumeric, dash, underscore)
|
|
402
|
+
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) {
|
|
403
|
+
console.error(`${colors.red}Error:${colors.reset} Invalid service name '${name}'`);
|
|
404
|
+
console.error('Name must start with a letter and contain only letters, numbers, dashes, underscores.');
|
|
405
|
+
return 1;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const envFilePath = path.join(ENV_FILE_DIR, `${name}.env`);
|
|
409
|
+
|
|
410
|
+
// Check if already exists
|
|
411
|
+
if (fs.existsSync(envFilePath) && !options.force) {
|
|
412
|
+
console.error(`${colors.red}Error:${colors.reset} Service '${name}' already exists at ${envFilePath}`);
|
|
413
|
+
console.error('Use --force to overwrite.');
|
|
414
|
+
return 1;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Get or generate configuration
|
|
418
|
+
const listenPort = options.port || (3000 + Math.floor(Math.random() * 1000));
|
|
419
|
+
const targetPort = options.target || (8000 + Math.floor(Math.random() * 1000));
|
|
420
|
+
const adminToken = options.token || generateToken();
|
|
421
|
+
const healthPath = options.health || '/health';
|
|
422
|
+
|
|
423
|
+
const envContent = `# Portok instance configuration for "${name}"
|
|
424
|
+
# Generated by: portok add ${name}
|
|
425
|
+
# Date: ${new Date().toISOString()}
|
|
426
|
+
|
|
427
|
+
# Required: Port the proxy listens on
|
|
428
|
+
LISTEN_PORT=${listenPort}
|
|
429
|
+
|
|
430
|
+
# Required: Initial target port (your app's port)
|
|
431
|
+
INITIAL_TARGET_PORT=${targetPort}
|
|
432
|
+
|
|
433
|
+
# Required: Admin token for API authentication
|
|
434
|
+
ADMIN_TOKEN=${adminToken}
|
|
435
|
+
|
|
436
|
+
# Optional: Health check configuration
|
|
437
|
+
HEALTH_PATH=${healthPath}
|
|
438
|
+
HEALTH_TIMEOUT_MS=5000
|
|
439
|
+
|
|
440
|
+
# Optional: Drain configuration (30 seconds)
|
|
441
|
+
DRAIN_MS=30000
|
|
442
|
+
|
|
443
|
+
# Optional: Rollback configuration
|
|
444
|
+
ROLLBACK_WINDOW_MS=60000
|
|
445
|
+
ROLLBACK_CHECK_EVERY_MS=5000
|
|
446
|
+
ROLLBACK_FAIL_THRESHOLD=3
|
|
447
|
+
`;
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
// Check if directory exists
|
|
451
|
+
if (!fs.existsSync(ENV_FILE_DIR)) {
|
|
452
|
+
console.error(`${colors.red}Error:${colors.reset} Config directory ${ENV_FILE_DIR} does not exist.`);
|
|
453
|
+
console.error(`Run ${colors.cyan}sudo portok init${colors.reset} first.`);
|
|
454
|
+
return 1;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
fs.writeFileSync(envFilePath, envContent, { mode: 0o600 });
|
|
458
|
+
} catch (err) {
|
|
459
|
+
console.error(`${colors.red}Error:${colors.reset} Could not create ${envFilePath}`);
|
|
460
|
+
console.error(err.message);
|
|
461
|
+
console.error(`Try running with: sudo portok add ${name}`);
|
|
462
|
+
return 1;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (options.json) {
|
|
466
|
+
console.log(JSON.stringify({
|
|
467
|
+
success: true,
|
|
468
|
+
name,
|
|
469
|
+
envFile: envFilePath,
|
|
470
|
+
listenPort,
|
|
471
|
+
targetPort,
|
|
472
|
+
adminToken,
|
|
473
|
+
}, null, 2));
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
console.log(`${colors.green}✓ Service '${name}' created successfully!${colors.reset}\n`);
|
|
478
|
+
console.log(`${colors.bold}Configuration:${colors.reset}`);
|
|
479
|
+
console.log(` File: ${envFilePath}`);
|
|
480
|
+
console.log(` Listen Port: ${colors.cyan}${listenPort}${colors.reset}`);
|
|
481
|
+
console.log(` Target Port: ${targetPort}`);
|
|
482
|
+
console.log(` Admin Token: ${colors.dim}${adminToken}${colors.reset}`);
|
|
483
|
+
console.log(` Health Path: ${healthPath}`);
|
|
484
|
+
|
|
485
|
+
console.log(`\n${colors.bold}Next steps:${colors.reset}`);
|
|
486
|
+
console.log(` 1. Start your app on port ${targetPort} with ${healthPath} endpoint`);
|
|
487
|
+
console.log(` 2. Start the service:`);
|
|
488
|
+
console.log(` ${colors.cyan}sudo systemctl start portok@${name}${colors.reset}`);
|
|
489
|
+
console.log(` 3. Enable at boot:`);
|
|
490
|
+
console.log(` ${colors.cyan}sudo systemctl enable portok@${name}${colors.reset}`);
|
|
491
|
+
console.log(` 4. Check status:`);
|
|
492
|
+
console.log(` ${colors.cyan}portok status --instance ${name}${colors.reset}`);
|
|
493
|
+
|
|
494
|
+
return 0;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function cmdList(options) {
|
|
498
|
+
const configDir = ENV_FILE_DIR;
|
|
499
|
+
|
|
500
|
+
if (!fs.existsSync(configDir)) {
|
|
501
|
+
if (options.json) {
|
|
502
|
+
console.log(JSON.stringify({ instances: [], error: 'Config directory not found' }, null, 2));
|
|
503
|
+
} else {
|
|
504
|
+
console.log(`${colors.yellow}No instances configured.${colors.reset}`);
|
|
505
|
+
console.log(`Run ${colors.cyan}sudo portok init${colors.reset} to initialize.`);
|
|
506
|
+
}
|
|
507
|
+
return 0;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Find all .env files
|
|
511
|
+
let files;
|
|
512
|
+
try {
|
|
513
|
+
files = fs.readdirSync(configDir).filter(f => f.endsWith('.env'));
|
|
514
|
+
} catch (err) {
|
|
515
|
+
console.error(`${colors.red}Error:${colors.reset} Could not read ${configDir}: ${err.message}`);
|
|
516
|
+
return 1;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (files.length === 0) {
|
|
520
|
+
if (options.json) {
|
|
521
|
+
console.log(JSON.stringify({ instances: [] }, null, 2));
|
|
522
|
+
} else {
|
|
523
|
+
console.log(`${colors.yellow}No instances configured.${colors.reset}`);
|
|
524
|
+
console.log(`Run ${colors.cyan}portok add <name>${colors.reset} to create one.`);
|
|
525
|
+
}
|
|
526
|
+
return 0;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const instances = [];
|
|
530
|
+
|
|
531
|
+
for (const file of files) {
|
|
532
|
+
const name = file.replace('.env', '');
|
|
533
|
+
const envFilePath = path.join(configDir, file);
|
|
534
|
+
const config = parseEnvFile(envFilePath);
|
|
535
|
+
|
|
536
|
+
if (!config) continue;
|
|
537
|
+
|
|
538
|
+
const instance = {
|
|
539
|
+
name,
|
|
540
|
+
envFile: envFilePath,
|
|
541
|
+
listenPort: config.LISTEN_PORT ? parseInt(config.LISTEN_PORT) : null,
|
|
542
|
+
targetPort: config.INITIAL_TARGET_PORT ? parseInt(config.INITIAL_TARGET_PORT) : null,
|
|
543
|
+
healthPath: config.HEALTH_PATH || '/health',
|
|
544
|
+
status: 'unknown',
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// Check if daemon is running (try to connect)
|
|
548
|
+
if (instance.listenPort && config.ADMIN_TOKEN) {
|
|
549
|
+
try {
|
|
550
|
+
const res = await request(`http://127.0.0.1:${instance.listenPort}/__status`, {
|
|
551
|
+
headers: { 'x-admin-token': config.ADMIN_TOKEN },
|
|
552
|
+
});
|
|
553
|
+
if (res.ok) {
|
|
554
|
+
instance.status = 'running';
|
|
555
|
+
instance.activePort = res.data.activePort;
|
|
556
|
+
} else {
|
|
557
|
+
instance.status = 'stopped';
|
|
558
|
+
}
|
|
559
|
+
} catch {
|
|
560
|
+
instance.status = 'stopped';
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
instances.push(instance);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (options.json) {
|
|
568
|
+
console.log(JSON.stringify({ instances }, null, 2));
|
|
569
|
+
return 0;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
console.log(`${colors.bold}Portok Instances${colors.reset}`);
|
|
573
|
+
console.log(`${'─'.repeat(70)}`);
|
|
574
|
+
|
|
575
|
+
if (instances.length === 0) {
|
|
576
|
+
console.log(`${colors.dim}No instances found.${colors.reset}`);
|
|
577
|
+
return 0;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Table header
|
|
581
|
+
console.log(
|
|
582
|
+
`${'Name'.padEnd(15)} ` +
|
|
583
|
+
`${'Listen'.padEnd(8)} ` +
|
|
584
|
+
`${'Target'.padEnd(8)} ` +
|
|
585
|
+
`${'Active'.padEnd(8)} ` +
|
|
586
|
+
`${'Status'.padEnd(10)}`
|
|
587
|
+
);
|
|
588
|
+
console.log(`${'─'.repeat(70)}`);
|
|
589
|
+
|
|
590
|
+
for (const inst of instances) {
|
|
591
|
+
const statusColor = inst.status === 'running' ? colors.green :
|
|
592
|
+
inst.status === 'stopped' ? colors.red : colors.dim;
|
|
593
|
+
const statusIcon = inst.status === 'running' ? '●' :
|
|
594
|
+
inst.status === 'stopped' ? '○' : '?';
|
|
595
|
+
|
|
596
|
+
console.log(
|
|
597
|
+
`${inst.name.padEnd(15)} ` +
|
|
598
|
+
`${String(inst.listenPort || '-').padEnd(8)} ` +
|
|
599
|
+
`${String(inst.targetPort || '-').padEnd(8)} ` +
|
|
600
|
+
`${String(inst.activePort || '-').padEnd(8)} ` +
|
|
601
|
+
`${statusColor}${statusIcon} ${inst.status}${colors.reset}`
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
console.log(`\n${colors.dim}Total: ${instances.length} instance(s)${colors.reset}`);
|
|
606
|
+
|
|
607
|
+
return 0;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function cmdHealth(baseUrl, token, options) {
|
|
611
|
+
const res = await request(`${baseUrl}/__health`, {
|
|
612
|
+
headers: { 'x-admin-token': token },
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
if (options.json) {
|
|
616
|
+
console.log(JSON.stringify(res.data, null, 2));
|
|
617
|
+
return res.ok ? 0 : 1;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (res.ok && res.data.healthy) {
|
|
621
|
+
console.log(`${colors.green}✓ Healthy${colors.reset}`);
|
|
622
|
+
console.log(` Active Port: ${res.data.activePort}`);
|
|
623
|
+
console.log(` Checked At: ${res.data.checkedAt}`);
|
|
624
|
+
return 0;
|
|
625
|
+
} else {
|
|
626
|
+
console.error(`${colors.red}✗ Unhealthy${colors.reset}`);
|
|
627
|
+
console.error(` Active Port: ${res.data.activePort || 'unknown'}`);
|
|
628
|
+
console.error(` Checked At: ${res.data.checkedAt || 'unknown'}`);
|
|
629
|
+
return 1;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function showHelp() {
|
|
634
|
+
console.log(`
|
|
635
|
+
${colors.bold}portok${colors.reset} - CLI for portokd zero-downtime proxy daemon
|
|
636
|
+
|
|
637
|
+
${colors.bold}USAGE${colors.reset}
|
|
638
|
+
portok <command> [options]
|
|
639
|
+
|
|
640
|
+
${colors.bold}COMMANDS${colors.reset}
|
|
641
|
+
${colors.cyan}Management:${colors.reset}
|
|
642
|
+
init Initialize portok directories (/etc/portok, /var/lib/portok)
|
|
643
|
+
add <name> Create a new service instance
|
|
644
|
+
list List all configured instances and their status
|
|
645
|
+
|
|
646
|
+
${colors.cyan}Operations:${colors.reset}
|
|
647
|
+
status Show current proxy status (activePort, drainUntil, lastSwitch)
|
|
648
|
+
metrics Show proxy metrics (requests, errors, health, RPS)
|
|
649
|
+
switch <port> Switch to a new target port (with health check)
|
|
650
|
+
health Check health of the current active port
|
|
651
|
+
|
|
652
|
+
${colors.bold}OPTIONS${colors.reset}
|
|
653
|
+
--url <url> Daemon URL (default: http://127.0.0.1:3000)
|
|
654
|
+
--instance <name> Target instance by name (reads /etc/portok/<name>.env)
|
|
655
|
+
--token <token> Admin token for authentication
|
|
656
|
+
--json Output as JSON (for scripting)
|
|
657
|
+
--help Show this help message
|
|
658
|
+
|
|
659
|
+
${colors.dim}For 'add' command:${colors.reset}
|
|
660
|
+
--port <port> Listen port (default: random 3000-3999)
|
|
661
|
+
--target <port> Target port (default: random 8000-8999)
|
|
662
|
+
--health <path> Health check path (default: /health)
|
|
663
|
+
--force Overwrite existing config
|
|
664
|
+
|
|
665
|
+
${colors.bold}EXAMPLES${colors.reset}
|
|
666
|
+
${colors.dim}# Initialize portok (run once, requires sudo)${colors.reset}
|
|
667
|
+
sudo portok init
|
|
668
|
+
|
|
669
|
+
${colors.dim}# Create a new service${colors.reset}
|
|
670
|
+
sudo portok add api --port 3001 --target 8001
|
|
671
|
+
sudo portok add web --port 3002 --target 8002
|
|
672
|
+
|
|
673
|
+
${colors.dim}# List all instances${colors.reset}
|
|
674
|
+
portok list
|
|
675
|
+
|
|
676
|
+
${colors.dim}# Check status of an instance${colors.reset}
|
|
677
|
+
portok status --instance api
|
|
678
|
+
|
|
679
|
+
${colors.dim}# Switch to new port${colors.reset}
|
|
680
|
+
portok switch 8081 --instance api
|
|
681
|
+
|
|
682
|
+
${colors.dim}# Get metrics as JSON${colors.reset}
|
|
683
|
+
portok metrics --instance api --json
|
|
684
|
+
|
|
685
|
+
${colors.dim}# Direct URL mode (without instance)${colors.reset}
|
|
686
|
+
portok status --url http://127.0.0.1:3000 --token mysecret
|
|
687
|
+
|
|
688
|
+
${colors.bold}MULTI-INSTANCE${colors.reset}
|
|
689
|
+
When using --instance, the CLI reads /etc/portok/<name>.env
|
|
690
|
+
to resolve LISTEN_PORT and ADMIN_TOKEN for that instance.
|
|
691
|
+
Explicit --url and --token flags override env file values.
|
|
692
|
+
|
|
693
|
+
${colors.bold}EXIT CODES${colors.reset}
|
|
694
|
+
0 Success
|
|
695
|
+
1 Failure (error or unhealthy)
|
|
696
|
+
`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// =============================================================================
|
|
700
|
+
// Main
|
|
701
|
+
// =============================================================================
|
|
702
|
+
|
|
703
|
+
async function main() {
|
|
704
|
+
const args = parseArgs(process.argv.slice(2));
|
|
705
|
+
|
|
706
|
+
// Help flag
|
|
707
|
+
if (args.options.help || args.command === 'help') {
|
|
708
|
+
showHelp();
|
|
709
|
+
process.exit(0);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Management commands (don't require daemon connection)
|
|
713
|
+
const managementCommands = ['init', 'add', 'list'];
|
|
714
|
+
|
|
715
|
+
if (managementCommands.includes(args.command)) {
|
|
716
|
+
let exitCode = 1;
|
|
717
|
+
switch (args.command) {
|
|
718
|
+
case 'init':
|
|
719
|
+
exitCode = await cmdInit(args.options);
|
|
720
|
+
break;
|
|
721
|
+
case 'add':
|
|
722
|
+
exitCode = await cmdAdd(args.positional[0], args.options);
|
|
723
|
+
break;
|
|
724
|
+
case 'list':
|
|
725
|
+
exitCode = await cmdList(args.options);
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
process.exit(exitCode);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Operational commands require authentication
|
|
732
|
+
// Get configuration - support --instance flag
|
|
733
|
+
let baseUrl = args.options.url || process.env.PORTOK_URL;
|
|
734
|
+
let token = args.options.token || process.env.PORTOK_TOKEN;
|
|
735
|
+
|
|
736
|
+
// If --instance is provided, resolve from env file
|
|
737
|
+
if (args.options.instance) {
|
|
738
|
+
const instanceConfig = resolveInstanceConfig(args.options.instance);
|
|
739
|
+
|
|
740
|
+
if (!instanceConfig) {
|
|
741
|
+
console.error(`${colors.red}Error:${colors.reset} Could not resolve instance '${args.options.instance}'`);
|
|
742
|
+
console.error(`Make sure ${ENV_FILE_DIR}/${args.options.instance}.env exists and contains LISTEN_PORT`);
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Env file values are defaults; explicit flags override
|
|
747
|
+
if (!baseUrl) baseUrl = instanceConfig.url;
|
|
748
|
+
if (!token) token = instanceConfig.token;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Apply final defaults
|
|
752
|
+
baseUrl = baseUrl || 'http://127.0.0.1:3000';
|
|
753
|
+
|
|
754
|
+
if (!token) {
|
|
755
|
+
console.error(`${colors.red}Error:${colors.reset} Admin token is required`);
|
|
756
|
+
console.error('Set --token flag, PORTOK_TOKEN env var, or use --instance with ADMIN_TOKEN in env file');
|
|
757
|
+
process.exit(1);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Route to operational command
|
|
761
|
+
let exitCode = 1;
|
|
762
|
+
|
|
763
|
+
switch (args.command) {
|
|
764
|
+
case 'status':
|
|
765
|
+
exitCode = await cmdStatus(baseUrl, token, args.options);
|
|
766
|
+
break;
|
|
767
|
+
case 'metrics':
|
|
768
|
+
exitCode = await cmdMetrics(baseUrl, token, args.options);
|
|
769
|
+
break;
|
|
770
|
+
case 'switch':
|
|
771
|
+
exitCode = await cmdSwitch(baseUrl, token, args.positional[0], args.options);
|
|
772
|
+
break;
|
|
773
|
+
case 'health':
|
|
774
|
+
exitCode = await cmdHealth(baseUrl, token, args.options);
|
|
775
|
+
break;
|
|
776
|
+
default:
|
|
777
|
+
if (args.command) {
|
|
778
|
+
console.error(`${colors.red}Unknown command:${colors.reset} ${args.command}`);
|
|
779
|
+
} else {
|
|
780
|
+
console.error(`${colors.red}No command specified${colors.reset}`);
|
|
781
|
+
}
|
|
782
|
+
showHelp();
|
|
783
|
+
exitCode = 1;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
process.exit(exitCode);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
main().catch((err) => {
|
|
790
|
+
console.error(`${colors.red}Fatal error:${colors.reset} ${err.message}`);
|
|
791
|
+
process.exit(1);
|
|
792
|
+
});
|
|
793
|
+
|