nterminal 1.3.13 → 1.3.14

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.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/dist/client/assets/MarkdownPreview-Bqq8nsbg.js +1 -0
  3. package/dist/client/assets/{TranscriptMarkdownBody-BAkL6ovK.js → TranscriptMarkdownBody-DkUMrYOc.js} +1 -1
  4. package/dist/client/assets/{index-WSS03d_S.js → index-DW6pRsCC.js} +29 -29
  5. package/dist/client/assets/{index-DLFQwECZ.js → index-iZGXrm6V.js} +1 -1
  6. package/dist/client/index.html +1 -1
  7. package/dist/scripts/onboarding.js +320 -134
  8. package/dist/scripts/onboarding.js.map +1 -1
  9. package/dist/server/accounts/accountService.d.ts +4 -0
  10. package/dist/server/accounts/accountService.js +102 -9
  11. package/dist/server/accounts/accountService.js.map +1 -1
  12. package/dist/server/agent/agentProxy.js +4 -1
  13. package/dist/server/agent/agentProxy.js.map +1 -1
  14. package/dist/server/agent/agentTransport.d.ts +11 -0
  15. package/dist/server/agent/agentTransport.js +117 -0
  16. package/dist/server/agent/agentTransport.js.map +1 -0
  17. package/dist/server/agent/agentWebSocketProxy.js +3 -0
  18. package/dist/server/agent/agentWebSocketProxy.js.map +1 -1
  19. package/dist/server/agent/directTlsIdentity.d.ts +39 -0
  20. package/dist/server/agent/directTlsIdentity.js +493 -0
  21. package/dist/server/agent/directTlsIdentity.js.map +1 -0
  22. package/dist/server/agent/directTlsMigration.d.ts +25 -0
  23. package/dist/server/agent/directTlsMigration.js +322 -0
  24. package/dist/server/agent/directTlsMigration.js.map +1 -0
  25. package/dist/server/agent/directTlsProtocol.d.ts +10 -0
  26. package/dist/server/agent/directTlsProtocol.js +32 -0
  27. package/dist/server/agent/directTlsProtocol.js.map +1 -0
  28. package/dist/server/agent/directTlsServer.d.ts +7 -0
  29. package/dist/server/agent/directTlsServer.js +72 -0
  30. package/dist/server/agent/directTlsServer.js.map +1 -0
  31. package/dist/server/agent/outboundAgentRegistry.js +2 -1
  32. package/dist/server/agent/outboundAgentRegistry.js.map +1 -1
  33. package/dist/server/agent/secondaryDirectTls.d.ts +20 -0
  34. package/dist/server/agent/secondaryDirectTls.js +143 -0
  35. package/dist/server/agent/secondaryDirectTls.js.map +1 -0
  36. package/dist/server/config.d.ts +1 -0
  37. package/dist/server/config.js +1 -0
  38. package/dist/server/config.js.map +1 -1
  39. package/dist/server/hooks/hookInstallation.d.ts +2 -0
  40. package/dist/server/hooks/hookInstallation.js +42 -17
  41. package/dist/server/hooks/hookInstallation.js.map +1 -1
  42. package/dist/server/hooks/verifiedLock.js +96 -2
  43. package/dist/server/hooks/verifiedLock.js.map +1 -1
  44. package/dist/server/http.d.ts +2 -0
  45. package/dist/server/http.js +9 -1
  46. package/dist/server/http.js.map +1 -1
  47. package/dist/server/index.js +26 -1
  48. package/dist/server/index.js.map +1 -1
  49. package/dist/server/notifications/agentCompletionMonitor.js +4 -2
  50. package/dist/server/notifications/agentCompletionMonitor.js.map +1 -1
  51. package/dist/server/routes/agentManagementRoutes.js +73 -7
  52. package/dist/server/routes/agentManagementRoutes.js.map +1 -1
  53. package/dist/server/storage/fileStore.d.ts +12 -1
  54. package/dist/server/storage/fileStore.js +30 -6
  55. package/dist/server/storage/fileStore.js.map +1 -1
  56. package/docs/onboarding.md +58 -5
  57. package/docs/provider-hooks.md +24 -3
  58. package/package.json +5 -4
  59. package/scripts/nterminalctl +25 -2
  60. package/dist/client/assets/MarkdownPreview-8vEkrQtk.js +0 -1
@@ -1,4 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
+ import https from 'node:https';
2
3
  import net from 'node:net';
3
4
  import os from 'node:os';
4
5
  import { accessSync, chmodSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
@@ -263,11 +264,71 @@ export function mergeEnv(existing, updates) {
263
264
  }
264
265
  return `${lines.join('\n').replace(/\n+$/, '')}\n`;
265
266
  }
