@lamalibre/portlama-agent 1.0.19 → 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.19",
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,7 +243,7 @@ async function runTokenSetup(flags) {
247
243
  },
248
244
  },
249
245
  {
250
- title: isDarwin() ? 'Importing certificate into Keychain' : 'Storing certificate',
246
+ title: 'Storing certificate',
251
247
  task: async (_ctx, task) => {
252
248
  const result = await storeEnrolledCert(
253
249
  ctx._keyData.keyPath,
@@ -256,14 +252,9 @@ async function runTokenSetup(flags) {
256
252
  ctx.resolvedLabel,
257
253
  console,
258
254
  );
259
- if (result.identity) {
260
- ctx.keychainIdentity = result.identity;
261
- task.output = `Identity "${result.identity}" imported (non-extractable)`;
262
- } else {
263
- ctx.p12Path = result.p12Path;
264
- ctx.p12Password = result.p12Password;
265
- task.output = `Certificate stored at ${result.p12Path}`;
266
- }
255
+ ctx.p12Path = result.p12Path;
256
+ ctx.p12Password = result.p12Password;
257
+ task.output = `Certificate stored at ${result.p12Path}`;
267
258
  },
268
259
  rendererOptions: { persistentOutput: true },
269
260
  },
@@ -277,9 +268,7 @@ async function runTokenSetup(flags) {
277
268
  {
278
269
  title: 'Verifying panel connectivity',
279
270
  task: async (_ctx, task) => {
280
- const authConfig = ctx.keychainIdentity
281
- ? { panelUrl: ctx.panelUrl, authMethod: 'keychain', keychainIdentity: ctx.keychainIdentity }
282
- : { 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 };
283
272
  const health = await fetchHealth(authConfig);
284
273
  task.output = `Panel is reachable (status: ${health.status || 'ok'})`;
285
274
  },
@@ -301,9 +290,7 @@ async function runTokenSetup(flags) {
301
290
  {
302
291
  title: 'Fetching tunnel configuration',
303
292
  task: async (_ctx, task) => {
304
- const authConfig = ctx.keychainIdentity
305
- ? { panelUrl: ctx.panelUrl, authMethod: 'keychain', keychainIdentity: ctx.keychainIdentity }
306
- : { 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 };
307
294
 
308
295
  const agentConfig = await fetchAgentConfig(authConfig);
309
296
  ctx.domain = agentConfig.domain;
@@ -362,30 +349,24 @@ async function runTokenSetup(flags) {
362
349
  task: async () => {
363
350
  const configData = {
364
351
  panelUrl: ctx.panelUrl,
352
+ authMethod: 'p12',
353
+ p12Path: ctx.p12Path,
354
+ p12Password: ctx.p12Password,
365
355
  agentLabel: ctx.agentLabel,
366
356
  domain: ctx.domain,
367
357
  chiselVersion: ctx.chiselVersion,
368
358
  setupAt: new Date().toISOString(),
369
359
  };
370
360
 
371
- if (ctx.keychainIdentity) {
372
- configData.authMethod = 'keychain';
373
- configData.keychainIdentity = ctx.keychainIdentity;
374
- } else {
375
- configData.authMethod = 'p12';
376
- configData.p12Path = ctx.p12Path;
377
- configData.p12Password = ctx.p12Password;
378
- }
379
-
380
361
  await saveAgentConfig(ctx.resolvedLabel, configData);
381
362
 
382
363
  // Add or update registry entry
383
364
  await upsertAgent({
384
365
  label: ctx.resolvedLabel,
385
366
  panelUrl: ctx.panelUrl,
386
- authMethod: configData.authMethod,
387
- p12Path: configData.p12Path || null,
388
- keychainIdentity: configData.keychainIdentity || null,
367
+ authMethod: 'p12',
368
+ p12Path: ctx.p12Path,
369
+ keychainIdentity: null,
389
370
  agentLabel: ctx.agentLabel,
390
371
  domain: ctx.domain,
391
372
  chiselVersion: ctx.chiselVersion,
@@ -440,7 +421,6 @@ async function runTokenSetupJson(flags) {
440
421
  explicitLabel: flags.label,
441
422
  agentLabel: null,
442
423
  resolvedLabel: null,
443
- keychainIdentity: null,
444
424
  p12Path: null,
445
425
  p12Password: null,
446
426
  chiselVersion: null,
@@ -499,7 +479,7 @@ async function runTokenSetupJson(flags) {
499
479
  },
500
480
  {
501
481
  key: 'import_cert',
502
- title: isDarwin() ? 'Importing certificate into Keychain' : 'Storing certificate',
482
+ title: 'Storing certificate',
503
483
  fn: async () => {
504
484
  const result = await storeEnrolledCert(
505
485
  ctx._keyData.keyPath,
@@ -508,12 +488,8 @@ async function runTokenSetupJson(flags) {
508
488
  ctx.resolvedLabel,
509
489
  { log: () => {}, warn: () => {}, error: () => {} },
510
490
  );
511
- if (result.identity) {
512
- ctx.keychainIdentity = result.identity;
513
- } else {
514
- ctx.p12Path = result.p12Path;
515
- ctx.p12Password = result.p12Password;
516
- }
491
+ ctx.p12Path = result.p12Path;
492
+ ctx.p12Password = result.p12Password;
517
493
  },
518
494
  },
519
495
  {
@@ -528,9 +504,7 @@ async function runTokenSetupJson(flags) {
528
504
  key: 'verify_connectivity',
529
505
  title: 'Verifying panel connectivity',
530
506
  fn: async () => {
531
- const authConfig = ctx.keychainIdentity
532
- ? { panelUrl: ctx.panelUrl, authMethod: 'keychain', keychainIdentity: ctx.keychainIdentity }
533
- : { 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 };
534
508
  await fetchHealth(authConfig);
535
509
  },
536
510
  },
@@ -546,9 +520,7 @@ async function runTokenSetupJson(flags) {
546
520
  key: 'fetch_config',
547
521
  title: 'Fetching tunnel configuration',
548
522
  fn: async () => {
549
- const authConfig = ctx.keychainIdentity
550
- ? { panelUrl: ctx.panelUrl, authMethod: 'keychain', keychainIdentity: ctx.keychainIdentity }
551
- : { 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 };
552
524
 
553
525
  const agentConfig = await fetchAgentConfig(authConfig);
554
526
  ctx.domain = agentConfig.domain;
@@ -605,29 +577,23 @@ async function runTokenSetupJson(flags) {
605
577
  fn: async () => {
606
578
  const configData = {
607
579
  panelUrl: ctx.panelUrl,
580
+ authMethod: 'p12',
581
+ p12Path: ctx.p12Path,
582
+ p12Password: ctx.p12Password,
608
583
  agentLabel: ctx.agentLabel,
609
584
  domain: ctx.domain,
610
585
  chiselVersion: ctx.chiselVersion,
611
586
  setupAt: new Date().toISOString(),
612
587
  };
613
588
 
614
- if (ctx.keychainIdentity) {
615
- configData.authMethod = 'keychain';
616
- configData.keychainIdentity = ctx.keychainIdentity;
617
- } else {
618
- configData.authMethod = 'p12';
619
- configData.p12Path = ctx.p12Path;
620
- configData.p12Password = ctx.p12Password;
621
- }
622
-
623
589
  await saveAgentConfig(ctx.resolvedLabel, configData);
624
590
 
625
591
  await upsertAgent({
626
592
  label: ctx.resolvedLabel,
627
593
  panelUrl: ctx.panelUrl,
628
- authMethod: configData.authMethod,
629
- p12Path: configData.p12Path || null,
630
- keychainIdentity: configData.keychainIdentity || null,
594
+ authMethod: 'p12',
595
+ p12Path: ctx.p12Path,
596
+ keychainIdentity: null,
631
597
  agentLabel: ctx.agentLabel,
632
598
  domain: ctx.domain,
633
599
  chiselVersion: ctx.chiselVersion,
@@ -650,12 +616,17 @@ async function runTokenSetupJson(flags) {
650
616
  process.exit(1);
651
617
  }
652
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.
653
622
  emitJson({
654
623
  event: 'complete',
655
624
  agent: {
656
625
  label: ctx.resolvedLabel,
657
626
  panelUrl: ctx.panelUrl,
658
- authMethod: ctx.keychainIdentity ? 'keychain' : 'p12',
627
+ authMethod: 'p12',
628
+ p12Path: ctx.p12Path,
629
+ p12Password: ctx.p12Password,
659
630
  domain: ctx.domain,
660
631
  chiselVersion: ctx.chiselVersion,
661
632
  },
@@ -676,9 +647,7 @@ async function runP12Setup(options = {}) {
676
647
 
677
648
  console.log('');
678
649
  console.log(chalk.bold(' Portlama Agent Setup'));
679
- console.log(chalk.dim(isDarwin()
680
- ? ' Connect this Mac to your Portlama server.'
681
- : ' Connect this machine to your Portlama server.'));
650
+ console.log(chalk.dim(' Connect this machine to your Portlama server.'));
682
651
  console.log('');
683
652
  console.log(chalk.dim(' The admin must generate an agent certificate from the panel first:'));
684
653
  console.log(chalk.dim(' Panel → Certificates → Agent Certificates → Generate'));
@@ -1,74 +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
- * @returns {Promise<{ identity?: string, p12Path?: string, p12Password?: string }>}
27
+ * @returns {Promise<{ p12Path: string, p12Password: string }>}
26
28
  */
27
29
  export async function storeEnrolledCert(keyPath, certPem, caCertPem, label, logger) {
28
- if (isDarwin()) {
29
- const { importIdentityToKeychain } = await import('./keychain.js');
30
- const { identity } = await importIdentityToKeychain(keyPath, certPem, caCertPem, label, logger);
31
- return { identity };
32
- }
33
- return storeP12Linux(keyPath, certPem, caCertPem, label, logger);
34
- }
35
-
36
- /**
37
- * Check if an enrolled certificate exists.
38
- * @param {string} label - Agent label
39
- * @returns {Promise<boolean>}
40
- */
41
- export async function enrolledCertExists(label) {
42
- if (isDarwin()) {
43
- const { keychainIdentityExists } = await import('./keychain.js');
44
- return keychainIdentityExists(label);
45
- }
46
- return linuxP12Exists(label);
47
- }
48
-
49
- /**
50
- * Remove an enrolled certificate.
51
- * @param {string} label - Agent label
52
- */
53
- export async function removeEnrolledCert(label) {
54
- if (isDarwin()) {
55
- const { removeKeychainIdentity } = await import('./keychain.js');
56
- return removeKeychainIdentity(label);
57
- }
58
- return removeLinuxP12(label);
59
- }
60
-
61
- // ---------------------------------------------------------------------------
62
- // Linux — P12 file storage
63
- // ---------------------------------------------------------------------------
64
-
65
- /**
66
- * Create a P12 from key + cert + CA and store at ~/.portlama/client.p12.
67
- *
68
- * Uses the same `-keypbe PBE-SHA1-3DES` parameters as keychain.js for
69
- * maximum curl compatibility.
70
- */
71
- async function storeP12Linux(keyPath, certPem, caCertPem, label, logger) {
72
30
  const dataDir = agentDataDir(label);
73
31
  const p12Path = path.join(dataDir, 'client.p12');
74
32
  const suffix = crypto.randomBytes(8).toString('hex');
@@ -124,7 +82,12 @@ async function storeP12Linux(keyPath, certPem, caCertPem, label, logger) {
124
82
  }
125
83
  }
126
84
 
127
- 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) {
128
91
  try {
129
92
  const p12Path = path.join(agentDataDir(label), 'client.p12');
130
93
  await access(p12Path, constants.F_OK);
@@ -134,7 +97,11 @@ async function linuxP12Exists(label) {
134
97
  }
135
98
  }
136
99
 
137
- async function removeLinuxP12(label) {
100
+ /**
101
+ * Remove an enrolled certificate.
102
+ * @param {string} label - Agent label
103
+ */
104
+ export async function removeEnrolledCert(label) {
138
105
  try {
139
106
  const p12Path = path.join(agentDataDir(label), 'client.p12');
140
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.
@@ -159,23 +176,22 @@ export async function importIdentityToKeychain(keyPath, certPem, caCertPem, labe
159
176
  }
160
177
 
161
178
  // Set the key partition list so curl can access the identity without prompts.
162
- // This uses the default Keychain password (empty string for login Keychain
163
- // on most macOS setups), which may prompt the user if the Keychain is locked.
179
+ // Requires the login Keychain password prompt via native macOS dialog.
180
+ const keychainPassword = await promptKeychainPassword();
164
181
  try {
165
182
  await execa('security', [
166
183
  'set-key-partition-list',
167
184
  '-S',
168
185
  'apple-tool:,apple:', // apple-tool: for /usr/bin/curl, apple: for GUI apps
169
186
  '-k',
170
- '', // Keychain password (empty for login Keychain)
187
+ keychainPassword,
171
188
  '-l', // match by Label (friendly name from -name), not -D (Description)
172
189
  identityName,
173
190
  ]);
174
- } catch (err) {
175
- // This can fail if the Keychain is locked or the password is wrong.
176
- // The import still succeeded the user may need to authorize curl manually.
177
- logger.warn?.({ err, label }, 'Could not set key partition list — curl may prompt for access') ??
178
- 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
+ );
179
195
  }
180
196
 
181
197
  return { identity: identityName };