@lamalibre/portlama-agent 1.0.20 → 1.0.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/portlama-agent",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "description": "Tunnel agent for Portlama — manages Chisel tunnel client as a system service",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -7,7 +7,6 @@ import { Listr } from 'listr2';
7
7
  import chalk from 'chalk';
8
8
  import {
9
9
  assertSupportedPlatform,
10
- isDarwin,
11
10
  CHISEL_BIN_DIR,
12
11
  AGENT_DIR,
13
12
  agentDataDir,
@@ -161,9 +160,7 @@ async function runTokenSetup(flags) {
161
160
 
162
161
  console.log('');
163
162
  console.log(chalk.bold(' Portlama Agent Setup (Token-Based Enrollment)'));
164
- console.log(chalk.dim(isDarwin()
165
- ? ' Connect this Mac to your Portlama server using a Keychain-bound certificate.'
166
- : ' Connect this machine to your Portlama server using a certificate.'));
163
+ console.log(chalk.dim(' Connect this machine to your Portlama server using a certificate.'));
167
164
  console.log('');
168
165
 
169
166
  let panelUrl = flags.panelUrl;
@@ -185,7 +182,6 @@ async function runTokenSetup(flags) {
185
182
  explicitLabel: flags.label,
186
183
  agentLabel: null,
187
184
  resolvedLabel: null,
188
- keychainIdentity: null,
189
185
  p12Path: null,
190
186
  p12Password: null,
191
187
  chiselVersion: null,
@@ -247,20 +243,7 @@ async function runTokenSetup(flags) {
247
243
  },
248
244
  },
249
245
  {
250
- title: 'Requesting macOS login password',
251
- skip: () => !isDarwin() && 'Not macOS',
252
- task: async (_ctx, task) => {
253
- task.output = chalk.dim('Required to authorize curl access to the Keychain certificate');
254
- ctx._keychainPassword = await prompt('macOS login password (for Keychain access)');
255
- if (!ctx._keychainPassword) {
256
- throw new Error('Keychain password is required on macOS to grant curl access to the certificate.');
257
- }
258
- task.output = 'Password received';
259
- },
260
- rendererOptions: { persistentOutput: true },
261
- },
262
- {
263
- title: isDarwin() ? 'Importing certificate into Keychain' : 'Storing certificate',
246
+ title: 'Storing certificate',
264
247
  task: async (_ctx, task) => {
265
248
  const result = await storeEnrolledCert(
266
249
  ctx._keyData.keyPath,
@@ -268,16 +251,10 @@ async function runTokenSetup(flags) {
268
251
  ctx._caCertPem,
269
252
  ctx.resolvedLabel,
270
253
  console,
271
- { keychainPassword: ctx._keychainPassword },
272
254
  );
273
- if (result.identity) {
274
- ctx.keychainIdentity = result.identity;
275
- task.output = `Identity "${result.identity}" imported (non-extractable)`;
276
- } else {
277
- ctx.p12Path = result.p12Path;
278
- ctx.p12Password = result.p12Password;
279
- task.output = `Certificate stored at ${result.p12Path}`;
280
- }
255
+ ctx.p12Path = result.p12Path;
256
+ ctx.p12Password = result.p12Password;
257
+ task.output = `Certificate stored at ${result.p12Path}`;
281
258
  },
282
259
  rendererOptions: { persistentOutput: true },
283
260
  },
@@ -291,9 +268,7 @@ async function runTokenSetup(flags) {
291
268
  {
292
269
  title: 'Verifying panel connectivity',
293
270
  task: async (_ctx, task) => {
294
- const authConfig = ctx.keychainIdentity
295
- ? { panelUrl: ctx.panelUrl, authMethod: 'keychain', keychainIdentity: ctx.keychainIdentity }
296
- : { panelUrl: ctx.panelUrl, authMethod: 'p12', p12Path: ctx.p12Path, p12Password: ctx.p12Password };
271
+ const authConfig = { panelUrl: ctx.panelUrl, authMethod: 'p12', p12Path: ctx.p12Path, p12Password: ctx.p12Password };
297
272
  const health = await fetchHealth(authConfig);
298
273
  task.output = `Panel is reachable (status: ${health.status || 'ok'})`;
299
274
  },
@@ -315,9 +290,7 @@ async function runTokenSetup(flags) {
315
290
  {
316
291
  title: 'Fetching tunnel configuration',
317
292
  task: async (_ctx, task) => {
318
- const authConfig = ctx.keychainIdentity
319
- ? { panelUrl: ctx.panelUrl, authMethod: 'keychain', keychainIdentity: ctx.keychainIdentity }
320
- : { panelUrl: ctx.panelUrl, authMethod: 'p12', p12Path: ctx.p12Path, p12Password: ctx.p12Password };
293
+ const authConfig = { panelUrl: ctx.panelUrl, authMethod: 'p12', p12Path: ctx.p12Path, p12Password: ctx.p12Password };
321
294
 
322
295
  const agentConfig = await fetchAgentConfig(authConfig);
323
296
  ctx.domain = agentConfig.domain;
@@ -376,30 +349,24 @@ async function runTokenSetup(flags) {
376
349
  task: async () => {
377
350
  const configData = {
378
351
  panelUrl: ctx.panelUrl,
352
+ authMethod: 'p12',
353
+ p12Path: ctx.p12Path,
354
+ p12Password: ctx.p12Password,
379
355
  agentLabel: ctx.agentLabel,
380
356
  domain: ctx.domain,
381
357
  chiselVersion: ctx.chiselVersion,
382
358
  setupAt: new Date().toISOString(),
383
359
  };
384
360
 
385
- if (ctx.keychainIdentity) {
386
- configData.authMethod = 'keychain';
387
- configData.keychainIdentity = ctx.keychainIdentity;
388
- } else {
389
- configData.authMethod = 'p12';
390
- configData.p12Path = ctx.p12Path;
391
- configData.p12Password = ctx.p12Password;
392
- }
393
-
394
361
  await saveAgentConfig(ctx.resolvedLabel, configData);
395
362
 
396
363
  // Add or update registry entry
397
364
  await upsertAgent({
398
365
  label: ctx.resolvedLabel,
399
366
  panelUrl: ctx.panelUrl,
400
- authMethod: configData.authMethod,
401
- p12Path: configData.p12Path || null,
402
- keychainIdentity: configData.keychainIdentity || null,
367
+ authMethod: 'p12',
368
+ p12Path: ctx.p12Path,
369
+ keychainIdentity: null,
403
370
  agentLabel: ctx.agentLabel,
404
371
  domain: ctx.domain,
405
372
  chiselVersion: ctx.chiselVersion,
@@ -454,7 +421,6 @@ async function runTokenSetupJson(flags) {
454
421
  explicitLabel: flags.label,
455
422
  agentLabel: null,
456
423
  resolvedLabel: null,
457
- keychainIdentity: null,
458
424
  p12Path: null,
459
425
  p12Password: null,
460
426
  chiselVersion: null,
@@ -513,7 +479,7 @@ async function runTokenSetupJson(flags) {
513
479
  },
514
480
  {
515
481
  key: 'import_cert',
516
- title: isDarwin() ? 'Importing certificate into Keychain' : 'Storing certificate',
482
+ title: 'Storing certificate',
517
483
  fn: async () => {
518
484
  const result = await storeEnrolledCert(
519
485
  ctx._keyData.keyPath,
@@ -522,12 +488,8 @@ async function runTokenSetupJson(flags) {
522
488
  ctx.resolvedLabel,
523
489
  { log: () => {}, warn: () => {}, error: () => {} },
524
490
  );
525
- if (result.identity) {
526
- ctx.keychainIdentity = result.identity;
527
- } else {
528
- ctx.p12Path = result.p12Path;
529
- ctx.p12Password = result.p12Password;
530
- }
491
+ ctx.p12Path = result.p12Path;
492
+ ctx.p12Password = result.p12Password;
531
493
  },
532
494
  },
533
495
  {
@@ -542,9 +504,7 @@ async function runTokenSetupJson(flags) {
542
504
  key: 'verify_connectivity',
543
505
  title: 'Verifying panel connectivity',
544
506
  fn: async () => {
545
- const authConfig = ctx.keychainIdentity
546
- ? { panelUrl: ctx.panelUrl, authMethod: 'keychain', keychainIdentity: ctx.keychainIdentity }
547
- : { panelUrl: ctx.panelUrl, authMethod: 'p12', p12Path: ctx.p12Path, p12Password: ctx.p12Password };
507
+ const authConfig = { panelUrl: ctx.panelUrl, authMethod: 'p12', p12Path: ctx.p12Path, p12Password: ctx.p12Password };
548
508
  await fetchHealth(authConfig);
549
509
  },
550
510
  },
@@ -560,9 +520,7 @@ async function runTokenSetupJson(flags) {
560
520
  key: 'fetch_config',
561
521
  title: 'Fetching tunnel configuration',
562
522
  fn: async () => {
563
- const authConfig = ctx.keychainIdentity
564
- ? { panelUrl: ctx.panelUrl, authMethod: 'keychain', keychainIdentity: ctx.keychainIdentity }
565
- : { panelUrl: ctx.panelUrl, authMethod: 'p12', p12Path: ctx.p12Path, p12Password: ctx.p12Password };
523
+ const authConfig = { panelUrl: ctx.panelUrl, authMethod: 'p12', p12Path: ctx.p12Path, p12Password: ctx.p12Password };
566
524
 
567
525
  const agentConfig = await fetchAgentConfig(authConfig);
568
526
  ctx.domain = agentConfig.domain;
@@ -619,29 +577,23 @@ async function runTokenSetupJson(flags) {
619
577
  fn: async () => {
620
578
  const configData = {
621
579
  panelUrl: ctx.panelUrl,
580
+ authMethod: 'p12',
581
+ p12Path: ctx.p12Path,
582
+ p12Password: ctx.p12Password,
622
583
  agentLabel: ctx.agentLabel,
623
584
  domain: ctx.domain,
624
585
  chiselVersion: ctx.chiselVersion,
625
586
  setupAt: new Date().toISOString(),
626
587
  };
627
588
 
628
- if (ctx.keychainIdentity) {
629
- configData.authMethod = 'keychain';
630
- configData.keychainIdentity = ctx.keychainIdentity;
631
- } else {
632
- configData.authMethod = 'p12';
633
- configData.p12Path = ctx.p12Path;
634
- configData.p12Password = ctx.p12Password;
635
- }
636
-
637
589
  await saveAgentConfig(ctx.resolvedLabel, configData);
638
590
 
639
591
  await upsertAgent({
640
592
  label: ctx.resolvedLabel,
641
593
  panelUrl: ctx.panelUrl,
642
- authMethod: configData.authMethod,
643
- p12Path: configData.p12Path || null,
644
- keychainIdentity: configData.keychainIdentity || null,
594
+ authMethod: 'p12',
595
+ p12Path: ctx.p12Path,
596
+ keychainIdentity: null,
645
597
  agentLabel: ctx.agentLabel,
646
598
  domain: ctx.domain,
647
599
  chiselVersion: ctx.chiselVersion,
@@ -664,12 +616,17 @@ async function runTokenSetupJson(flags) {
664
616
  process.exit(1);
665
617
  }
666
618
 
619
+ // The p12Password transits via stdout pipe to the parent process (Tauri desktop app),
620
+ // which stores it in the OS credential store. Pipes are not visible in process listings.
621
+ // This is the same trust boundary as the server provisioner's SCP-based P12 transfer.
667
622
  emitJson({
668
623
  event: 'complete',
669
624
  agent: {
670
625
  label: ctx.resolvedLabel,
671
626
  panelUrl: ctx.panelUrl,
672
- authMethod: ctx.keychainIdentity ? 'keychain' : 'p12',
627
+ authMethod: 'p12',
628
+ p12Path: ctx.p12Path,
629
+ p12Password: ctx.p12Password,
673
630
  domain: ctx.domain,
674
631
  chiselVersion: ctx.chiselVersion,
675
632
  },
@@ -690,9 +647,7 @@ async function runP12Setup(options = {}) {
690
647
 
691
648
  console.log('');
692
649
  console.log(chalk.bold(' Portlama Agent Setup'));
693
- console.log(chalk.dim(isDarwin()
694
- ? ' Connect this Mac to your Portlama server.'
695
- : ' Connect this machine to your Portlama server.'));
650
+ console.log(chalk.dim(' Connect this machine to your Portlama server.'));
696
651
  console.log('');
697
652
  console.log(chalk.dim(' The admin must generate an agent certificate from the panel first:'));
698
653
  console.log(chalk.dim(' Panel → Certificates → Agent Certificates → Generate'));
@@ -1,75 +1,32 @@
1
1
  /**
2
2
  * Portable certificate storage for token-based enrollment.
3
3
  *
4
- * Dispatches to macOS Keychain or Linux P12 file storage based on process.platform.
4
+ * Stores enrolled certificates as P12 files on all platforms.
5
+ * The P12 password is returned to the caller for secure storage
6
+ * (e.g., macOS Keychain via security-framework, Linux libsecret).
5
7
  */
6
8
 
7
9
  import crypto from 'node:crypto';
8
10
  import { writeFile, access, constants } from 'node:fs/promises';
9
11
  import path from 'node:path';
10
12
  import { execa } from 'execa';
11
- import { isDarwin, agentDataDir } from './platform.js';
13
+ import { agentDataDir } from './platform.js';
12
14
  import { secureDelete } from './keychain.js';
13
15
 
14
16
  /**
15
- * Store an enrolled certificate using the platform-appropriate mechanism.
17
+ * Store an enrolled certificate as a P12 file.
16
18
  *
17
- * - macOS: imports identity into Keychain (non-extractable)
18
- * - Linux: creates a P12 file at ~/.portlama/client.p12 with mode 0600
19
+ * Creates a P12 bundle from key + cert + CA at
20
+ * ~/.portlama/agents/<label>/client.p12 with mode 0600.
19
21
  *
20
22
  * @param {string} keyPath - Path to the temporary private key PEM
21
23
  * @param {string} certPem - PEM-encoded signed certificate
22
24
  * @param {string} caCertPem - PEM-encoded CA certificate
23
25
  * @param {string} label - Agent label
24
26
  * @param {import('pino').Logger | Console} logger
25
- * @param {{ keychainPassword?: string }} [options]
26
- * @returns {Promise<{ identity?: string, p12Path?: string, p12Password?: string }>}
27
+ * @returns {Promise<{ p12Path: string, p12Password: string }>}
27
28
  */
28
- export async function storeEnrolledCert(keyPath, certPem, caCertPem, label, logger, options = {}) {
29
- if (isDarwin()) {
30
- const { importIdentityToKeychain } = await import('./keychain.js');
31
- const { identity } = await importIdentityToKeychain(keyPath, certPem, caCertPem, label, logger, options);
32
- return { identity };
33
- }
34
- return storeP12Linux(keyPath, certPem, caCertPem, label, logger);
35
- }
36
-
37
- /**
38
- * Check if an enrolled certificate exists.
39
- * @param {string} label - Agent label
40
- * @returns {Promise<boolean>}
41
- */
42
- export async function enrolledCertExists(label) {
43
- if (isDarwin()) {
44
- const { keychainIdentityExists } = await import('./keychain.js');
45
- return keychainIdentityExists(label);
46
- }
47
- return linuxP12Exists(label);
48
- }
49
-
50
- /**
51
- * Remove an enrolled certificate.
52
- * @param {string} label - Agent label
53
- */
54
- export async function removeEnrolledCert(label) {
55
- if (isDarwin()) {
56
- const { removeKeychainIdentity } = await import('./keychain.js');
57
- return removeKeychainIdentity(label);
58
- }
59
- return removeLinuxP12(label);
60
- }
61
-
62
- // ---------------------------------------------------------------------------
63
- // Linux — P12 file storage
64
- // ---------------------------------------------------------------------------
65
-
66
- /**
67
- * Create a P12 from key + cert + CA and store at ~/.portlama/client.p12.
68
- *
69
- * Uses the same `-keypbe PBE-SHA1-3DES` parameters as keychain.js for
70
- * maximum curl compatibility.
71
- */
72
- async function storeP12Linux(keyPath, certPem, caCertPem, label, logger) {
29
+ export async function storeEnrolledCert(keyPath, certPem, caCertPem, label, logger) {
73
30
  const dataDir = agentDataDir(label);
74
31
  const p12Path = path.join(dataDir, 'client.p12');
75
32
  const suffix = crypto.randomBytes(8).toString('hex');
@@ -125,7 +82,12 @@ async function storeP12Linux(keyPath, certPem, caCertPem, label, logger) {
125
82
  }
126
83
  }
127
84
 
128
- async function linuxP12Exists(label) {
85
+ /**
86
+ * Check if an enrolled certificate exists.
87
+ * @param {string} label - Agent label
88
+ * @returns {Promise<boolean>}
89
+ */
90
+ export async function enrolledCertExists(label) {
129
91
  try {
130
92
  const p12Path = path.join(agentDataDir(label), 'client.p12');
131
93
  await access(p12Path, constants.F_OK);
@@ -135,7 +97,11 @@ async function linuxP12Exists(label) {
135
97
  }
136
98
  }
137
99
 
138
- async function removeLinuxP12(label) {
100
+ /**
101
+ * Remove an enrolled certificate.
102
+ * @param {string} label - Agent label
103
+ */
104
+ export async function removeEnrolledCert(label) {
139
105
  try {
140
106
  const p12Path = path.join(agentDataDir(label), 'client.p12');
141
107
  await secureDelete(p12Path);
@@ -4,6 +4,23 @@ import path from 'node:path';
4
4
  import { execa } from 'execa';
5
5
  import { AGENT_DIR } from './platform.js';
6
6
 
7
+ /**
8
+ * Prompt for the macOS login Keychain password via a native OS dialog.
9
+ * Uses osascript to display a secure input dialog (hidden answer).
10
+ * Throws if the user cancels.
11
+ *
12
+ * @returns {Promise<string>}
13
+ */
14
+ async function promptKeychainPassword() {
15
+ const { stdout } = await execa('osascript', [
16
+ '-e',
17
+ 'display dialog "Portlama needs your macOS login password to store the agent certificate in your Keychain." default answer "" with hidden answer buttons {"Cancel", "OK"} default button "OK" with title "Portlama — Keychain Access" with icon caution',
18
+ '-e',
19
+ 'text returned of result',
20
+ ]);
21
+ return stdout.trim();
22
+ }
23
+
7
24
  /**
8
25
  * Overwrite a file with random bytes, then unlink it.
9
26
  * Provides defense-in-depth against key recovery from disk.
@@ -82,10 +99,9 @@ export async function generateKeypairAndCSR(label) {
82
99
  * @param {string} caCertPem - PEM-encoded CA certificate
83
100
  * @param {string} label - Agent label
84
101
  * @param {import('pino').Logger | Console} logger
85
- * @param {{ keychainPassword?: string }} [options]
86
102
  * @returns {Promise<{ identity: string }>}
87
103
  */
88
- export async function importIdentityToKeychain(keyPath, certPem, caCertPem, label, logger, options = {}) {
104
+ export async function importIdentityToKeychain(keyPath, certPem, caCertPem, label, logger) {
89
105
  const suffix = crypto.randomBytes(8).toString('hex');
90
106
  const certPath = path.join(AGENT_DIR, `.tmp-cert-${suffix}.pem`);
91
107
  const caPath = path.join(AGENT_DIR, `.tmp-ca-${suffix}.pem`);
@@ -160,8 +176,8 @@ export async function importIdentityToKeychain(keyPath, certPem, caCertPem, labe
160
176
  }
161
177
 
162
178
  // Set the key partition list so curl can access the identity without prompts.
163
- // Requires the login Keychain password — callers should prompt the user for it.
164
- const keychainPassword = options.keychainPassword ?? '';
179
+ // Requires the login Keychain password — prompt via native macOS dialog.
180
+ const keychainPassword = await promptKeychainPassword();
165
181
  try {
166
182
  await execa('security', [
167
183
  'set-key-partition-list',
@@ -172,11 +188,10 @@ export async function importIdentityToKeychain(keyPath, certPem, caCertPem, labe
172
188
  '-l', // match by Label (friendly name from -name), not -D (Description)
173
189
  identityName,
174
190
  ]);
175
- } catch (err) {
176
- // This can fail if the Keychain is locked or the password is wrong.
177
- // The import still succeeded the user may need to authorize curl manually.
178
- logger.warn?.({ err, label }, 'Could not set key partition list — curl may prompt for access') ??
179
- logger.log?.(`Warning: Could not set key partition list for ${label}`);
191
+ } catch {
192
+ throw new Error(
193
+ 'Could not authorize Keychain access. The macOS login password may be incorrect.',
194
+ );
180
195
  }
181
196
 
182
197
  return { identity: identityName };