266
- export function buildAgentRegisterPayload(name, url, token, transport = 'direct') {
267
+ export function defaultSecondaryTransport(existing) {
268
+ return existing?.trim() === 'outbound' ? 'outbound' : 'direct';
269
+ }
270
+ export function buildAgentRegisterPayload(name, url, token, transport = 'direct', tlsCertificate) {
267
271
  const base = { name: name.trim(), token: token.trim() };
268
- return transport === 'outbound'
269
- ? { ...base, transport }
270
- : { ...base, url: new URL(url.trim()).origin };
272
+ if (transport === 'outbound')
273
+ return { ...base, transport };
274
+ const parsed = new URL(url.trim());
275
+ if (tlsCertificate !== undefined && (parsed.protocol !== 'https:' || !tlsCertificate.trim())) {
276
+ throw new Error('Direct TLS registration requires an HTTPS URL and a certificate.');
277
+ }
278
+ return { ...base, url: parsed.origin, ...(tlsCertificate === undefined ? {} : { tlsCertificate }) };
279
+ }
280
+ // Only this temporary listener is owned by onboarding; an occupied port may
281
+ // belong to an existing runtime or another service and must not be disturbed.
282
+ export async function startDirectTlsProbe(identity, agentToken, host, port) {
283
+ const expected = crypto.createHash('sha256').update(`Bearer ${agentToken}`).digest();
284
+ const server = https.createServer({ key: identity.privateKey, cert: identity.certificate, minVersion: 'TLSv1.2' }, (req, res) => {
285
+ res.setHeader('content-type', 'application/json');
286
+ res.setHeader('cache-control', 'no-store');
287
+ if (req.method !== 'GET' || req.url !== '/api/agent/direct-tls/probe') {
288
+ res.writeHead(404).end(JSON.stringify({ error: 'not_found' }));
289
+ return;
290
+ }
291
+ const supplied = crypto.createHash('sha256').update(req.headers.authorization ?? '').digest();
292
+ if (!agentToken || !crypto.timingSafeEqual(expected, supplied)) {
293
+ res.writeHead(401).end(JSON.stringify({ error: 'unauthorized' }));
294
+ return;
295
+ }
296
+ res.end(JSON.stringify({ ok: true }));
297
+ });
298
+ server.requestTimeout = 8000;
299
+ server.headersTimeout = 8000;
300
+ server.setTimeout(8000, (socket) => socket.destroy());
301
+ const sockets = new Set();
302
+ server.on('connection', (socket) => {
303
+ sockets.add(socket);
304
+ socket.once('close', () => sockets.delete(socket));
305
+ });
306
+ const listening = await new Promise((resolve, reject) => {
307
+ const onError = (error) => {
308
+ if (error.code === 'EADDRINUSE')
309
+ resolve(false);
310
+ else
311
+ reject(error);
312
+ };
313
+ server.once('error', onError);
314
+ server.listen(port, host, () => {
315
+ server.removeListener('error', onError);
316
+ resolve(true);
317
+ });
318
+ });
319
+ if (!listening)
320
+ return null;
321
+ let closing;
322
+ return {
323
+ close() {
324
+ closing ??= new Promise((resolve, reject) => {
325
+ server.close((error) => error ? reject(error) : resolve());
326
+ for (const socket of sockets)
327
+ socket.destroy();
328
+ });
329
+ return closing;
330
+ }
331
+ };
271
332
  }
272
333
  export function shouldBlockRootOnboarding(uid, env = process.env) {
273
334
  if (uid !== 0) {
@@ -819,9 +880,9 @@ function printStep(section, current, total, title, detail) {
819
880
  function shouldUseColor() {
820
881
  return Boolean(process.stdout.isTTY && process.env.NO_COLOR === undefined && process.env.NTERMINAL_COLOR !== '0');
821
882
  }
822
- async function askUrl(p, question, fallback, defaultProtocol = 'https') {
883
+ async function askUrl(p, question, fallback) {
823
884
  for (;;) {
824
- const value = normalizeUrlInput(await p.ask(question, fallback), defaultProtocol);
885
+ const value = normalizeUrlInput(await p.ask(question, fallback));
825
886
  if (!value) {
826
887
  console.log('A URL is required. Try again.');
827
888
  continue;
@@ -834,8 +895,12 @@ async function askUrl(p, question, fallback, defaultProtocol = 'https') {
834
895
  console.log('Invalid URL. Try again.');
835
896
  continue;
836
897
  }
837
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
838
- console.log('URL must start with http:// or https://. Try again.');
898
+ if (parsed.protocol !== 'https:') {
899
+ console.log('An https:// URL is required. Try again.');
900
+ continue;
901
+ }
902
+ if (parsed.username || parsed.password) {
903
+ console.log('Enter a URL without embedded credentials.');
839
904
  continue;
840
905
  }
841
906
  return value;
@@ -844,7 +909,7 @@ async function askUrl(p, question, fallback, defaultProtocol = 'https') {
844
909
  async function askSecondaryAdvertisedUrl(p, bindHost, port) {
845
910
  console.log('Checking the public IP that this machine uses for outbound internet access...');
846
911
  const publicIp = await detectPublicIp(2500);
847
- const suggestion = suggestSecondaryAdvertisedUrl({ bindHost, port, publicIp });
912
+ const suggestion = suggestSecondaryAdvertisedUrl({ bindHost, port, publicIp, protocol: 'https' });
848
913
  console.log(`Local/interface URL: ${suggestion.localUrl}`);
849
914
  if (suggestion.publicIp) {
850
915
  console.log(`Detected public IP: ${suggestion.publicIp}`);
@@ -855,17 +920,8 @@ async function askSecondaryAdvertisedUrl(p, bindHost, port) {
855
920
  if (suggestion.warning) {
856
921
  console.log(colorText(`Warning: ${suggestion.warning}`, '33', shouldUseColor()));
857
922
  }
858
- console.log('Cloud firewalls/security groups must allow the chosen port from the main server, otherwise registration can succeed but the server will show offline.');
859
- for (;;) {
860
- const url = await askUrl(p, 'URL the main should use to reach this secondary', suggestion.url, 'http');
861
- if (new URL(url).protocol === 'https:') {
862
- return url;
863
- }
864
- console.log(colorText('Warning: direct HTTP sends the agent token and terminal traffic without transport encryption. Use it only over a private VPN/trusted network and restrict the port to the main server.', '33', shouldUseColor()));
865
- if (await p.confirm('Continue with this unencrypted direct HTTP URL?', false)) {
866
- return url;
867
- }
868
- }
923
+ console.log('Cloud firewalls/security groups must allow the chosen port from the main server. Direct TLS connectivity must pass before configuration is saved.');
924
+ return askUrl(p, 'HTTPS URL the main should use to reach this secondary', suggestion.url);
869
925
  }
870
926
  async function askSecondaryFirewallSource(p, port) {
871
927
  console.log('\nFor secondary servers, the browser talks to the main and the main talks to this agent. This port should normally accept only the main server.');
@@ -1215,21 +1271,45 @@ async function onboardMain(p) {
1215
1271
  console.log(`\n${colorText('⚠', '33', shouldUseColor())} NTerminal itself is running on 127.0.0.1:${port.value}, but the public URL is not verified yet — the reverse-proxy or TLS step above was skipped or did not pass its check.`);
1216
1272
  console.log(`Finish the remaining commands shown above, then re-run \`${onboardingCommand()}\` to re-verify ${publicUrl}.`);
1217
1273
  }
1274
+ export async function prepareOnboardingDirectTlsIdentity(p, stateDir, ensure, requireExisting = false) {
1275
+ for (;;) {
1276
+ try {
1277
+ return { action: 'direct', identity: await ensure(stateDir, { requireExisting }) };
1278
+ }
1279
+ catch (error) {
1280
+ if (error instanceof Error && error.message === 'direct_tls_identity_missing') {
1281
+ console.error('The registered direct TLS identity is missing. Restore the original identity or explicitly re-register a replacement with the main; onboarding will not silently replace the key.');
1282
+ }
1283
+ if (!(error instanceof Error) || error.message !== 'direct_tls_openssl_failed')
1284
+ throw error;
1285
+ }
1286
+ console.log('OpenSSL could not generate the direct TLS identity. Install or check the openssl executable on this user\'s PATH, then retry.');
1287
+ console.log('You can choose outbound instead without generating a direct TLS certificate, or cancel without saving configuration.');
1288
+ for (;;) {
1289
+ const action = (await p.ask('TLS identity setup (retry / outbound / cancel)', 'retry')).trim().toLowerCase();
1290
+ if (action === 'retry')
1291
+ break;
1292
+ if (action === 'outbound' || action === 'cancel')
1293
+ return { action };
1294
+ console.log('Choose one of the listed actions.');
1295
+ }
1296
+ }
1297
+ }
1218
1298
  async function onboardSecondary(p) {
1219
1299
  const updateHelperNeed = detectUpdateHelperNeed();
1220
- const totalSteps = 8 + (updateHelperNeed.needed ? 1 : 0);
1300
+ const totalSteps = 7 + (updateHelperNeed.needed ? 1 : 0);
1221
1301
  let currentStep = 0;
1222
1302
  const section = 'Secondary server setup';
1223
1303
  const step = (title, detail) => printStep(section, ++currentStep, totalSteps, title, detail);
1224
1304
  console.log(`\n=== ${section} ===`);
1225
- step('Choose connection mode', 'Direct is best for stable reachable servers; outbound works behind NAT or changing mobile IPs.');
1226
1305
  const previousTransport = existingEnvValue('NTERMINAL_AGENT_TRANSPORT');
1227
- const transport = await chooseSecondaryTransport(p, previousTransport);
1306
+ let transport = defaultSecondaryTransport(previousTransport);
1307
+ console.log(transport === 'direct' ? 'Using direct HTTPS with a certificate pinned by the main.' : 'Preserving the existing outbound connection mode.');
1228
1308
  step('Bind local agent', transport === 'direct'
1229
1309
  ? 'The main server must be able to reach this host and port.'
1230
1310
  : 'The agent stays on loopback and opens an encrypted outbound link to the main.');
1231
1311
  const existingHost = existingEnvValue('NTERMINAL_HOST');
1232
- const host = resolveSecondaryBindHost(transport, previousTransport, existingHost);
1312
+ let host = resolveSecondaryBindHost(transport, previousTransport, existingHost);
1233
1313
  printResolvedBindHost(host, transport === 'direct'
1234
1314
  ? 'listens on all interfaces so the main can reach this server'
1235
1315
  : 'loopback only; no inbound agent port is required');
@@ -1238,16 +1318,35 @@ async function onboardSecondary(p) {
1238
1318
  step('Name this server', transport === 'direct'
1239
1319
  ? 'Choose the URL the main can reach. Public IP detection is only a suggested default.'
1240
1320
  : 'No public IP or inbound firewall rule is needed for this connection mode.');
1241
- const selfUrl = transport === 'direct' ? await askSecondaryAdvertisedUrl(p, host.value, port.value) : null;
1321
+ let selfUrl = transport === 'direct' ? await askSecondaryAdvertisedUrl(p, host.value, port.value) : null;
1242
1322
  const name = await p.ask('Name shown in the main', os.hostname());
1243
1323
  // Preserve existing secret/token on re-run so a re-registration keeps the same agent identity.
1244
- // Do not write the selected transport yet: an outbound registration can still fail, and
1245
- // persisting a half-configured outbound mode would prevent the existing runtime from restarting.
1324
+ // Do not change runtime config until registration and direct TLS verification pass.
1246
1325
  const sessionSecret = existingEnvValue('NTERMINAL_SESSION_SECRET') ?? generateSessionSecret();
1247
1326
  const agentToken = existingEnvValue('NTERMINAL_AGENT_TOKEN') ?? generateAgentToken();
1248
1327
  const reusedSessionSecret = Boolean(existingEnvValue('NTERMINAL_SESSION_SECRET'));
1249
1328
  const reusedAgentToken = Boolean(existingEnvValue('NTERMINAL_AGENT_TOKEN'));
1250
1329
  const statePath = resolveRuntimeStatePath(APP_DIR, existingEnvValue('NTERMINAL_STATE_PATH'), process.env.NTERMINAL_RUNTIME_STATE_PATH);
1330
+ let identity;
1331
+ if (transport === 'direct') {
1332
+ const { ensureDirectTlsIdentity } = await import(serverModuleUrl('agent/directTlsIdentity'));
1333
+ const preparation = await prepareOnboardingDirectTlsIdentity(p, path.dirname(statePath), ensureDirectTlsIdentity, existingEnvValue('NTERMINAL_DIRECT_TLS_REQUIRED') === 'true');
1334
+ if (preparation.action === 'cancel') {
1335
+ p.close();
1336
+ console.error(`\nTLS identity setup cancelled. ${ENV_PATH} was left unchanged.`);
1337
+ process.exitCode = 1;
1338
+ return;
1339
+ }
1340
+ if (preparation.action === 'outbound') {
1341
+ transport = 'outbound';
1342
+ selfUrl = null;
1343
+ host = resolveSecondaryBindHost(transport, previousTransport, existingHost);
1344
+ printResolvedBindHost(host, 'loopback only; no inbound agent port is required');
1345
+ }
1346
+ else if (preparation.action === 'direct') {
1347
+ identity = preparation.identity;
1348
+ }
1349
+ }
1251
1350
  // Firewall: when the agent is publicly bound, the main can only reach it
1252
1351
  // if the host firewall lets the bind port in. Loopback binds never need
1253
1352
  // this so we skip the prompt entirely there. Onboarding used
@@ -1255,7 +1354,9 @@ async function onboardSecondary(p) {
1255
1354
  // "unreachable", the operator forgets ufw is even on, and debugging
1256
1355
  // burns a half hour before someone runs `ufw status`.
1257
1356
  step('Check network access', transport === 'direct' ? 'Restrict inbound access to the main where possible.' : 'Outbound mode needs HTTPS/WebSocket egress only.');
1258
- if (transport === 'direct' && isPubliclyBoundHost(host.value)) {
1357
+ const checkNetworkAccess = async () => {
1358
+ if (!isPubliclyBoundHost(host.value))
1359
+ return;
1259
1360
  const portNumber = Number(port.value);
1260
1361
  if (Number.isFinite(portNumber)) {
1261
1362
  const source = await askSecondaryFirewallSource(p, port.value);
@@ -1264,33 +1365,33 @@ async function onboardSecondary(p) {
1264
1365
  await ensureFirewallAllowsPort(portNumber, p, sudoMode, source ? { source } : {});
1265
1366
  }
1266
1367
  }
1368
+ };
1369
+ if (transport === 'direct')
1370
+ await checkNetworkAccess();
1371
+ step('Register with main', 'Required for both connection modes. Direct HTTPS must also pass the main server connection check.');
1372
+ const agent = { name, selfUrl, agentToken, transport, ...(identity ? { tlsCertificate: identity.certificate } : {}) };
1373
+ const registration = await registerWithMain(p, agent, {
1374
+ mainUrl: existingEnvValue('NTERMINAL_MAIN_URL') ?? '',
1375
+ ...(identity ? { probe: { privateKey: identity.privateKey, host: host.value, port: Number(port.value) } } : {}),
1376
+ retryNetwork: checkNetworkAccess
1377
+ });
1378
+ transport = agent.transport;
1379
+ selfUrl = agent.selfUrl;
1380
+ host = resolveSecondaryBindHost(transport, previousTransport, existingHost);
1381
+ if (!registration.registered) {
1382
+ p.close();
1383
+ console.error(`\nRegistration did not complete. ${ENV_PATH} was left unchanged; fix the error and re-run onboarding.`);
1384
+ process.exitCode = 1;
1385
+ return;
1267
1386
  }
1268
- step('Register with main', transport === 'outbound' ? 'Required so the main can authenticate and route this outbound link.' : 'Optional, but registration is the supported way to add this server to the main.');
1269
- const wantRegister = transport === 'outbound' || await p.confirm('\nRegister this server with a main now?', true);
1270
- let registration = { registered: false, mainUrl: null, agentId: null };
1271
- if (wantRegister) {
1272
- registration = await registerWithMain(p, { name, selfUrl, agentToken, transport });
1273
- }
1274
- else {
1275
- console.log('Skipped registration. Re-run onboarding on this secondary when you are ready to register it with the main.');
1276
- }
1277
- if (transport === 'outbound') {
1278
- if (!registration.registered || !registration.mainUrl || !registration.agentId) {
1279
- p.close();
1280
- console.error(`\nOutbound registration is required before this secondary can start. ${ENV_PATH} was left unchanged; fix the registration error and re-run onboarding.`);
1281
- process.exitCode = 1;
1282
- return;
1283
- }
1284
- }
1387
+ const registrationEnv = buildSecondaryRegistrationEnv(agent, registration);
1285
1388
  step('Write runtime config', `Writing the complete configuration to ${ENV_PATH}.`);
1286
1389
  writeEnv({
1287
1390
  NTERMINAL_ROLE: 'secondary',
1288
1391
  NTERMINAL_SESSION_SECRET: sessionSecret,
1289
1392
  NTERMINAL_STATE_PATH: statePath,
1290
1393
  NTERMINAL_AGENT_TOKEN: agentToken,
1291
- NTERMINAL_AGENT_TRANSPORT: transport,
1292
- NTERMINAL_AGENT_ID: transport === 'outbound' ? registration.agentId : '',
1293
- NTERMINAL_MAIN_URL: transport === 'outbound' ? registration.mainUrl : '',
1394
+ ...registrationEnv,
1294
1395
  NTERMINAL_HOST: host.value,
1295
1396
  NTERMINAL_PORT: port.value
1296
1397
  });
@@ -1316,47 +1417,76 @@ async function onboardSecondary(p) {
1316
1417
  process.exitCode = 1;
1317
1418
  return;
1318
1419
  }
1319
- const registeredWithMain = registration.registered;
1320
- step('Finish', registeredWithMain ? 'Registered secondary is ready.' : 'Secondary is running; registration can be retried later.');
1321
- printSummaryBox(`${colorText('✓', '32', shouldUseColor())} Secondary "${name}" is ready`, [
1420
+ step('Finish', deploy ? 'Registered secondary has started.' : 'Configuration saved; start this secondary to bring it online.');
1421
+ printSummaryBox(`${colorText('✓', '32', shouldUseColor())} Secondary "${name}" ${deploy ? 'is ready' : 'is configured'}`, [
1322
1422
  { label: transport === 'direct' ? 'Reachable at' : 'Connection', value: selfUrl ?? `outbound to ${registration.mainUrl}` },
1323
1423
  { label: 'Bind', value: `${host.value}:${port.value}` },
1324
1424
  {
1325
1425
  label: 'Registered',
1326
- value: registeredWithMain
1327
- ? 'yes (logged into the main)'
1328
- : wantRegister
1329
- ? 'no — registration did not complete'
1330
- : 'no — re-run onboarding to register later'
1426
+ value: transport === 'direct' ? 'yes (direct TLS connection verified)' : 'yes (outbound link identity saved)'
1331
1427
  }
1332
- ], registeredWithMain
1333
- ? [
1334
- 'On the main, the Servers list should now show this agent',
1335
- 'Refresh the main browser tab if the Servers list was already open'
1336
- ]
1337
- : [
1338
- `This secondary is listening at ${selfUrl ?? `${host.value}:${port.value}`}`,
1339
- ...(wantRegister
1340
- ? ['Fix the registration error above and re-run onboarding on this secondary']
1341
- : ['Re-run onboarding on this secondary and choose registration when ready'])
1342
- ]);
1428
+ ], [
1429
+ 'On the main, the Servers list should now show this agent',
1430
+ deploy ? 'Refresh the main browser tab if the Servers list was already open' : `Start the secondary with: ${controlCommand('deploy')}`
1431
+ ]);
1343
1432
  }
1344
- async function registerWithMain(p, agent) {
1433
+ export function buildSecondaryRegistrationEnv(agent, registration) {
1434
+ if (!registration.registered || !registration.mainUrl || !registration.agentId ||
1435
+ new URL(registration.mainUrl).protocol !== 'https:' ||
1436
+ (agent.transport === 'direct' && (!agent.tlsCertificate || !registration.tlsSupported || !registration.connectionVerified))) {
1437
+ throw new Error('Registration and connection verification must complete before saving runtime configuration.');
1438
+ }
1439
+ return {
1440
+ NTERMINAL_AGENT_TRANSPORT: agent.transport,
1441
+ NTERMINAL_AGENT_ID: registration.agentId,
1442
+ NTERMINAL_MAIN_URL: registration.mainUrl,
1443
+ NTERMINAL_DIRECT_TLS_REQUIRED: agent.transport === 'direct' ? 'true' : 'false'
1444
+ };
1445
+ }
1446
+ export function directTlsPortRecoveryInstructions(port, appDir = APP_DIR, envPath = ENV_PATH) {
1447
+ const control = `NTERMINAL_APP_DIR=${shellSingleQuote(appDir)} NTERMINAL_ENV_FILE=${shellSingleQuote(envPath)} bash ${shellSingleQuote(path.join(appDir, 'scripts/nterminalctl'))}`;
1448
+ return [
1449
+ `Port ${port} is occupied, so onboarding could not start its temporary HTTPS probe. No process was stopped or configuration changed.`,
1450
+ 'An older NTerminal process may still serve HTTP even after installing the new package. It must be restarted before direct HTTPS registration can succeed.',
1451
+ 'In another shell as the same runtime user, check the saved instance and the process listening on this port:',
1452
+ ` ${control} status`,
1453
+ ` lsof -nP -iTCP:${port} -sTCP:LISTEN`,
1454
+ ` (On Linux without lsof, use: ss -ltnp 'sport = :${port}')`,
1455
+ 'The status URL comes from saved configuration; confirm that its PID matches the listener before restarting. If this is the intended old NTerminal instance, run:',
1456
+ ` ${control} restart`,
1457
+ 'Restart uses the existing saved configuration and briefly disconnects clients. Tmux sessions survive; non-tmux terminals may end.',
1458
+ 'If another service owns the port, free it manually only if appropriate, or cancel and choose an unused port. Then select retry here; the port will be checked again without another main login.'
1459
+ ];
1460
+ }
1461
+ async function askRegistrationRetry(p, transport) {
1462
+ console.log(transport === 'direct'
1463
+ ? 'Retry after resolving any local port/runtime issue, correcting the secondary HTTPS URL or opening its port in the host/cloud firewall, choose outbound (no inbound port), or cancel without saving configuration.'
1464
+ : 'Retry registration or cancel without saving configuration.');
1465
+ for (;;) {
1466
+ const answer = (await p.ask(transport === 'direct' ? 'Next action (retry / outbound / cancel)' : 'Next action (retry / cancel)', 'retry')).trim().toLowerCase();
1467
+ if (answer === 'retry' || answer === 'cancel' || (answer === 'outbound' && transport === 'direct'))
1468
+ return answer;
1469
+ console.log('Choose one of the listed actions.');
1470
+ }
1471
+ }
1472
+ export async function registerWithMain(p, agent, options = {}) {
1473
+ const result = { registered: false, mainUrl: null, agentId: null, tlsSupported: false, connectionVerified: false };
1474
+ if (agent.transport === 'direct' && !agent.tlsCertificate?.trim()) {
1475
+ console.log('Direct HTTPS requires a TLS identity before registration. Configuration was not saved.');
1476
+ return result;
1477
+ }
1345
1478
  let mainUrl = '';
1346
- let mainOrigin = '';
1347
1479
  for (;;) {
1348
- mainUrl = originOf(await askUrl(p, 'Main server URL (required, e.g. https://terminal.example.test)'));
1349
- mainOrigin = mainUrl;
1350
- if (agent.transport === 'outbound' && new URL(mainOrigin).protocol !== 'https:') {
1351
- console.log('Outbound mode requires an https:// main URL so agent tokens and tunneled traffic use encrypted WebSockets.');
1352
- continue;
1353
- }
1480
+ mainUrl = originOf(await askUrl(p, 'Main server HTTPS URL (required, e.g. https://terminal.example.test)', options.mainUrl));
1481
+ result.mainUrl = mainUrl;
1354
1482
  try {
1355
- const res = await fetch(`${mainUrl}/api/auth/session`, { signal: AbortSignal.timeout(8000) });
1483
+ const res = await fetch(`${mainUrl}/api/auth/session`, { redirect: 'error', signal: AbortSignal.timeout(8000) });
1484
+ if (!res.ok)
1485
+ throw new Error(`Session check failed (${res.status})`);
1356
1486
  const status = (await res.json());
1357
1487
  if (!status.passwordSet) {
1358
1488
  console.log('That main has no password set yet. Onboard the MAIN first, then re-run this on the secondary.');
1359
- return { registered: false, mainUrl, agentId: null };
1489
+ return result;
1360
1490
  }
1361
1491
  break;
1362
1492
  }
@@ -1371,7 +1501,8 @@ async function registerWithMain(p, agent) {
1371
1501
  try {
1372
1502
  const res = await fetch(`${mainUrl}/api/auth/login`, {
1373
1503
  method: 'POST',
1374
- headers: { 'content-type': 'application/json', origin: mainOrigin },
1504
+ redirect: 'error',
1505
+ headers: { 'content-type': 'application/json', origin: mainUrl },
1375
1506
  body: JSON.stringify({ password, otp, rememberNetwork: false }),
1376
1507
  signal: AbortSignal.timeout(8000)
1377
1508
  });
@@ -1381,56 +1512,129 @@ async function registerWithMain(p, agent) {
1381
1512
  continue;
1382
1513
  }
1383
1514
  cookie = collectCookies(res.headers);
1515
+ if (!cookie) {
1516
+ console.log('Login failed (main returned no session cookie). Configuration was not saved.');
1517
+ return result;
1518
+ }
1384
1519
  break;
1385
1520
  }
1386
1521
  catch (error) {
1387
1522
  console.log(`Login error: ${errorText(error)}. Try again.`);
1388
1523
  }
1389
1524
  }
1390
- const payload = buildAgentRegisterPayload(agent.name, agent.selfUrl, agent.agentToken, agent.transport);
1391
- let registered = false;
1392
- let agentId = null;
1525
+ let probe = null;
1393
1526
  try {
1394
- const regRes = await fetch(`${mainUrl}/api/agents`, {
1395
- method: 'POST',
1396
- headers: { 'content-type': 'application/json', origin: mainOrigin, cookie },
1397
- body: JSON.stringify(payload),
1398
- signal: AbortSignal.timeout(8000)
1399
- });
1400
- if (regRes.status === 403) {
1401
- console.log(`Registration blocked by origin policy. Add "${mainOrigin}" to the main's NTERMINAL_ALLOWED_ORIGINS.`);
1402
- }
1403
- else if (!regRes.ok) {
1404
- const body = (await regRes.json().catch(() => ({})));
1405
- console.log(`Registration failed (${body.error ?? regRes.status}).`);
1527
+ if (agent.transport === 'direct') {
1528
+ try {
1529
+ const capability = await fetch(`${mainUrl}/api/direct-tls/capabilities`, {
1530
+ headers: { origin: mainUrl, cookie }, redirect: 'error', signal: AbortSignal.timeout(8000)
1531
+ });
1532
+ const body = await capability.json().catch(() => null);
1533
+ if (!capability.ok || body?.version !== 1) {
1534
+ console.log('This main does not advertise direct TLS support. Update the main before onboarding this secondary. Configuration was not saved.');
1535
+ return result;
1536
+ }
1537
+ }
1538
+ catch (error) {
1539
+ console.log(`Could not verify main direct TLS capabilities: ${errorText(error)}. Check connectivity and update the main before retrying. Configuration was not saved.`);
1540
+ return result;
1541
+ }
1406
1542
  }
1407
- else {
1408
- const body = (await regRes.json().catch(() => null));
1409
- if (typeof body?.agent?.id !== 'string' || !body.agent.id) {
1410
- console.log('Registration failed (main returned no agent identity).');
1411
- return { registered: false, mainUrl, agentId: null };
1543
+ for (;;) {
1544
+ let probePortOccupied = false;
1545
+ let directProbeFailed = false;
1546
+ try {
1547
+ const payload = buildAgentRegisterPayload(agent.name, agent.selfUrl, agent.agentToken, agent.transport, agent.tlsCertificate);
1548
+ if (agent.transport === 'direct' && options.probe && !probe) {
1549
+ probe = await startDirectTlsProbe({ certificate: agent.tlsCertificate, privateKey: options.probe.privateKey }, agent.agentToken, options.probe.host, options.probe.port);
1550
+ probePortOccupied = probe === null;
1551
+ console.log(probe ? 'Temporary HTTPS probe is listening for the main connection check.' : 'Agent port is already in use; the main will verify the existing server.');
1552
+ }
1553
+ const regRes = await fetch(`${mainUrl}/api/agents`, {
1554
+ method: 'POST',
1555
+ redirect: 'error',
1556
+ headers: { 'content-type': 'application/json', origin: mainUrl, cookie },
1557
+ body: JSON.stringify(payload),
1558
+ signal: AbortSignal.timeout(20000)
1559
+ });
1560
+ if (regRes.status === 403) {
1561
+ console.log(`Registration blocked by origin policy. Add "${mainUrl}" to the main's NTERMINAL_ALLOWED_ORIGINS.`);
1562
+ }
1563
+ else if (!regRes.ok) {
1564
+ const body = (await regRes.json().catch(() => ({})));
1565
+ if (regRes.status === 502 && body.error === 'tls_probe_failed') {
1566
+ directProbeFailed = true;
1567
+ console.log('The main could not verify the direct TLS connection. Check the secondary HTTPS URL and host/cloud firewall. Configuration was not saved.');
1568
+ }
1569
+ else {
1570
+ console.log(`Registration failed (${body.error ?? regRes.status}).`);
1571
+ }
1572
+ }
1573
+ else {
1574
+ const body = (await regRes.json().catch(() => null));
1575
+ if (agent.transport === 'direct' && body?.tlsSupported !== true) {
1576
+ console.log('The main did not acknowledge certificate pinning. Update the main before retrying; configuration was not saved.');
1577
+ return result;
1578
+ }
1579
+ if (typeof body?.agent?.id !== 'string' || !body.agent.id.trim()) {
1580
+ console.log('Registration failed (main returned no agent identity).');
1581
+ }
1582
+ else {
1583
+ result.agentId = body.agent.id;
1584
+ result.tlsSupported = agent.transport === 'direct' && body.tlsSupported === true;
1585
+ result.connectionVerified = agent.transport === 'direct' && body.connectionVerified === true;
1586
+ if (agent.transport === 'outbound' || result.connectionVerified) {
1587
+ result.registered = true;
1588
+ console.log(`Registered with main as "${payload.name}" (${agent.transport === 'direct' ? 'direct TLS connection verified' : 'outbound'}).`);
1589
+ return result;
1590
+ }
1591
+ directProbeFailed = true;
1592
+ console.log('The main did not verify the direct TLS connection. Configuration was not saved.');
1593
+ }
1594
+ }
1595
+ }
1596
+ catch (error) {
1597
+ console.log(`Registration error: ${errorText(error)}.`);
1598
+ }
1599
+ if (probePortOccupied && directProbeFailed && options.probe) {
1600
+ // The control script's status URL is configuration, not proof of socket
1601
+ // ownership. Keep restart operator-controlled rather than killing by port.
1602
+ for (const line of directTlsPortRecoveryInstructions(options.probe.port))
1603
+ console.log(line);
1604
+ }
1605
+ const action = await askRegistrationRetry(p, agent.transport);
1606
+ if (action === 'cancel')
1607
+ return result;
1608
+ if (action === 'outbound') {
1609
+ agent.transport = 'outbound';
1610
+ agent.selfUrl = null;
1611
+ }
1612
+ else if (agent.transport === 'direct') {
1613
+ agent.selfUrl = await askUrl(p, 'HTTPS URL the main should use to reach this secondary', agent.selfUrl ?? undefined);
1614
+ await options.retryNetwork?.();
1412
1615
  }
1413
- registered = true;
1414
- agentId = body.agent.id;
1415
- console.log(`Registered with main as "${payload.name}".`);
1416
1616
  }
1417
1617
  }
1418
- catch (error) {
1419
- console.log(`Registration error: ${errorText(error)}.`);
1420
- }
1421
1618
  finally {
1422
1619
  // Always clean up the CLI session + the trusted-device record it created on the main,
1423
- // even if registration threw.
1424
- await fetch(`${mainUrl}/api/auth/logout`, {
1425
- method: 'POST',
1426
- headers: { 'content-type': 'application/json', origin: mainOrigin, cookie },
1427
- body: JSON.stringify({ closeTerminals: false }),
1428
- signal: AbortSignal.timeout(8000)
1429
- }).catch(() => undefined);
1430
- }
1431
- console.log('\nNote: this CLI creates a temporary main session for registration and removes it afterward.');
1432
- console.log(' Existing browser sessions and terminal/tmux sessions are left running.');
1433
- return { registered, mainUrl, agentId };
1620
+ // and only our own probe, even if registration or a retry prompt threw.
1621
+ let loggedOut = false;
1622
+ try {
1623
+ const logout = await fetch(`${mainUrl}/api/auth/logout`, {
1624
+ method: 'POST',
1625
+ redirect: 'error',
1626
+ headers: { 'content-type': 'application/json', origin: mainUrl, cookie },
1627
+ body: JSON.stringify({ closeTerminals: false }),
1628
+ signal: AbortSignal.timeout(8000)
1629
+ }).catch(() => undefined);
1630
+ loggedOut = logout?.ok === true;
1631
+ }
1632
+ finally {
1633
+ await probe?.close();
1634
+ }
1635
+ console.log(loggedOut ? '\nThe temporary CLI main session was closed.' : '\nCould not confirm logout of the temporary CLI main session.');
1636
+ console.log('Existing browser and terminal/tmux sessions were left running.');
1637
+ }
1434
1638
  }
1435
1639
  function collectCookies(headers) {
1436
1640
  // Node fetch exposes combined set-cookie via getSetCookie()
@@ -1467,7 +1671,7 @@ function printTopology() {
1467
1671
  ' ┌───────────┐',
1468
1672
  ` │ main │${dim(' ← password + TOTP, fronts everything')}`,
1469
1673
  ' └─────┬─────┘',
1470
- dim(' │ agent token over LAN/VPN'),
1674
+ dim(' │ pinned HTTPS / outbound WSS'),
1471
1675
  dim(' ▼'),
1472
1676
  ' ┌───────────┐',
1473
1677
  ` │ secondary │${dim(' ← additional shell hosts, registered with the main')}`,
@@ -1497,24 +1701,6 @@ async function chooseRole() {
1497
1701
  });
1498
1702
  });
1499
1703
  }
1500
- async function chooseSecondaryTransport(p, existing) {
1501
- const current = existing === 'outbound' ? 'outbound' : 'direct';
1502
- if (process.stdin.isTTY) {
1503
- const color = shouldUseColor();
1504
- return pickFromList('Connect this secondary to the main using', [
1505
- {
1506
- label: `direct ${colorText('— main connects to this server; best for stable reachable IPs/VPNs', '2', color)}`,
1507
- value: 'direct'
1508
- },
1509
- {
1510
- label: `outbound ${colorText('— this server connects to main; for NAT, mobile, or changing IPs', '2', color)}`,
1511
- value: 'outbound'
1512
- }
1513
- ], current === 'outbound' ? 1 : 0);
1514
- }
1515
- const answer = await p.ask('Connection mode (direct / outbound)', current);
1516
- return answer.trim().toLowerCase().startsWith('o') ? 'outbound' : 'direct';
1517
- }
1518
1704
  async function main() {
1519
1705
  try {
1520
1706
  assertNodeRuntime();