@lamalibre/portlama-agent 1.0.5 → 1.0.6

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.5",
3
+ "version": "1.0.6",
4
4
  "description": "Mac agent for Portlama — installs Chisel tunnel client and manages launchd agent",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -96,9 +96,7 @@ async function runDownload(config, source, dest) {
96
96
 
97
97
  try {
98
98
  await downloadRemoteFile(
99
- config.panelUrl,
100
- config.p12Path,
101
- config.p12Password,
99
+ config,
102
100
  agentLabel,
103
101
  remotePath,
104
102
  localPath,
@@ -152,9 +150,7 @@ async function runUpload(config, source, dest) {
152
150
 
153
151
  try {
154
152
  await uploadRemoteFile(
155
- config.panelUrl,
156
- config.p12Path,
157
- config.p12Password,
153
+ config,
158
154
  agentLabel,
159
155
  remotePath,
160
156
  localPath,
@@ -71,7 +71,7 @@ export async function runDeploy(args) {
71
71
 
72
72
  let data;
73
73
  try {
74
- data = await fetchSites(config.panelUrl, config.p12Path, config.p12Password);
74
+ data = await fetchSites(config);
75
75
  } catch (err) {
76
76
  console.error(`\n ${chalk.red(`Failed to connect to panel: ${err.message}`)}\n`);
77
77
  process.exit(1);
@@ -161,22 +161,10 @@ export async function runDeploy(args) {
161
161
  {
162
162
  title: 'Clearing remote files',
163
163
  task: async (_ctx, task) => {
164
- const { files: remoteFiles } = await fetchSiteFiles(
165
- config.panelUrl,
166
- config.p12Path,
167
- config.p12Password,
168
- siteId,
169
- '.',
170
- );
164
+ const { files: remoteFiles } = await fetchSiteFiles(config, siteId, '.');
171
165
  for (const f of remoteFiles) {
172
166
  task.output = `Removing ${f.name}`;
173
- await deleteSiteFile(
174
- config.panelUrl,
175
- config.p12Path,
176
- config.p12Password,
177
- siteId,
178
- f.name,
179
- );
167
+ await deleteSiteFile(config, siteId, f.name);
180
168
  }
181
169
  },
182
170
  rendererOptions: { persistentOutput: false },
@@ -200,9 +188,7 @@ export async function runDeploy(args) {
200
188
  const batch = groupFiles.slice(i, i + 10);
201
189
  const uploadDir = dir === '.' ? '.' : dir;
202
190
  await uploadSiteFiles(
203
- config.panelUrl,
204
- config.p12Path,
205
- config.p12Password,
191
+ config,
206
192
  siteId,
207
193
  uploadDir,
208
194
  batch.map((f) => f.absolutePath),
@@ -220,13 +206,7 @@ export async function runDeploy(args) {
220
206
  // Count remote files recursively to match local recursive scan
221
207
  let remoteCount = 0;
222
208
  const countRemote = async (dirPath) => {
223
- const { files: entries } = await fetchSiteFiles(
224
- config.panelUrl,
225
- config.p12Path,
226
- config.p12Password,
227
- siteId,
228
- dirPath,
229
- );
209
+ const { files: entries } = await fetchSiteFiles(config, siteId, dirPath);
230
210
  for (const entry of entries) {
231
211
  if (entry.type === 'directory') {
232
212
  const subPath = dirPath === '.' ? entry.name : `${dirPath}/${entry.name}`;
@@ -1,16 +1,18 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { existsSync } from 'node:fs';
3
- import { mkdir } from 'node:fs/promises';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
4
  import { resolve } from 'node:path';
5
+ import path from 'node:path';
5
6
  import { Listr } from 'listr2';
6
7
  import chalk from 'chalk';
7
- import { assertMacOS, CHISEL_BIN_DIR, LOGS_DIR } from '../lib/platform.js';
8
+ import { assertMacOS, CHISEL_BIN_DIR, LOGS_DIR, AGENT_DIR } from '../lib/platform.js';
8
9
  import { loadAgentConfig, saveAgentConfig } from '../lib/config.js';
9
- import { fetchHealth, fetchPlist, fetchTunnels } from '../lib/panel-api.js';
10
+ import { fetchHealth, fetchPlist, fetchTunnels, curlPostUnauthenticated } from '../lib/panel-api.js';
10
11
  import { extractPemFromP12, cleanupPemFiles } from '../lib/ws-helpers.js';
11
12
  import { installChisel } from '../lib/chisel.js';
12
13
  import { rewritePlist, writePlistFile } from '../lib/plist.js';
13
14
  import { isAgentLoaded, unloadAgent, loadAgent, getAgentPid } from '../lib/launchctl.js';
15
+ import { generateKeypairAndCSR, importIdentityToKeychain, secureDelete } from '../lib/keychain.js';
14
16
 
15
17
  /**
16
18
  * Prompt for user input via readline.
@@ -34,10 +36,283 @@ function prompt(question, defaultValue) {
34
36
  });
35
37
  }
36
38
 
39
+ /**
40
+ * Parse --token and --panel-url flags from argv.
41
+ * @returns {{ token?: string, panelUrl?: string }}
42
+ */
43
+ function parseSetupFlags() {
44
+ const args = process.argv.slice(2);
45
+ const flags = {};
46
+ for (let i = 0; i < args.length; i++) {
47
+ if (args[i] === '--token' && args[i + 1]) {
48
+ flags.token = args[++i];
49
+ } else if (args[i] === '--panel-url' && args[i + 1]) {
50
+ flags.panelUrl = args[++i];
51
+ }
52
+ }
53
+ return flags;
54
+ }
55
+
37
56
  /**
38
57
  * Run the interactive setup flow.
58
+ * If --token is provided, uses the hardware-bound enrollment flow.
39
59
  */
40
60
  export async function runSetup() {
61
+ const flags = parseSetupFlags();
62
+
63
+ if (flags.token) {
64
+ return runTokenSetup(flags);
65
+ }
66
+
67
+ return runP12Setup();
68
+ }
69
+
70
+ /**
71
+ * Hardware-bound enrollment flow using a one-time token.
72
+ * Generates a keypair locally, sends CSR to the panel, imports the signed
73
+ * certificate into macOS Keychain as a non-extractable identity.
74
+ *
75
+ * @param {{ token: string, panelUrl?: string }} flags
76
+ */
77
+ async function runTokenSetup(flags) {
78
+ // Step 1: Verify macOS
79
+ assertMacOS();
80
+
81
+ // Check for existing config
82
+ const existingConfig = await loadAgentConfig();
83
+ if (existingConfig) {
84
+ console.log('');
85
+ console.log(chalk.yellow(' An existing agent configuration was found.'));
86
+ console.log(chalk.yellow(' Running setup again will overwrite it.'));
87
+ console.log('');
88
+ }
89
+
90
+ console.log('');
91
+ console.log(chalk.bold(' Portlama Agent Setup (Hardware-Bound Certificate)'));
92
+ console.log(chalk.dim(' Connect this Mac to your Portlama server using a Keychain-bound certificate.'));
93
+ console.log('');
94
+
95
+ let panelUrl = flags.panelUrl;
96
+ if (!panelUrl) {
97
+ panelUrl = await prompt('Panel URL (e.g. https://1.2.3.4:9292)', existingConfig?.panelUrl);
98
+ }
99
+ if (!panelUrl) {
100
+ throw new Error('Panel URL is required. Pass --panel-url <url> or enter interactively.');
101
+ }
102
+
103
+ const normalizedUrl = panelUrl.replace(/\/+$/, '');
104
+
105
+ console.log('');
106
+
107
+ // Context shared across tasks
108
+ const ctx = {
109
+ panelUrl: normalizedUrl,
110
+ token: flags.token,
111
+ agentLabel: null,
112
+ keychainIdentity: null,
113
+ chiselVersion: null,
114
+ plistXml: null,
115
+ domain: null,
116
+ tunnels: [],
117
+ };
118
+
119
+ const tasks = new Listr(
120
+ [
121
+ {
122
+ title: 'Creating directories',
123
+ task: async () => {
124
+ await mkdir(AGENT_DIR, { recursive: true, mode: 0o700 });
125
+ await mkdir(CHISEL_BIN_DIR, { recursive: true });
126
+ await mkdir(LOGS_DIR, { recursive: true });
127
+ },
128
+ },
129
+ {
130
+ title: 'Generating keypair and CSR',
131
+ task: async (_ctx, task) => {
132
+ // We use a temporary label placeholder — the actual label comes from the token
133
+ // We'll pass 'pending' and fix after enrollment
134
+ ctx._keyData = await generateKeypairAndCSR('pending');
135
+ task.output = 'Keypair generated (4096-bit RSA)';
136
+ },
137
+ rendererOptions: { persistentOutput: true },
138
+ },
139
+ {
140
+ title: 'Enrolling with panel',
141
+ task: async (_ctx, task) => {
142
+ const enrollUrl = `${ctx.panelUrl}/api/enroll`;
143
+ const result = await curlPostUnauthenticated(enrollUrl, {
144
+ token: ctx.token,
145
+ csr: ctx._keyData.csrPem,
146
+ });
147
+
148
+ if (!result.ok) {
149
+ throw new Error(result.error || 'Enrollment failed');
150
+ }
151
+
152
+ ctx.agentLabel = result.label;
153
+ ctx._certPem = result.cert;
154
+ ctx._caCertPem = result.caCert;
155
+ task.output = `Enrolled as "${result.label}" (serial: ${result.serial})`;
156
+ },
157
+ rendererOptions: { persistentOutput: true },
158
+ },
159
+ {
160
+ title: 'Importing certificate into Keychain',
161
+ task: async (_ctx, task) => {
162
+ // The server overrides the CSR subject with the correct CN=agent:<label>
163
+ // during signing, so the CSR placeholder subject doesn't matter.
164
+ const { identity } = await importIdentityToKeychain(
165
+ ctx._keyData.keyPath,
166
+ ctx._certPem,
167
+ ctx._caCertPem,
168
+ ctx.agentLabel,
169
+ console,
170
+ );
171
+ ctx.keychainIdentity = identity;
172
+ task.output = `Identity "${identity}" imported (non-extractable)`;
173
+ },
174
+ rendererOptions: { persistentOutput: true },
175
+ },
176
+ {
177
+ title: 'Saving CA certificate',
178
+ task: async () => {
179
+ const caPath = path.join(AGENT_DIR, 'ca.crt');
180
+ await writeFile(caPath, ctx._caCertPem, { mode: 0o644 });
181
+ },
182
+ },
183
+ {
184
+ title: 'Verifying panel connectivity',
185
+ task: async (_ctx, task) => {
186
+ const config = {
187
+ panelUrl: ctx.panelUrl,
188
+ authMethod: 'keychain',
189
+ keychainIdentity: ctx.keychainIdentity,
190
+ };
191
+ const health = await fetchHealth(config);
192
+ task.output = `Panel is reachable (status: ${health.status || 'ok'})`;
193
+ },
194
+ rendererOptions: { persistentOutput: true },
195
+ },
196
+ {
197
+ title: 'Installing Chisel',
198
+ task: async (_ctx, task) => {
199
+ const result = await installChisel();
200
+ ctx.chiselVersion = result.version;
201
+ if (result.skipped) {
202
+ task.output = `Already installed (${result.version})`;
203
+ } else {
204
+ task.output = `Installed ${result.version}`;
205
+ }
206
+ },
207
+ rendererOptions: { persistentOutput: true },
208
+ },
209
+ {
210
+ title: 'Fetching tunnel configuration',
211
+ task: async (_ctx, task) => {
212
+ const config = {
213
+ panelUrl: ctx.panelUrl,
214
+ authMethod: 'keychain',
215
+ keychainIdentity: ctx.keychainIdentity,
216
+ };
217
+ const data = await fetchPlist(config);
218
+ ctx.plistXml = data.plist;
219
+
220
+ const tunnelData = await fetchTunnels(config);
221
+ ctx.tunnels = tunnelData.tunnels || [];
222
+ task.output = `${ctx.tunnels.length} tunnel(s) configured`;
223
+ },
224
+ rendererOptions: { persistentOutput: true },
225
+ },
226
+ {
227
+ title: 'Rewriting plist paths',
228
+ task: async () => {
229
+ ctx.plistXml = rewritePlist(ctx.plistXml);
230
+ },
231
+ },
232
+ {
233
+ title: 'Writing plist file',
234
+ task: async () => {
235
+ await writePlistFile(ctx.plistXml);
236
+ },
237
+ },
238
+ {
239
+ title: 'Unloading previous agent',
240
+ skip: async () => {
241
+ const loaded = await isAgentLoaded();
242
+ return !loaded && 'No previous agent loaded';
243
+ },
244
+ task: async () => {
245
+ await unloadAgent();
246
+ },
247
+ },
248
+ {
249
+ title: 'Loading agent',
250
+ task: async () => {
251
+ await loadAgent();
252
+ },
253
+ },
254
+ {
255
+ title: 'Verifying agent is running',
256
+ task: async (_ctx, task) => {
257
+ await new Promise((r) => setTimeout(r, 2000));
258
+ const pid = await getAgentPid();
259
+ if (pid) {
260
+ task.output = `Agent running (PID ${pid})`;
261
+ } else {
262
+ const loaded = await isAgentLoaded();
263
+ if (loaded) {
264
+ task.output = 'Agent loaded (process starting...)';
265
+ } else {
266
+ throw new Error('Agent failed to load. Check logs with: portlama-agent logs');
267
+ }
268
+ }
269
+ },
270
+ rendererOptions: { persistentOutput: true },
271
+ },
272
+ {
273
+ title: 'Saving configuration',
274
+ task: async () => {
275
+ const domainMatch = ctx.plistXml.match(/wss:\/\/tunnel\.([^:]+):/);
276
+ ctx.domain = domainMatch ? domainMatch[1] : null;
277
+
278
+ await saveAgentConfig({
279
+ panelUrl: ctx.panelUrl,
280
+ authMethod: 'keychain',
281
+ keychainIdentity: ctx.keychainIdentity,
282
+ agentLabel: ctx.agentLabel,
283
+ domain: ctx.domain,
284
+ chiselVersion: ctx.chiselVersion,
285
+ setupAt: new Date().toISOString(),
286
+ });
287
+ },
288
+ },
289
+ ],
290
+ {
291
+ renderer: 'default',
292
+ rendererOptions: { collapseSubtasks: false },
293
+ exitOnError: true,
294
+ },
295
+ );
296
+
297
+ try {
298
+ await tasks.run();
299
+ } catch (err) {
300
+ // Securely delete the temporary private key if it was generated but not
301
+ // yet imported into Keychain (importIdentityToKeychain handles its own cleanup).
302
+ if (ctx._keyData?.keyPath) {
303
+ await secureDelete(ctx._keyData.keyPath).catch(() => {});
304
+ }
305
+ throw err;
306
+ }
307
+
308
+ // Print summary
309
+ printSetupSummary(ctx);
310
+ }
311
+
312
+ /**
313
+ * Traditional P12-based setup flow.
314
+ */
315
+ async function runP12Setup() {
41
316
  // Step 1: Verify macOS
42
317
  assertMacOS();
43
318
 
@@ -206,6 +481,7 @@ export async function runSetup() {
206
481
 
207
482
  await saveAgentConfig({
208
483
  panelUrl: ctx.panelUrl,
484
+ authMethod: 'p12',
209
485
  p12Path: ctx.p12Path,
210
486
  p12Password: ctx.p12Password,
211
487
  domain: ctx.domain,
@@ -76,7 +76,7 @@ async function runList(config, agentLabel) {
76
76
 
77
77
  let sessions;
78
78
  try {
79
- const data = await fetchShellSessions(config.panelUrl, config.p12Path, config.p12Password);
79
+ const data = await fetchShellSessions(config);
80
80
  sessions = data.sessions || [];
81
81
  } catch (err) {
82
82
  console.log(` ${y(`Could not fetch sessions: ${err.message}`)}`);
@@ -142,9 +142,7 @@ async function runDownload(config, agentLabel, sessionId) {
142
142
 
143
143
  try {
144
144
  await downloadShellRecording(
145
- config.panelUrl,
146
- config.p12Path,
147
- config.p12Password,
145
+ config,
148
146
  agentLabel,
149
147
  sessionId,
150
148
  outputPath,
@@ -399,6 +399,13 @@ export async function runShellServer() {
399
399
  process.exit(1);
400
400
  }
401
401
 
402
+ // WebSocket requires PEM cert/key — not available with Keychain-bound keys
403
+ if (config.authMethod === 'keychain') {
404
+ console.error(chalk.red(' Shell server is not yet supported with hardware-bound (Keychain) certificates.'));
405
+ console.error(chalk.dim(' Use a P12-enrolled agent for shell access.'));
406
+ process.exit(1);
407
+ }
408
+
402
409
  // Extract PEM certificates from p12
403
410
  let pem;
404
411
  try {
@@ -438,7 +445,7 @@ export async function runShellServer() {
438
445
  while (running) {
439
446
  let agentStatus;
440
447
  try {
441
- agentStatus = await fetchAgentStatus(config.panelUrl, config.p12Path, config.p12Password);
448
+ agentStatus = await fetchAgentStatus(config);
442
449
  } catch (err) {
443
450
  console.error(chalk.yellow(` Could not reach panel: ${err.message}`));
444
451
  await sleep(POLL_INTERVAL_MS);
@@ -20,6 +20,13 @@ export async function runShell(args) {
20
20
  console.log('');
21
21
  console.log(chalk.dim(` Connecting to agent ${chalk.bold(agentLabel)}...`));
22
22
 
23
+ // WebSocket requires PEM cert/key — not available with Keychain-bound keys
24
+ if (config.authMethod === 'keychain') {
25
+ console.error(chalk.red('\n Shell access is not yet supported with hardware-bound (Keychain) certificates.'));
26
+ console.error(chalk.dim(' Use a P12-enrolled agent for shell access.\n'));
27
+ process.exit(1);
28
+ }
29
+
23
30
  // Extract PEM certificates from p12
24
31
  let pem;
25
32
  try {
@@ -87,7 +87,7 @@ async function runList(config) {
87
87
 
88
88
  let sites;
89
89
  try {
90
- const data = await fetchSites(config.panelUrl, config.p12Path, config.p12Password);
90
+ const data = await fetchSites(config);
91
91
  sites = data.sites || [];
92
92
  } catch {
93
93
  console.log(` ${y('Could not reach panel to fetch site list.')}`);
@@ -163,7 +163,7 @@ async function runCreate(config, args) {
163
163
 
164
164
  let result;
165
165
  try {
166
- result = await createSite(config.panelUrl, config.p12Path, config.p12Password, body);
166
+ result = await createSite(config, body);
167
167
  } catch (err) {
168
168
  const detail = err.message || 'Unknown error';
169
169
  console.error(`\n ${chalk.red(`Failed to create site: ${detail}`)}\n`);
@@ -207,7 +207,7 @@ async function runDelete(config, args) {
207
207
  } else {
208
208
  let data;
209
209
  try {
210
- data = await fetchSites(config.panelUrl, config.p12Path, config.p12Password);
210
+ data = await fetchSites(config);
211
211
  } catch (err) {
212
212
  console.error(`\n ${chalk.red(`Failed to connect to panel: ${err.message}`)}\n`);
213
213
  process.exit(1);
@@ -229,7 +229,7 @@ async function runDelete(config, args) {
229
229
  }
230
230
 
231
231
  try {
232
- await deleteSite(config.panelUrl, config.p12Path, config.p12Password, siteId);
232
+ await deleteSite(config, siteId);
233
233
  } catch (err) {
234
234
  const detail = err.message || 'Unknown error';
235
235
  console.error(`\n ${chalk.red(`Failed to delete site: ${detail}`)}\n`);
@@ -69,7 +69,7 @@ export async function runStatus() {
69
69
  console.log(d(' ─'.repeat(28)));
70
70
 
71
71
  try {
72
- const data = await fetchTunnels(config.panelUrl, config.p12Path, config.p12Password);
72
+ const data = await fetchTunnels(config);
73
73
  const tunnels = data.tunnels || [];
74
74
 
75
75
  if (tunnels.length === 0) {
@@ -25,14 +25,10 @@ export async function runUpdate() {
25
25
  {
26
26
  title: 'Fetching updated tunnel configuration',
27
27
  task: async (_ctx, task) => {
28
- const data = await fetchPlist(config.panelUrl, config.p12Path, config.p12Password);
28
+ const data = await fetchPlist(config);
29
29
  ctx.plistXml = data.plist;
30
30
 
31
- const tunnelData = await fetchTunnels(
32
- config.panelUrl,
33
- config.p12Path,
34
- config.p12Password,
35
- );
31
+ const tunnelData = await fetchTunnels(config);
36
32
  ctx.tunnels = tunnelData.tunnels || [];
37
33
  task.output = `${ctx.tunnels.length} tunnel(s) configured`;
38
34
  },
package/src/lib/config.js CHANGED
@@ -4,12 +4,29 @@ import { CONFIG_PATH, AGENT_DIR } from './platform.js';
4
4
  /**
5
5
  * Load the agent config from ~/.portlama/agent.json.
6
6
  * Returns null if the file does not exist.
7
+ *
8
+ * Config fields:
9
+ * - panelUrl: string — Panel URL
10
+ * - authMethod: 'p12' | 'keychain' — Authentication method (defaults to 'p12' if missing)
11
+ * - p12Path: string — Path to P12 file (when authMethod is 'p12')
12
+ * - p12Password: string — P12 password (when authMethod is 'p12')
13
+ * - keychainIdentity: string — Keychain identity name (when authMethod is 'keychain')
14
+ * - agentLabel: string — Agent label (when authMethod is 'keychain')
15
+ * - domain?: string
16
+ * - chiselVersion?: string
17
+ * - setupAt?: string
18
+ *
7
19
  * @returns {Promise<object | null>}
8
20
  */
9
21
  export async function loadAgentConfig() {
10
22
  try {
11
23
  const raw = await readFile(CONFIG_PATH, 'utf8');
12
- return JSON.parse(raw);
24
+ const config = JSON.parse(raw);
25
+ // Default authMethod to 'p12' for backwards compatibility
26
+ if (config && !config.authMethod) {
27
+ config.authMethod = 'p12';
28
+ }
29
+ return config;
13
30
  } catch {
14
31
  return null;
15
32
  }