@mmmbuto/nexuscrew 0.8.10 → 0.8.12
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 +25 -12
- package/frontend/dist/assets/index-DrroT7uq.js +91 -0
- package/frontend/dist/assets/{index-ChGJawuv.css → index-PkKeNfT7.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +51 -6
- package/lib/cli/doctor.js +45 -1
- package/lib/cli/service.js +53 -0
- package/lib/nodes/tunnel-supervisor.js +26 -4
- package/lib/nodes/tunnel.js +22 -6
- package/lib/update/runner.js +4 -1
- package/package.json +2 -2
- package/frontend/dist/assets/index-CbUkgtAz.js +0 -91
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.nc-icon{display:block;flex:0 0 auto}.nc-sheet-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:50;background:#0000008c;display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box}.nc-launch-overlay{z-index:70}.nc-sheet{width:100%;max-width:380px;background:#0e140e;color:#cfe;border:1px solid #243;border-radius:14px;padding:16px;display:flex;flex-direction:column;gap:12px;box-shadow:0 12px 40px #00000080}.nc-power-sheet{max-width:min(620px,calc(100vw - 32px));max-height:calc(100dvh - 32px);overflow-y:auto;box-sizing:border-box;min-width:0}.nc-launch-sheet{max-width:min(520px,calc(100vw - 32px));width:100%;min-width:0}.nc-launch-fields{display:flex;flex-direction:column;gap:10px;min-width:0}.nc-launch-fields .nc-field,.nc-launch-fields .nc-field input,.nc-launch-fields .nc-field select{width:100%;min-width:0;max-width:100%;box-sizing:border-box}.nc-sheet-head{display:flex;align-items:center;justify-content:space-between}.nc-sheet-head b{font-size:16px;color:#d8ffe8}.nc-sheet-state{font-size:12px;opacity:.6}.nc-sheet-label{font-size:12px;opacity:.7;text-transform:uppercase;letter-spacing:.5px}.nc-power-config{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 10px;padding:10px;border:1px solid #243;border-radius:8px;background:#0a0e0a;min-width:0;overflow-wrap:anywhere}.nc-power-config span{opacity:.7}.nc-power-config small{grid-column:1 / -1;opacity:.65}.nc-check{display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer}.nc-check input{accent-color:#2e7d32}.nc-note{font-size:11px;color:#e0b020;opacity:.9}.nc-field{display:flex;flex-direction:column;gap:4px;font-size:12px;opacity:.8}.nc-field input,.nc-field select{padding:9px 10px;font:14px/1.2 inherit;background:#0a0e0a;color:#cfe;border:1px solid #243;border-radius:8px;width:100%;min-width:0;max-width:100%;box-sizing:border-box}.nc-field input:focus,.nc-field select:focus{outline:none;border-color:#2e7d32}.nc-err{color:#f88;font-size:12px}.nc-sheet-actions{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;min-width:0}.nc-btn{border:none;border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer}.nc-btn.ghost{background:none;border:1px solid #243;color:#9fd}.nc-btn.primary{background:#2e7d32;color:#fff}.nc-btn.primary:active{background:#255028}.nc-btn:disabled{opacity:.5;cursor:default}@media(max-width:600px){.nc-power-sheet{max-width:380px}}.nc-home{display:flex;flex-direction:column;gap:12px;padding:18px 14px 10px;color:#cfe;max-width:560px;margin:0 auto;height:100dvh;min-height:0;overflow:hidden;box-sizing:border-box}.nc-home-scroll{min-height:0;flex:1 1 auto;overflow-y:auto;overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;display:flex;flex-direction:column;gap:12px;padding:0 2px 76px 0}.nc-home-head{position:relative}.nc-wordmark{font-size:26px;font-weight:700;letter-spacing:.5px;color:#d8ffe8}.nc-cursor{display:inline-block;width:12px;height:22px;margin-left:6px;background:#0c6;vertical-align:-2px;animation:nc-blink 1.1s steps(1) infinite}@keyframes nc-blink{50%{opacity:0}}@media(prefers-reduced-motion:reduce){.nc-cursor{animation:none}}.nc-home-sub{margin-top:4px;font-size:13px;opacity:.65}.nc-head-actions{position:absolute;top:0;right:0;display:inline-flex;gap:6px}.nc-refresh{display:inline-flex;align-items:center;justify-content:center;background:none;border:1px solid #243;color:#9fd;border-radius:8px;min-width:40px;min-height:40px}.nc-filter{padding:10px 12px;font:16px/1.2 inherit;background:#0e140e;color:#cfe;border:1px solid #243;border-radius:8px}.nc-filter:focus{outline:none;border-color:#2e7d32}.nc-session-list{display:flex;flex-direction:column;gap:8px}.nc-session{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid #1c241c;background:#0e140e;color:#cfe;border-radius:10px;text-align:left;min-height:56px}.nc-session:active{background:#14200f;border-color:#2e7d32}.nc-session .dot{flex:0 0 auto;width:9px;height:9px;border-radius:50%;background:#355}.nc-session .dot.on{background:#0c6;box-shadow:0 0 6px #0c68}.nc-session-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.nc-session-main b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}.nc-session-main small{opacity:.55;font-size:12px}.nc-badge{flex:0 0 auto;background:#2e7d32;color:#fff;border-radius:10px;padding:1px 8px;font-size:12px;line-height:18px}.nc-err{color:#f88;font-size:13px;padding:4px 2px}.nc-empty{opacity:.5;padding:18px 4px;font-size:13px}.nc-home-foot{margin-top:auto;padding
|
|
1
|
+
.nc-icon{display:block;flex:0 0 auto}.nc-sheet-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:50;background:#0000008c;display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box}.nc-launch-overlay{z-index:70}.nc-sheet{width:100%;max-width:380px;background:#0e140e;color:#cfe;border:1px solid #243;border-radius:14px;padding:16px;display:flex;flex-direction:column;gap:12px;box-shadow:0 12px 40px #00000080}.nc-power-sheet{max-width:min(620px,calc(100vw - 32px));max-height:calc(100dvh - 32px);overflow-y:auto;box-sizing:border-box;min-width:0}.nc-launch-sheet{max-width:min(520px,calc(100vw - 32px));width:100%;min-width:0}.nc-launch-fields{display:flex;flex-direction:column;gap:10px;min-width:0}.nc-launch-fields .nc-field,.nc-launch-fields .nc-field input,.nc-launch-fields .nc-field select{width:100%;min-width:0;max-width:100%;box-sizing:border-box}.nc-sheet-head{display:flex;align-items:center;justify-content:space-between}.nc-sheet-head b{font-size:16px;color:#d8ffe8}.nc-sheet-state{font-size:12px;opacity:.6}.nc-sheet-label{font-size:12px;opacity:.7;text-transform:uppercase;letter-spacing:.5px}.nc-power-config{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 10px;padding:10px;border:1px solid #243;border-radius:8px;background:#0a0e0a;min-width:0;overflow-wrap:anywhere}.nc-power-config span{opacity:.7}.nc-power-config small{grid-column:1 / -1;opacity:.65}.nc-check{display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer}.nc-check input{accent-color:#2e7d32}.nc-note{font-size:11px;color:#e0b020;opacity:.9}.nc-field{display:flex;flex-direction:column;gap:4px;font-size:12px;opacity:.8}.nc-field input,.nc-field select{padding:9px 10px;font:14px/1.2 inherit;background:#0a0e0a;color:#cfe;border:1px solid #243;border-radius:8px;width:100%;min-width:0;max-width:100%;box-sizing:border-box}.nc-field input:focus,.nc-field select:focus{outline:none;border-color:#2e7d32}.nc-err{color:#f88;font-size:12px}.nc-sheet-actions{display:flex;justify-content:flex-end;gap:8px;flex-wrap:wrap;min-width:0}.nc-btn{border:none;border-radius:8px;padding:8px 16px;font-size:13px;cursor:pointer}.nc-btn.ghost{background:none;border:1px solid #243;color:#9fd}.nc-btn.primary{background:#2e7d32;color:#fff}.nc-btn.primary:active{background:#255028}.nc-btn:disabled{opacity:.5;cursor:default}@media(max-width:600px){.nc-power-sheet{max-width:380px}}.nc-home{display:flex;flex-direction:column;gap:12px;padding:18px 14px 10px;color:#cfe;max-width:560px;margin:0 auto;height:100dvh;min-height:0;overflow:hidden;box-sizing:border-box}.nc-home-scroll{min-height:0;flex:1 1 auto;overflow-y:auto;overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;display:flex;flex-direction:column;gap:12px;padding:0 2px 76px 0}.nc-home-head{position:relative}.nc-wordmark{font-size:26px;font-weight:700;letter-spacing:.5px;color:#d8ffe8}.nc-cursor{display:inline-block;width:12px;height:22px;margin-left:6px;background:#0c6;vertical-align:-2px;animation:nc-blink 1.1s steps(1) infinite}@keyframes nc-blink{50%{opacity:0}}@media(prefers-reduced-motion:reduce){.nc-cursor{animation:none}}.nc-home-sub{margin-top:4px;font-size:13px;opacity:.65}.nc-head-actions{position:absolute;top:0;right:0;display:inline-flex;gap:6px}.nc-refresh{display:inline-flex;align-items:center;justify-content:center;background:none;border:1px solid #243;color:#9fd;border-radius:8px;min-width:40px;min-height:40px}.nc-filter{padding:10px 12px;font:16px/1.2 inherit;background:#0e140e;color:#cfe;border:1px solid #243;border-radius:8px}.nc-filter:focus{outline:none;border-color:#2e7d32}.nc-session-list{display:flex;flex-direction:column;gap:8px}.nc-session{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid #1c241c;background:#0e140e;color:#cfe;border-radius:10px;text-align:left;min-height:56px}.nc-session:active{background:#14200f;border-color:#2e7d32}.nc-session .dot{flex:0 0 auto;width:9px;height:9px;border-radius:50%;background:#355}.nc-session .dot.on{background:#0c6;box-shadow:0 0 6px #0c68}.nc-session-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.nc-session-main b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px}.nc-session-main small{opacity:.55;font-size:12px}.nc-badge{flex:0 0 auto;background:#2e7d32;color:#fff;border-radius:10px;padding:1px 8px;font-size:12px;line-height:18px}.nc-err{color:#f88;font-size:13px;padding:4px 2px}.nc-empty{opacity:.5;padding:18px 4px;font-size:13px}.nc-home-foot{margin-top:auto;padding:14px 4px 4px;width:100%;box-sizing:border-box;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;column-gap:14px;font-size:11px}.nc-home-meta{min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:2px 8px;opacity:.48;line-height:1.35}.nc-home-version{flex:0 0 auto}.nc-home-endpoint{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nc-group{display:flex;flex-direction:column;gap:8px}.nc-group-title{font-size:11px;opacity:.55;text-transform:uppercase;letter-spacing:.5px;padding:6px 2px 2px}.nc-mcard{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid #1c241c;background:#0e140e;color:#cfe;border-radius:12px;min-height:56px}.nc-mcard-main{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:12px;background:none;border:none;color:inherit;cursor:pointer;padding:0;text-align:left}.nc-mcard-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.nc-mcard-text b{font-size:14px;color:#d8ffe8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nc-mcard-text small{font-size:12px;opacity:.6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dot.warn{background:#e0b020;box-shadow:0 0 6px #e0b02088}.nc-group-title.nc-node-title{display:flex;align-items:center;gap:6px}.nc-group-title.nc-node-title .dot{flex:0 0 auto;width:8px;height:8px;border-radius:50%;background:#355}.nc-group-title.nc-node-title .dot.on{background:#0c6;box-shadow:0 0 6px #0c68}.nc-mcard .dot{flex:0 0 auto;width:9px;height:9px;border-radius:50%;background:#355}.nc-mcard .dot.on{background:#0c6;box-shadow:0 0 6px #0c68}.nc-mobile-position-head{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:8px;min-height:48px;padding:2px 0;border-bottom:1px solid #1c2b20}.nc-mobile-position-toggle{min-width:0;min-height:44px;padding:4px 2px;display:flex;align-items:center;gap:8px;border:0;background:none;color:inherit;text-align:left;cursor:pointer}.nc-mobile-position-toggle .dot{flex:0 0 auto;width:9px;height:9px;border-radius:50%;background:#355}.nc-mobile-position-toggle .dot.on{background:#0c6;box-shadow:0 0 6px #0c68}.nc-mobile-chevron{flex:0 0 14px;font-size:18px;color:#8fc;text-align:center}.nc-mobile-position-copy{min-width:0;display:flex;flex-direction:column;gap:1px}.nc-mobile-position-copy b,.nc-mobile-position-copy small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nc-mobile-position-copy b{font-size:13px;color:#d8ffe8}.nc-mobile-position-copy small{font-size:11px;opacity:.55}.nc-mobile-position-filter{min-height:44px;max-width:104px;padding:0 26px 0 9px;border:1px solid #294433;border-radius:8px;background:#0e140e;color:#bfe8cf;font:12px/1 inherit}.nc-mobile-position-head>.nc-act{width:44px;height:44px}.nc-rel{flex:0 0 auto;font-size:11px;opacity:.45}.nc-power,.nc-menu{flex:0 0 auto;background:none;border:1px solid #243;color:#9fd;border-radius:8px;min-width:44px;min-height:44px;font-size:17px;cursor:pointer}.nc-power:active,.nc-menu:active{border-color:#2e7d32}.nc-fab{position:fixed;right:18px;bottom:18px;z-index:20;width:54px;height:54px;border-radius:50%;background:#2e7d32;color:#fff;border:none;font-size:28px;line-height:1;cursor:pointer;box-shadow:0 6px 18px #00000073}.nc-fab:active{background:#255028}.nc-lang{display:inline-flex;align-items:center;justify-content:flex-end;gap:2px;white-space:nowrap;opacity:.7}@media(max-width:380px){.nc-home-meta{flex-direction:column;align-items:flex-start;gap:0}.nc-home-endpoint{max-width:100%}}.nc-act{width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:#ffffff0d;border:1px solid rgba(255,255,255,.22);color:#9aa89a;cursor:pointer;flex:0 0 auto;font-size:15px;line-height:1}.nc-act.power.on{color:#0c6;border-color:#00cc668c;background:#00cc661a}.nc-act.power.warn{color:#e8c547;border-color:#e8c5478c;background:#e8c5471a}.nc-act.pin.on{color:#e8c547;border-color:#e8c5478c;background:#e8c54714}.nc-mcard{gap:8px}@media(max-width:390px){.nc-mobile-position-head{gap:5px}.nc-mobile-position-filter{max-width:92px;padding-left:7px}.nc-mcard{padding:10px 9px;gap:5px}}/**
|
|
2
2
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
|
3
3
|
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
|
4
4
|
* https://github.com/chjj/term.js
|
package/frontend/dist/index.html
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-DrroT7uq.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-PkKeNfT7.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.12"}
|
package/lib/cli/commands.js
CHANGED
|
@@ -8,7 +8,7 @@ const fs = require('node:fs');
|
|
|
8
8
|
const path = require('node:path');
|
|
9
9
|
const net = require('node:net');
|
|
10
10
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('./platform.js');
|
|
11
|
-
const { installPath: serviceInstallPath } = require('./service.js');
|
|
11
|
+
const { installPath: serviceInstallPath, ensureLinuxTmuxSurvival } = require('./service.js');
|
|
12
12
|
const { fleetInstallPath } = require('./fleet-service.js');
|
|
13
13
|
const pidf = require('./pidfile.js');
|
|
14
14
|
const { runInit } = require('./init.js');
|
|
@@ -263,6 +263,20 @@ function stop(opts = {}) {
|
|
|
263
263
|
const stoppedOwners = [];
|
|
264
264
|
const errors = [];
|
|
265
265
|
|
|
266
|
+
// The shared tmux server can live in the NexusCrew systemd cgroup. If the
|
|
267
|
+
// effective KillMode cannot be proven safe, stop must not mutate *any*
|
|
268
|
+
// runtime owner or detached tunnel: callers can then fix the unit and retry
|
|
269
|
+
// without ending up in a partially stopped state.
|
|
270
|
+
if (before.managedRunning && platform === 'linux') {
|
|
271
|
+
try {
|
|
272
|
+
(opts.ensureTmuxSurvivalImpl || ensureLinuxTmuxSurvival)({ ...opts, home: opts.home, execImpl });
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const reason = String(error.message || error);
|
|
275
|
+
log(`stop: annullato — protezione tmux non verificata: ${reason}`);
|
|
276
|
+
return { platform, owner: before.owner, stopped: false, stoppedOwners, reason };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
266
280
|
if (before.managedRunning) {
|
|
267
281
|
try {
|
|
268
282
|
if (platform === 'linux') {
|
|
@@ -477,15 +491,24 @@ function restart(opts = {}) {
|
|
|
477
491
|
// conflict, first remove only the stray portable owner, then keep managed as
|
|
478
492
|
// the single authority.
|
|
479
493
|
if (before.managedRunning) {
|
|
494
|
+
if (platform === 'linux') {
|
|
495
|
+
try {
|
|
496
|
+
(opts.ensureTmuxSurvivalImpl || ensureLinuxTmuxSurvival)({ ...opts, home: opts.home, execImpl });
|
|
497
|
+
} catch (error) {
|
|
498
|
+
return { platform, owner: before.owner, runtimeOwner: 'managed', restarted: false, reason: String(error.message || error) };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
480
501
|
if (before.portableRunning) {
|
|
481
502
|
const portable = (opts.stopPortableImpl || stopPortableRuntime)(opts);
|
|
482
503
|
if (!portable.killed) return { platform, owner: before.owner, restarted: false, reason: portable.reason };
|
|
483
504
|
}
|
|
484
505
|
try {
|
|
485
506
|
if (platform === 'linux') {
|
|
507
|
+
(opts.stopTunnelsImpl || stopManagedTunnels)(opts);
|
|
486
508
|
execImpl('systemctl', ['--user', 'restart', 'nexuscrew'], { stdio: 'ignore' });
|
|
487
509
|
log('restart: systemctl --user restart nexuscrew');
|
|
488
510
|
} else {
|
|
511
|
+
(opts.stopTunnelsImpl || stopManagedTunnels)(opts);
|
|
489
512
|
const label = `gui/${opts.uid || uid()}/com.mmmbuto.nexuscrew`;
|
|
490
513
|
execImpl('launchctl', ['kickstart', '-k', label], { stdio: 'ignore' });
|
|
491
514
|
log(`restart: launchctl kickstart -k ${label}`);
|
|
@@ -709,17 +732,34 @@ function url(opts = {}) {
|
|
|
709
732
|
function tokenRotate(opts = {}) {
|
|
710
733
|
const log = opts.log || console.log;
|
|
711
734
|
const platform = opts.platform || detectPlatform();
|
|
735
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
712
736
|
const { tokenPath } = urlmod.resolvePaths(opts);
|
|
713
737
|
const readonly = process.env.NEXUSCREW_READONLY === '1' || opts.readonly;
|
|
714
738
|
if (readonly) {
|
|
715
739
|
log('token rotate: READONLY, rotazione bloccata');
|
|
716
740
|
return { rotated: false, reason: 'readonly' };
|
|
717
741
|
}
|
|
742
|
+
const runtime = resolveRuntimeOwner({ ...opts, platform, execImpl });
|
|
743
|
+
const running = runtime.owner !== 'stopped';
|
|
744
|
+
if (runtime.managedRunning && platform === 'linux') {
|
|
745
|
+
try {
|
|
746
|
+
(opts.ensureTmuxSurvivalImpl || ensureLinuxTmuxSurvival)({ ...opts, home: opts.home, execImpl });
|
|
747
|
+
} catch (error) {
|
|
748
|
+
const reason = String(error.message || error);
|
|
749
|
+
log(`token rotate: annullata — protezione tmux non verificata: ${reason}`);
|
|
750
|
+
return { rotated: false, tokenWritten: false, restarted: false, running, reason };
|
|
751
|
+
}
|
|
752
|
+
}
|
|
718
753
|
rotateToken(tokenPath); // atomico (tmp+rename, 0600, no-symlink); non stampa il token
|
|
719
754
|
log(`token: nuovo segreto scritto (${tokenPath})`);
|
|
720
|
-
const running = isServiceRunning({ ...opts, platform });
|
|
721
755
|
if (running) {
|
|
722
|
-
restart({ ...opts, platform, log });
|
|
756
|
+
const restarted = (opts.restartImpl || restart)({ ...opts, platform, execImpl, log });
|
|
757
|
+
if (!restarted || restarted.restarted !== true) {
|
|
758
|
+
const reason = (restarted && restarted.reason) || 'esito restart non verificato';
|
|
759
|
+
log(`token rotate: INCOMPLETA — nuovo token scritto ma restart fallito: ${reason}`);
|
|
760
|
+
log('token rotate: non e\' possibile confermare l\'invalidazione del vecchio token; correggi il servizio e ripeti il restart');
|
|
761
|
+
return { rotated: false, tokenWritten: true, restarted: false, running, reason };
|
|
762
|
+
}
|
|
723
763
|
log('token rotate: servizio riavviato — vecchio token invalidato (401), nuovo attivo (200)');
|
|
724
764
|
} else {
|
|
725
765
|
// il service manager non lo vede, ma un `serve` manuale (pidfile) puo' essere
|
|
@@ -732,7 +772,7 @@ function tokenRotate(opts = {}) {
|
|
|
732
772
|
}
|
|
733
773
|
}
|
|
734
774
|
log('usa `nexuscrew show token` per il nuovo URL (il token non si stampa qui)');
|
|
735
|
-
return { rotated: true, running };
|
|
775
|
+
return { rotated: true, tokenWritten: true, restarted: running, running };
|
|
736
776
|
}
|
|
737
777
|
|
|
738
778
|
// logs [-f]: passthrough. linux -> journalctl --user -u nexuscrew; mac/termux -> logfile.
|
|
@@ -779,12 +819,17 @@ function update(opts = {}) {
|
|
|
779
819
|
}
|
|
780
820
|
const running = isServiceRunning({ ...opts, platform });
|
|
781
821
|
if (running) {
|
|
782
|
-
restart({ ...opts, platform, log });
|
|
822
|
+
const restarted = (opts.restartImpl || restart)({ ...opts, platform, log });
|
|
823
|
+
if (!restarted || restarted.restarted !== true) {
|
|
824
|
+
const reason = (restarted && restarted.reason) || 'esito restart non verificato';
|
|
825
|
+
log(`update: pacchetto installato ma restart fallito — ${reason}`);
|
|
826
|
+
return { updated: true, running, restarted: false, reason, code: 1 };
|
|
827
|
+
}
|
|
783
828
|
log('update: servizio riavviato sul nuovo codice');
|
|
784
829
|
} else {
|
|
785
830
|
log('update: servizio non attivo (nessun restart)');
|
|
786
831
|
}
|
|
787
|
-
return { updated: true, running, code: 0 };
|
|
832
|
+
return { updated: true, running, restarted: running, code: 0 };
|
|
788
833
|
}
|
|
789
834
|
|
|
790
835
|
// B4.3 — fleet-boot companion: avvia le celle boot:true del provider selezionato.
|
package/lib/cli/doctor.js
CHANGED
|
@@ -67,7 +67,12 @@ function checkBoot(platform, home, execImpl) {
|
|
|
67
67
|
if (platform === 'termux') {
|
|
68
68
|
const p = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
|
|
69
69
|
const ok = fs.existsSync(p);
|
|
70
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
name: 'boot script', ok, warn: ok,
|
|
72
|
+
detail: ok
|
|
73
|
+
? `${p} · app Termux:Boot non verificabile da CLI: installala e avviala una volta`
|
|
74
|
+
: 'nessun Termux:boot script',
|
|
75
|
+
};
|
|
71
76
|
}
|
|
72
77
|
if (platform === 'linux') {
|
|
73
78
|
try {
|
|
@@ -84,6 +89,42 @@ function checkBoot(platform, home, execImpl) {
|
|
|
84
89
|
return { name: 'boot (launchd RunAtLoad)', ok: true, warn: !ok, detail: ok ? 'plist installato' : 'plist non installato' };
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
function checkUserLinger(platform, execImpl, uidVal) {
|
|
93
|
+
if (platform !== 'linux') {
|
|
94
|
+
return { name: 'user linger', ok: true, detail: `${platform}: non applicabile` };
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const value = String(execImpl('loginctl', [
|
|
98
|
+
'show-user', String(uidVal), '--property=Linger', '--value',
|
|
99
|
+
], { encoding: 'utf8' }) || '').trim().toLowerCase();
|
|
100
|
+
const enabled = value === 'yes';
|
|
101
|
+
return {
|
|
102
|
+
name: 'user linger', ok: true, warn: !enabled,
|
|
103
|
+
detail: enabled ? 'enabled · il servizio user puo partire senza login' : 'disabled · abilita con loginctl enable-linger per il boot senza login',
|
|
104
|
+
};
|
|
105
|
+
} catch (_) {
|
|
106
|
+
return { name: 'user linger', ok: true, warn: true, detail: 'non verificabile; il boot senza login potrebbe non partire' };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function checkTmuxSurvival(platform, execImpl) {
|
|
111
|
+
if (platform !== 'linux') {
|
|
112
|
+
return { name: 'tmux survival on service restart', ok: true, detail: `${platform}: systemd cgroup non applicabile` };
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const value = String(execImpl('systemctl', [
|
|
116
|
+
'--user', 'show', 'nexuscrew.service', '--property=KillMode', '--value',
|
|
117
|
+
], { encoding: 'utf8' }) || '').trim();
|
|
118
|
+
const ok = value === 'process';
|
|
119
|
+
return {
|
|
120
|
+
name: 'tmux survival on service restart', ok,
|
|
121
|
+
detail: ok ? 'KillMode=process' : `KillMode=${value || 'sconosciuto'} (restart NexusCrew puo terminare tmux)`,
|
|
122
|
+
};
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return { name: 'tmux survival on service restart', ok: false, detail: `KillMode non verificabile: ${error.message || error}` };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
87
128
|
// ssh client presente su PATH: prerequisito dei tunnel multi-node (design §4).
|
|
88
129
|
function checkSshClient(existsImpl) {
|
|
89
130
|
return existsImpl('ssh')
|
|
@@ -141,6 +182,8 @@ function doctor(opts = {}) {
|
|
|
141
182
|
checkPty(ptyLoad),
|
|
142
183
|
checkService(platform, home, execImpl, uidVal, opts.installPath),
|
|
143
184
|
checkBoot(platform, home, execImpl),
|
|
185
|
+
checkUserLinger(platform, execImpl, uidVal),
|
|
186
|
+
checkTmuxSurvival(platform, execImpl),
|
|
144
187
|
checkTokenPerms(tokenPath),
|
|
145
188
|
checkSshClient(existsImpl),
|
|
146
189
|
checkAutossh(existsImpl),
|
|
@@ -159,5 +202,6 @@ function doctor(opts = {}) {
|
|
|
159
202
|
module.exports = {
|
|
160
203
|
doctor, nodeMajor,
|
|
161
204
|
checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
|
|
205
|
+
checkTmuxSurvival, checkUserLinger,
|
|
162
206
|
checkSshClient, checkAutossh, checkSshPermitlisten,
|
|
163
207
|
};
|
package/lib/cli/service.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const os = require('node:os');
|
|
6
6
|
const path = require('node:path');
|
|
7
|
+
const crypto = require('node:crypto');
|
|
7
8
|
const { execFileSync } = require('node:child_process');
|
|
8
9
|
const { detectPlatform, uid } = require('./platform.js');
|
|
9
10
|
|
|
@@ -65,10 +66,14 @@ function generateLinux(ctx) {
|
|
|
65
66
|
return `# NexusCrew service (systemd --user, loopback, solo tunnel SSH/VPN)
|
|
66
67
|
[Unit]
|
|
67
68
|
Description=NexusCrew - browser tmux client (loopback, solo tunnel SSH/VPN)
|
|
69
|
+
Wants=network-online.target
|
|
68
70
|
After=network-online.target
|
|
69
71
|
|
|
70
72
|
[Service]
|
|
71
73
|
Type=simple
|
|
74
|
+
# The HTTP service may be the process that creates the shared tmux server.
|
|
75
|
+
# Restart only NexusCrew itself; tmux sessions are independent user workloads.
|
|
76
|
+
KillMode=process
|
|
72
77
|
WorkingDirectory=${repo}
|
|
73
78
|
Environment=PATH=${nodeDir}:/usr/local/bin:/usr/bin:/bin
|
|
74
79
|
ExecStart=${node} ${repoBin} serve
|
|
@@ -153,6 +158,53 @@ function fileMode(platform) {
|
|
|
153
158
|
return 0o644; // systemd unit + launchd plist
|
|
154
159
|
}
|
|
155
160
|
|
|
161
|
+
const LINUX_TMUX_SURVIVAL_DROPIN = `[Service]\n# NexusCrew owns the HTTP process, not the shared tmux server it may start.\nKillMode=process\n`;
|
|
162
|
+
|
|
163
|
+
function linuxTmuxSurvivalPath(home) {
|
|
164
|
+
return path.join(home, '.config', 'systemd', 'user', 'nexuscrew.service.d', '10-tmux-survival.conf');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Upgrade existing Linux installations before stop/restart. Merely updating
|
|
168
|
+
// the npm package does not rewrite an already-installed unit, so the new CLI
|
|
169
|
+
// installs a narrow drop-in and reloads systemd before any destructive service
|
|
170
|
+
// lifecycle operation. The target is atomic and never follows a symlink.
|
|
171
|
+
function ensureLinuxTmuxSurvival(opts = {}) {
|
|
172
|
+
const home = opts.home || os.homedir();
|
|
173
|
+
const target = opts.dropInPath || linuxTmuxSurvivalPath(home);
|
|
174
|
+
const execImpl = opts.execImpl || execFileSync;
|
|
175
|
+
let current = '';
|
|
176
|
+
try {
|
|
177
|
+
const st = fs.lstatSync(target);
|
|
178
|
+
if (st.isSymbolicLink() || !st.isFile()) throw new Error(`refusing unsafe systemd drop-in: ${target}`);
|
|
179
|
+
current = fs.readFileSync(target, 'utf8');
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error.code !== 'ENOENT') throw error;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let written = false;
|
|
185
|
+
if (current !== LINUX_TMUX_SURVIVAL_DROPIN) {
|
|
186
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
187
|
+
const tmp = `${target}.tmp.${process.pid}.${crypto.randomBytes(6).toString('hex')}`;
|
|
188
|
+
try {
|
|
189
|
+
fs.writeFileSync(tmp, LINUX_TMUX_SURVIVAL_DROPIN, { mode: 0o644, flag: 'wx' });
|
|
190
|
+
fs.chmodSync(tmp, 0o644);
|
|
191
|
+
fs.renameSync(tmp, target);
|
|
192
|
+
written = true;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
execImpl('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
|
|
199
|
+
const effective = String(execImpl('systemctl', [
|
|
200
|
+
'--user', 'show', 'nexuscrew.service', '--property=KillMode', '--value',
|
|
201
|
+
], { encoding: 'utf8' }) || '').trim();
|
|
202
|
+
if (effective !== 'process') {
|
|
203
|
+
throw new Error(`unsafe effective systemd KillMode=${effective || 'unknown'}; refusing NexusCrew lifecycle operation`);
|
|
204
|
+
}
|
|
205
|
+
return { target, written, killMode: effective };
|
|
206
|
+
}
|
|
207
|
+
|
|
156
208
|
// Install no-symlink + atomic rename. [M3]
|
|
157
209
|
// - lstat target: reject symlink (no follow)
|
|
158
210
|
// - write temp file nella stessa dir -> chmod mode -> atomic rename
|
|
@@ -230,6 +282,7 @@ function installCommands(platform, target, ctx) {
|
|
|
230
282
|
module.exports = {
|
|
231
283
|
generateService, generateLinux, generateMac, generateTermux,
|
|
232
284
|
installService, installPath, installCommands, fileMode,
|
|
285
|
+
ensureLinuxTmuxSurvival, linuxTmuxSurvivalPath, LINUX_TMUX_SURVIVAL_DROPIN,
|
|
233
286
|
escapeSystemdPath, escapeSystemdExec, escapeXml, shellQuote,
|
|
234
287
|
assertSystemdSafe, SYSTEMD_FORBIDDEN,
|
|
235
288
|
};
|
|
@@ -14,6 +14,9 @@ const pidPath = process.env.NEXUSCREW_TUNNEL_PIDFILE;
|
|
|
14
14
|
const runId = process.env.NEXUSCREW_TUNNEL_RUN_ID;
|
|
15
15
|
const stableMsRaw = Number(process.env.NEXUSCREW_TUNNEL_STABLE_MS || 3000);
|
|
16
16
|
const stableMs = Number.isFinite(stableMsRaw) && stableMsRaw >= 100 ? Math.min(stableMsRaw, 30000) : 3000;
|
|
17
|
+
const ownershipGraceRaw = Number(process.env.NEXUSCREW_TUNNEL_OWNERSHIP_GRACE_MS || 2000);
|
|
18
|
+
const ownershipGraceMs = Number.isFinite(ownershipGraceRaw) && ownershipGraceRaw >= 100
|
|
19
|
+
? Math.min(ownershipGraceRaw, 10000) : 2000;
|
|
17
20
|
if (!sshBin || !statePath || !pidPath || !runId) process.exit(2);
|
|
18
21
|
|
|
19
22
|
let child = null;
|
|
@@ -21,6 +24,8 @@ let stopping = false;
|
|
|
21
24
|
let attempt = 0;
|
|
22
25
|
let retryTimer = null;
|
|
23
26
|
let upTimer = null;
|
|
27
|
+
let ownershipWaitTimer = null;
|
|
28
|
+
let ownershipTimer = null;
|
|
24
29
|
|
|
25
30
|
function ownsGeneration() {
|
|
26
31
|
try {
|
|
@@ -48,14 +53,14 @@ function writeState(status, extra = {}) {
|
|
|
48
53
|
function scheduleRetry(detail) {
|
|
49
54
|
if (stopping) return finish();
|
|
50
55
|
const delayMs = backoffDelay(attempt, { baseMs: 1000, capMs: 60000 });
|
|
51
|
-
writeState('retrying', { delayMs, detail });
|
|
56
|
+
if (!writeState('retrying', { delayMs, detail })) return stop();
|
|
52
57
|
attempt += 1;
|
|
53
58
|
retryTimer = setTimeout(run, delayMs);
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
function run() {
|
|
57
62
|
if (stopping) return finish();
|
|
58
|
-
writeState('starting');
|
|
63
|
+
if (!writeState('starting')) return stop();
|
|
59
64
|
try {
|
|
60
65
|
child = spawn(sshBin, sshArgs, { stdio: 'inherit' });
|
|
61
66
|
} catch (e) {
|
|
@@ -78,7 +83,7 @@ function run() {
|
|
|
78
83
|
upTimer = setTimeout(() => {
|
|
79
84
|
if (!stopping && child && child.exitCode == null) {
|
|
80
85
|
attempt = 0;
|
|
81
|
-
writeState('transport-ready', { sshPid: child.pid, stableMs });
|
|
86
|
+
if (!writeState('transport-ready', { sshPid: child.pid, stableMs })) stop();
|
|
82
87
|
}
|
|
83
88
|
}, stableMs);
|
|
84
89
|
});
|
|
@@ -94,6 +99,8 @@ function run() {
|
|
|
94
99
|
function finish() {
|
|
95
100
|
clearTimeout(retryTimer);
|
|
96
101
|
clearTimeout(upTimer);
|
|
102
|
+
clearTimeout(ownershipWaitTimer);
|
|
103
|
+
clearInterval(ownershipTimer);
|
|
97
104
|
if (ownsGeneration()) {
|
|
98
105
|
writeState('down');
|
|
99
106
|
try {
|
|
@@ -119,4 +126,19 @@ function stop() {
|
|
|
119
126
|
|
|
120
127
|
process.on('SIGTERM', stop);
|
|
121
128
|
process.on('SIGINT', stop);
|
|
122
|
-
|
|
129
|
+
|
|
130
|
+
// The parent can only write our PID after spawn returns. Give that narrow race
|
|
131
|
+
// a bounded grace window, then enforce generation ownership continuously. A
|
|
132
|
+
// replaced/removed pidfile must stop both supervisor and ssh instead of leaving
|
|
133
|
+
// an invisible retrying orphan behind.
|
|
134
|
+
const ownershipDeadline = Date.now() + ownershipGraceMs;
|
|
135
|
+
function acquireGeneration() {
|
|
136
|
+
if (stopping) return finish();
|
|
137
|
+
if (ownsGeneration()) {
|
|
138
|
+
ownershipTimer = setInterval(() => { if (!ownsGeneration()) stop(); }, 500);
|
|
139
|
+
return run();
|
|
140
|
+
}
|
|
141
|
+
if (Date.now() >= ownershipDeadline) return finish();
|
|
142
|
+
ownershipWaitTimer = setTimeout(acquireGeneration, 20);
|
|
143
|
+
}
|
|
144
|
+
acquireGeneration();
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -37,6 +37,11 @@ function assertForwardSpec(node) {
|
|
|
37
37
|
if (node.sshPort !== undefined && !store.isPort(node.sshPort)) throw new Error('tunnel: sshPort non valida');
|
|
38
38
|
if (node.identityFile !== undefined && !store.isAbsPath(node.identityFile)) throw new Error('tunnel: identityFile non valido');
|
|
39
39
|
if (node.keyPath !== undefined && !store.isAbsPath(node.keyPath)) throw new Error('tunnel: keyPath non valido');
|
|
40
|
+
if (node.reversePort !== undefined && !store.isPort(node.reversePort)) throw new Error('tunnel: reversePort non valida');
|
|
41
|
+
if (node.localAppPort !== undefined && !store.isPort(node.localAppPort)) throw new Error('tunnel: localAppPort non valida');
|
|
42
|
+
if (node.shared === true && node.reversePort !== undefined && !store.isPort(node.localAppPort)) {
|
|
43
|
+
throw new Error('tunnel: Share richiede localAppPort esplicita');
|
|
44
|
+
}
|
|
40
45
|
if (!store.parseSshTarget(node.ssh)) throw new Error('tunnel: target ssh non valido');
|
|
41
46
|
}
|
|
42
47
|
|
|
@@ -52,7 +57,7 @@ function buildForwardArgs(node) {
|
|
|
52
57
|
// The reverse channel publishes this device back through the hub, so it is
|
|
53
58
|
// opt-in only. A negotiated reversePort alone must never imply consent.
|
|
54
59
|
const reverse = node.shared === true && node.reversePort !== undefined ? [
|
|
55
|
-
'-R', `127.0.0.1:${node.reversePort}:127.0.0.1:${node.localAppPort
|
|
60
|
+
'-R', `127.0.0.1:${node.reversePort}:127.0.0.1:${node.localAppPort}`,
|
|
56
61
|
] : [];
|
|
57
62
|
return SSH_BASE_OPTS.concat(transport, identity ? ['-i', identity] : [], [
|
|
58
63
|
'-L', `127.0.0.1:${node.localPort}:127.0.0.1:${node.remotePort}`,
|
|
@@ -246,13 +251,23 @@ function removeStateIfOwned(home, name, meta) {
|
|
|
246
251
|
return false;
|
|
247
252
|
}
|
|
248
253
|
|
|
249
|
-
function supervisorExited(pid, timeoutMs = 2500) {
|
|
254
|
+
function supervisorExited(pid, timeoutMs = 2500, impl = {}) {
|
|
250
255
|
const deadline = Date.now() + timeoutMs;
|
|
251
256
|
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
257
|
+
const pidExistsImpl = impl.pidExistsImpl || pidf.pidExists;
|
|
258
|
+
const procReadImpl = impl.procReadImpl || fs.readFileSync;
|
|
259
|
+
const spawnSyncImpl = impl.spawnSyncImpl || spawnSync;
|
|
252
260
|
const exited = () => {
|
|
253
|
-
if (!
|
|
254
|
-
try { return
|
|
255
|
-
catch (_) {
|
|
261
|
+
if (!pidExistsImpl(pid)) return true;
|
|
262
|
+
try { return procReadImpl(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z'; }
|
|
263
|
+
catch (_) {
|
|
264
|
+
// macOS has no /proc. `ps` is argv-only and its STAT starts with Z for a
|
|
265
|
+
// zombie, which is no longer a usable supervisor even before wait/reap.
|
|
266
|
+
try {
|
|
267
|
+
const result = spawnSyncImpl('ps', ['-p', String(pid), '-o', 'stat='], { encoding: 'utf8' });
|
|
268
|
+
return !result?.error && /^Z/.test(String(result?.stdout || '').trim());
|
|
269
|
+
} catch (_error) { return false; }
|
|
270
|
+
}
|
|
256
271
|
};
|
|
257
272
|
while (!exited() && Date.now() < deadline) Atomics.wait(sleeper, 0, 0, 25);
|
|
258
273
|
return exited();
|
|
@@ -418,7 +433,8 @@ function restartTunnel(opts) {
|
|
|
418
433
|
// Helper di alto livello: costruisce args dal nodo e avvia il forward.
|
|
419
434
|
function startForward(opts) {
|
|
420
435
|
const node = opts.node;
|
|
421
|
-
const
|
|
436
|
+
const localAppPort = opts.localAppPort === undefined ? node.localAppPort : opts.localAppPort;
|
|
437
|
+
const args = buildForwardArgs({ ...node, localAppPort });
|
|
422
438
|
// NexusCrew already owns retry/backoff in tunnel-supervisor.js. Nesting
|
|
423
439
|
// autossh inside that supervisor made liveness dishonest: autossh could stay
|
|
424
440
|
// alive while every SSH child exited 255, so the UI reported "tunnel up".
|
package/lib/update/runner.js
CHANGED
|
@@ -29,7 +29,10 @@ async function restartRuntime(opts = {}) {
|
|
|
29
29
|
const token = opts.token || url.readToken(url.resolvePaths({ home }).tokenPath);
|
|
30
30
|
let mode = 'inactive';
|
|
31
31
|
if (commands.isServiceRunning({ platform, home })) {
|
|
32
|
-
commands.restart({ platform, home, log: () => {} });
|
|
32
|
+
const restarted = commands.restart({ platform, home, log: () => {} });
|
|
33
|
+
if (!restarted || restarted.restarted !== true) {
|
|
34
|
+
throw new Error(`restart service fallito: ${(restarted && restarted.reason) || 'esito non verificato'}`);
|
|
35
|
+
}
|
|
33
36
|
mode = 'service';
|
|
34
37
|
} else {
|
|
35
38
|
const pidPath = pidf.defaultPidfilePath(home);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmmbuto/nexuscrew",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.12",
|
|
4
4
|
"description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
|
|
5
5
|
"main": "lib/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"start": "node bin/nexuscrew.js serve",
|
|
21
21
|
"dev": "node bin/nexuscrew.js serve",
|
|
22
22
|
"build": "cd frontend && npm install && npm run build && cd ..",
|
|
23
|
-
"test": "node tests/run-isolated.js"
|
|
23
|
+
"test": "node tests/run-isolated.js && npm --prefix frontend test"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"express": "^4.21.0",
|