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 CHANGED
@@ -452,42 +452,120 @@ ADMIN_TOKEN=web-secret-token-change-me
452
452
 
453
453
  ### systemd Template Unit
454
454
 
455
- Use the template unit `portok@.service` for managing instances:
455
+ The `portok init` command automatically installs and configures the systemd template:
456
456
 
457
457
  ```bash
458
- # Copy the template
459
- sudo cp portok@.service /etc/systemd/system/
458
+ # Initialize Portok (creates dirs, installs systemd service)
459
+ sudo portok init
460
460
 
461
- # Create config directory and state directory
462
- sudo mkdir -p /etc/portok /var/lib/portok
463
- sudo chown www-data:www-data /var/lib/portok
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
- # Create instance configs
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
- # Edit tokens and ports
470
- sudo nano /etc/portok/api.env
471
- sudo nano /etc/portok/web.env
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portok",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Zero-downtime deployment proxy - routes traffic through a stable port to internal app instances with health-gated switching",
5
5
  "main": "portokd.js",
6
6
  "bin": {
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
- console.log(`${colors.bold}Initializing Portok...${colors.reset}\n`);
335
- console.log(` User: ${colors.cyan}${currentUser}${colors.reset}`);
336
- console.log(` Group: ${colors.cyan}${currentGroup}${colors.reset}`);
337
- console.log(` Node: ${colors.dim}${process.execPath}${colors.reset}\n`);
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
- try {
348
- if (!fs.existsSync(configDir)) {
349
- fs.mkdirSync(configDir, { recursive: true, mode: 0o755 });
350
- results.push({ path: configDir, status: 'created' });
351
- } else {
352
- results.push({ path: configDir, status: 'exists' });
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
- } catch (err) {
355
- results.push({ path: configDir, status: 'error', error: err.message });
382
+ } else {
383
+ results.push({ path: configDir, status: fs.existsSync(configDir) ? 'exists' : 'would-create' });
356
384
  }
357
385
 
358
386
  // Create state directory
359
- try {
360
- if (!fs.existsSync(stateDir)) {
361
- fs.mkdirSync(stateDir, { recursive: true, mode: 0o755 });
362
- results.push({ path: stateDir, status: 'created' });
363
- } else {
364
- results.push({ path: stateDir, status: 'exists' });
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
- } catch (err) {
367
- results.push({ path: stateDir, status: 'error', error: err.message });
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, serviceFileName);
403
+ const systemdDest = path.join(systemdDir, destServiceFileName);
372
404
 
373
- // Find portok@.service file relative to this script
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
- // Get actual node path (supports nvm, fnm, volta, etc.)
401
- const nodePath = process.execPath;
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, nodePath);
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
- const existingContent = fs.existsSync(systemdDest) ? fs.readFileSync(systemdDest, 'utf-8') : null;
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 (existingContent && existingContent.trim() === serviceContent.trim()) {
429
- results.push({ path: systemdDest, status: 'exists' });
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.writeFileSync(systemdDest, serviceContent, { mode: 0o644 });
432
- results.push({ path: systemdDest, status: 'created' });
526
+ const existingContent = fs.existsSync(systemdDest) ? fs.readFileSync(systemdDest, 'utf-8') : null;
433
527
 
434
- // Reload systemd daemon
435
- try {
436
- execSync('systemctl daemon-reload', { stdio: 'pipe' });
437
- results.push({ path: 'systemctl daemon-reload', status: 'created' });
438
- } catch (e) {
439
- results.push({ path: 'systemctl daemon-reload', status: 'skipped', error: 'systemctl failed' });
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
- # Portok systemd template unit for multi-instance deployments
1
+ # =============================================================================
2
+ # Portok systemd Template Unit - Production Configuration
3
+ # =============================================================================
2
4
  #
3
- # This file is used as a template by 'portok init'.
4
- # The following paths are automatically replaced:
5
- # - /usr/bin/node -> actual node path (supports nvm, fnm, volta)
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
- # Usage:
9
- # systemctl start portok@api # Start instance "api"
10
- # systemctl enable portok@web # Enable instance "web" at boot
11
- # systemctl status portok@api # Check status
12
- # journalctl -u portok@api -f # View logs
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
- # Each instance reads its config from /etc/portok/%i.env
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
- Documentation=https://github.com/your-org/portok
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
- # Instance configuration from env file
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
- # Set instance name automatically from systemd specifier
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
- # Working directory (for relative paths if needed)
33
- WorkingDirectory=/opt/portok
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
- # Start the daemon
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
- # Restart policy
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
- # Logging
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
- # Security hardening
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
- # Allow writing to state directory
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
- # Network access (required for proxying)
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