@lamalibre/portlama-agent 1.0.7 → 1.0.8

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/LICENSE.md CHANGED
@@ -10,7 +10,7 @@ You may use, copy, modify, and distribute this software for any **noncommercial*
10
10
 
11
11
  ## Commercial Use
12
12
 
13
- Commercial use of this software requires a commercial license, available by contacting licence@codelama.com.tr.
13
+ Commercial use of this software requires a commercial license, available by contacting license@codelama.com.tr.
14
14
 
15
15
  Commercial use includes, but is not limited to:
16
16
 
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @lamalibre/portlama-agent
2
2
 
3
- Mac tunnel agent for Portlama — installs a Chisel tunnel client and manages it
4
- as a macOS launchd agent.
3
+ Tunnel agent for Portlama — manages a Chisel tunnel client as a system service
4
+ on macOS (launchd) and Linux (systemd).
5
5
 
6
6
  ## Installation
7
7
 
@@ -10,16 +10,17 @@ npx @lamalibre/portlama-agent setup
10
10
  ```
11
11
 
12
12
  The setup command downloads the Chisel binary, configures the tunnel connection,
13
- installs a launchd plist, and starts the agent. The panel provides the
14
- connection details and an agent-scoped mTLS certificate.
13
+ installs a system service (launchd plist on macOS, systemd unit on Linux), and
14
+ starts the agent. The panel provides the connection details and an agent-scoped
15
+ mTLS certificate.
15
16
 
16
17
  ## Commands
17
18
 
18
19
  | Command | Description |
19
20
  | ---------------------------------- | ------------------------------------------ |
20
21
  | `setup` | Install Chisel and configure the tunnel |
21
- | `update` | Update Chisel binary to latest version |
22
- | `uninstall` | Remove Chisel, plist, and configuration |
22
+ | `update` | Re-fetch config from panel and restart |
23
+ | `uninstall` | Remove Chisel, service, and configuration |
23
24
  | `status` | Show tunnel connection status |
24
25
  | `logs` | Display recent tunnel logs |
25
26
  | `sites` | List all static sites |
@@ -102,11 +103,11 @@ portlama-agent deploy blog ./dist
102
103
 
103
104
  ## Requirements
104
105
 
105
- | Requirement | Details |
106
- | ----------- | ------------------------------- |
107
- | OS | macOS |
108
- | Node.js | >= 20.0.0 |
109
- | Access | User account (no root required) |
106
+ | Requirement | Details |
107
+ | ----------- | ----------------------------------------------- |
108
+ | OS | macOS or Ubuntu Linux (24.04 LTS) |
109
+ | Node.js | >= 20.0.0 |
110
+ | Access | User account on macOS; root/sudo on Linux |
110
111
 
111
112
  ## How It Works
112
113
 
@@ -115,8 +116,8 @@ certificate (not the admin certificate). It connects to the server's Chisel
115
116
  endpoint over WebSocket-over-HTTPS and exposes local ports as configured
116
117
  in the panel's tunnel settings.
117
118
 
118
- The launchd plist ensures the tunnel reconnects automatically after reboot
119
- or network changes.
119
+ The system service (launchd on macOS, systemd on Linux) ensures the tunnel
120
+ reconnects automatically after reboot or network changes.
120
121
 
121
122
  ## Further Reading
122
123
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lamalibre/portlama-agent",
3
- "version": "1.0.7",
4
- "description": "Mac agent for Portlama — installs Chisel tunnel client and manages launchd agent",
3
+ "version": "1.0.8",
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
  "author": "Code Lama Software",
@@ -30,6 +30,9 @@
30
30
  "chisel",
31
31
  "macos",
32
32
  "launchd",
33
+ "linux",
34
+ "systemd",
35
+ "ubuntu",
33
36
  "agent"
34
37
  ],
35
38
  "dependencies": {
@@ -2,7 +2,7 @@ import chalk from 'chalk';
2
2
  import { Listr } from 'listr2';
3
3
  import { readdir, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import { assertMacOS } from '../lib/platform.js';
5
+ import { assertSupportedPlatform } from '../lib/platform.js';
6
6
  import { requireAgentConfig } from '../lib/config.js';
7
7
  import { fetchSites, fetchSiteFiles, deleteSiteFile, uploadSiteFiles } from '../lib/panel-api.js';
8
8
  import { formatBytes } from '../lib/format.js';
@@ -40,7 +40,7 @@ async function scanDirectory(dir, base = '') {
40
40
  * @param {string[]} args
41
41
  */
42
42
  export async function runDeploy(args) {
43
- assertMacOS();
43
+ assertSupportedPlatform();
44
44
  const config = await requireAgentConfig();
45
45
 
46
46
  const target = args[0];
@@ -1,14 +1,14 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { execa } from 'execa';
3
3
  import chalk from 'chalk';
4
- import { assertMacOS, LOG_FILE, ERROR_LOG_FILE } from '../lib/platform.js';
4
+ import { assertSupportedPlatform, LOG_FILE, ERROR_LOG_FILE } from '../lib/platform.js';
5
5
 
6
6
  /**
7
7
  * Stream chisel logs to the terminal.
8
8
  * Tails both stdout and stderr log files.
9
9
  */
10
10
  export async function runLogs() {
11
- assertMacOS();
11
+ assertSupportedPlatform();
12
12
 
13
13
  const files = [];
14
14
  if (existsSync(LOG_FILE)) files.push(LOG_FILE);
@@ -3,7 +3,7 @@ import { execa } from 'execa';
3
3
  import { readFile, writeFile, rename, open, mkdir, rm } from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
- import { assertMacOS, AGENT_DIR } from '../lib/platform.js';
6
+ import { assertSupportedPlatform, AGENT_DIR } from '../lib/platform.js';
7
7
 
8
8
  const PLUGINS_FILE = path.join(AGENT_DIR, 'plugins.json');
9
9
 
@@ -225,7 +225,7 @@ async function showStatus() {
225
225
  * Entry point for the plugin subcommand.
226
226
  */
227
227
  export async function runPlugin(args) {
228
- assertMacOS();
228
+ assertSupportedPlatform();
229
229
 
230
230
  const subcommand = args[0];
231
231
  const target = args[1];
@@ -5,14 +5,15 @@ import { resolve } from 'node:path';
5
5
  import path from 'node:path';
6
6
  import { Listr } from 'listr2';
7
7
  import chalk from 'chalk';
8
- import { assertMacOS, CHISEL_BIN_DIR, LOGS_DIR, AGENT_DIR } from '../lib/platform.js';
8
+ import { assertSupportedPlatform, isDarwin, CHISEL_BIN_DIR, LOGS_DIR, AGENT_DIR } from '../lib/platform.js';
9
9
  import { loadAgentConfig, saveAgentConfig } from '../lib/config.js';
10
- import { fetchHealth, fetchPlist, fetchTunnels, curlPostUnauthenticated } from '../lib/panel-api.js';
10
+ import { fetchHealth, fetchAgentConfig, fetchTunnels, curlPostUnauthenticated } from '../lib/panel-api.js';
11
11
  import { extractPemFromP12, cleanupPemFiles } from '../lib/ws-helpers.js';
12
12
  import { installChisel } from '../lib/chisel.js';
13
- import { rewritePlist, writePlistFile } from '../lib/plist.js';
14
- import { isAgentLoaded, unloadAgent, loadAgent, getAgentPid } from '../lib/launchctl.js';
15
- import { generateKeypairAndCSR, importIdentityToKeychain, secureDelete } from '../lib/keychain.js';
13
+ import { generateServiceConfig, writeServiceConfigFile } from '../lib/service-config.js';
14
+ import { isAgentLoaded, unloadAgent, loadAgent, getAgentPid } from '../lib/service.js';
15
+ import { generateKeypairAndCSR, secureDelete } from '../lib/keychain.js';
16
+ import { storeEnrolledCert } from '../lib/cert-store.js';
16
17
 
17
18
  /**
18
19
  * Prompt for user input via readline.
@@ -38,6 +39,8 @@ function prompt(question, defaultValue) {
38
39
 
39
40
  /**
40
41
  * Parse --token and --panel-url flags from argv.
42
+ * Token can also be provided via PORTLAMA_ENROLLMENT_TOKEN env var
43
+ * to avoid exposure in process listings.
41
44
  * @returns {{ token?: string, panelUrl?: string }}
42
45
  */
43
46
  function parseSetupFlags() {
@@ -50,6 +53,10 @@ function parseSetupFlags() {
50
53
  flags.panelUrl = args[++i];
51
54
  }
52
55
  }
56
+ // Prefer env var over CLI arg to keep token out of process listings
57
+ if (process.env.PORTLAMA_ENROLLMENT_TOKEN) {
58
+ flags.token = process.env.PORTLAMA_ENROLLMENT_TOKEN;
59
+ }
53
60
  return flags;
54
61
  }
55
62
 
@@ -75,8 +82,8 @@ export async function runSetup() {
75
82
  * @param {{ token: string, panelUrl?: string }} flags
76
83
  */
77
84
  async function runTokenSetup(flags) {
78
- // Step 1: Verify macOS
79
- assertMacOS();
85
+ // Step 1: Verify supported platform
86
+ assertSupportedPlatform();
80
87
 
81
88
  // Check for existing config
82
89
  const existingConfig = await loadAgentConfig();
@@ -88,8 +95,10 @@ async function runTokenSetup(flags) {
88
95
  }
89
96
 
90
97
  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.'));
98
+ console.log(chalk.bold(' Portlama Agent Setup (Token-Based Enrollment)'));
99
+ console.log(chalk.dim(isDarwin()
100
+ ? ' Connect this Mac to your Portlama server using a Keychain-bound certificate.'
101
+ : ' Connect this machine to your Portlama server using a certificate.'));
93
102
  console.log('');
94
103
 
95
104
  let panelUrl = flags.panelUrl;
@@ -110,8 +119,10 @@ async function runTokenSetup(flags) {
110
119
  token: flags.token,
111
120
  agentLabel: null,
112
121
  keychainIdentity: null,
122
+ p12Path: null,
123
+ p12Password: null,
113
124
  chiselVersion: null,
114
- plistXml: null,
125
+ serviceConfig: null,
115
126
  domain: null,
116
127
  tunnels: [],
117
128
  };
@@ -157,19 +168,25 @@ async function runTokenSetup(flags) {
157
168
  rendererOptions: { persistentOutput: true },
158
169
  },
159
170
  {
160
- title: 'Importing certificate into Keychain',
171
+ title: isDarwin() ? 'Importing certificate into Keychain' : 'Storing certificate',
161
172
  task: async (_ctx, task) => {
162
173
  // The server overrides the CSR subject with the correct CN=agent:<label>
163
174
  // during signing, so the CSR placeholder subject doesn't matter.
164
- const { identity } = await importIdentityToKeychain(
175
+ const result = await storeEnrolledCert(
165
176
  ctx._keyData.keyPath,
166
177
  ctx._certPem,
167
178
  ctx._caCertPem,
168
179
  ctx.agentLabel,
169
180
  console,
170
181
  );
171
- ctx.keychainIdentity = identity;
172
- task.output = `Identity "${identity}" imported (non-extractable)`;
182
+ if (result.identity) {
183
+ ctx.keychainIdentity = result.identity;
184
+ task.output = `Identity "${result.identity}" imported (non-extractable)`;
185
+ } else {
186
+ ctx.p12Path = result.p12Path;
187
+ ctx.p12Password = result.p12Password;
188
+ task.output = `Certificate stored at ${result.p12Path}`;
189
+ }
173
190
  },
174
191
  rendererOptions: { persistentOutput: true },
175
192
  },
@@ -183,12 +200,10 @@ async function runTokenSetup(flags) {
183
200
  {
184
201
  title: 'Verifying panel connectivity',
185
202
  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);
203
+ const authConfig = ctx.keychainIdentity
204
+ ? { panelUrl: ctx.panelUrl, authMethod: 'keychain', keychainIdentity: ctx.keychainIdentity }
205
+ : { panelUrl: ctx.panelUrl, authMethod: 'p12', p12Path: ctx.p12Path, p12Password: ctx.p12Password };
206
+ const health = await fetchHealth(authConfig);
192
207
  task.output = `Panel is reachable (status: ${health.status || 'ok'})`;
193
208
  },
194
209
  rendererOptions: { persistentOutput: true },
@@ -209,30 +224,24 @@ async function runTokenSetup(flags) {
209
224
  {
210
225
  title: 'Fetching tunnel configuration',
211
226
  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;
227
+ const authConfig = ctx.keychainIdentity
228
+ ? { panelUrl: ctx.panelUrl, authMethod: 'keychain', keychainIdentity: ctx.keychainIdentity }
229
+ : { panelUrl: ctx.panelUrl, authMethod: 'p12', p12Path: ctx.p12Path, p12Password: ctx.p12Password };
219
230
 
220
- const tunnelData = await fetchTunnels(config);
231
+ const agentConfig = await fetchAgentConfig(authConfig);
232
+ ctx.domain = agentConfig.domain;
233
+ ctx.serviceConfig = generateServiceConfig(agentConfig.chiselArgs);
234
+
235
+ const tunnelData = await fetchTunnels(authConfig);
221
236
  ctx.tunnels = tunnelData.tunnels || [];
222
237
  task.output = `${ctx.tunnels.length} tunnel(s) configured`;
223
238
  },
224
239
  rendererOptions: { persistentOutput: true },
225
240
  },
226
241
  {
227
- title: 'Rewriting plist paths',
228
- task: async () => {
229
- ctx.plistXml = rewritePlist(ctx.plistXml);
230
- },
231
- },
232
- {
233
- title: 'Writing plist file',
242
+ title: 'Writing service config',
234
243
  task: async () => {
235
- await writePlistFile(ctx.plistXml);
244
+ await writeServiceConfigFile(ctx.serviceConfig);
236
245
  },
237
246
  },
238
247
  {
@@ -247,13 +256,16 @@ async function runTokenSetup(flags) {
247
256
  },
248
257
  {
249
258
  title: 'Loading agent',
259
+ skip: () => ctx.tunnels.length === 0 && 'No tunnels configured — run portlama-agent update after creating tunnels',
250
260
  task: async () => {
251
261
  await loadAgent();
252
262
  },
253
263
  },
254
264
  {
255
265
  title: 'Verifying agent is running',
266
+ skip: () => ctx.tunnels.length === 0 && 'No tunnels configured',
256
267
  task: async (_ctx, task) => {
268
+ // Give the service manager a moment to start the process
257
269
  await new Promise((r) => setTimeout(r, 2000));
258
270
  const pid = await getAgentPid();
259
271
  if (pid) {
@@ -272,18 +284,24 @@ async function runTokenSetup(flags) {
272
284
  {
273
285
  title: 'Saving configuration',
274
286
  task: async () => {
275
- const domainMatch = ctx.plistXml.match(/wss:\/\/tunnel\.([^:]+):/);
276
- ctx.domain = domainMatch ? domainMatch[1] : null;
277
-
278
- await saveAgentConfig({
287
+ const configData = {
279
288
  panelUrl: ctx.panelUrl,
280
- authMethod: 'keychain',
281
- keychainIdentity: ctx.keychainIdentity,
282
289
  agentLabel: ctx.agentLabel,
283
290
  domain: ctx.domain,
284
291
  chiselVersion: ctx.chiselVersion,
285
292
  setupAt: new Date().toISOString(),
286
- });
293
+ };
294
+
295
+ if (ctx.keychainIdentity) {
296
+ configData.authMethod = 'keychain';
297
+ configData.keychainIdentity = ctx.keychainIdentity;
298
+ } else {
299
+ configData.authMethod = 'p12';
300
+ configData.p12Path = ctx.p12Path;
301
+ configData.p12Password = ctx.p12Password;
302
+ }
303
+
304
+ await saveAgentConfig(configData);
287
305
  },
288
306
  },
289
307
  ],
@@ -298,7 +316,7 @@ async function runTokenSetup(flags) {
298
316
  await tasks.run();
299
317
  } catch (err) {
300
318
  // Securely delete the temporary private key if it was generated but not
301
- // yet imported into Keychain (importIdentityToKeychain handles its own cleanup).
319
+ // yet consumed by storeEnrolledCert (which handles its own cleanup).
302
320
  if (ctx._keyData?.keyPath) {
303
321
  await secureDelete(ctx._keyData.keyPath).catch(() => {});
304
322
  }
@@ -313,8 +331,8 @@ async function runTokenSetup(flags) {
313
331
  * Traditional P12-based setup flow.
314
332
  */
315
333
  async function runP12Setup() {
316
- // Step 1: Verify macOS
317
- assertMacOS();
334
+ // Step 1: Verify supported platform
335
+ assertSupportedPlatform();
318
336
 
319
337
  // Check for existing config
320
338
  const existingConfig = await loadAgentConfig();
@@ -328,7 +346,9 @@ async function runP12Setup() {
328
346
  // Step 2: Prompt credentials
329
347
  console.log('');
330
348
  console.log(chalk.bold(' Portlama Agent Setup'));
331
- console.log(chalk.dim(' Connect this Mac to your Portlama server.'));
349
+ console.log(chalk.dim(isDarwin()
350
+ ? ' Connect this Mac to your Portlama server.'
351
+ : ' Connect this machine to your Portlama server.'));
332
352
  console.log('');
333
353
  console.log(chalk.dim(' The admin must generate an agent certificate from the panel first:'));
334
354
  console.log(chalk.dim(' Panel → Certificates → Agent Certificates → Generate'));
@@ -363,7 +383,7 @@ async function runP12Setup() {
363
383
  p12Path,
364
384
  p12Password,
365
385
  chiselVersion: null,
366
- plistXml: null,
386
+ serviceConfig: null,
367
387
  domain: null,
368
388
  tunnels: [],
369
389
  };
@@ -415,8 +435,9 @@ async function runP12Setup() {
415
435
  {
416
436
  title: 'Fetching tunnel configuration',
417
437
  task: async (_ctx, task) => {
418
- const data = await fetchPlist(ctx.panelUrl, ctx.p12Path, ctx.p12Password);
419
- ctx.plistXml = data.plist;
438
+ const agentConfig = await fetchAgentConfig(ctx.panelUrl, ctx.p12Path, ctx.p12Password);
439
+ ctx.domain = agentConfig.domain;
440
+ ctx.serviceConfig = generateServiceConfig(agentConfig.chiselArgs);
420
441
 
421
442
  // Also fetch tunnel list for the summary
422
443
  const tunnelData = await fetchTunnels(ctx.panelUrl, ctx.p12Path, ctx.p12Password);
@@ -426,15 +447,9 @@ async function runP12Setup() {
426
447
  rendererOptions: { persistentOutput: true },
427
448
  },
428
449
  {
429
- title: 'Rewriting plist paths',
430
- task: async () => {
431
- ctx.plistXml = rewritePlist(ctx.plistXml);
432
- },
433
- },
434
- {
435
- title: 'Writing plist file',
450
+ title: 'Writing service config',
436
451
  task: async () => {
437
- await writePlistFile(ctx.plistXml);
452
+ await writeServiceConfigFile(ctx.serviceConfig);
438
453
  },
439
454
  },
440
455
  {
@@ -449,14 +464,16 @@ async function runP12Setup() {
449
464
  },
450
465
  {
451
466
  title: 'Loading agent',
467
+ skip: () => ctx.tunnels.length === 0 && 'No tunnels configured — run portlama-agent update after creating tunnels',
452
468
  task: async () => {
453
469
  await loadAgent();
454
470
  },
455
471
  },
456
472
  {
457
473
  title: 'Verifying agent is running',
474
+ skip: () => ctx.tunnels.length === 0 && 'No tunnels configured',
458
475
  task: async (_ctx, task) => {
459
- // Give launchd a moment to start the process
476
+ // Give the service manager a moment to start the process
460
477
  await new Promise((r) => setTimeout(r, 2000));
461
478
  const pid = await getAgentPid();
462
479
  if (pid) {
@@ -475,10 +492,6 @@ async function runP12Setup() {
475
492
  {
476
493
  title: 'Saving configuration',
477
494
  task: async () => {
478
- // Extract domain from the plist (look for wss://tunnel.<domain>)
479
- const domainMatch = ctx.plistXml.match(/wss:\/\/tunnel\.([^:]+):/);
480
- ctx.domain = domainMatch ? domainMatch[1] : null;
481
-
482
495
  await saveAgentConfig({
483
496
  panelUrl: ctx.panelUrl,
484
497
  authMethod: 'p12',
@@ -1,6 +1,6 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import chalk from 'chalk';
3
- import { assertMacOS } from '../lib/platform.js';
3
+ import { assertSupportedPlatform } from '../lib/platform.js';
4
4
  import { requireAgentConfig } from '../lib/config.js';
5
5
  import { fetchSites, createSite, deleteSite } from '../lib/panel-api.js';
6
6
  import { formatBytes } from '../lib/format.js';
@@ -57,7 +57,7 @@ function parseFlags(args) {
57
57
  * @param {string[]} args
58
58
  */
59
59
  export async function runSites(args) {
60
- assertMacOS();
60
+ assertSupportedPlatform();
61
61
  const config = await requireAgentConfig();
62
62
  const sub = args[0];
63
63
 
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
- import { assertMacOS, CHISEL_BIN_PATH, PLIST_PATH, LOG_FILE, AGENT_DIR } from '../lib/platform.js';
2
+ import { assertSupportedPlatform, CHISEL_BIN_PATH, SERVICE_CONFIG_PATH, LOG_FILE, AGENT_DIR } from '../lib/platform.js';
3
3
  import { loadAgentConfig } from '../lib/config.js';
4
- import { isAgentLoaded, getAgentPid } from '../lib/launchctl.js';
4
+ import { isAgentLoaded, getAgentPid } from '../lib/service.js';
5
5
  import { getInstalledVersion } from '../lib/chisel.js';
6
6
  import { fetchTunnels } from '../lib/panel-api.js';
7
7
  import { existsSync } from 'node:fs';
@@ -10,7 +10,7 @@ import { existsSync } from 'node:fs';
10
10
  * Print formatted status information about the agent.
11
11
  */
12
12
  export async function runStatus() {
13
- assertMacOS();
13
+ assertSupportedPlatform();
14
14
 
15
15
  const b = chalk.bold;
16
16
  const c = chalk.cyan;
@@ -52,7 +52,7 @@ export async function runStatus() {
52
52
  );
53
53
 
54
54
  // Files
55
- console.log(` ${b('Plist:')} ${existsSync(PLIST_PATH) ? g('present') : y('missing')}`);
55
+ console.log(` ${b('Service:')} ${existsSync(SERVICE_CONFIG_PATH) ? g('present') : y('missing')}`);
56
56
  console.log(` ${b('Config:')} ${existsSync(AGENT_DIR) ? g('present') : y('missing')}`);
57
57
  console.log(` ${b('Logs:')} ${d(LOG_FILE)}`);
58
58
 
@@ -2,14 +2,14 @@ import { rm } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { Listr } from 'listr2';
4
4
  import chalk from 'chalk';
5
- import { assertMacOS, AGENT_DIR, PLIST_PATH } from '../lib/platform.js';
6
- import { isAgentLoaded, unloadAgent } from '../lib/launchctl.js';
5
+ import { assertSupportedPlatform, AGENT_DIR, SERVICE_CONFIG_PATH, isLinux } from '../lib/platform.js';
6
+ import { isAgentLoaded, unloadAgent } from '../lib/service.js';
7
7
 
8
8
  /**
9
- * Unload the agent, remove the plist, chisel binary, and config.
9
+ * Unload the agent, remove the service config, chisel binary, and config.
10
10
  */
11
11
  export async function runUninstall() {
12
- assertMacOS();
12
+ assertSupportedPlatform();
13
13
 
14
14
  const tasks = new Listr(
15
15
  [
@@ -24,10 +24,14 @@ export async function runUninstall() {
24
24
  },
25
25
  },
26
26
  {
27
- title: 'Removing plist file',
28
- skip: () => !existsSync(PLIST_PATH) && 'Plist not found',
27
+ title: 'Removing service config',
28
+ skip: () => !existsSync(SERVICE_CONFIG_PATH) && 'Service config not found',
29
29
  task: async () => {
30
- await rm(PLIST_PATH);
30
+ await rm(SERVICE_CONFIG_PATH);
31
+ if (isLinux()) {
32
+ const { execa } = await import('execa');
33
+ await execa('systemctl', ['daemon-reload']);
34
+ }
31
35
  },
32
36
  },
33
37
  {
@@ -1,22 +1,22 @@
1
1
  import { Listr } from 'listr2';
2
2
  import chalk from 'chalk';
3
- import { assertMacOS } from '../lib/platform.js';
3
+ import { assertSupportedPlatform } from '../lib/platform.js';
4
4
  import { requireAgentConfig, saveAgentConfig } from '../lib/config.js';
5
- import { fetchPlist, fetchTunnels } from '../lib/panel-api.js';
6
- import { rewritePlist, writePlistFile } from '../lib/plist.js';
7
- import { isAgentLoaded, unloadAgent, loadAgent, getAgentPid } from '../lib/launchctl.js';
5
+ import { fetchAgentConfig, fetchTunnels } from '../lib/panel-api.js';
6
+ import { generateServiceConfig, writeServiceConfigFile } from '../lib/service-config.js';
7
+ import { isAgentLoaded, unloadAgent, loadAgent, getAgentPid } from '../lib/service.js';
8
8
 
9
9
  /**
10
- * Re-fetch the plist from the panel and restart the agent.
10
+ * Re-fetch tunnel config from the panel and restart the agent.
11
11
  * Used after adding/removing tunnels on the panel.
12
12
  */
13
13
  export async function runUpdate() {
14
- assertMacOS();
14
+ assertSupportedPlatform();
15
15
 
16
16
  const config = await requireAgentConfig();
17
17
 
18
18
  const ctx = {
19
- plistXml: null,
19
+ serviceConfig: null,
20
20
  tunnels: [],
21
21
  };
22
22
 
@@ -25,8 +25,8 @@ 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);
29
- ctx.plistXml = data.plist;
28
+ const agentConfig = await fetchAgentConfig(config);
29
+ ctx.serviceConfig = generateServiceConfig(agentConfig.chiselArgs);
30
30
 
31
31
  const tunnelData = await fetchTunnels(config);
32
32
  ctx.tunnels = tunnelData.tunnels || [];
@@ -35,15 +35,9 @@ export async function runUpdate() {
35
35
  rendererOptions: { persistentOutput: true },
36
36
  },
37
37
  {
38
- title: 'Rewriting plist paths',
38
+ title: 'Writing service config',
39
39
  task: async () => {
40
- ctx.plistXml = rewritePlist(ctx.plistXml);
41
- },
42
- },
43
- {
44
- title: 'Writing plist file',
45
- task: async () => {
46
- await writePlistFile(ctx.plistXml);
40
+ await writeServiceConfigFile(ctx.serviceConfig);
47
41
  },
48
42
  },
49
43
  {
package/src/index.js CHANGED
@@ -9,7 +9,7 @@ function printHelp() {
9
9
  const d = chalk.dim;
10
10
 
11
11
  console.log(`
