@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 +1 -1
- package/README.md +14 -13
- package/package.json +5 -2
- package/src/commands/deploy.js +2 -2
- package/src/commands/logs.js +2 -2
- package/src/commands/plugin.js +2 -2
- package/src/commands/setup.js +76 -63
- package/src/commands/sites.js +2 -2
- package/src/commands/status.js +4 -4
- package/src/commands/uninstall.js +11 -7
- package/src/commands/update.js +11 -17
- package/src/index.js +9 -6
- package/src/lib/cert-store.js +142 -0
- package/src/lib/panel-api.js +9 -5
- package/src/lib/platform.js +34 -7
- package/src/lib/service-config.js +189 -0
- package/src/lib/service.js +102 -0
- package/src/lib/plist.js +0 -34
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
|
|
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
|
-
|
|
4
|
-
|
|
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
|
|
14
|
-
connection details and an agent-scoped
|
|
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` |
|
|
22
|
-
| `uninstall` | Remove Chisel,
|
|
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
|
|
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
|
|
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.
|
|
4
|
-
"description": "
|
|
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": {
|
package/src/commands/deploy.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
43
|
+
assertSupportedPlatform();
|
|
44
44
|
const config = await requireAgentConfig();
|
|
45
45
|
|
|
46
46
|
const target = args[0];
|
package/src/commands/logs.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
11
|
+
assertSupportedPlatform();
|
|
12
12
|
|
|
13
13
|
const files = [];
|
|
14
14
|
if (existsSync(LOG_FILE)) files.push(LOG_FILE);
|
package/src/commands/plugin.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
228
|
+
assertSupportedPlatform();
|
|
229
229
|
|
|
230
230
|
const subcommand = args[0];
|
|
231
231
|
const target = args[1];
|
package/src/commands/setup.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
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 {
|
|
14
|
-
import { isAgentLoaded, unloadAgent, loadAgent, getAgentPid } from '../lib/
|
|
15
|
-
import { generateKeypairAndCSR,
|
|
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
|
|
79
|
-
|
|
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 (
|
|
92
|
-
console.log(chalk.dim(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
172
|
-
|
|
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
|
|
187
|
-
panelUrl: ctx.panelUrl,
|
|
188
|
-
authMethod: '
|
|
189
|
-
|
|
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
|
|
213
|
-
panelUrl: ctx.panelUrl,
|
|
214
|
-
authMethod: '
|
|
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
|
|
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: '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
317
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
419
|
-
ctx.
|
|
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: '
|
|
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
|
|
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
|
|
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',
|
package/src/commands/sites.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import {
|
|
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
|
-
|
|
60
|
+
assertSupportedPlatform();
|
|
61
61
|
const config = await requireAgentConfig();
|
|
62
62
|
const sub = args[0];
|
|
63
63
|
|
package/src/commands/status.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
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/
|
|
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
|
-
|
|
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('
|
|
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 {
|
|
6
|
-
import { isAgentLoaded, unloadAgent } from '../lib/
|
|
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
|
|
9
|
+
* Unload the agent, remove the service config, chisel binary, and config.
|
|
10
10
|
*/
|
|
11
11
|
export async function runUninstall() {
|
|
12
|
-
|
|
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
|
|
28
|
-
skip: () => !existsSync(
|
|
27
|
+
title: 'Removing service config',
|
|
28
|
+
skip: () => !existsSync(SERVICE_CONFIG_PATH) && 'Service config not found',
|
|
29
29
|
task: async () => {
|
|
30
|
-
await rm(
|
|
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
|
{
|
package/src/commands/update.js
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
import { Listr } from 'listr2';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import {
|
|
3
|
+
import { assertSupportedPlatform } from '../lib/platform.js';
|
|
4
4
|
import { requireAgentConfig, saveAgentConfig } from '../lib/config.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { isAgentLoaded, unloadAgent, loadAgent, getAgentPid } from '../lib/
|
|
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
|
|
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
|
-
|
|
14
|
+
assertSupportedPlatform();
|
|
15
15
|
|
|
16
16
|
const config = await requireAgentConfig();
|
|
17
17
|
|
|
18
18
|
const ctx = {
|
|
19
|
-
|
|
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
|
|
29
|
-
ctx.
|
|
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: '
|
|
38
|
+
title: 'Writing service config',
|
|
39
39
|
task: async () => {
|
|
40
|
-
|
|
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')} —
|
|
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
|
|
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)
|
|
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
|
+
}
|
package/src/lib/panel-api.js
CHANGED
|
@@ -216,22 +216,26 @@ export async function fetchHealth(panelUrlOrConfig, p12Path, p12Password) {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
/**
|
|
219
|
-
* Fetch the
|
|
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<{
|
|
225
|
+
* @returns {Promise<{ domain: string, chiselServerUrl: string, chiselArgs: string[], tunnels: Array<{ port: number, subdomain: string }> }>}
|
|
224
226
|
*/
|
|
225
|
-
export async function
|
|
227
|
+
export async function fetchAgentConfig(panelUrlOrConfig, p12Path, p12Password) {
|
|
226
228
|
const panelUrl = resolvePanelUrl(panelUrlOrConfig);
|
|
227
|
-
const url = `${panelUrl}/api/tunnels/
|
|
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(
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Failed to fetch agent config from panel. ` + `Details: ${err.stderr || err.message}`,
|
|
238
|
+
);
|
|
235
239
|
}
|
|
236
240
|
}
|
|
237
241
|
|
package/src/lib/platform.js
CHANGED
|
@@ -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
|
|
41
|
+
* Assert we are running on a supported platform (macOS or Linux).
|
|
42
|
+
* Throws if not.
|
|
18
43
|
*/
|
|
19
|
-
export function
|
|
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
|
|
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
|
|
61
|
+
return `${platform}_arm64`;
|
|
35
62
|
case 'x64':
|
|
36
|
-
return
|
|
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, '&')
|
|
95
|
+
.replace(/</g, '<')
|
|
96
|
+
.replace(/>/g, '>')
|
|
97
|
+
.replace(/"/g, '"')
|
|
98
|
+
.replace(/'/g, ''');
|
|
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
|
-
}
|