portok 1.0.7 → 1.0.9
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/README.md +103 -25
- package/package.json +1 -1
- package/portok.js +408 -40
- package/portok@.service +142 -23
- package/portok@.service.nvm +128 -0
- package/test/init.test.js +70 -0
package/README.md
CHANGED
|
@@ -452,42 +452,120 @@ ADMIN_TOKEN=web-secret-token-change-me
|
|
|
452
452
|
|
|
453
453
|
### systemd Template Unit
|
|
454
454
|
|
|
455
|
-
|
|
455
|
+
The `portok init` command automatically installs and configures the systemd template:
|
|
456
456
|
|
|
457
457
|
```bash
|
|
458
|
-
#
|
|
459
|
-
sudo
|
|
458
|
+
# Initialize Portok (creates dirs, installs systemd service)
|
|
459
|
+
sudo portok init
|
|
460
460
|
|
|
461
|
-
# Create
|
|
462
|
-
sudo
|
|
463
|
-
|
|
461
|
+
# Create a new instance
|
|
462
|
+
sudo portok add api --port 3001 --target 8001
|
|
463
|
+
|
|
464
|
+
# Start and enable
|
|
465
|
+
sudo portok start api
|
|
466
|
+
sudo portok enable api
|
|
467
|
+
|
|
468
|
+
# Check status
|
|
469
|
+
sudo portok status api
|
|
470
|
+
portok logs api --follow
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
#### Node.js Installation Methods
|
|
474
|
+
|
|
475
|
+
**System-wide Node.js (Recommended for Production):**
|
|
476
|
+
```bash
|
|
477
|
+
# Install via OS package manager
|
|
478
|
+
sudo apt install nodejs # Debian/Ubuntu
|
|
479
|
+
sudo yum install nodejs # RHEL/CentOS
|
|
480
|
+
|
|
481
|
+
# Standard init
|
|
482
|
+
sudo portok init
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
**nvm/fnm/volta (Development or when system node unavailable):**
|
|
486
|
+
```bash
|
|
487
|
+
# Use --nvm flag for less restrictive security settings
|
|
488
|
+
sudo portok init --nvm
|
|
489
|
+
|
|
490
|
+
# Or specify custom node path
|
|
491
|
+
sudo portok init --node-path=/custom/path/to/node
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
**Preview changes before applying:**
|
|
495
|
+
```bash
|
|
496
|
+
sudo portok init --dry-run
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
#### Diagnosing Issues
|
|
464
500
|
|
|
465
|
-
|
|
466
|
-
sudo cp examples/api.env /etc/portok/api.env
|
|
467
|
-
sudo cp examples/web.env /etc/portok/web.env
|
|
501
|
+
Use `portok doctor` to check your installation:
|
|
468
502
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
503
|
+
```bash
|
|
504
|
+
portok doctor
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
Example output:
|
|
508
|
+
```
|
|
509
|
+
Portok Doctor - Diagnosing installation...
|
|
510
|
+
|
|
511
|
+
✓ Node.js binary
|
|
512
|
+
/usr/local/bin/node (v20.19.6)
|
|
513
|
+
✓ System Node.js
|
|
514
|
+
/usr/local/bin/node (v20.19.6)
|
|
515
|
+
✓ Config directory
|
|
516
|
+
/etc/portok (2 config files)
|
|
517
|
+
✓ State directory
|
|
518
|
+
/var/lib/portok (writable)
|
|
519
|
+
✓ systemd service
|
|
520
|
+
/etc/systemd/system/portok@.service
|
|
521
|
+
✓ ExecStart node
|
|
522
|
+
/usr/local/bin/node
|
|
523
|
+
✓ ProtectHome
|
|
524
|
+
ProtectHome=true
|
|
525
|
+
✓ systemctl
|
|
526
|
+
Available
|
|
527
|
+
✓ Running instances
|
|
528
|
+
2 running, 0 failed
|
|
529
|
+
|
|
530
|
+
All checks passed. Portok is ready to use.
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
#### Security Hardening
|
|
534
|
+
|
|
535
|
+
The default `portok@.service` includes production-grade security settings:
|
|
536
|
+
|
|
537
|
+
| Setting | Value | Purpose |
|
|
538
|
+
|---------|-------|---------|
|
|
539
|
+
| ProtectHome | true | Block access to home directories |
|
|
540
|
+
| ProtectSystem | strict | Make /usr, /boot, /etc read-only |
|
|
541
|
+
| PrivateTmp | true | Private /tmp per service |
|
|
542
|
+
| NoNewPrivileges | true | Prevent privilege escalation |
|
|
543
|
+
| ReadWritePaths | /var/lib/portok | Only state directory writable |
|
|
544
|
+
|
|
545
|
+
For nvm users, `portok init --nvm` uses `ProtectHome=read-only` to allow `~/.nvm` access.
|
|
546
|
+
|
|
547
|
+
#### Manual systemd Setup (Advanced)
|
|
548
|
+
|
|
549
|
+
For manual setup without using `portok init`:
|
|
550
|
+
|
|
551
|
+
```bash
|
|
552
|
+
# Copy the template (choose one)
|
|
553
|
+
sudo cp portok@.service /etc/systemd/system/ # Production (system node)
|
|
554
|
+
sudo cp portok@.service.nvm /etc/systemd/system/portok@.service # NVM variant
|
|
555
|
+
|
|
556
|
+
# Edit paths in the service file
|
|
557
|
+
sudo nano /etc/systemd/system/portok@.service
|
|
558
|
+
# Update: ExecStart, WorkingDirectory, User, Group
|
|
559
|
+
|
|
560
|
+
# Create directories
|
|
561
|
+
sudo mkdir -p /etc/portok /var/lib/portok
|
|
562
|
+
sudo chown $(whoami):$(id -gn) /var/lib/portok
|
|
472
563
|
|
|
473
564
|
# Reload systemd
|
|
474
565
|
sudo systemctl daemon-reload
|
|
475
566
|
|
|
476
567
|
# Start instances
|
|
477
568
|
sudo systemctl start portok@api
|
|
478
|
-
sudo systemctl start portok@web
|
|
479
|
-
|
|
480
|
-
# Enable at boot
|
|
481
|
-
sudo systemctl enable portok@api
|
|
482
|
-
sudo systemctl enable portok@web
|
|
483
|
-
|
|
484
|
-
# Check status
|
|
485
|
-
sudo systemctl status portok@api
|
|
486
|
-
sudo systemctl status portok@web
|
|
487
|
-
|
|
488
|
-
# View logs
|
|
489
|
-
journalctl -u portok@api -f
|
|
490
|
-
journalctl -u portok@web -f
|
|
491
569
|
```
|
|
492
570
|
|
|
493
571
|
### CLI with Multi-Instance
|
package/package.json
CHANGED
package/portok.js
CHANGED
|
@@ -315,9 +315,13 @@ async function cmdInit(options) {
|
|
|
315
315
|
const configDir = ENV_FILE_DIR;
|
|
316
316
|
const stateDir = '/var/lib/portok';
|
|
317
317
|
const systemdDir = '/etc/systemd/system';
|
|
318
|
-
const serviceFileName = 'portok@.service';
|
|
319
318
|
const { execSync } = require('child_process');
|
|
320
319
|
|
|
320
|
+
// Determine which service template to use
|
|
321
|
+
const useNvmVariant = options.nvm || false;
|
|
322
|
+
const serviceFileName = useNvmVariant ? 'portok@.service.nvm' : 'portok@.service';
|
|
323
|
+
const destServiceFileName = 'portok@.service'; // Always install as portok@.service
|
|
324
|
+
|
|
321
325
|
// Detect current user for service configuration
|
|
322
326
|
let currentUser = 'root';
|
|
323
327
|
let currentGroup = 'root';
|
|
@@ -331,46 +335,74 @@ async function cmdInit(options) {
|
|
|
331
335
|
|
|
332
336
|
const isRoot = process.getuid && process.getuid() === 0;
|
|
333
337
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
+
// Determine node path
|
|
339
|
+
let nodePath = options['node-path'] || process.execPath;
|
|
340
|
+
const isNvmStyle = nodePath.includes('.nvm') || nodePath.includes('.fnm') || nodePath.includes('.volta');
|
|
341
|
+
|
|
342
|
+
if (!options.json) {
|
|
343
|
+
console.log(`${colors.bold}Initializing Portok...${colors.reset}\n`);
|
|
344
|
+
console.log(` User: ${colors.cyan}${currentUser}${colors.reset}`);
|
|
345
|
+
console.log(` Group: ${colors.cyan}${currentGroup}${colors.reset}`);
|
|
346
|
+
console.log(` Node: ${colors.dim}${nodePath}${colors.reset}`);
|
|
347
|
+
if (isNvmStyle && !options.nvm) {
|
|
348
|
+
console.log(` ${colors.yellow}(nvm detected - consider using --nvm flag)${colors.reset}`);
|
|
349
|
+
}
|
|
350
|
+
if (useNvmVariant) {
|
|
351
|
+
console.log(` Mode: ${colors.yellow}NVM-compatible (less secure)${colors.reset}`);
|
|
352
|
+
} else {
|
|
353
|
+
console.log(` Mode: ${colors.green}Production (secure)${colors.reset}`);
|
|
354
|
+
}
|
|
355
|
+
console.log('');
|
|
356
|
+
}
|
|
338
357
|
|
|
339
358
|
if (!isRoot && !options.force) {
|
|
340
359
|
console.log(`${colors.yellow}Warning:${colors.reset} This command typically requires root privileges.`);
|
|
341
360
|
console.log(`Run with sudo or use --force to attempt anyway.\n`);
|
|
342
361
|
}
|
|
343
362
|
|
|
363
|
+
// Dry run mode
|
|
364
|
+
if (options['dry-run']) {
|
|
365
|
+
console.log(`${colors.cyan}Dry run mode - no changes will be made${colors.reset}\n`);
|
|
366
|
+
}
|
|
367
|
+
|
|
344
368
|
const results = [];
|
|
345
369
|
|
|
346
370
|
// Create config directory
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
fs.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
371
|
+
if (!options['dry-run']) {
|
|
372
|
+
try {
|
|
373
|
+
if (!fs.existsSync(configDir)) {
|
|
374
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o755 });
|
|
375
|
+
results.push({ path: configDir, status: 'created' });
|
|
376
|
+
} else {
|
|
377
|
+
results.push({ path: configDir, status: 'exists' });
|
|
378
|
+
}
|
|
379
|
+
} catch (err) {
|
|
380
|
+
results.push({ path: configDir, status: 'error', error: err.message });
|
|
353
381
|
}
|
|
354
|
-
}
|
|
355
|
-
results.push({ path: configDir, status: '
|
|
382
|
+
} else {
|
|
383
|
+
results.push({ path: configDir, status: fs.existsSync(configDir) ? 'exists' : 'would-create' });
|
|
356
384
|
}
|
|
357
385
|
|
|
358
386
|
// Create state directory
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
fs.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
387
|
+
if (!options['dry-run']) {
|
|
388
|
+
try {
|
|
389
|
+
if (!fs.existsSync(stateDir)) {
|
|
390
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o755 });
|
|
391
|
+
results.push({ path: stateDir, status: 'created' });
|
|
392
|
+
} else {
|
|
393
|
+
results.push({ path: stateDir, status: 'exists' });
|
|
394
|
+
}
|
|
395
|
+
} catch (err) {
|
|
396
|
+
results.push({ path: stateDir, status: 'error', error: err.message });
|
|
365
397
|
}
|
|
366
|
-
}
|
|
367
|
-
results.push({ path: stateDir, status: '
|
|
398
|
+
} else {
|
|
399
|
+
results.push({ path: stateDir, status: fs.existsSync(stateDir) ? 'exists' : 'would-create' });
|
|
368
400
|
}
|
|
369
401
|
|
|
370
402
|
// Install systemd template unit from external file
|
|
371
|
-
const systemdDest = path.join(systemdDir,
|
|
403
|
+
const systemdDest = path.join(systemdDir, destServiceFileName);
|
|
372
404
|
|
|
373
|
-
// Find
|
|
405
|
+
// Find service template file relative to this script
|
|
374
406
|
const scriptDir = path.dirname(process.argv[1]);
|
|
375
407
|
const possibleServicePaths = [
|
|
376
408
|
path.join(scriptDir, serviceFileName),
|
|
@@ -397,24 +429,58 @@ async function cmdInit(options) {
|
|
|
397
429
|
// Read template and replace placeholders with actual paths
|
|
398
430
|
let serviceContent = fs.readFileSync(serviceSourcePath, 'utf-8');
|
|
399
431
|
|
|
400
|
-
//
|
|
401
|
-
const
|
|
432
|
+
// Handle node path for nvm/fnm/volta
|
|
433
|
+
const systemNodePaths = ['/usr/local/bin/node', '/usr/bin/node'];
|
|
434
|
+
let systemNodePath = systemNodePaths.find(p => fs.existsSync(p));
|
|
435
|
+
let finalNodePath = nodePath;
|
|
436
|
+
|
|
437
|
+
// If using nvm-style path and NOT using --nvm flag, try to use/create system symlink
|
|
438
|
+
if (isNvmStyle && !useNvmVariant) {
|
|
439
|
+
if (!systemNodePath && !options['dry-run']) {
|
|
440
|
+
// No system node found, create symlink to /usr/local/bin/node
|
|
441
|
+
const symlinkTarget = '/usr/local/bin/node';
|
|
442
|
+
try {
|
|
443
|
+
// Ensure /usr/local/bin exists
|
|
444
|
+
if (!fs.existsSync('/usr/local/bin')) {
|
|
445
|
+
fs.mkdirSync('/usr/local/bin', { recursive: true });
|
|
446
|
+
}
|
|
447
|
+
// Create symlink
|
|
448
|
+
fs.symlinkSync(nodePath, symlinkTarget);
|
|
449
|
+
results.push({ path: symlinkTarget, status: 'created', note: `symlink → ${nodePath}` });
|
|
450
|
+
systemNodePath = symlinkTarget;
|
|
451
|
+
} catch (symlinkErr) {
|
|
452
|
+
// If symlink fails, show warning
|
|
453
|
+
if (!options.json) {
|
|
454
|
+
console.log(`${colors.yellow}Warning:${colors.reset} Could not create symlink at ${symlinkTarget}`);
|
|
455
|
+
console.log(` Error: ${symlinkErr.message}`);
|
|
456
|
+
console.log(` Consider using --nvm flag for nvm-compatible setup\n`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
} else if (!systemNodePath && options['dry-run']) {
|
|
460
|
+
results.push({ path: '/usr/local/bin/node', status: 'would-create', note: `symlink → ${nodePath}` });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Use system node path if available, prefer /usr/local/bin/node
|
|
464
|
+
if (systemNodePath) {
|
|
465
|
+
finalNodePath = systemNodePath;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
402
468
|
|
|
403
469
|
// Get actual portok installation directory
|
|
404
470
|
const portokDir = path.dirname(serviceSourcePath);
|
|
405
471
|
|
|
406
472
|
// Determine WorkingDirectory - use /var/lib/portok for state files
|
|
407
|
-
// Don't use node_modules path as WorkingDirectory (too long, might change)
|
|
408
473
|
const workingDir = stateDir;
|
|
409
474
|
|
|
410
475
|
// Replace hardcoded paths with actual paths
|
|
411
|
-
serviceContent = serviceContent.replace(/\/usr\/bin\/node/g,
|
|
476
|
+
serviceContent = serviceContent.replace(/\/usr\/bin\/node/g, finalNodePath);
|
|
412
477
|
serviceContent = serviceContent.replace(/WorkingDirectory=\/opt\/portok/g, `WorkingDirectory=${workingDir}`);
|
|
478
|
+
serviceContent = serviceContent.replace(/WorkingDirectory=\/var\/lib\/portok/g, `WorkingDirectory=${workingDir}`);
|
|
413
479
|
serviceContent = serviceContent.replace(/\/opt\/portok\/portokd\.js/g, `${portokDir}/portokd.js`);
|
|
414
480
|
|
|
415
481
|
// Check if installed via npm global (path contains node_modules)
|
|
416
482
|
const isGlobalNpm = portokDir.includes('node_modules');
|
|
417
|
-
if (isGlobalNpm && !options.json) {
|
|
483
|
+
if (isGlobalNpm && !options.json && !options['dry-run']) {
|
|
418
484
|
console.log(`${colors.dim} Note: Using global npm installation${colors.reset}`);
|
|
419
485
|
console.log(`${colors.dim} Portok: ${portokDir}${colors.reset}\n`);
|
|
420
486
|
}
|
|
@@ -423,20 +489,55 @@ async function cmdInit(options) {
|
|
|
423
489
|
serviceContent = serviceContent.replace(/User=www-data/g, `User=${currentUser}`);
|
|
424
490
|
serviceContent = serviceContent.replace(/Group=www-data/g, `Group=${currentGroup}`);
|
|
425
491
|
|
|
426
|
-
|
|
492
|
+
// For nvm variant, add NVM_DIR and user home to ReadOnlyPaths
|
|
493
|
+
if (useNvmVariant && isNvmStyle) {
|
|
494
|
+
const userHome = process.env.HOME || `/home/${currentUser}`;
|
|
495
|
+
const nvmDir = process.env.NVM_DIR || `${userHome}/.nvm`;
|
|
496
|
+
|
|
497
|
+
// Add NVM_DIR environment variable
|
|
498
|
+
serviceContent = serviceContent.replace(
|
|
499
|
+
/# Environment=NVM_DIR=.*/,
|
|
500
|
+
`Environment=NVM_DIR=${nvmDir}`
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
// Add ReadOnlyPaths for nvm directory
|
|
504
|
+
serviceContent = serviceContent.replace(
|
|
505
|
+
/# ReadOnlyPaths=\/home\/user\/.nvm/,
|
|
506
|
+
`ReadOnlyPaths=${nvmDir}`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
427
509
|
|
|
428
|
-
if (
|
|
429
|
-
results.push({ path: systemdDest, status: '
|
|
510
|
+
if (options['dry-run']) {
|
|
511
|
+
results.push({ path: systemdDest, status: 'would-create' });
|
|
512
|
+
if (!options.json) {
|
|
513
|
+
console.log(`${colors.dim}Generated service file (preview):${colors.reset}`);
|
|
514
|
+
console.log(`${colors.dim}${'─'.repeat(60)}${colors.reset}`);
|
|
515
|
+
// Show key lines only
|
|
516
|
+
const keyLines = serviceContent.split('\n').filter(l =>
|
|
517
|
+
l.includes('ExecStart=') ||
|
|
518
|
+
l.includes('User=') ||
|
|
519
|
+
l.includes('WorkingDirectory=') ||
|
|
520
|
+
l.includes('ProtectHome=')
|
|
521
|
+
);
|
|
522
|
+
keyLines.forEach(l => console.log(` ${l.trim()}`));
|
|
523
|
+
console.log(`${colors.dim}${'─'.repeat(60)}${colors.reset}\n`);
|
|
524
|
+
}
|
|
430
525
|
} else {
|
|
431
|
-
fs.
|
|
432
|
-
results.push({ path: systemdDest, status: 'created' });
|
|
526
|
+
const existingContent = fs.existsSync(systemdDest) ? fs.readFileSync(systemdDest, 'utf-8') : null;
|
|
433
527
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
528
|
+
if (existingContent && existingContent.trim() === serviceContent.trim()) {
|
|
529
|
+
results.push({ path: systemdDest, status: 'exists' });
|
|
530
|
+
} else {
|
|
531
|
+
fs.writeFileSync(systemdDest, serviceContent, { mode: 0o644 });
|
|
532
|
+
results.push({ path: systemdDest, status: 'created' });
|
|
533
|
+
|
|
534
|
+
// Reload systemd daemon
|
|
535
|
+
try {
|
|
536
|
+
execSync('systemctl daemon-reload', { stdio: 'pipe' });
|
|
537
|
+
results.push({ path: 'systemctl daemon-reload', status: 'done' });
|
|
538
|
+
} catch (e) {
|
|
539
|
+
results.push({ path: 'systemctl daemon-reload', status: 'skipped', error: 'systemctl failed' });
|
|
540
|
+
}
|
|
440
541
|
}
|
|
441
542
|
}
|
|
442
543
|
} catch (err) {
|
|
@@ -452,6 +553,8 @@ async function cmdInit(options) {
|
|
|
452
553
|
// Display results
|
|
453
554
|
for (const r of results) {
|
|
454
555
|
const icon = r.status === 'created' ? `${colors.green}✓${colors.reset}` :
|
|
556
|
+
r.status === 'done' ? `${colors.green}✓${colors.reset}` :
|
|
557
|
+
r.status === 'would-create' ? `${colors.cyan}○${colors.reset}` :
|
|
455
558
|
r.status === 'exists' ? `${colors.dim}○${colors.reset}` :
|
|
456
559
|
r.status === 'skipped' ? `${colors.yellow}○${colors.reset}` :
|
|
457
560
|
`${colors.red}✗${colors.reset}`;
|
|
@@ -715,6 +818,253 @@ async function cmdRemove(name, options) {
|
|
|
715
818
|
return 0;
|
|
716
819
|
}
|
|
717
820
|
|
|
821
|
+
// =============================================================================
|
|
822
|
+
// Diagnostic Commands
|
|
823
|
+
// =============================================================================
|
|
824
|
+
|
|
825
|
+
async function cmdDoctor(options) {
|
|
826
|
+
const { execSync } = require('child_process');
|
|
827
|
+
const configDir = ENV_FILE_DIR;
|
|
828
|
+
const stateDir = '/var/lib/portok';
|
|
829
|
+
const systemdDir = '/etc/systemd/system';
|
|
830
|
+
const serviceFile = path.join(systemdDir, 'portok@.service');
|
|
831
|
+
|
|
832
|
+
const checks = [];
|
|
833
|
+
|
|
834
|
+
console.log(`${colors.bold}Portok Doctor${colors.reset} - Diagnosing installation...\n`);
|
|
835
|
+
|
|
836
|
+
// Check 1: Node.js binary
|
|
837
|
+
const nodePath = process.execPath;
|
|
838
|
+
const isNvmStyle = nodePath.includes('.nvm') || nodePath.includes('.fnm') || nodePath.includes('.volta');
|
|
839
|
+
|
|
840
|
+
if (fs.existsSync(nodePath)) {
|
|
841
|
+
let nodeVersion = 'unknown';
|
|
842
|
+
try {
|
|
843
|
+
nodeVersion = execSync(`${nodePath} --version`, { encoding: 'utf-8' }).trim();
|
|
844
|
+
} catch (e) {}
|
|
845
|
+
|
|
846
|
+
checks.push({
|
|
847
|
+
name: 'Node.js binary',
|
|
848
|
+
status: 'ok',
|
|
849
|
+
detail: `${nodePath} (${nodeVersion})`
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
if (isNvmStyle) {
|
|
853
|
+
checks.push({
|
|
854
|
+
name: 'Node.js source',
|
|
855
|
+
status: 'warn',
|
|
856
|
+
detail: 'Using nvm/fnm/volta - consider system-wide installation for production'
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
} else {
|
|
860
|
+
checks.push({
|
|
861
|
+
name: 'Node.js binary',
|
|
862
|
+
status: 'error',
|
|
863
|
+
detail: `Not found at ${nodePath}`
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Check 2: System node availability
|
|
868
|
+
const systemNodePaths = ['/usr/local/bin/node', '/usr/bin/node'];
|
|
869
|
+
const systemNode = systemNodePaths.find(p => fs.existsSync(p));
|
|
870
|
+
|
|
871
|
+
if (systemNode) {
|
|
872
|
+
let version = 'unknown';
|
|
873
|
+
try {
|
|
874
|
+
version = execSync(`${systemNode} --version`, { encoding: 'utf-8' }).trim();
|
|
875
|
+
} catch (e) {}
|
|
876
|
+
|
|
877
|
+
// Check if it's a symlink
|
|
878
|
+
let linkInfo = '';
|
|
879
|
+
try {
|
|
880
|
+
const stats = fs.lstatSync(systemNode);
|
|
881
|
+
if (stats.isSymbolicLink()) {
|
|
882
|
+
linkInfo = ` → ${fs.readlinkSync(systemNode)}`;
|
|
883
|
+
}
|
|
884
|
+
} catch (e) {}
|
|
885
|
+
|
|
886
|
+
checks.push({
|
|
887
|
+
name: 'System Node.js',
|
|
888
|
+
status: 'ok',
|
|
889
|
+
detail: `${systemNode}${linkInfo} (${version})`
|
|
890
|
+
});
|
|
891
|
+
} else {
|
|
892
|
+
checks.push({
|
|
893
|
+
name: 'System Node.js',
|
|
894
|
+
status: isNvmStyle ? 'warn' : 'error',
|
|
895
|
+
detail: 'Not found - run `portok init` to create symlink'
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Check 3: Config directory
|
|
900
|
+
if (fs.existsSync(configDir)) {
|
|
901
|
+
const files = fs.readdirSync(configDir).filter(f => f.endsWith('.env'));
|
|
902
|
+
checks.push({
|
|
903
|
+
name: 'Config directory',
|
|
904
|
+
status: 'ok',
|
|
905
|
+
detail: `${configDir} (${files.length} config file${files.length !== 1 ? 's' : ''})`
|
|
906
|
+
});
|
|
907
|
+
} else {
|
|
908
|
+
checks.push({
|
|
909
|
+
name: 'Config directory',
|
|
910
|
+
status: 'warn',
|
|
911
|
+
detail: `${configDir} not found - run 'portok init'`
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Check 4: State directory
|
|
916
|
+
if (fs.existsSync(stateDir)) {
|
|
917
|
+
try {
|
|
918
|
+
fs.accessSync(stateDir, fs.constants.W_OK);
|
|
919
|
+
checks.push({
|
|
920
|
+
name: 'State directory',
|
|
921
|
+
status: 'ok',
|
|
922
|
+
detail: `${stateDir} (writable)`
|
|
923
|
+
});
|
|
924
|
+
} catch (e) {
|
|
925
|
+
checks.push({
|
|
926
|
+
name: 'State directory',
|
|
927
|
+
status: 'warn',
|
|
928
|
+
detail: `${stateDir} (not writable)`
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
} else {
|
|
932
|
+
checks.push({
|
|
933
|
+
name: 'State directory',
|
|
934
|
+
status: 'warn',
|
|
935
|
+
detail: `${stateDir} not found - run 'portok init'`
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Check 5: systemd service file
|
|
940
|
+
if (fs.existsSync(serviceFile)) {
|
|
941
|
+
const content = fs.readFileSync(serviceFile, 'utf-8');
|
|
942
|
+
const execStartMatch = content.match(/ExecStart=(\S+)/);
|
|
943
|
+
const userMatch = content.match(/User=(\S+)/);
|
|
944
|
+
const protectHomeMatch = content.match(/ProtectHome=(\S+)/);
|
|
945
|
+
|
|
946
|
+
checks.push({
|
|
947
|
+
name: 'systemd service',
|
|
948
|
+
status: 'ok',
|
|
949
|
+
detail: serviceFile
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
if (execStartMatch) {
|
|
953
|
+
const execNode = execStartMatch[1];
|
|
954
|
+
if (fs.existsSync(execNode)) {
|
|
955
|
+
checks.push({
|
|
956
|
+
name: 'ExecStart node',
|
|
957
|
+
status: 'ok',
|
|
958
|
+
detail: execNode
|
|
959
|
+
});
|
|
960
|
+
} else {
|
|
961
|
+
checks.push({
|
|
962
|
+
name: 'ExecStart node',
|
|
963
|
+
status: 'error',
|
|
964
|
+
detail: `${execNode} NOT FOUND - service will fail!`
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (userMatch && protectHomeMatch) {
|
|
970
|
+
const user = userMatch[1];
|
|
971
|
+
const protectHome = protectHomeMatch[1];
|
|
972
|
+
|
|
973
|
+
if (isNvmStyle && protectHome === 'true' && user !== 'root') {
|
|
974
|
+
checks.push({
|
|
975
|
+
name: 'ProtectHome',
|
|
976
|
+
status: 'warn',
|
|
977
|
+
detail: `ProtectHome=true may block ~/.nvm access for User=${user}`
|
|
978
|
+
});
|
|
979
|
+
} else {
|
|
980
|
+
checks.push({
|
|
981
|
+
name: 'ProtectHome',
|
|
982
|
+
status: 'ok',
|
|
983
|
+
detail: `ProtectHome=${protectHome}`
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
} else {
|
|
988
|
+
checks.push({
|
|
989
|
+
name: 'systemd service',
|
|
990
|
+
status: 'warn',
|
|
991
|
+
detail: `${serviceFile} not found - run 'portok init'`
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Check 6: systemctl availability
|
|
996
|
+
try {
|
|
997
|
+
execSync('which systemctl', { stdio: 'pipe' });
|
|
998
|
+
checks.push({
|
|
999
|
+
name: 'systemctl',
|
|
1000
|
+
status: 'ok',
|
|
1001
|
+
detail: 'Available'
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
// Check running instances
|
|
1005
|
+
try {
|
|
1006
|
+
const units = execSync('systemctl list-units "portok@*" --no-legend 2>/dev/null || true', { encoding: 'utf-8' });
|
|
1007
|
+
const running = units.trim().split('\n').filter(l => l.includes('running')).length;
|
|
1008
|
+
const failed = units.trim().split('\n').filter(l => l.includes('failed')).length;
|
|
1009
|
+
|
|
1010
|
+
if (running > 0 || failed > 0) {
|
|
1011
|
+
checks.push({
|
|
1012
|
+
name: 'Running instances',
|
|
1013
|
+
status: failed > 0 ? 'warn' : 'ok',
|
|
1014
|
+
detail: `${running} running, ${failed} failed`
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
} catch (e) {}
|
|
1018
|
+
} catch (e) {
|
|
1019
|
+
checks.push({
|
|
1020
|
+
name: 'systemctl',
|
|
1021
|
+
status: 'warn',
|
|
1022
|
+
detail: 'Not available (not using systemd?)'
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Display results
|
|
1027
|
+
if (options.json) {
|
|
1028
|
+
console.log(JSON.stringify({ checks }, null, 2));
|
|
1029
|
+
return checks.some(c => c.status === 'error') ? 1 : 0;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
let hasError = false;
|
|
1033
|
+
let hasWarn = false;
|
|
1034
|
+
|
|
1035
|
+
for (const check of checks) {
|
|
1036
|
+
let icon, color;
|
|
1037
|
+
if (check.status === 'ok') {
|
|
1038
|
+
icon = '✓';
|
|
1039
|
+
color = colors.green;
|
|
1040
|
+
} else if (check.status === 'warn') {
|
|
1041
|
+
icon = '⚠';
|
|
1042
|
+
color = colors.yellow;
|
|
1043
|
+
hasWarn = true;
|
|
1044
|
+
} else {
|
|
1045
|
+
icon = '✗';
|
|
1046
|
+
color = colors.red;
|
|
1047
|
+
hasError = true;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
console.log(` ${color}${icon}${colors.reset} ${check.name}`);
|
|
1051
|
+
console.log(` ${colors.dim}${check.detail}${colors.reset}`);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
console.log('');
|
|
1055
|
+
|
|
1056
|
+
if (hasError) {
|
|
1057
|
+
console.log(`${colors.red}Some checks failed.${colors.reset} Fix errors above and run again.`);
|
|
1058
|
+
return 1;
|
|
1059
|
+
} else if (hasWarn) {
|
|
1060
|
+
console.log(`${colors.yellow}Some warnings found.${colors.reset} Review recommendations above.`);
|
|
1061
|
+
return 0;
|
|
1062
|
+
} else {
|
|
1063
|
+
console.log(`${colors.green}All checks passed.${colors.reset} Portok is ready to use.`);
|
|
1064
|
+
return 0;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
718
1068
|
async function cmdClean(options) {
|
|
719
1069
|
const configDir = ENV_FILE_DIR;
|
|
720
1070
|
const stateDir = '/var/lib/portok';
|
|
@@ -1070,6 +1420,7 @@ ${colors.bold}COMMANDS${colors.reset}
|
|
|
1070
1420
|
remove <name> Remove a service instance (config + state)
|
|
1071
1421
|
clean Remove ALL portok data (configs, states, systemd service)
|
|
1072
1422
|
list List all configured instances and their status
|
|
1423
|
+
doctor Diagnose installation and runtime issues
|
|
1073
1424
|
|
|
1074
1425
|
${colors.cyan}Service Control:${colors.reset}
|
|
1075
1426
|
start <name> Start a portok service (systemctl start portok@<name>)
|
|
@@ -1102,6 +1453,11 @@ ${colors.bold}OPTIONS${colors.reset}
|
|
|
1102
1453
|
--force Skip confirmation prompt
|
|
1103
1454
|
--keep-state Keep state file (/var/lib/portok/<name>.json)
|
|
1104
1455
|
|
|
1456
|
+
${colors.dim}For 'init' command:${colors.reset}
|
|
1457
|
+
--nvm Use nvm-compatible template (less secure, allows ~/.nvm access)
|
|
1458
|
+
--node-path Specify custom node binary path
|
|
1459
|
+
--dry-run Preview changes without applying
|
|
1460
|
+
|
|
1105
1461
|
${colors.dim}For 'clean' command:${colors.reset}
|
|
1106
1462
|
--force Skip confirmation prompt (required)
|
|
1107
1463
|
|
|
@@ -1113,6 +1469,15 @@ ${colors.bold}EXAMPLES${colors.reset}
|
|
|
1113
1469
|
${colors.dim}# Initialize portok (run once, requires sudo)${colors.reset}
|
|
1114
1470
|
sudo portok init
|
|
1115
1471
|
|
|
1472
|
+
${colors.dim}# Initialize with nvm-compatible mode (if using nvm)${colors.reset}
|
|
1473
|
+
sudo portok init --nvm
|
|
1474
|
+
|
|
1475
|
+
${colors.dim}# Preview init changes without applying${colors.reset}
|
|
1476
|
+
sudo portok init --dry-run
|
|
1477
|
+
|
|
1478
|
+
${colors.dim}# Diagnose installation issues${colors.reset}
|
|
1479
|
+
portok doctor
|
|
1480
|
+
|
|
1116
1481
|
${colors.dim}# Create and start a new service${colors.reset}
|
|
1117
1482
|
sudo portok add api --port 3001 --target 8001
|
|
1118
1483
|
sudo portok start api
|
|
@@ -1164,7 +1529,7 @@ async function main() {
|
|
|
1164
1529
|
}
|
|
1165
1530
|
|
|
1166
1531
|
// Management commands (don't require daemon connection)
|
|
1167
|
-
const managementCommands = ['init', 'add', 'remove', 'clean', 'list', 'start', 'stop', 'restart', 'enable', 'disable', 'logs'];
|
|
1532
|
+
const managementCommands = ['init', 'add', 'remove', 'clean', 'list', 'doctor', 'start', 'stop', 'restart', 'enable', 'disable', 'logs'];
|
|
1168
1533
|
|
|
1169
1534
|
if (managementCommands.includes(args.command)) {
|
|
1170
1535
|
let exitCode = 1;
|
|
@@ -1184,6 +1549,9 @@ async function main() {
|
|
|
1184
1549
|
case 'list':
|
|
1185
1550
|
exitCode = await cmdList(args.options);
|
|
1186
1551
|
break;
|
|
1552
|
+
case 'doctor':
|
|
1553
|
+
exitCode = await cmdDoctor(args.options);
|
|
1554
|
+
break;
|
|
1187
1555
|
case 'start':
|
|
1188
1556
|
case 'stop':
|
|
1189
1557
|
case 'restart':
|
package/portok@.service
CHANGED
|
@@ -1,66 +1,185 @@
|
|
|
1
|
-
#
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Portok systemd Template Unit - Production Configuration
|
|
3
|
+
# =============================================================================
|
|
2
4
|
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# - /opt/portok -> actual portok installation directory
|
|
5
|
+
# OVERVIEW
|
|
6
|
+
# This is a systemd template unit for running multiple Portok proxy instances.
|
|
7
|
+
# Each instance is identified by a name (e.g., api, web, worker).
|
|
7
8
|
#
|
|
8
|
-
#
|
|
9
|
-
# systemctl start portok@api
|
|
10
|
-
# systemctl enable portok@web
|
|
11
|
-
# systemctl status portok@api
|
|
12
|
-
# journalctl -u portok@api -f
|
|
9
|
+
# USAGE
|
|
10
|
+
# systemctl start portok@api # Start instance "api"
|
|
11
|
+
# systemctl enable portok@web # Enable instance "web" at boot
|
|
12
|
+
# systemctl status portok@api # Check instance status
|
|
13
|
+
# journalctl -u portok@api -f # Follow instance logs
|
|
14
|
+
# systemctl list-units 'portok@*' # List all running instances
|
|
13
15
|
#
|
|
14
|
-
#
|
|
16
|
+
# INSTANCE CONFIGURATION
|
|
17
|
+
# Each instance reads: /etc/portok/%i.env
|
|
18
|
+
# Create config with: portok add <name>
|
|
19
|
+
#
|
|
20
|
+
# INSTALLATION
|
|
21
|
+
# This template is installed by: portok init
|
|
22
|
+
# The following placeholders are replaced during installation:
|
|
23
|
+
# - /usr/bin/node -> detected node binary path
|
|
24
|
+
# - /opt/portok -> actual portok installation directory
|
|
25
|
+
# - User=www-data -> user running 'portok init'
|
|
26
|
+
# - Group=www-data -> primary group of that user
|
|
27
|
+
#
|
|
28
|
+
# NODE.JS REQUIREMENTS
|
|
29
|
+
# - PREFERRED: System-wide installation via OS package manager
|
|
30
|
+
# apt install nodejs / yum install nodejs / apk add nodejs
|
|
31
|
+
# - SUPPORTED: nvm/fnm/volta (requires symlink, see 'portok init')
|
|
32
|
+
# - systemd does NOT load shell profiles - absolute paths required
|
|
33
|
+
#
|
|
34
|
+
# =============================================================================
|
|
15
35
|
|
|
16
36
|
[Unit]
|
|
17
37
|
Description=Portok Zero-Downtime Proxy (%i)
|
|
38
|
+
Documentation=https://github.com/litepacks/portok
|
|
39
|
+
|
|
40
|
+
# Start after network is available
|
|
18
41
|
After=network.target
|
|
19
|
-
|
|
42
|
+
|
|
43
|
+
# Optional: If using local services, add dependencies
|
|
44
|
+
# After=network.target postgresql.service redis.service
|
|
20
45
|
|
|
21
46
|
[Service]
|
|
47
|
+
# -----------------------------------------------------------------------------
|
|
48
|
+
# PROCESS TYPE
|
|
49
|
+
# -----------------------------------------------------------------------------
|
|
50
|
+
# simple: systemd considers service started immediately after ExecStart
|
|
22
51
|
Type=simple
|
|
52
|
+
|
|
53
|
+
# -----------------------------------------------------------------------------
|
|
54
|
+
# USER & GROUP
|
|
55
|
+
# -----------------------------------------------------------------------------
|
|
56
|
+
# IMPORTANT: These are placeholders replaced by 'portok init'
|
|
57
|
+
# The init command detects the current user and replaces these values
|
|
58
|
+
# For production: Consider creating a dedicated 'portok' user
|
|
23
59
|
User=www-data
|
|
24
60
|
Group=www-data
|
|
25
61
|
|
|
26
|
-
#
|
|
62
|
+
# -----------------------------------------------------------------------------
|
|
63
|
+
# ENVIRONMENT
|
|
64
|
+
# -----------------------------------------------------------------------------
|
|
65
|
+
# Instance-specific configuration from env file
|
|
66
|
+
# File must exist and contain at minimum: LISTEN_PORT, INITIAL_TARGET_PORT, ADMIN_TOKEN
|
|
27
67
|
EnvironmentFile=/etc/portok/%i.env
|
|
28
68
|
|
|
29
|
-
#
|
|
69
|
+
# Inject instance name for logging and state file naming
|
|
70
|
+
# This allows portokd to identify itself: INSTANCE_NAME=api
|
|
30
71
|
Environment=INSTANCE_NAME=%i
|
|
31
72
|
|
|
32
|
-
#
|
|
33
|
-
|
|
73
|
+
# Node.js runtime settings (optional, can be set in env file)
|
|
74
|
+
# Environment=NODE_ENV=production
|
|
75
|
+
# Environment=NODE_OPTIONS=--max-old-space-size=256
|
|
34
76
|
|
|
35
|
-
#
|
|
77
|
+
# -----------------------------------------------------------------------------
|
|
78
|
+
# WORKING DIRECTORY
|
|
79
|
+
# -----------------------------------------------------------------------------
|
|
80
|
+
# Use state directory as working dir (not installation directory)
|
|
81
|
+
# This avoids issues with npm global paths and node_modules locations
|
|
82
|
+
# State files are written to: /var/lib/portok/<instance>.json
|
|
83
|
+
WorkingDirectory=/var/lib/portok
|
|
84
|
+
|
|
85
|
+
# -----------------------------------------------------------------------------
|
|
86
|
+
# EXECUTION
|
|
87
|
+
# -----------------------------------------------------------------------------
|
|
88
|
+
# CRITICAL: Absolute paths required - systemd does NOT use shell PATH
|
|
89
|
+
# These placeholders are replaced by 'portok init' with detected paths:
|
|
90
|
+
# /usr/bin/node -> actual node binary (e.g., /usr/local/bin/node)
|
|
91
|
+
# /opt/portok -> actual installation (e.g., /usr/lib/node_modules/portok)
|
|
36
92
|
ExecStart=/usr/bin/node /opt/portok/portokd.js
|
|
37
93
|
|
|
38
|
-
#
|
|
94
|
+
# Optional: Graceful shutdown command (portokd handles SIGTERM)
|
|
95
|
+
# ExecStop=/bin/kill -SIGTERM $MAINPID
|
|
96
|
+
|
|
97
|
+
# -----------------------------------------------------------------------------
|
|
98
|
+
# RESTART POLICY
|
|
99
|
+
# -----------------------------------------------------------------------------
|
|
100
|
+
# Always restart on failure with rate limiting
|
|
39
101
|
Restart=always
|
|
40
102
|
RestartSec=5
|
|
103
|
+
|
|
104
|
+
# Prevent restart loops: max 5 restarts in 60 seconds
|
|
41
105
|
StartLimitBurst=5
|
|
42
106
|
StartLimitIntervalSec=60
|
|
43
107
|
|
|
44
|
-
#
|
|
108
|
+
# Timeout for start/stop operations
|
|
109
|
+
TimeoutStartSec=30
|
|
110
|
+
TimeoutStopSec=30
|
|
111
|
+
|
|
112
|
+
# -----------------------------------------------------------------------------
|
|
113
|
+
# LOGGING
|
|
114
|
+
# -----------------------------------------------------------------------------
|
|
115
|
+
# Send stdout/stderr to journald
|
|
45
116
|
StandardOutput=journal
|
|
46
117
|
StandardError=journal
|
|
118
|
+
|
|
119
|
+
# Instance-specific syslog identifier for filtering:
|
|
120
|
+
# journalctl -u portok@api
|
|
121
|
+
# journalctl -t portok-api
|
|
47
122
|
SyslogIdentifier=portok-%i
|
|
48
123
|
|
|
49
|
-
#
|
|
124
|
+
# -----------------------------------------------------------------------------
|
|
125
|
+
# SECURITY HARDENING
|
|
126
|
+
# -----------------------------------------------------------------------------
|
|
127
|
+
# Prevent privilege escalation
|
|
50
128
|
NoNewPrivileges=true
|
|
129
|
+
|
|
130
|
+
# Protect system directories (make /usr, /boot, /etc read-only)
|
|
51
131
|
ProtectSystem=strict
|
|
132
|
+
|
|
133
|
+
# Protect home directories (required for security)
|
|
134
|
+
# NOTE: If using nvm from ~/.nvm, use portok@.service.nvm variant instead
|
|
52
135
|
ProtectHome=true
|
|
136
|
+
|
|
137
|
+
# Private /tmp for this service
|
|
53
138
|
PrivateTmp=true
|
|
139
|
+
|
|
140
|
+
# Protect kernel tunables
|
|
54
141
|
ProtectKernelTunables=true
|
|
55
142
|
ProtectKernelModules=true
|
|
143
|
+
ProtectKernelLogs=true
|
|
56
144
|
ProtectControlGroups=true
|
|
57
145
|
|
|
58
|
-
#
|
|
146
|
+
# Protect hostname and clock
|
|
147
|
+
ProtectHostname=true
|
|
148
|
+
ProtectClock=true
|
|
149
|
+
|
|
150
|
+
# Restrict system calls (commented - may cause issues on some systems)
|
|
151
|
+
# SystemCallFilter=@system-service
|
|
152
|
+
# SystemCallErrorNumber=EPERM
|
|
153
|
+
|
|
154
|
+
# -----------------------------------------------------------------------------
|
|
155
|
+
# FILESYSTEM ACCESS
|
|
156
|
+
# -----------------------------------------------------------------------------
|
|
157
|
+
# Allow writing only to state directory
|
|
158
|
+
# Add additional paths if your app needs to write elsewhere
|
|
59
159
|
ReadWritePaths=/var/lib/portok
|
|
60
160
|
|
|
61
|
-
#
|
|
161
|
+
# Deny write access to /etc/portok (config should be read-only at runtime)
|
|
162
|
+
ReadOnlyPaths=/etc/portok
|
|
163
|
+
|
|
164
|
+
# -----------------------------------------------------------------------------
|
|
165
|
+
# NETWORK ACCESS
|
|
166
|
+
# -----------------------------------------------------------------------------
|
|
167
|
+
# Required for proxy functionality - allow IPv4, IPv6, and Unix sockets
|
|
62
168
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
63
169
|
|
|
170
|
+
# Optional: Restrict to specific port ranges (if known)
|
|
171
|
+
# SocketBindAllow=tcp:3000-3999
|
|
172
|
+
# SocketBindAllow=tcp:8000-8999
|
|
173
|
+
# SocketBindDeny=any
|
|
174
|
+
|
|
175
|
+
# -----------------------------------------------------------------------------
|
|
176
|
+
# RESOURCE LIMITS (Optional)
|
|
177
|
+
# -----------------------------------------------------------------------------
|
|
178
|
+
# Uncomment to set limits
|
|
179
|
+
# MemoryMax=512M
|
|
180
|
+
# CPUQuota=100%
|
|
181
|
+
# TasksMax=100
|
|
182
|
+
# LimitNOFILE=65535
|
|
183
|
+
|
|
64
184
|
[Install]
|
|
65
185
|
WantedBy=multi-user.target
|
|
66
|
-
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# Portok systemd Template Unit - NVM/FNM/Volta Compatible Version
|
|
3
|
+
# =============================================================================
|
|
4
|
+
#
|
|
5
|
+
# WARNING: This is a FALLBACK variant for environments where Node.js is
|
|
6
|
+
# installed via nvm, fnm, or volta (user-space version managers).
|
|
7
|
+
#
|
|
8
|
+
# PREFER the standard portok@.service with system-wide Node.js for production.
|
|
9
|
+
#
|
|
10
|
+
# KEY DIFFERENCES FROM STANDARD TEMPLATE:
|
|
11
|
+
# - ProtectHome=read-only (instead of true) - allows ~/.nvm access
|
|
12
|
+
# - ReadWritePaths includes user home for nvm cache
|
|
13
|
+
# - Additional comments about nvm limitations
|
|
14
|
+
#
|
|
15
|
+
# USAGE
|
|
16
|
+
# Install with: portok init --nvm
|
|
17
|
+
#
|
|
18
|
+
# LIMITATIONS
|
|
19
|
+
# - Less secure than standard template (home directory accessible)
|
|
20
|
+
# - Node.js path may change when nvm switches versions
|
|
21
|
+
# - Recommend creating symlink: ln -s ~/.nvm/.../node /usr/local/bin/node
|
|
22
|
+
#
|
|
23
|
+
# =============================================================================
|
|
24
|
+
|
|
25
|
+
[Unit]
|
|
26
|
+
Description=Portok Zero-Downtime Proxy (%i) [NVM]
|
|
27
|
+
Documentation=https://github.com/litepacks/portok
|
|
28
|
+
|
|
29
|
+
After=network.target
|
|
30
|
+
|
|
31
|
+
[Service]
|
|
32
|
+
Type=simple
|
|
33
|
+
|
|
34
|
+
# -----------------------------------------------------------------------------
|
|
35
|
+
# USER & GROUP - Replaced by 'portok init --nvm'
|
|
36
|
+
# -----------------------------------------------------------------------------
|
|
37
|
+
# IMPORTANT: When using nvm, service MUST run as the user who installed nvm
|
|
38
|
+
# Running as different user will fail - nvm is user-specific
|
|
39
|
+
User=www-data
|
|
40
|
+
Group=www-data
|
|
41
|
+
|
|
42
|
+
# -----------------------------------------------------------------------------
|
|
43
|
+
# ENVIRONMENT
|
|
44
|
+
# -----------------------------------------------------------------------------
|
|
45
|
+
EnvironmentFile=/etc/portok/%i.env
|
|
46
|
+
Environment=INSTANCE_NAME=%i
|
|
47
|
+
|
|
48
|
+
# NVM-specific: Set NVM_DIR explicitly (systemd doesn't load .bashrc)
|
|
49
|
+
# This is replaced by 'portok init --nvm' with detected NVM_DIR
|
|
50
|
+
# Environment=NVM_DIR=/home/user/.nvm
|
|
51
|
+
|
|
52
|
+
# PATH must include nvm bin directory
|
|
53
|
+
# This is also replaced during init
|
|
54
|
+
# Environment=PATH=/home/user/.nvm/versions/node/v20.x.x/bin:/usr/local/bin:/usr/bin:/bin
|
|
55
|
+
|
|
56
|
+
# -----------------------------------------------------------------------------
|
|
57
|
+
# WORKING DIRECTORY
|
|
58
|
+
# -----------------------------------------------------------------------------
|
|
59
|
+
WorkingDirectory=/var/lib/portok
|
|
60
|
+
|
|
61
|
+
# -----------------------------------------------------------------------------
|
|
62
|
+
# EXECUTION
|
|
63
|
+
# -----------------------------------------------------------------------------
|
|
64
|
+
# For nvm: 'portok init --nvm' creates a symlink at /usr/local/bin/node
|
|
65
|
+
# pointing to the active nvm node version. This is more reliable than
|
|
66
|
+
# using nvm paths directly.
|
|
67
|
+
#
|
|
68
|
+
# Alternative (less reliable): Use absolute nvm path
|
|
69
|
+
# ExecStart=/home/user/.nvm/versions/node/v20.19.6/bin/node /path/to/portokd.js
|
|
70
|
+
#
|
|
71
|
+
# If symlink approach fails, use bash wrapper (slower, less clean):
|
|
72
|
+
# ExecStart=/bin/bash -c 'source ~/.nvm/nvm.sh && node /path/to/portokd.js'
|
|
73
|
+
ExecStart=/usr/bin/node /opt/portok/portokd.js
|
|
74
|
+
|
|
75
|
+
# -----------------------------------------------------------------------------
|
|
76
|
+
# RESTART POLICY
|
|
77
|
+
# -----------------------------------------------------------------------------
|
|
78
|
+
Restart=always
|
|
79
|
+
RestartSec=5
|
|
80
|
+
StartLimitBurst=5
|
|
81
|
+
StartLimitIntervalSec=60
|
|
82
|
+
TimeoutStartSec=30
|
|
83
|
+
TimeoutStopSec=30
|
|
84
|
+
|
|
85
|
+
# -----------------------------------------------------------------------------
|
|
86
|
+
# LOGGING
|
|
87
|
+
# -----------------------------------------------------------------------------
|
|
88
|
+
StandardOutput=journal
|
|
89
|
+
StandardError=journal
|
|
90
|
+
SyslogIdentifier=portok-%i
|
|
91
|
+
|
|
92
|
+
# -----------------------------------------------------------------------------
|
|
93
|
+
# SECURITY HARDENING (Relaxed for NVM compatibility)
|
|
94
|
+
# -----------------------------------------------------------------------------
|
|
95
|
+
NoNewPrivileges=true
|
|
96
|
+
ProtectSystem=strict
|
|
97
|
+
|
|
98
|
+
# CRITICAL DIFFERENCE: read-only instead of true
|
|
99
|
+
# This allows reading ~/.nvm but not writing to other home directories
|
|
100
|
+
# Security trade-off required for nvm functionality
|
|
101
|
+
ProtectHome=read-only
|
|
102
|
+
|
|
103
|
+
PrivateTmp=true
|
|
104
|
+
ProtectKernelTunables=true
|
|
105
|
+
ProtectKernelModules=true
|
|
106
|
+
ProtectKernelLogs=true
|
|
107
|
+
ProtectControlGroups=true
|
|
108
|
+
ProtectHostname=true
|
|
109
|
+
ProtectClock=true
|
|
110
|
+
|
|
111
|
+
# -----------------------------------------------------------------------------
|
|
112
|
+
# FILESYSTEM ACCESS (Expanded for NVM)
|
|
113
|
+
# -----------------------------------------------------------------------------
|
|
114
|
+
ReadWritePaths=/var/lib/portok
|
|
115
|
+
|
|
116
|
+
# Allow reading nvm directory (path replaced during init)
|
|
117
|
+
# ReadOnlyPaths=/home/user/.nvm
|
|
118
|
+
|
|
119
|
+
ReadOnlyPaths=/etc/portok
|
|
120
|
+
|
|
121
|
+
# -----------------------------------------------------------------------------
|
|
122
|
+
# NETWORK ACCESS
|
|
123
|
+
# -----------------------------------------------------------------------------
|
|
124
|
+
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
125
|
+
|
|
126
|
+
[Install]
|
|
127
|
+
WantedBy=multi-user.target
|
|
128
|
+
|
package/test/init.test.js
CHANGED
|
@@ -222,6 +222,76 @@ describe('Init Command', () => {
|
|
|
222
222
|
});
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
+
describe('NVM Symlink Handling', () => {
|
|
226
|
+
it('should detect nvm-style paths', () => {
|
|
227
|
+
const nodePath = process.execPath;
|
|
228
|
+
const isNvmStyle = nodePath.includes('.nvm') || nodePath.includes('.fnm') || nodePath.includes('.volta');
|
|
229
|
+
|
|
230
|
+
console.log(` Is nvm-style path: ${isNvmStyle}`);
|
|
231
|
+
console.log(` Current node path: ${nodePath}`);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should check for system node paths', () => {
|
|
235
|
+
const systemNodePaths = ['/usr/local/bin/node', '/usr/bin/node'];
|
|
236
|
+
const existingSystemNode = systemNodePaths.find(p => fs.existsSync(p));
|
|
237
|
+
|
|
238
|
+
console.log(` System node found: ${existingSystemNode || 'none'}`);
|
|
239
|
+
|
|
240
|
+
// In Docker Alpine, one of these should exist
|
|
241
|
+
if (existingSystemNode) {
|
|
242
|
+
const version = execSync(`"${existingSystemNode}" --version`, { encoding: 'utf-8' }).trim();
|
|
243
|
+
console.log(` System node version: ${version}`);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should prefer system node over nvm path for systemd', () => {
|
|
248
|
+
const nodePath = process.execPath;
|
|
249
|
+
const isNvmStyle = nodePath.includes('.nvm') || nodePath.includes('.fnm') || nodePath.includes('.volta');
|
|
250
|
+
const systemNodePaths = ['/usr/local/bin/node', '/usr/bin/node'];
|
|
251
|
+
const systemNodePath = systemNodePaths.find(p => fs.existsSync(p));
|
|
252
|
+
|
|
253
|
+
// The logic: if nvm and system node exists, use system node
|
|
254
|
+
// If nvm and no system node, create symlink (tested separately)
|
|
255
|
+
if (isNvmStyle && systemNodePath) {
|
|
256
|
+
console.log(` Would use system node: ${systemNodePath}`);
|
|
257
|
+
assert.ok(true, 'System node should be preferred');
|
|
258
|
+
} else if (!isNvmStyle) {
|
|
259
|
+
console.log(` Not using nvm, current path is fine: ${nodePath}`);
|
|
260
|
+
assert.ok(true, 'Non-nvm path is fine');
|
|
261
|
+
} else {
|
|
262
|
+
console.log(` NVM without system node - symlink needed`);
|
|
263
|
+
assert.ok(true, 'Symlink would be created');
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should be able to create symlinks (if root)', () => {
|
|
268
|
+
const isRoot = process.getuid && process.getuid() === 0;
|
|
269
|
+
|
|
270
|
+
if (isRoot) {
|
|
271
|
+
const testSymlink = path.join(TEST_DIR, 'test-symlink');
|
|
272
|
+
const testTarget = process.execPath;
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
fs.symlinkSync(testTarget, testSymlink);
|
|
276
|
+
assert.ok(fs.existsSync(testSymlink), 'Symlink should be created');
|
|
277
|
+
|
|
278
|
+
// Verify symlink works
|
|
279
|
+
const version = execSync(`"${testSymlink}" --version`, { encoding: 'utf-8' }).trim();
|
|
280
|
+
assert.ok(version.startsWith('v'), 'Symlink should be executable');
|
|
281
|
+
|
|
282
|
+
console.log(` Created test symlink: ${testSymlink} → ${testTarget}`);
|
|
283
|
+
|
|
284
|
+
// Cleanup
|
|
285
|
+
fs.unlinkSync(testSymlink);
|
|
286
|
+
} catch (e) {
|
|
287
|
+
console.log(` Symlink test skipped: ${e.message}`);
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
console.log(` Symlink test skipped: not root`);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
225
295
|
describe('Init Output Format', () => {
|
|
226
296
|
it('should display user info in init output', async () => {
|
|
227
297
|
// Run portok.js with a mock to capture output
|