12
- ${b('portlama-agent')} — Mac tunnel agent for Portlama
12
+ ${b('portlama-agent')} — tunnel agent for Portlama (macOS & Linux)
13
13
 
14
14
  ${b('USAGE')}
15
15
 
@@ -18,7 +18,7 @@ ${b('USAGE')}
18
18
  ${b('COMMANDS')}
19
19
 
20
20
  ${c('setup')} Interactive setup: install Chisel, fetch tunnel config, start agent
21
- ${c('update')} Re-fetch plist from panel after tunnel changes
21
+ ${c('update')} Re-fetch config from panel after tunnel changes
22
22
  ${c('uninstall')} Stop agent and remove all files
23
23
  ${c('status')} Show agent health, tunnel list, connection status
24
24
  ${c('logs')} Stream Chisel log output (tail -f)
@@ -28,9 +28,12 @@ ${b('COMMANDS')}
28
28
 
29
29
  ${b('EXAMPLES')}
30
30
 
31
- ${d('# First-time setup')}
31
+ ${d('# First-time setup (interactive)')}
32
32
  ${c('npx @lamalibre/portlama-agent setup')}
33
33
 
34
+ ${d('# Token-based setup (non-interactive)')}
35
+ ${c('PORTLAMA_ENROLLMENT_TOKEN=<token> portlama-agent setup --panel-url https://1.2.3.4:9292')}
36
+
34
37
  ${d('# After adding a tunnel on the panel')}
