@lamalibre/portlama-agent 1.0.0
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 +100 -0
- package/README.md +51 -0
- package/bin/portlama-agent.js +13 -0
- package/package.json +43 -0
- package/src/commands/logs.js +33 -0
- package/src/commands/setup.js +260 -0
- package/src/commands/status.js +83 -0
- package/src/commands/uninstall.js +54 -0
- package/src/commands/update.js +110 -0
- package/src/index.js +89 -0
- package/src/lib/chisel.js +112 -0
- package/src/lib/config.js +42 -0
- package/src/lib/launchctl.js +57 -0
- package/src/lib/panel-api.js +88 -0
- package/src/lib/platform.js +41 -0
- package/src/lib/plist.js +43 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Code Lama Software
|
|
4
|
+
|
|
5
|
+
## Noncommercial Use
|
|
6
|
+
|
|
7
|
+
This software is licensed under the [Polyform Noncommercial License 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0/).
|
|
8
|
+
|
|
9
|
+
You may use, copy, modify, and distribute this software for any **noncommercial** purpose. This includes personal projects, academic research, education, and evaluation.
|
|
10
|
+
|
|
11
|
+
## Commercial Use
|
|
12
|
+
|
|
13
|
+
Commercial use of this software requires a commercial license, available by contacting licence@codelama.com.tr.
|
|
14
|
+
|
|
15
|
+
Commercial use includes, but is not limited to:
|
|
16
|
+
- Using Portlama in a business to serve data to clients or partners
|
|
17
|
+
- Deploying Portlama as part of a revenue-generating service or product
|
|
18
|
+
- Using Portlama internally at a for-profit organization
|
|
19
|
+
|
|
20
|
+
Sponsorship tiers and terms are listed on our GitHub Sponsors page. Once you become an active sponsor at a qualifying tier, you are granted a commercial use license for the duration of your sponsorship.
|
|
21
|
+
|
|
22
|
+
## Questions
|
|
23
|
+
|
|
24
|
+
If you are unsure whether your use qualifies as noncommercial, please open a [GitHub Discussion](https://github.com/lamalibre/portlama/discussions) or contact us directly.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Polyform Noncommercial License 1.0.0
|
|
29
|
+
|
|
30
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0/>
|
|
31
|
+
|
|
32
|
+
### Acceptance
|
|
33
|
+
|
|
34
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
35
|
+
|
|
36
|
+
### Copyright License
|
|
37
|
+
|
|
38
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
39
|
+
|
|
40
|
+
### Distribution License
|
|
41
|
+
|
|
42
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
43
|
+
|
|
44
|
+
### Notices
|
|
45
|
+
|
|
46
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
47
|
+
|
|
48
|
+
> Required Notice: Copyright (c) 2026 Code Lama Software (https://github.com/lamalibre/portlama)
|
|
49
|
+
|
|
50
|
+
### Changes and New Works License
|
|
51
|
+
|
|
52
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
53
|
+
|
|
54
|
+
### Patent License
|
|
55
|
+
|
|
56
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
57
|
+
|
|
58
|
+
### Noncommercial Purposes
|
|
59
|
+
|
|
60
|
+
Any noncommercial purpose is a permitted purpose.
|
|
61
|
+
|
|
62
|
+
### Personal Uses
|
|
63
|
+
|
|
64
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
|
65
|
+
|
|
66
|
+
### Noncommercial Organizations
|
|
67
|
+
|
|
68
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
|
69
|
+
|
|
70
|
+
### Fair Use
|
|
71
|
+
|
|
72
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
73
|
+
|
|
74
|
+
### No Other Rights
|
|
75
|
+
|
|
76
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
77
|
+
|
|
78
|
+
### Patent Defense
|
|
79
|
+
|
|
80
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
81
|
+
|
|
82
|
+
### Violations
|
|
83
|
+
|
|
84
|
+
The first time you are notified in writing that you have violated any of these terms, or any agreement made under them, you have 32 calendar days to come into compliance. If you come into compliance within that time, your licenses under these terms will not be permanently revoked.
|
|
85
|
+
|
|
86
|
+
### No Liability
|
|
87
|
+
|
|
88
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
89
|
+
|
|
90
|
+
### Definitions
|
|
91
|
+
|
|
92
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
93
|
+
|
|
94
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
95
|
+
|
|
96
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
97
|
+
|
|
98
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
99
|
+
|
|
100
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @lamalibre/portlama-agent
|
|
2
|
+
|
|
3
|
+
Mac tunnel agent for Portlama — installs a Chisel tunnel client and manages it
|
|
4
|
+
as a macOS launchd agent.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx @lamalibre/portlama-agent setup
|
|
10
|
+
```
|
|
11
|
+
|
|
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.
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
| Command | Description |
|
|
19
|
+
| ----------- | ---------------------------------------- |
|
|
20
|
+
| `setup` | Install Chisel and configure the tunnel |
|
|
21
|
+
| `update` | Update Chisel binary to latest version |
|
|
22
|
+
| `uninstall` | Remove Chisel, plist, and configuration |
|
|
23
|
+
| `status` | Show tunnel connection status |
|
|
24
|
+
| `logs` | Display recent tunnel logs |
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
| Requirement | Details |
|
|
29
|
+
| ----------- | --------------- |
|
|
30
|
+
| OS | macOS |
|
|
31
|
+
| Node.js | >= 20.0.0 |
|
|
32
|
+
| Access | User account (no root required) |
|
|
33
|
+
|
|
34
|
+
## How It Works
|
|
35
|
+
|
|
36
|
+
The agent registers with the Portlama panel using an agent-scoped mTLS
|
|
37
|
+
certificate (not the admin certificate). It connects to the server's Chisel
|
|
38
|
+
endpoint over WebSocket-over-HTTPS and exposes local ports as configured
|
|
39
|
+
in the panel's tunnel settings.
|
|
40
|
+
|
|
41
|
+
The launchd plist ensures the tunnel reconnects automatically after reboot
|
|
42
|
+
or network changes.
|
|
43
|
+
|
|
44
|
+
## Further Reading
|
|
45
|
+
|
|
46
|
+
See the main repository for architecture, tunnel configuration, and the full
|
|
47
|
+
development plan: <https://github.com/lamalibre/portlama>
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
[Polyform Noncommercial 1.0.0](./LICENSE.md) — see [LICENSE.md](./LICENSE.md) for details.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { main } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
await main();
|
|
7
|
+
} catch (error) {
|
|
8
|
+
console.error('\n');
|
|
9
|
+
console.error(' Portlama Agent failed.');
|
|
10
|
+
console.error(` Error: ${error.message}`);
|
|
11
|
+
console.error('\n');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lamalibre/portlama-agent",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Mac agent for Portlama — installs Chisel tunnel client and manages launchd agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
7
|
+
"author": "Code Lama Software",
|
|
8
|
+
"bin": {
|
|
9
|
+
"portlama-agent": "bin/portlama-agent.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"lint": "eslint ."
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/lamalibre/portlama.git",
|
|
21
|
+
"directory": "packages/portlama-agent"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/lamalibre/portlama#readme",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/lamalibre/portlama/issues"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"portlama",
|
|
29
|
+
"tunnel",
|
|
30
|
+
"chisel",
|
|
31
|
+
"macos",
|
|
32
|
+
"launchd",
|
|
33
|
+
"agent"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"chalk": "^5.3.0",
|
|
37
|
+
"execa": "^9.0.0",
|
|
38
|
+
"listr2": "^8.0.0"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { assertMacOS, LOG_FILE, ERROR_LOG_FILE } from '../lib/platform.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stream chisel logs to the terminal.
|
|
8
|
+
* Tails both stdout and stderr log files.
|
|
9
|
+
*/
|
|
10
|
+
export async function runLogs() {
|
|
11
|
+
assertMacOS();
|
|
12
|
+
|
|
13
|
+
const files = [];
|
|
14
|
+
if (existsSync(LOG_FILE)) files.push(LOG_FILE);
|
|
15
|
+
if (existsSync(ERROR_LOG_FILE)) files.push(ERROR_LOG_FILE);
|
|
16
|
+
|
|
17
|
+
if (files.length === 0) {
|
|
18
|
+
console.log('');
|
|
19
|
+
console.log(chalk.yellow(' No log files found.'));
|
|
20
|
+
console.log(chalk.dim(` Expected: ${LOG_FILE}`));
|
|
21
|
+
console.log(chalk.dim(` Expected: ${ERROR_LOG_FILE}`));
|
|
22
|
+
console.log(chalk.dim(' Has the agent been started? Run "portlama-agent setup" first.'));
|
|
23
|
+
console.log('');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log(chalk.dim(` Streaming logs from: ${files.join(', ')}`));
|
|
28
|
+
console.log(chalk.dim(' Press Ctrl+C to stop.'));
|
|
29
|
+
console.log('');
|
|
30
|
+
|
|
31
|
+
// tail -f streams indefinitely — use stdio: 'inherit' to forward to terminal
|
|
32
|
+
await execa('tail', ['-f', ...files], { stdio: 'inherit' });
|
|
33
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir } from 'node:fs/promises';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { Listr } from 'listr2';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { assertMacOS, CHISEL_BIN_DIR, LOGS_DIR } from '../lib/platform.js';
|
|
8
|
+
import { loadAgentConfig, saveAgentConfig } from '../lib/config.js';
|
|
9
|
+
import { fetchHealth, fetchPlist, fetchTunnels } from '../lib/panel-api.js';
|
|
10
|
+
import { installChisel } from '../lib/chisel.js';
|
|
11
|
+
import { rewritePlist, writePlistFile } from '../lib/plist.js';
|
|
12
|
+
import { isAgentLoaded, unloadAgent, loadAgent, getAgentPid } from '../lib/launchctl.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Prompt for user input via readline.
|
|
16
|
+
* @param {string} question
|
|
17
|
+
* @param {string} [defaultValue]
|
|
18
|
+
* @returns {Promise<string>}
|
|
19
|
+
*/
|
|
20
|
+
function prompt(question, defaultValue) {
|
|
21
|
+
const rl = createInterface({
|
|
22
|
+
input: process.stdin,
|
|
23
|
+
output: process.stdout,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const suffix = defaultValue ? ` ${chalk.dim(`[${defaultValue}]`)}` : '';
|
|
27
|
+
|
|
28
|
+
return new Promise((resolvePromise) => {
|
|
29
|
+
rl.question(` ${question}${suffix}: `, (answer) => {
|
|
30
|
+
rl.close();
|
|
31
|
+
resolvePromise(answer.trim() || defaultValue || '');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run the interactive setup flow.
|
|
38
|
+
*/
|
|
39
|
+
export async function runSetup() {
|
|
40
|
+
// Step 1: Verify macOS
|
|
41
|
+
assertMacOS();
|
|
42
|
+
|
|
43
|
+
// Check for existing config
|
|
44
|
+
const existingConfig = await loadAgentConfig();
|
|
45
|
+
if (existingConfig) {
|
|
46
|
+
console.log('');
|
|
47
|
+
console.log(chalk.yellow(' An existing agent configuration was found.'));
|
|
48
|
+
console.log(chalk.yellow(' Running setup again will overwrite it.'));
|
|
49
|
+
console.log('');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Step 2: Prompt credentials
|
|
53
|
+
console.log('');
|
|
54
|
+
console.log(chalk.bold(' Portlama Agent Setup'));
|
|
55
|
+
console.log(chalk.dim(' Connect this Mac to your Portlama server.'));
|
|
56
|
+
console.log('');
|
|
57
|
+
console.log(chalk.dim(' The admin must generate an agent certificate from the panel first:'));
|
|
58
|
+
console.log(chalk.dim(' Panel → Certificates → Agent Certificates → Generate'));
|
|
59
|
+
console.log('');
|
|
60
|
+
|
|
61
|
+
const panelUrl = await prompt(
|
|
62
|
+
'Panel URL (e.g. https://1.2.3.4:9292)',
|
|
63
|
+
existingConfig?.panelUrl,
|
|
64
|
+
);
|
|
65
|
+
if (!panelUrl) {
|
|
66
|
+
throw new Error('Panel URL is required.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Normalize URL: strip trailing slash
|
|
70
|
+
const normalizedUrl = panelUrl.replace(/\/+$/, '');
|
|
71
|
+
|
|
72
|
+
const defaultP12 = existingConfig?.p12Path || './agent.p12';
|
|
73
|
+
const p12Input = await prompt('Path to agent certificate (.p12)', defaultP12);
|
|
74
|
+
const p12Path = resolve(p12Input);
|
|
75
|
+
|
|
76
|
+
if (!existsSync(p12Path)) {
|
|
77
|
+
throw new Error(`client.p12 not found at: ${p12Path}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const p12Password = await prompt('P12 password');
|
|
81
|
+
if (!p12Password) {
|
|
82
|
+
throw new Error('P12 password is required.');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log('');
|
|
86
|
+
|
|
87
|
+
// Context shared across tasks
|
|
88
|
+
const ctx = {
|
|
89
|
+
panelUrl: normalizedUrl,
|
|
90
|
+
p12Path,
|
|
91
|
+
p12Password,
|
|
92
|
+
chiselVersion: null,
|
|
93
|
+
plistXml: null,
|
|
94
|
+
domain: null,
|
|
95
|
+
tunnels: [],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const tasks = new Listr(
|
|
99
|
+
[
|
|
100
|
+
{
|
|
101
|
+
title: 'Verifying panel connectivity',
|
|
102
|
+
task: async (_ctx, task) => {
|
|
103
|
+
const health = await fetchHealth(ctx.panelUrl, ctx.p12Path, ctx.p12Password);
|
|
104
|
+
task.output = `Panel is reachable (status: ${health.status || 'ok'})`;
|
|
105
|
+
},
|
|
106
|
+
rendererOptions: { persistentOutput: true },
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
title: 'Creating directories',
|
|
110
|
+
task: async () => {
|
|
111
|
+
await mkdir(CHISEL_BIN_DIR, { recursive: true });
|
|
112
|
+
await mkdir(LOGS_DIR, { recursive: true });
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
title: 'Installing Chisel',
|
|
117
|
+
task: async (_ctx, task) => {
|
|
118
|
+
const result = await installChisel();
|
|
119
|
+
ctx.chiselVersion = result.version;
|
|
120
|
+
if (result.skipped) {
|
|
121
|
+
task.output = `Already installed (${result.version})`;
|
|
122
|
+
} else {
|
|
123
|
+
task.output = `Installed ${result.version}`;
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
rendererOptions: { persistentOutput: true },
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
title: 'Fetching tunnel configuration',
|
|
130
|
+
task: async (_ctx, task) => {
|
|
131
|
+
const data = await fetchPlist(ctx.panelUrl, ctx.p12Path, ctx.p12Password);
|
|
132
|
+
ctx.plistXml = data.plist;
|
|
133
|
+
|
|
134
|
+
// Also fetch tunnel list for the summary
|
|
135
|
+
const tunnelData = await fetchTunnels(ctx.panelUrl, ctx.p12Path, ctx.p12Password);
|
|
136
|
+
ctx.tunnels = tunnelData.tunnels || [];
|
|
137
|
+
task.output = `${ctx.tunnels.length} tunnel(s) configured`;
|
|
138
|
+
},
|
|
139
|
+
rendererOptions: { persistentOutput: true },
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
title: 'Rewriting plist paths',
|
|
143
|
+
task: async () => {
|
|
144
|
+
ctx.plistXml = rewritePlist(ctx.plistXml);
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
title: 'Writing plist file',
|
|
149
|
+
task: async () => {
|
|
150
|
+
await writePlistFile(ctx.plistXml);
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
title: 'Unloading previous agent',
|
|
155
|
+
skip: async () => {
|
|
156
|
+
const loaded = await isAgentLoaded();
|
|
157
|
+
return !loaded && 'No previous agent loaded';
|
|
158
|
+
},
|
|
159
|
+
task: async () => {
|
|
160
|
+
await unloadAgent();
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
title: 'Loading agent',
|
|
165
|
+
task: async () => {
|
|
166
|
+
await loadAgent();
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
title: 'Verifying agent is running',
|
|
171
|
+
task: async (_ctx, task) => {
|
|
172
|
+
// Give launchd a moment to start the process
|
|
173
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
174
|
+
const pid = await getAgentPid();
|
|
175
|
+
if (pid) {
|
|
176
|
+
task.output = `Agent running (PID ${pid})`;
|
|
177
|
+
} else {
|
|
178
|
+
const loaded = await isAgentLoaded();
|
|
179
|
+
if (loaded) {
|
|
180
|
+
task.output = 'Agent loaded (process starting...)';
|
|
181
|
+
} else {
|
|
182
|
+
throw new Error(
|
|
183
|
+
'Agent failed to load. Check logs with: portlama-agent logs',
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
rendererOptions: { persistentOutput: true },
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
title: 'Saving configuration',
|
|
192
|
+
task: async () => {
|
|
193
|
+
// Extract domain from the plist (look for wss://tunnel.<domain>)
|
|
194
|
+
const domainMatch = ctx.plistXml.match(/wss:\/\/tunnel\.([^:]+):/);
|
|
195
|
+
ctx.domain = domainMatch ? domainMatch[1] : null;
|
|
196
|
+
|
|
197
|
+
await saveAgentConfig({
|
|
198
|
+
panelUrl: ctx.panelUrl,
|
|
199
|
+
p12Path: ctx.p12Path,
|
|
200
|
+
p12Password: ctx.p12Password,
|
|
201
|
+
domain: ctx.domain,
|
|
202
|
+
chiselVersion: ctx.chiselVersion,
|
|
203
|
+
setupAt: new Date().toISOString(),
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
{
|
|
209
|
+
renderer: 'default',
|
|
210
|
+
rendererOptions: { collapseSubtasks: false },
|
|
211
|
+
exitOnError: true,
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
await tasks.run();
|
|
216
|
+
|
|
217
|
+
// Print summary
|
|
218
|
+
printSetupSummary(ctx);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Print a formatted summary after successful setup.
|
|
223
|
+
* @param {object} ctx
|
|
224
|
+
*/
|
|
225
|
+
function printSetupSummary(ctx) {
|
|
226
|
+
const b = chalk.bold;
|
|
227
|
+
const c = chalk.cyan;
|
|
228
|
+
const d = chalk.dim;
|
|
229
|
+
const g = chalk.green;
|
|
230
|
+
|
|
231
|
+
console.log('');
|
|
232
|
+
console.log(c(' ╔══════════════════════════════════════════════════════════╗'));
|
|
233
|
+
console.log(c(' ║') + ` ${g.bold('Portlama Agent installed successfully!')}` + ' '.repeat(17) + c('║'));
|
|
234
|
+
console.log(c(' ╠══════════════════════════════════════════════════════════╣'));
|
|
235
|
+
|
|
236
|
+
if (ctx.domain) {
|
|
237
|
+
console.log(c(' ║') + ` ${b('Domain:')} ${c(ctx.domain)}` + ' '.repeat(Math.max(0, 46 - ctx.domain.length)) + c('║'));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log(c(' ║') + ` ${b('Chisel:')} ${ctx.chiselVersion}` + ' '.repeat(Math.max(0, 46 - (ctx.chiselVersion || '').length)) + c('║'));
|
|
241
|
+
console.log(c(' ║') + ` ${b('Tunnels:')} ${ctx.tunnels.length} configured` + ' '.repeat(33) + c('║'));
|
|
242
|
+
console.log(c(' ║') + ' '.repeat(58) + c('║'));
|
|
243
|
+
|
|
244
|
+
if (ctx.tunnels.length > 0) {
|
|
245
|
+
for (const t of ctx.tunnels) {
|
|
246
|
+
const line = `${t.subdomain} → localhost:${t.port}`;
|
|
247
|
+
console.log(c(' ║') + ` ${d('•')} ${line}` + ' '.repeat(Math.max(0, 54 - line.length)) + c('║'));
|
|
248
|
+
}
|
|
249
|
+
console.log(c(' ║') + ' '.repeat(58) + c('║'));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
console.log(c(' ║') + ` ${b('Commands:')}` + ' '.repeat(47) + c('║'));
|
|
253
|
+
console.log(c(' ║') + ` ${d('portlama-agent status')} ${d('— check agent health')}` + ' '.repeat(11) + c('║'));
|
|
254
|
+
console.log(c(' ║') + ` ${d('portlama-agent logs')} ${d('— stream chisel logs')}` + ' '.repeat(11) + c('║'));
|
|
255
|
+
console.log(c(' ║') + ` ${d('portlama-agent update')} ${d('— refresh tunnel config')}` + ' '.repeat(8) + c('║'));
|
|
256
|
+
console.log(c(' ║') + ` ${d('portlama-agent uninstall')} ${d('— remove everything')}` + ' '.repeat(12) + c('║'));
|
|
257
|
+
console.log(c(' ║') + ' '.repeat(58) + c('║'));
|
|
258
|
+
console.log(c(' ╚══════════════════════════════════════════════════════════╝'));
|
|
259
|
+
console.log('');
|
|
260
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { assertMacOS, CHISEL_BIN_PATH, PLIST_PATH, LOG_FILE, AGENT_DIR } from '../lib/platform.js';
|
|
3
|
+
import { loadAgentConfig } from '../lib/config.js';
|
|
4
|
+
import { isAgentLoaded, getAgentPid } from '../lib/launchctl.js';
|
|
5
|
+
import { getInstalledVersion } from '../lib/chisel.js';
|
|
6
|
+
import { fetchTunnels } from '../lib/panel-api.js';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Print formatted status information about the agent.
|
|
11
|
+
*/
|
|
12
|
+
export async function runStatus() {
|
|
13
|
+
assertMacOS();
|
|
14
|
+
|
|
15
|
+
const b = chalk.bold;
|
|
16
|
+
const c = chalk.cyan;
|
|
17
|
+
const g = chalk.green;
|
|
18
|
+
const r = chalk.red;
|
|
19
|
+
const d = chalk.dim;
|
|
20
|
+
const y = chalk.yellow;
|
|
21
|
+
|
|
22
|
+
console.log('');
|
|
23
|
+
console.log(b(' Portlama Agent Status'));
|
|
24
|
+
console.log(d(' ─'.repeat(28)));
|
|
25
|
+
|
|
26
|
+
// Config
|
|
27
|
+
const config = await loadAgentConfig();
|
|
28
|
+
if (!config) {
|
|
29
|
+
console.log(` ${r('Not configured.')} Run ${c('portlama-agent setup')} first.`);
|
|
30
|
+
console.log('');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Agent status
|
|
35
|
+
const loaded = await isAgentLoaded();
|
|
36
|
+
const pid = await getAgentPid();
|
|
37
|
+
|
|
38
|
+
console.log(` ${b('Agent:')} ${loaded ? g('loaded') : r('not loaded')}${pid ? ` (PID ${pid})` : ''}`);
|
|
39
|
+
console.log(` ${b('Panel:')} ${c(config.panelUrl)}`);
|
|
40
|
+
|
|
41
|
+
if (config.domain) {
|
|
42
|
+
console.log(` ${b('Domain:')} ${c(config.domain)}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Chisel
|
|
46
|
+
const chiselVersion = await getInstalledVersion();
|
|
47
|
+
const chiselInstalled = existsSync(CHISEL_BIN_PATH);
|
|
48
|
+
console.log(` ${b('Chisel:')} ${chiselInstalled ? g(chiselVersion || 'installed') : r('not installed')}`);
|
|
49
|
+
|
|
50
|
+
// Files
|
|
51
|
+
console.log(` ${b('Plist:')} ${existsSync(PLIST_PATH) ? g('present') : y('missing')}`);
|
|
52
|
+
console.log(` ${b('Config:')} ${existsSync(AGENT_DIR) ? g('present') : y('missing')}`);
|
|
53
|
+
console.log(` ${b('Logs:')} ${d(LOG_FILE)}`);
|
|
54
|
+
|
|
55
|
+
if (config.setupAt) {
|
|
56
|
+
console.log(` ${b('Setup at:')} ${d(config.setupAt)}`);
|
|
57
|
+
}
|
|
58
|
+
if (config.updatedAt) {
|
|
59
|
+
console.log(` ${b('Updated:')} ${d(config.updatedAt)}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Try to fetch tunnels from panel
|
|
63
|
+
console.log('');
|
|
64
|
+
console.log(b(' Tunnels'));
|
|
65
|
+
console.log(d(' ─'.repeat(28)));
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const data = await fetchTunnels(config.panelUrl, config.p12Path, config.p12Password);
|
|
69
|
+
const tunnels = data.tunnels || [];
|
|
70
|
+
|
|
71
|
+
if (tunnels.length === 0) {
|
|
72
|
+
console.log(` ${d('No tunnels configured.')}`);
|
|
73
|
+
} else {
|
|
74
|
+
for (const t of tunnels) {
|
|
75
|
+
console.log(` ${c('•')} ${b(t.subdomain)}.${config.domain || '?'} → localhost:${t.port}${t.description ? d(` (${t.description})`) : ''}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
console.log(` ${y('Could not reach panel to fetch tunnel list.')}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log('');
|
|
83
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { Listr } from 'listr2';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { assertMacOS, AGENT_DIR, PLIST_PATH } from '../lib/platform.js';
|
|
6
|
+
import { isAgentLoaded, unloadAgent } from '../lib/launchctl.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Unload the agent, remove the plist, chisel binary, and config.
|
|
10
|
+
*/
|
|
11
|
+
export async function runUninstall() {
|
|
12
|
+
assertMacOS();
|
|
13
|
+
|
|
14
|
+
const tasks = new Listr(
|
|
15
|
+
[
|
|
16
|
+
{
|
|
17
|
+
title: 'Unloading agent',
|
|
18
|
+
skip: async () => {
|
|
19
|
+
const loaded = await isAgentLoaded();
|
|
20
|
+
return !loaded && 'Agent not loaded';
|
|
21
|
+
},
|
|
22
|
+
task: async () => {
|
|
23
|
+
await unloadAgent();
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
title: 'Removing plist file',
|
|
28
|
+
skip: () => !existsSync(PLIST_PATH) && 'Plist not found',
|
|
29
|
+
task: async () => {
|
|
30
|
+
await rm(PLIST_PATH);
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
title: 'Removing ~/.portlama directory',
|
|
35
|
+
skip: () => !existsSync(AGENT_DIR) && 'Directory not found',
|
|
36
|
+
task: async () => {
|
|
37
|
+
await rm(AGENT_DIR, { recursive: true });
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
{
|
|
42
|
+
renderer: 'default',
|
|
43
|
+
rendererOptions: { collapseSubtasks: false },
|
|
44
|
+
exitOnError: true,
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
await tasks.run();
|
|
49
|
+
|
|
50
|
+
console.log('');
|
|
51
|
+
console.log(chalk.green(' Portlama Agent uninstalled successfully.'));
|
|
52
|
+
console.log(chalk.dim(' All agent files have been removed.'));
|
|
53
|
+
console.log('');
|
|
54
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Listr } from 'listr2';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { assertMacOS } from '../lib/platform.js';
|
|
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';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Re-fetch the plist from the panel and restart the agent.
|
|
11
|
+
* Used after adding/removing tunnels on the panel.
|
|
12
|
+
*/
|
|
13
|
+
export async function runUpdate() {
|
|
14
|
+
assertMacOS();
|
|
15
|
+
|
|
16
|
+
const config = await requireAgentConfig();
|
|
17
|
+
|
|
18
|
+
const ctx = {
|
|
19
|
+
plistXml: null,
|
|
20
|
+
tunnels: [],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const tasks = new Listr(
|
|
24
|
+
[
|
|
25
|
+
{
|
|
26
|
+
title: 'Fetching updated tunnel configuration',
|
|
27
|
+
task: async (_ctx, task) => {
|
|
28
|
+
const data = await fetchPlist(config.panelUrl, config.p12Path, config.p12Password);
|
|
29
|
+
ctx.plistXml = data.plist;
|
|
30
|
+
|
|
31
|
+
const tunnelData = await fetchTunnels(config.panelUrl, config.p12Path, config.p12Password);
|
|
32
|
+
ctx.tunnels = tunnelData.tunnels || [];
|
|
33
|
+
task.output = `${ctx.tunnels.length} tunnel(s) configured`;
|
|
34
|
+
},
|
|
35
|
+
rendererOptions: { persistentOutput: true },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
title: 'Rewriting plist paths',
|
|
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);
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
title: 'Unloading agent',
|
|
51
|
+
skip: async () => {
|
|
52
|
+
const loaded = await isAgentLoaded();
|
|
53
|
+
return !loaded && 'Agent not currently loaded';
|
|
54
|
+
},
|
|
55
|
+
task: async () => {
|
|
56
|
+
await unloadAgent();
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
title: 'Loading agent',
|
|
61
|
+
task: async () => {
|
|
62
|
+
await loadAgent();
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
title: 'Verifying agent is running',
|
|
67
|
+
task: async (_ctx, task) => {
|
|
68
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
69
|
+
const pid = await getAgentPid();
|
|
70
|
+
if (pid) {
|
|
71
|
+
task.output = `Agent running (PID ${pid})`;
|
|
72
|
+
} else {
|
|
73
|
+
const loaded = await isAgentLoaded();
|
|
74
|
+
if (loaded) {
|
|
75
|
+
task.output = 'Agent loaded (process starting...)';
|
|
76
|
+
} else {
|
|
77
|
+
throw new Error(
|
|
78
|
+
'Agent failed to load. Check logs with: portlama-agent logs',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
rendererOptions: { persistentOutput: true },
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
title: 'Saving configuration',
|
|
87
|
+
task: async () => {
|
|
88
|
+
await saveAgentConfig({
|
|
89
|
+
...config,
|
|
90
|
+
updatedAt: new Date().toISOString(),
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
{
|
|
96
|
+
renderer: 'default',
|
|
97
|
+
rendererOptions: { collapseSubtasks: false },
|
|
98
|
+
exitOnError: true,
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await tasks.run();
|
|
103
|
+
|
|
104
|
+
console.log('');
|
|
105
|
+
console.log(chalk.green(' Agent updated successfully.'));
|
|
106
|
+
if (ctx.tunnels.length > 0) {
|
|
107
|
+
console.log(chalk.dim(` ${ctx.tunnels.length} tunnel(s) active.`));
|
|
108
|
+
}
|
|
109
|
+
console.log('');
|
|
110
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Print help message and exit.
|
|
5
|
+
*/
|
|
6
|
+
function printHelp() {
|
|
7
|
+
const b = chalk.bold;
|
|
8
|
+
const c = chalk.cyan;
|
|
9
|
+
const d = chalk.dim;
|
|
10
|
+
|
|
11
|
+
console.log(`
|
|
12
|
+
${b('portlama-agent')} — Mac tunnel agent for Portlama
|
|
13
|
+
|
|
14
|
+
${b('USAGE')}
|
|
15
|
+
|
|
16
|
+
${c('portlama-agent')} ${d('<command>')}
|
|
17
|
+
|
|
18
|
+
${b('COMMANDS')}
|
|
19
|
+
|
|
20
|
+
${c('setup')} Interactive setup: install Chisel, fetch tunnel config, start agent
|
|
21
|
+
${c('update')} Re-fetch plist from panel after tunnel changes
|
|
22
|
+
${c('uninstall')} Stop agent and remove all files
|
|
23
|
+
${c('status')} Show agent health, tunnel list, connection status
|
|
24
|
+
${c('logs')} Stream Chisel log output (tail -f)
|
|
25
|
+
|
|
26
|
+
${b('EXAMPLES')}
|
|
27
|
+
|
|
28
|
+
${d('# First-time setup')}
|
|
29
|
+
${c('npx @lamalibre/portlama-agent setup')}
|
|
30
|
+
|
|
31
|
+
${d('# After adding a tunnel on the panel')}
|
|
32
|
+
${c('portlama-agent update')}
|
|
33
|
+
|
|
34
|
+
${d('# Check if the agent is running')}
|
|
35
|
+
${c('portlama-agent status')}
|
|
36
|
+
|
|
37
|
+
${b('PREREQUISITES')}
|
|
38
|
+
|
|
39
|
+
${d('•')} macOS (arm64 or x64)
|
|
40
|
+
${d('•')} Agent certificate (.p12) generated from your Portlama panel
|
|
41
|
+
(Panel → Certificates → Agent Certificates → Generate)
|
|
42
|
+
${d('•')} Panel URL (e.g. https://1.2.3.4:9292)
|
|
43
|
+
`);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse command from argv and dispatch to the appropriate module.
|
|
49
|
+
*/
|
|
50
|
+
export async function main() {
|
|
51
|
+
const args = process.argv.slice(2);
|
|
52
|
+
const command = args[0];
|
|
53
|
+
|
|
54
|
+
if (!command || command === '--help' || command === '-h') {
|
|
55
|
+
printHelp();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
switch (command) {
|
|
59
|
+
case 'setup': {
|
|
60
|
+
const { runSetup } = await import('./commands/setup.js');
|
|
61
|
+
await runSetup();
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case 'update': {
|
|
65
|
+
const { runUpdate } = await import('./commands/update.js');
|
|
66
|
+
await runUpdate();
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case 'uninstall': {
|
|
70
|
+
const { runUninstall } = await import('./commands/uninstall.js');
|
|
71
|
+
await runUninstall();
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case 'status': {
|
|
75
|
+
const { runStatus } = await import('./commands/status.js');
|
|
76
|
+
await runStatus();
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case 'logs': {
|
|
80
|
+
const { runLogs } = await import('./commands/logs.js');
|
|
81
|
+
await runLogs();
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
default:
|
|
85
|
+
console.error(`\n Unknown command: ${chalk.red(command)}`);
|
|
86
|
+
console.error(` Run ${chalk.cyan('portlama-agent --help')} for usage.\n`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { access, constants, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { CHISEL_BIN_PATH, CHISEL_BIN_DIR } from './platform.js';
|
|
7
|
+
import { detectArch } from './platform.js';
|
|
8
|
+
|
|
9
|
+
const GITHUB_API = 'https://api.github.com/repos/jpillora/chisel/releases/latest';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if a file exists at the given path.
|
|
13
|
+
*/
|
|
14
|
+
async function fileExists(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
await access(filePath, constants.F_OK);
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the currently installed Chisel version, or null if not installed.
|
|
25
|
+
*/
|
|
26
|
+
export async function getInstalledVersion() {
|
|
27
|
+
try {
|
|
28
|
+
const { stdout } = await execa(CHISEL_BIN_PATH, ['--version']);
|
|
29
|
+
return stdout.trim();
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Download and install the Chisel binary from GitHub releases.
|
|
37
|
+
* Installs to ~/.portlama/bin/chisel — no sudo needed.
|
|
38
|
+
* @returns {Promise<{ skipped?: boolean, installed?: boolean, version: string }>}
|
|
39
|
+
*/
|
|
40
|
+
export async function installChisel() {
|
|
41
|
+
const exists = await fileExists(CHISEL_BIN_PATH);
|
|
42
|
+
if (exists) {
|
|
43
|
+
const version = await getInstalledVersion();
|
|
44
|
+
if (version) {
|
|
45
|
+
return { skipped: true, version };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Fetch latest release info
|
|
50
|
+
let releaseInfo;
|
|
51
|
+
try {
|
|
52
|
+
const { stdout } = await execa('curl', [
|
|
53
|
+
'-s', '-L',
|
|
54
|
+
'-H', 'Accept: application/vnd.github+json',
|
|
55
|
+
GITHUB_API,
|
|
56
|
+
]);
|
|
57
|
+
releaseInfo = JSON.parse(stdout);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Failed to fetch Chisel release info from GitHub: ${err.message}. Check internet connectivity.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (releaseInfo.message && releaseInfo.message.includes('rate limit')) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
'GitHub API rate limit exceeded. Please try again later or set a GITHUB_TOKEN environment variable.',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Find the correct asset for this platform
|
|
71
|
+
const archSuffix = detectArch();
|
|
72
|
+
const asset = releaseInfo.assets?.find(
|
|
73
|
+
(a) => a.name.includes(archSuffix) && a.name.endsWith('.gz'),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (!asset) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Could not find ${archSuffix} asset in the latest Chisel release. Available assets: ` +
|
|
79
|
+
(releaseInfo.assets?.map((a) => a.name).join(', ') || 'none'),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const downloadUrl = asset.browser_download_url;
|
|
84
|
+
const tmpGz = path.join(tmpdir(), `chisel-${crypto.randomBytes(4).toString('hex')}.gz`);
|
|
85
|
+
const tmpBin = tmpGz.replace('.gz', '');
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await execa('curl', ['-L', '-o', tmpGz, downloadUrl]);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Failed to download Chisel from ${downloadUrl}: ${err.stderr || err.message}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await mkdir(CHISEL_BIN_DIR, { recursive: true });
|
|
97
|
+
await execa('gunzip', ['-f', tmpGz]);
|
|
98
|
+
await execa('mv', [tmpBin, CHISEL_BIN_PATH]);
|
|
99
|
+
await execa('chmod', ['+x', CHISEL_BIN_PATH]);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new Error(`Failed to install Chisel binary: ${err.stderr || err.message}`);
|
|
102
|
+
} finally {
|
|
103
|
+
await execa('rm', ['-f', tmpGz, tmpBin]).catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const version = await getInstalledVersion();
|
|
107
|
+
if (!version) {
|
|
108
|
+
throw new Error('Chisel was installed but version check failed. The binary may be corrupted.');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { installed: true, version };
|
|
112
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { CONFIG_PATH, AGENT_DIR } from './platform.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Load the agent config from ~/.portlama/agent.json.
|
|
6
|
+
* Returns null if the file does not exist.
|
|
7
|
+
* @returns {Promise<object | null>}
|
|
8
|
+
*/
|
|
9
|
+
export async function loadAgentConfig() {
|
|
10
|
+
try {
|
|
11
|
+
const raw = await readFile(CONFIG_PATH, 'utf8');
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Save the agent config atomically (write tmp → rename).
|
|
20
|
+
* @param {object} config
|
|
21
|
+
*/
|
|
22
|
+
export async function saveAgentConfig(config) {
|
|
23
|
+
await mkdir(AGENT_DIR, { recursive: true });
|
|
24
|
+
const tmp = CONFIG_PATH + '.tmp';
|
|
25
|
+
await writeFile(tmp, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
26
|
+
await rename(tmp, CONFIG_PATH);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load agent config or throw if it doesn't exist.
|
|
31
|
+
* Used by commands that require prior setup.
|
|
32
|
+
* @returns {Promise<object>}
|
|
33
|
+
*/
|
|
34
|
+
export async function requireAgentConfig() {
|
|
35
|
+
const config = await loadAgentConfig();
|
|
36
|
+
if (!config) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
'No agent configuration found. Run "portlama-agent setup" first.',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return config;
|
|
42
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { PLIST_PATH, PLIST_LABEL } from './platform.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if the launchd agent is currently loaded.
|
|
6
|
+
* @returns {Promise<boolean>}
|
|
7
|
+
*/
|
|
8
|
+
export async function isAgentLoaded() {
|
|
9
|
+
try {
|
|
10
|
+
const { stdout } = await execa('launchctl', ['list']);
|
|
11
|
+
return stdout.includes(PLIST_LABEL);
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the PID of the running agent, or null if not running.
|
|
19
|
+
* @returns {Promise<number | null>}
|
|
20
|
+
*/
|
|
21
|
+
export async function getAgentPid() {
|
|
22
|
+
try {
|
|
23
|
+
const { stdout } = await execa('launchctl', ['list']);
|
|
24
|
+
for (const line of stdout.split('\n')) {
|
|
25
|
+
if (line.includes(PLIST_LABEL)) {
|
|
26
|
+
const pid = line.split('\t')[0];
|
|
27
|
+
const parsed = parseInt(pid, 10);
|
|
28
|
+
return Number.isNaN(parsed) || parsed <= 0 ? null : parsed;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load the launchd agent.
|
|
39
|
+
*/
|
|
40
|
+
export async function loadAgent() {
|
|
41
|
+
try {
|
|
42
|
+
await execa('launchctl', ['load', PLIST_PATH]);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw new Error(`Failed to load agent: ${err.stderr || err.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Unload the launchd agent. Silent if not loaded.
|
|
50
|
+
*/
|
|
51
|
+
export async function unloadAgent() {
|
|
52
|
+
try {
|
|
53
|
+
await execa('launchctl', ['unload', PLIST_PATH]);
|
|
54
|
+
} catch {
|
|
55
|
+
// Agent may not be loaded — this is fine
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build the common curl args for mTLS authentication.
|
|
5
|
+
* @param {string} p12Path - Path to client.p12
|
|
6
|
+
* @param {string} p12Password - P12 password
|
|
7
|
+
* @returns {string[]}
|
|
8
|
+
*/
|
|
9
|
+
function certArgs(p12Path, p12Password) {
|
|
10
|
+
return [
|
|
11
|
+
'--cert-type', 'P12',
|
|
12
|
+
'--cert', `${p12Path}:${p12Password}`,
|
|
13
|
+
'-k', // accept self-signed server cert
|
|
14
|
+
'-s', // silent
|
|
15
|
+
'-f', // fail on HTTP errors
|
|
16
|
+
'--max-time', '30',
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check panel connectivity by hitting /api/health.
|
|
22
|
+
* @param {string} panelUrl - e.g. "https://1.2.3.4:9292"
|
|
23
|
+
* @param {string} p12Path
|
|
24
|
+
* @param {string} p12Password
|
|
25
|
+
* @returns {Promise<object>}
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchHealth(panelUrl, p12Path, p12Password) {
|
|
28
|
+
const url = `${panelUrl}/api/health`;
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await execa('curl', [
|
|
31
|
+
...certArgs(p12Path, p12Password),
|
|
32
|
+
url,
|
|
33
|
+
]);
|
|
34
|
+
return JSON.parse(stdout);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Cannot reach panel at ${url}. ` +
|
|
38
|
+
`Check the URL and that your client.p12 is valid. ` +
|
|
39
|
+
`Details: ${err.stderr || err.message}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fetch the plist XML and metadata from the panel.
|
|
46
|
+
* @param {string} panelUrl
|
|
47
|
+
* @param {string} p12Path
|
|
48
|
+
* @param {string} p12Password
|
|
49
|
+
* @returns {Promise<{ plist: string, instructions: object }>}
|
|
50
|
+
*/
|
|
51
|
+
export async function fetchPlist(panelUrl, p12Path, p12Password) {
|
|
52
|
+
const url = `${panelUrl}/api/tunnels/mac-plist?format=json`;
|
|
53
|
+
try {
|
|
54
|
+
const { stdout } = await execa('curl', [
|
|
55
|
+
...certArgs(p12Path, p12Password),
|
|
56
|
+
url,
|
|
57
|
+
]);
|
|
58
|
+
return JSON.parse(stdout);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Failed to fetch plist from panel. ` +
|
|
62
|
+
`Details: ${err.stderr || err.message}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fetch the tunnel list from the panel.
|
|
69
|
+
* @param {string} panelUrl
|
|
70
|
+
* @param {string} p12Path
|
|
71
|
+
* @param {string} p12Password
|
|
72
|
+
* @returns {Promise<{ tunnels: Array<{ id: string, subdomain: string, port: number }> }>}
|
|
73
|
+
*/
|
|
74
|
+
export async function fetchTunnels(panelUrl, p12Path, p12Password) {
|
|
75
|
+
const url = `${panelUrl}/api/tunnels`;
|
|
76
|
+
try {
|
|
77
|
+
const { stdout } = await execa('curl', [
|
|
78
|
+
...certArgs(p12Path, p12Password),
|
|
79
|
+
url,
|
|
80
|
+
]);
|
|
81
|
+
return JSON.parse(stdout);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Failed to fetch tunnels from panel. ` +
|
|
85
|
+
`Details: ${err.stderr || err.message}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const HOME = homedir();
|
|
5
|
+
|
|
6
|
+
export const AGENT_DIR = path.join(HOME, '.portlama');
|
|
7
|
+
export const CHISEL_BIN_DIR = path.join(AGENT_DIR, 'bin');
|
|
8
|
+
export const CHISEL_BIN_PATH = path.join(CHISEL_BIN_DIR, 'chisel');
|
|
9
|
+
export const LOGS_DIR = path.join(AGENT_DIR, 'logs');
|
|
10
|
+
export const CONFIG_PATH = path.join(AGENT_DIR, 'agent.json');
|
|
11
|
+
export const PLIST_LABEL = 'com.portlama.chisel';
|
|
12
|
+
export const PLIST_PATH = path.join(HOME, 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
|
|
13
|
+
export const LOG_FILE = path.join(LOGS_DIR, 'chisel.log');
|
|
14
|
+
export const ERROR_LOG_FILE = path.join(LOGS_DIR, 'chisel.error.log');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Assert we are running on macOS. Throws if not.
|
|
18
|
+
*/
|
|
19
|
+
export function assertMacOS() {
|
|
20
|
+
if (process.platform !== 'darwin') {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'portlama-agent is designed for macOS only. ' +
|
|
23
|
+
`Detected platform: ${process.platform}`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detect architecture and return the Chisel release suffix.
|
|
30
|
+
* @returns {'darwin_arm64' | 'darwin_amd64'}
|
|
31
|
+
*/
|
|
32
|
+
export function detectArch() {
|
|
33
|
+
switch (process.arch) {
|
|
34
|
+
case 'arm64':
|
|
35
|
+
return 'darwin_arm64';
|
|
36
|
+
case 'x64':
|
|
37
|
+
return 'darwin_amd64';
|
|
38
|
+
default:
|
|
39
|
+
throw new Error(`Unsupported architecture: ${process.arch}. Expected arm64 or x64.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/lib/plist.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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(
|
|
19
|
+
/\/usr\/local\/bin\/chisel/g,
|
|
20
|
+
CHISEL_BIN_PATH,
|
|
21
|
+
);
|
|
22
|
+
result = result.replace(
|
|
23
|
+
/\/usr\/local\/var\/log\/chisel\.log/g,
|
|
24
|
+
LOG_FILE,
|
|
25
|
+
);
|
|
26
|
+
result = result.replace(
|
|
27
|
+
/\/usr\/local\/var\/log\/chisel\.error\.log/g,
|
|
28
|
+
ERROR_LOG_FILE,
|
|
29
|
+
);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Write the plist file to ~/Library/LaunchAgents/ atomically.
|
|
35
|
+
* @param {string} xml - Plist XML content (already rewritten)
|
|
36
|
+
*/
|
|
37
|
+
export async function writePlistFile(xml) {
|
|
38
|
+
const dir = path.dirname(PLIST_PATH);
|
|
39
|
+
await mkdir(dir, { recursive: true });
|
|
40
|
+
const tmp = PLIST_PATH + '.tmp';
|
|
41
|
+
await writeFile(tmp, xml, 'utf8');
|
|
42
|
+
await rename(tmp, PLIST_PATH);
|
|
43
|
+
}
|