35
38
  ${c('portlama-agent update')}
36
39
 
@@ -52,9 +55,9 @@ ${b('EXAMPLES')}
52
55
 
53
56
  ${b('PREREQUISITES')}
54
57
 
55
- ${d('•')} macOS (arm64 or x64)
56
- ${d('•')} Agent certificate (.p12) generated from your Portlama panel
57
- (Panel → Certificates → Agent Certificates → Generate)
58
+ ${d('•')} macOS (arm64 or x64) or Ubuntu Linux (arm64 or x64)
59
+ ${d('•')} Agent certificate (.p12) or enrollment token from your Portlama panel
60
+ (Panel → Certificates → Agent Certificates → Generate / Enroll)
58
61
  ${d('•')} Panel URL (e.g. https://1.2.3.4:9292)
59
62
  `);
60
63
  process.exit(0);
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Portable certificate storage for token-based enrollment.
3
+ *
4
+ * Dispatches to macOS Keychain or Linux P12 file storage based on process.platform.
5
+ */
6
+
7
+ import crypto from 'node:crypto';
8
+ import { writeFile, access, constants } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { execa } from 'execa';
11
+ import { isDarwin, AGENT_DIR } from './platform.js';
12
+ import { secureDelete } from './keychain.js';
13
+
14
+ const LINUX_P12_PATH = path.join(AGENT_DIR, 'client.p12');
15
+
16
+ /**
17
+ * Store an enrolled certificate using the platform-appropriate mechanism.
18
+ *
19
+ * - macOS: imports identity into Keychain (non-extractable)
20
+ * - Linux: creates a P12 file at ~/.portlama/client.p12 with mode 0600
21
+ *
22
+ * @param {string} keyPath - Path to the temporary private key PEM
23
+ * @param {string} certPem - PEM-encoded signed certificate
24
+ * @param {string} caCertPem - PEM-encoded CA certificate
25
+ * @param {string} label - Agent label
26
+ * @param {import('pino').Logger | Console} logger
27
+ * @returns {Promise<{ identity?: string, p12Path?: string, p12Password?: string }>}
28
+ */
29
+ export async function storeEnrolledCert(keyPath, certPem, caCertPem, label, logger) {
30
+ if (isDarwin()) {
31
+ const { importIdentityToKeychain } = await import('./keychain.js');
32
+ const { identity } = await importIdentityToKeychain(keyPath, certPem, caCertPem, label, logger);
33
+ return { identity };
34
+ }
35
+ return storeP12Linux(keyPath, certPem, caCertPem, label, logger);
36
+ }
37
+
38
+ /**
39
+ * Check if an enrolled certificate exists.
40
+ * @param {string} label - Agent label
41
+ * @returns {Promise<boolean>}
42
+ */
43
+ export async function enrolledCertExists(label) {
44
+ if (isDarwin()) {
45
+ const { keychainIdentityExists } = await import('./keychain.js');
46
+ return keychainIdentityExists(label);
47
+ }
48
+ return linuxP12Exists();
49
+ }
50
+
51
+ /**
52
+ * Remove an enrolled certificate.
53
+ * @param {string} label - Agent label
54
+ */
55
+ export async function removeEnrolledCert(label) {
56
+ if (isDarwin()) {
57
+ const { removeKeychainIdentity } = await import('./keychain.js');
58
+ return removeKeychainIdentity(label);
59
+ }
60
+ return removeLinuxP12();
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Linux — P12 file storage
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Create a P12 from key + cert + CA and store at ~/.portlama/client.p12.
69
+ *
70
+ * Uses the same `-keypbe PBE-SHA1-3DES` parameters as keychain.js for
71
+ * maximum curl compatibility.
72
+ */
73
+ async function storeP12Linux(keyPath, certPem, caCertPem, label, logger) {
74
+ const suffix = crypto.randomBytes(8).toString('hex');
75
+ const certPath = path.join(AGENT_DIR, `.tmp-cert-${suffix}.pem`);
76
+ const caPath = path.join(AGENT_DIR, `.tmp-ca-${suffix}.pem`);
77
+ const p12Password = crypto.randomBytes(16).toString('hex');
78
+
79
+ try {
80
+ // Write cert and CA to temp files
81
+ await writeFile(certPath, certPem, { mode: 0o600 });
82
+ await writeFile(caPath, caCertPem, { mode: 0o600 });
83
+
84
+ logger.info?.({ label }, 'Creating P12 certificate bundle') ??
85
+ logger.log?.(`Creating P12 certificate bundle: ${label}`);
86
+
87
+ await execa('openssl', [
88
+ 'pkcs12',
89
+ '-export',
90
+ '-keypbe',
91
+ 'PBE-SHA1-3DES',
92
+ '-certpbe',
93
+ 'PBE-SHA1-3DES',
94
+ '-macalg',
95
+ 'sha1',
96
+ '-out',
97
+ LINUX_P12_PATH,
98
+ '-inkey',
99
+ keyPath,
100
+ '-in',
101
+ certPath,
102
+ '-certfile',
103
+ caPath,
104
+ '-name',
105
+ `Portlama Agent (${label})`,
106
+ '-passout',
107
+ 'env:PORTLAMA_TMP_P12_PASS',
108
+ ], {
109
+ env: { ...process.env, PORTLAMA_TMP_P12_PASS: p12Password },
110
+ });
111
+
112
+ // Set restrictive permissions on the P12
113
+ await execa('chmod', ['600', LINUX_P12_PATH]);
114
+
115
+ logger.info?.({ label, path: LINUX_P12_PATH }, 'P12 stored') ??
116
+ logger.log?.(`P12 stored at ${LINUX_P12_PATH}`);
117
+
118
+ return { p12Path: LINUX_P12_PATH, p12Password };
119
+ } finally {
120
+ // Securely delete temp files — the key is consumed here
121
+ await secureDelete(keyPath);
122
+ await secureDelete(certPath);
123
+ await secureDelete(caPath);
124
+ }
125
+ }
126
+
127
+ async function linuxP12Exists() {
128
+ try {
129
+ await access(LINUX_P12_PATH, constants.F_OK);
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ async function removeLinuxP12() {
137
+ try {
138
+ await secureDelete(LINUX_P12_PATH);
139
+ } catch {
140
+ // May not exist — this is fine
141
+ }
142
+ }
@@ -216,22 +216,26 @@ export async function fetchHealth(panelUrlOrConfig, p12Path, p12Password) {
216
216
  }
217
217
 
218
218
  /**
219
- * Fetch the plist XML and metadata from the panel.
219
+ * Fetch the platform-agnostic agent config from the panel.
220
+ * Returns chiselArgs, domain, and tunnel metadata for any platform.
221
+ *
220
222
  * @param {string|object} panelUrlOrConfig
221
223
  * @param {string} [p12Path]
222
224
  * @param {string} [p12Password]
223
- * @returns {Promise<{ plist: string, instructions: object }>}
225
+ * @returns {Promise<{ domain: string, chiselServerUrl: string, chiselArgs: string[], tunnels: Array<{ port: number, subdomain: string }> }>}
224
226
  */
225
- export async function fetchPlist(panelUrlOrConfig, p12Path, p12Password) {
227
+ export async function fetchAgentConfig(panelUrlOrConfig, p12Path, p12Password) {
226
228
  const panelUrl = resolvePanelUrl(panelUrlOrConfig);
227
- const url = `${panelUrl}/api/tunnels/mac-plist?format=json`;
229
+ const url = `${panelUrl}/api/tunnels/agent-config`;
228
230
  try {
229
231
  const { stdout } = typeof panelUrlOrConfig === 'object'
230
232
  ? await curlAuthenticated(panelUrlOrConfig, [url])
231
233
  : await curlWithConfig(p12Path, p12Password, [url]);
232
234
  return JSON.parse(stdout);
233
235
  } catch (err) {
234
- throw new Error(`Failed to fetch plist from panel. ` + `Details: ${err.stderr || err.message}`);
236
+ throw new Error(
237
+ `Failed to fetch agent config from panel. ` + `Details: ${err.stderr || err.message}`,
238
+ );
235
239
  }
236
240
  }
237
241
 
@@ -13,27 +13,54 @@ export const PLIST_PATH = path.join(HOME, 'Library', 'LaunchAgents', `${PLIST_LA
13
13
  export const LOG_FILE = path.join(LOGS_DIR, 'chisel.log');
14
14
  export const ERROR_LOG_FILE = path.join(LOGS_DIR, 'chisel.error.log');
15
15
 
16
+ /** systemd unit file path on Linux */
17
+ export const SYSTEMD_UNIT_PATH = '/etc/systemd/system/portlama-chisel.service';
18
+
19
+ /**
20
+ * Platform-appropriate service config path.
21
+ * - macOS: ~/Library/LaunchAgents/com.portlama.chisel.plist
22
+ * - Linux: /etc/systemd/system/portlama-chisel.service
23
+ */
24
+ export const SERVICE_CONFIG_PATH = process.platform === 'darwin' ? PLIST_PATH : SYSTEMD_UNIT_PATH;
25
+
26
+ /**
27
+ * @returns {boolean} true if running on macOS
28
+ */
29
+ export function isDarwin() {
30
+ return process.platform === 'darwin';
31
+ }
32
+
33
+ /**
34
+ * @returns {boolean} true if running on Linux
35
+ */
36
+ export function isLinux() {
37
+ return process.platform === 'linux';
38
+ }
39
+
16
40
  /**
17
- * Assert we are running on macOS. Throws if not.
41
+ * Assert we are running on a supported platform (macOS or Linux).
42
+ * Throws if not.
18
43
  */
19
- export function assertMacOS() {
20
- if (process.platform !== 'darwin') {
44
+ export function assertSupportedPlatform() {
45
+ if (process.platform !== 'darwin' && process.platform !== 'linux') {
21
46
  throw new Error(
22
- 'portlama-agent is designed for macOS only. ' + `Detected platform: ${process.platform}`,
47
+ 'portlama-agent supports macOS and Linux only. ' +
48
+ `Detected platform: ${process.platform}`,
23
49
  );
24
50
  }
25
51
  }
26
52
 
27
53
  /**
28
54
  * Detect architecture and return the Chisel release suffix.
29
- * @returns {'darwin_arm64' | 'darwin_amd64'}
55
+ * @returns {'darwin_arm64' | 'darwin_amd64' | 'linux_arm64' | 'linux_amd64'}
30
56
  */
31
57
  export function detectArch() {
58
+ const platform = process.platform === 'darwin' ? 'darwin' : 'linux';
32
59
  switch (process.arch) {
33
60
  case 'arm64':
34
- return 'darwin_arm64';
61
+ return `${platform}_arm64`;
35
62
  case 'x64':
36
- return 'darwin_amd64';
63
+ return `${platform}_amd64`;
37
64
  default:
38
65
  throw new Error(`Unsupported architecture: ${process.arch}. Expected arm64 or x64.`);
39
66
  }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Unified service config generation.
3
+ *
4
+ * Dispatches to plist (macOS) or systemd unit (Linux) based on process.platform.
5
+ */
6
+
7
+ import { writeFile, rename, mkdir } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { execa } from 'execa';
10
+ import {
11
+ isDarwin,
12
+ CHISEL_BIN_PATH,
13
+ LOG_FILE,
14
+ ERROR_LOG_FILE,
15
+ PLIST_PATH,
16
+ SYSTEMD_UNIT_PATH,
17
+ LOGS_DIR,
18
+ } from './platform.js';
19
+
20
+ /**
21
+ * Validate chiselArgs against expected patterns to prevent injection.
22
+ * Chisel args must be: ['client', '--tls-skip-verify', 'https://tunnel.DOMAIN:443', 'R:...', ...]
23
+ * @param {string[]} chiselArgs
24
+ */
25
+ function validateChiselArgs(chiselArgs) {
26
+ if (!Array.isArray(chiselArgs) || chiselArgs.length < 3) {
27
+ throw new Error('Invalid chiselArgs: expected at least 3 elements');
28
+ }
29
+ if (chiselArgs[0] !== 'client') {
30
+ throw new Error('Invalid chiselArgs: first element must be "client"');
31
+ }
32
+
33
+ for (const arg of chiselArgs) {
34
+ if (typeof arg !== 'string') {
35
+ throw new Error('Invalid chiselArgs: all elements must be strings');
36
+ }
37
+ // Reject newlines, null bytes, and other control characters
38
+ if (/[\n\r\0]/.test(arg)) {
39
+ throw new Error('Invalid chiselArgs: element contains newline or null byte');
40
+ }
41
+ }
42
+
43
+ // Validate the --tls-skip-verify flag at index 1 (only allowed flag)
44
+ if (chiselArgs[1] !== '--tls-skip-verify') {
45
+ throw new Error(`Invalid chiselArgs: expected --tls-skip-verify at index 1, got: ${chiselArgs[1]}`);
46
+ }
47
+
48
+ // Validate URL argument (3rd element)
49
+ if (!/^https:\/\/[a-z0-9._-]+:\d+$/.test(chiselArgs[2])) {
50
+ throw new Error(`Invalid chiselArgs: unexpected server URL format: ${chiselArgs[2]}`);
51
+ }
52
+
53
+ // Validate remaining args are R:127.0.0.1:port:127.0.0.1:port tunnel mappings.
54
+ // Only 127.0.0.1 is accepted to prevent binding to all interfaces or pivoting
55
+ // to internal network addresses via a compromised panel response.
56
+ for (let i = 3; i < chiselArgs.length; i++) {
57
+ const arg = chiselArgs[i];
58
+ if (/^R:127\.0\.0\.1:\d+:127\.0\.0\.1:\d+$/.test(arg)) continue;
59
+ throw new Error(`Invalid chiselArgs: unexpected argument at index ${i}: ${arg}`);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Generate service config text from chiselArgs.
65
+ * @param {string[]} chiselArgs - Chisel client argument array (e.g. ['client', '--tls-skip-verify', ...])
66
+ * @returns {string} Config file content (plist XML on macOS, systemd unit on Linux)
67
+ */
68
+ export function generateServiceConfig(chiselArgs) {
69
+ validateChiselArgs(chiselArgs);
70
+ if (isDarwin()) {
71
+ return generatePlistConfig(chiselArgs);
72
+ }
73
+ return generateSystemdUnit(chiselArgs);
74
+ }
75
+
76
+ /**
77
+ * Write the service config file to the platform-appropriate location.
78
+ * @param {string} content - Config file content
79
+ */
80
+ export async function writeServiceConfigFile(content) {
81
+ if (isDarwin()) {
82
+ return writePlistConfigFile(content);
83
+ }
84
+ return writeSystemdUnitFile(content);
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // macOS — plist
89
+ // ---------------------------------------------------------------------------
90
+
91
+ function generatePlistConfig(chiselArgs) {
92
+ const xmlEsc = (str) =>
93
+ String(str)
94
+ .replace(/&/g, '&amp;')
95
+ .replace(/</g, '&lt;')
96
+ .replace(/>/g, '&gt;')
97
+ .replace(/"/g, '&quot;')
98
+ .replace(/'/g, '&apos;');
99
+
100
+ // First arg is the binary path, rest are arguments
101
+ const programArgs = [
102
+ ` <string>${xmlEsc(CHISEL_BIN_PATH)}</string>`,
103
+ ...chiselArgs.map((arg) => ` <string>${xmlEsc(arg)}</string>`),
104
+ ];
105
+
106
+ return `<?xml version="1.0" encoding="UTF-8"?>
107
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
108
+ <plist version="1.0">
109
+ <dict>
110
+ <key>Label</key>
111
+ <string>com.portlama.chisel</string>
112
+
113
+ <key>ProgramArguments</key>
114
+ <array>
115
+ ${programArgs.join('\n')}
116
+ </array>
117
+
118
+ <key>KeepAlive</key>
119
+ <true/>
120
+
121
+ <key>RunAtLoad</key>
122
+ <true/>
123
+
124
+ <key>StandardOutPath</key>
125
+ <string>${xmlEsc(LOG_FILE)}</string>
126
+
127
+ <key>StandardErrorPath</key>
128
+ <string>${xmlEsc(ERROR_LOG_FILE)}</string>
129
+
130
+ <key>EnvironmentVariables</key>
131
+ <dict>
132
+ <key>PATH</key>
133
+ <string>/usr/local/bin:/usr/bin:/bin</string>
134
+ </dict>
135
+ </dict>
136
+ </plist>
137
+ `;
138
+ }
139
+
140
+ async function writePlistConfigFile(content) {
141
+ const dir = path.dirname(PLIST_PATH);
142
+ await mkdir(dir, { recursive: true });
143
+ const tmp = PLIST_PATH + '.tmp';
144
+ await writeFile(tmp, content, 'utf8');
145
+ await rename(tmp, PLIST_PATH);
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Linux — systemd
150
+ // ---------------------------------------------------------------------------
151
+
152
+ function generateSystemdUnit(chiselArgs) {
153
+ // Build ExecStart with proper systemd quoting (double-quote each argument)
154
+ const systemdQuote = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
155
+ const execStart = [CHISEL_BIN_PATH, ...chiselArgs].map(systemdQuote).join(' ');
156
+
157
+ return `[Unit]
158
+ Description=Portlama Chisel Tunnel Client
159
+ After=network-online.target
160
+ Wants=network-online.target
161
+
162
+ [Service]
163
+ Type=simple
164
+ ExecStart=${execStart}
165
+ Restart=always
166
+ RestartSec=5
167
+ StandardOutput=append:${LOG_FILE}
168
+ StandardError=append:${ERROR_LOG_FILE}
169
+ Environment=PATH=/usr/local/bin:/usr/bin:/bin
170
+ NoNewPrivileges=true
171
+ ProtectSystem=strict
172
+ ReadWritePaths=${LOGS_DIR}
173
+
174
+ [Install]
175
+ WantedBy=multi-user.target
176
+ `;
177
+ }
178
+
179
+ async function writeSystemdUnitFile(content) {
180
+ // Ensure logs directory exists
181
+ await mkdir(LOGS_DIR, { recursive: true });
182
+
183
+ const tmp = SYSTEMD_UNIT_PATH + '.tmp';
184
+ await writeFile(tmp, content, { encoding: 'utf8', mode: 0o644 });
185
+ await rename(tmp, SYSTEMD_UNIT_PATH);
186
+
187
+ // Reload systemd so it picks up the new/changed unit file
188
+ await execa('systemctl', ['daemon-reload']);
189
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Unified service management interface.
3
+ *
4
+ * Dispatches to launchctl (macOS) or systemctl (Linux) based on process.platform.
5
+ */
6
+
7
+ import { execa } from 'execa';
8
+ import { isDarwin } from './platform.js';
9
+
10
+ // Lazy imports for macOS-specific modules to avoid loading them on Linux
11
+
12
+ /**
13
+ * Check if the agent service is currently loaded/active.
14
+ * @returns {Promise<boolean>}
15
+ */
16
+ export async function isAgentLoaded() {
17
+ if (isDarwin()) {
18
+ const { isAgentLoaded: macIsLoaded } = await import('./launchctl.js');
19
+ return macIsLoaded();
20
+ }
21
+ return systemctlIsActive();
22
+ }
23
+
24
+ /**
25
+ * Get the PID of the running agent, or null if not running.
26
+ * @returns {Promise<number | null>}
27
+ */
28
+ export async function getAgentPid() {
29
+ if (isDarwin()) {
30
+ const { getAgentPid: macGetPid } = await import('./launchctl.js');
31
+ return macGetPid();
32
+ }
33
+ return systemctlGetPid();
34
+ }
35
+
36
+ /**
37
+ * Load/start the agent service.
38
+ */
39
+ export async function loadAgent() {
40
+ if (isDarwin()) {
41
+ const { loadAgent: macLoad } = await import('./launchctl.js');
42
+ return macLoad();
43
+ }
44
+ return systemctlStart();
45
+ }
46
+
47
+ /**
48
+ * Unload/stop the agent service. Silent if not loaded.
49
+ */
50
+ export async function unloadAgent() {
51
+ if (isDarwin()) {
52
+ const { unloadAgent: macUnload } = await import('./launchctl.js');
53
+ return macUnload();
54
+ }
55
+ return systemctlStop();
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Linux / systemd helpers
60
+ // ---------------------------------------------------------------------------
61
+
62
+ async function systemctlIsActive() {
63
+ try {
64
+ await execa('systemctl', ['is-active', '--quiet', 'portlama-chisel']);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ async function systemctlGetPid() {
72
+ try {
73
+ const { stdout } = await execa('systemctl', [
74
+ 'show',
75
+ '-p',
76
+ 'MainPID',
77
+ '--value',
78
+ 'portlama-chisel',
79
+ ]);
80
+ const pid = parseInt(stdout.trim(), 10);
81
+ return Number.isNaN(pid) || pid <= 0 ? null : pid;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ async function systemctlStart() {
88
+ try {
89
+ await execa('systemctl', ['daemon-reload']);
90
+ await execa('systemctl', ['enable', '--now', 'portlama-chisel']);
91
+ } catch (err) {
92
+ throw new Error(`Failed to start agent: ${err.stderr || err.message}`);
93
+ }
94
+ }
95
+
96
+ async function systemctlStop() {
97
+ try {
98
+ await execa('systemctl', ['disable', '--now', 'portlama-chisel']);
99
+ } catch {
100
+ // Agent may not be active — this is fine
101
+ }
102
+ }
package/src/lib/plist.js DELETED
@@ -1,34 +0,0 @@
1
- import { writeFile, rename, mkdir } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { CHISEL_BIN_PATH, LOG_FILE, ERROR_LOG_FILE, PLIST_PATH } from './platform.js';
4
-
5
- /**
6
- * Rewrite server-generated plist XML to use user-scoped paths.
7
- *
8
- * Replaces:
9
- * /usr/local/bin/chisel → ~/.portlama/bin/chisel
10
- * /usr/local/var/log/chisel.log → ~/.portlama/logs/chisel.log
11
- * /usr/local/var/log/chisel.error.log → ~/.portlama/logs/chisel.error.log
12
- *
13
- * @param {string} xml - Original plist XML from the panel
14
- * @returns {string} Rewritten plist XML
15
- */
16
- export function rewritePlist(xml) {
17
- let result = xml;
18
- result = result.replace(/\/usr\/local\/bin\/chisel/g, CHISEL_BIN_PATH);
19
- result = result.replace(/\/usr\/local\/var\/log\/chisel\.log/g, LOG_FILE);
20
- result = result.replace(/\/usr\/local\/var\/log\/chisel\.error\.log/g, ERROR_LOG_FILE);
21
- return result;
22
- }
23
-
24
- /**
25
- * Write the plist file to ~/Library/LaunchAgents/ atomically.
26
- * @param {string} xml - Plist XML content (already rewritten)
27
- */
28
- export async function writePlistFile(xml) {
29
- const dir = path.dirname(PLIST_PATH);
30
- await mkdir(dir, { recursive: true });
31
- const tmp = PLIST_PATH + '.tmp';
32
- await writeFile(tmp, xml, 'utf8');
33
- await rename(tmp, PLIST_PATH);
34
- }