@lamalibre/create-portlama 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/bin/create-portlama.js +15 -0
- package/package.json +50 -0
- package/scripts/bundle-vendor.js +60 -0
- package/src/index.js +484 -0
- package/src/lib/cert-help-page.js +160 -0
- package/src/lib/env.js +136 -0
- package/src/lib/secrets.js +19 -0
- package/src/lib/summary.js +101 -0
- package/src/tasks/harden.js +302 -0
- package/src/tasks/mtls.js +195 -0
- package/src/tasks/nginx.js +184 -0
- package/src/tasks/node.js +110 -0
- package/src/tasks/panel.js +434 -0
- package/vendor/panel-client/dist/assets/index-BDOylgUN.js +323 -0
- package/vendor/panel-client/dist/assets/index-BZTMcuQt.css +1 -0
- package/vendor/panel-client/dist/index.html +13 -0
- package/vendor/panel-server/package.json +31 -0
- package/vendor/panel-server/src/index.js +86 -0
- package/vendor/panel-server/src/lib/app-error.js +14 -0
- package/vendor/panel-server/src/lib/authelia.js +482 -0
- package/vendor/panel-server/src/lib/certbot.js +328 -0
- package/vendor/panel-server/src/lib/chisel.js +357 -0
- package/vendor/panel-server/src/lib/config.js +100 -0
- package/vendor/panel-server/src/lib/files.js +251 -0
- package/vendor/panel-server/src/lib/mtls.js +197 -0
- package/vendor/panel-server/src/lib/nginx.js +529 -0
- package/vendor/panel-server/src/lib/plist.js +65 -0
- package/vendor/panel-server/src/lib/services.js +128 -0
- package/vendor/panel-server/src/lib/state.js +95 -0
- package/vendor/panel-server/src/lib/system-stats.js +58 -0
- package/vendor/panel-server/src/middleware/errors.js +58 -0
- package/vendor/panel-server/src/middleware/mtls.js +30 -0
- package/vendor/panel-server/src/middleware/onboarding-guard.js +30 -0
- package/vendor/panel-server/src/routes/health.js +22 -0
- package/vendor/panel-server/src/routes/management/certs.js +225 -0
- package/vendor/panel-server/src/routes/management/logs.js +132 -0
- package/vendor/panel-server/src/routes/management/services.js +51 -0
- package/vendor/panel-server/src/routes/management/sites.js +448 -0
- package/vendor/panel-server/src/routes/management/system.js +12 -0
- package/vendor/panel-server/src/routes/management/tunnels.js +225 -0
- package/vendor/panel-server/src/routes/management/users.js +237 -0
- package/vendor/panel-server/src/routes/management.js +20 -0
- package/vendor/panel-server/src/routes/onboarding/dns.js +73 -0
- package/vendor/panel-server/src/routes/onboarding/domain.js +35 -0
- package/vendor/panel-server/src/routes/onboarding/index.js +18 -0
- package/vendor/panel-server/src/routes/onboarding/provision.js +291 -0
- package/vendor/panel-server/src/routes/onboarding/status.js +12 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { writeFile as fsWriteFile, readdir } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const SITES_AVAILABLE = '/etc/nginx/sites-available';
|
|
8
|
+
const SITES_ENABLED = '/etc/nginx/sites-enabled';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if a file exists at the given nginx path using sudo.
|
|
12
|
+
*/
|
|
13
|
+
async function fileExistsSudo(filePath) {
|
|
14
|
+
try {
|
|
15
|
+
await execa('sudo', ['test', '-f', filePath]);
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Write content to an nginx site file using a temp file and sudo mv.
|
|
24
|
+
*/
|
|
25
|
+
async function writeVhostFile(name, content) {
|
|
26
|
+
const tmpFile = path.join(tmpdir(), `nginx-${name}-${crypto.randomBytes(4).toString('hex')}`);
|
|
27
|
+
await fsWriteFile(tmpFile, content, 'utf-8');
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await execa('sudo', ['mv', tmpFile, path.join(SITES_AVAILABLE, name)]);
|
|
31
|
+
await execa('sudo', ['chmod', '644', path.join(SITES_AVAILABLE, name)]);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
throw new Error(`Failed to write nginx vhost file ${name}: ${err.stderr || err.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Write the panel domain vhost with mTLS and Let's Encrypt certs.
|
|
39
|
+
*/
|
|
40
|
+
export async function writePanelVhost(domain) {
|
|
41
|
+
const fqdn = `panel.${domain}`;
|
|
42
|
+
const config = `server {
|
|
43
|
+
listen 443 ssl;
|
|
44
|
+
server_name ${fqdn};
|
|
45
|
+
|
|
46
|
+
ssl_certificate /etc/letsencrypt/live/${fqdn}/fullchain.pem;
|
|
47
|
+
ssl_certificate_key /etc/letsencrypt/live/${fqdn}/privkey.pem;
|
|
48
|
+
|
|
49
|
+
# mTLS — same as IP-based access
|
|
50
|
+
include /etc/nginx/snippets/portlama-mtls.conf;
|
|
51
|
+
|
|
52
|
+
# SSL settings
|
|
53
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
54
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
55
|
+
ssl_prefer_server_ciphers on;
|
|
56
|
+
|
|
57
|
+
# Client cert headers
|
|
58
|
+
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
|
|
59
|
+
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
|
|
60
|
+
|
|
61
|
+
# Standard proxy headers
|
|
62
|
+
proxy_set_header Host $host;
|
|
63
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
64
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
65
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
66
|
+
|
|
67
|
+
location / {
|
|
68
|
+
proxy_pass http://127.0.0.1:3100;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
location /api {
|
|
72
|
+
proxy_pass http://127.0.0.1:3100;
|
|
73
|
+
proxy_http_version 1.1;
|
|
74
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
75
|
+
proxy_set_header Connection "upgrade";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
await writeVhostFile('portlama-panel-domain', config);
|
|
81
|
+
return path.join(SITES_AVAILABLE, 'portlama-panel-domain');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Write the Authelia auth portal vhost.
|
|
86
|
+
*/
|
|
87
|
+
export async function writeAuthVhost(domain) {
|
|
88
|
+
const fqdn = `auth.${domain}`;
|
|
89
|
+
const config = `server {
|
|
90
|
+
listen 443 ssl;
|
|
91
|
+
server_name ${fqdn};
|
|
92
|
+
|
|
93
|
+
ssl_certificate /etc/letsencrypt/live/${fqdn}/fullchain.pem;
|
|
94
|
+
ssl_certificate_key /etc/letsencrypt/live/${fqdn}/privkey.pem;
|
|
95
|
+
|
|
96
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
97
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
98
|
+
ssl_prefer_server_ciphers on;
|
|
99
|
+
|
|
100
|
+
proxy_set_header Host $host;
|
|
101
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
102
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
103
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
104
|
+
|
|
105
|
+
location / {
|
|
106
|
+
proxy_pass http://127.0.0.1:9091;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
await writeVhostFile('portlama-auth', config);
|
|
112
|
+
return path.join(SITES_AVAILABLE, 'portlama-auth');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Write the Chisel tunnel WebSocket vhost.
|
|
117
|
+
*/
|
|
118
|
+
export async function writeTunnelVhost(domain) {
|
|
119
|
+
const fqdn = `tunnel.${domain}`;
|
|
120
|
+
const config = `server {
|
|
121
|
+
listen 443 ssl;
|
|
122
|
+
server_name ${fqdn};
|
|
123
|
+
|
|
124
|
+
ssl_certificate /etc/letsencrypt/live/${fqdn}/fullchain.pem;
|
|
125
|
+
ssl_certificate_key /etc/letsencrypt/live/${fqdn}/privkey.pem;
|
|
126
|
+
|
|
127
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
128
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
129
|
+
ssl_prefer_server_ciphers on;
|
|
130
|
+
|
|
131
|
+
proxy_set_header Host $host;
|
|
132
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
133
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
134
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
135
|
+
|
|
136
|
+
location / {
|
|
137
|
+
proxy_pass http://127.0.0.1:9090;
|
|
138
|
+
proxy_http_version 1.1;
|
|
139
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
140
|
+
proxy_set_header Connection "upgrade";
|
|
141
|
+
|
|
142
|
+
# Long timeout for WebSocket tunnel connections
|
|
143
|
+
proxy_read_timeout 86400s;
|
|
144
|
+
proxy_send_timeout 86400s;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
await writeVhostFile('portlama-tunnel', config);
|
|
150
|
+
return path.join(SITES_AVAILABLE, 'portlama-tunnel');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Write an app tunnel vhost with Authelia forward auth.
|
|
155
|
+
*
|
|
156
|
+
* Performs a safe write-with-rollback sequence:
|
|
157
|
+
* 1. Backup existing vhost (if any)
|
|
158
|
+
* 2. Write the new vhost config
|
|
159
|
+
* 3. Create symlink in sites-enabled
|
|
160
|
+
* 4. Test nginx config
|
|
161
|
+
* 5. On success: reload nginx; on failure: rollback to backup
|
|
162
|
+
*
|
|
163
|
+
* @param {string} subdomain - The subdomain name (e.g., "myapp")
|
|
164
|
+
* @param {string} domain - The base domain (e.g., "example.com")
|
|
165
|
+
* @param {number} port - The local port to proxy to
|
|
166
|
+
* @param {string} [certPath] - Optional cert directory path override (e.g. for wildcard certs)
|
|
167
|
+
*/
|
|
168
|
+
export async function writeAppVhost(subdomain, domain, port, certPath) {
|
|
169
|
+
const fqdn = `${subdomain}.${domain}`;
|
|
170
|
+
const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
|
|
171
|
+
// Normalize: remove trailing slash if present
|
|
172
|
+
const certDirClean = certDir.replace(/\/+$/, '');
|
|
173
|
+
|
|
174
|
+
const config = `server {
|
|
175
|
+
listen 443 ssl;
|
|
176
|
+
server_name ${fqdn};
|
|
177
|
+
|
|
178
|
+
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
179
|
+
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
180
|
+
|
|
181
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
182
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
183
|
+
ssl_prefer_server_ciphers on;
|
|
184
|
+
|
|
185
|
+
proxy_set_header Host $host;
|
|
186
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
187
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
188
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
189
|
+
|
|
190
|
+
# Authelia forward authentication
|
|
191
|
+
location /authelia {
|
|
192
|
+
internal;
|
|
193
|
+
proxy_pass http://127.0.0.1:9091/api/verify?rd=https://auth.${domain}/;
|
|
194
|
+
proxy_pass_request_body off;
|
|
195
|
+
proxy_set_header Content-Length "";
|
|
196
|
+
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
|
197
|
+
proxy_set_header X-Forwarded-Method $request_method;
|
|
198
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
199
|
+
proxy_set_header X-Forwarded-Host $http_host;
|
|
200
|
+
proxy_set_header X-Forwarded-Uri $request_uri;
|
|
201
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
location / {
|
|
205
|
+
auth_request /authelia;
|
|
206
|
+
auth_request_set $user $upstream_http_remote_user;
|
|
207
|
+
auth_request_set $groups $upstream_http_remote_groups;
|
|
208
|
+
auth_request_set $name $upstream_http_remote_name;
|
|
209
|
+
auth_request_set $email $upstream_http_remote_email;
|
|
210
|
+
|
|
211
|
+
proxy_set_header Remote-User $user;
|
|
212
|
+
proxy_set_header Remote-Groups $groups;
|
|
213
|
+
proxy_set_header Remote-Name $name;
|
|
214
|
+
proxy_set_header Remote-Email $email;
|
|
215
|
+
|
|
216
|
+
proxy_pass http://127.0.0.1:${port};
|
|
217
|
+
proxy_http_version 1.1;
|
|
218
|
+
|
|
219
|
+
# WebSocket support
|
|
220
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
221
|
+
proxy_set_header Connection "upgrade";
|
|
222
|
+
|
|
223
|
+
proxy_read_timeout 86400s;
|
|
224
|
+
proxy_send_timeout 86400s;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# Error page for unauthenticated requests — redirect to Authelia
|
|
228
|
+
error_page 401 =302 https://auth.${domain}/?rd=$scheme://$http_host$request_uri;
|
|
229
|
+
}
|
|
230
|
+
`;
|
|
231
|
+
|
|
232
|
+
const name = `portlama-app-${subdomain}`;
|
|
233
|
+
const availablePath = path.join(SITES_AVAILABLE, name);
|
|
234
|
+
const bakPath = `${availablePath}.bak`;
|
|
235
|
+
|
|
236
|
+
// 1. Backup existing vhost if present
|
|
237
|
+
const existed = await fileExistsSudo(availablePath);
|
|
238
|
+
if (existed) {
|
|
239
|
+
await execa('sudo', ['cp', availablePath, bakPath]);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
// 2. Write new vhost
|
|
244
|
+
await writeVhostFile(name, config);
|
|
245
|
+
|
|
246
|
+
// 3. Create symlink in sites-enabled
|
|
247
|
+
await enableSite(name);
|
|
248
|
+
|
|
249
|
+
// 4. Test nginx config
|
|
250
|
+
const result = await testConfig();
|
|
251
|
+
if (!result.valid) {
|
|
252
|
+
// Rollback: restore backup or remove new file
|
|
253
|
+
if (existed) {
|
|
254
|
+
await execa('sudo', ['mv', bakPath, availablePath]);
|
|
255
|
+
} else {
|
|
256
|
+
await execa('sudo', ['rm', '-f', availablePath]);
|
|
257
|
+
await execa('sudo', ['rm', '-f', path.join(SITES_ENABLED, name)]);
|
|
258
|
+
}
|
|
259
|
+
throw new Error(`Nginx config test failed after writing vhost for ${fqdn}: ${result.error}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 5. Reload nginx
|
|
263
|
+
await reload();
|
|
264
|
+
|
|
265
|
+
// Clean up backup on success
|
|
266
|
+
if (existed) {
|
|
267
|
+
await execa('sudo', ['rm', '-f', bakPath]).catch(() => {});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return availablePath;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
// If the error is already from our config test, re-throw it
|
|
273
|
+
if (err.message.includes('Nginx config test failed')) {
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Rollback on unexpected errors
|
|
278
|
+
if (existed) {
|
|
279
|
+
await execa('sudo', ['mv', bakPath, availablePath]).catch(() => {});
|
|
280
|
+
} else {
|
|
281
|
+
await execa('sudo', ['rm', '-f', availablePath]).catch(() => {});
|
|
282
|
+
await execa('sudo', ['rm', '-f', path.join(SITES_ENABLED, name)]).catch(() => {});
|
|
283
|
+
}
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Write a static site vhost with optional Authelia forward auth.
|
|
290
|
+
*
|
|
291
|
+
* Performs a safe write-with-rollback sequence (same as writeAppVhost):
|
|
292
|
+
* 1. Backup existing vhost (if any)
|
|
293
|
+
* 2. Write the new vhost config
|
|
294
|
+
* 3. Create symlink in sites-enabled
|
|
295
|
+
* 4. Test nginx config
|
|
296
|
+
* 5. On success: reload nginx; on failure: rollback to backup
|
|
297
|
+
*
|
|
298
|
+
* @param {object} site - The site object from sites.json
|
|
299
|
+
* @param {string} site.id - Site UUID
|
|
300
|
+
* @param {string} site.fqdn - Full domain (e.g., "blog.example.com")
|
|
301
|
+
* @param {boolean} site.spaMode - If true, try_files falls back to /index.html
|
|
302
|
+
* @param {boolean} site.autheliaProtected - If true, add Authelia forward auth
|
|
303
|
+
* @param {string} site.rootPath - Directory root (e.g., /var/www/portlama/{id}/)
|
|
304
|
+
* @param {string} certDir - Certificate directory path
|
|
305
|
+
* @param {string} [domain] - Base domain (needed for Authelia redirect URL)
|
|
306
|
+
*/
|
|
307
|
+
export async function writeStaticSiteVhost(site, certDir, domain) {
|
|
308
|
+
const certDirClean = certDir.replace(/\/+$/, '');
|
|
309
|
+
const tryFiles = site.spaMode
|
|
310
|
+
? 'try_files $uri $uri/ /index.html'
|
|
311
|
+
: 'try_files $uri $uri/ =404';
|
|
312
|
+
|
|
313
|
+
let autheliaBlock = '';
|
|
314
|
+
let locationAuthDirectives = '';
|
|
315
|
+
|
|
316
|
+
if (site.autheliaProtected && domain) {
|
|
317
|
+
autheliaBlock = `
|
|
318
|
+
# Authelia forward authentication
|
|
319
|
+
location /authelia {
|
|
320
|
+
internal;
|
|
321
|
+
proxy_pass http://127.0.0.1:9091/api/verify?rd=https://auth.${domain}/;
|
|
322
|
+
proxy_pass_request_body off;
|
|
323
|
+
proxy_set_header Content-Length "";
|
|
324
|
+
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
|
|
325
|
+
proxy_set_header X-Forwarded-Method $request_method;
|
|
326
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
327
|
+
proxy_set_header X-Forwarded-Host $http_host;
|
|
328
|
+
proxy_set_header X-Forwarded-Uri $request_uri;
|
|
329
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
330
|
+
}
|
|
331
|
+
`;
|
|
332
|
+
locationAuthDirectives = `
|
|
333
|
+
auth_request /authelia;
|
|
334
|
+
auth_request_set $user $upstream_http_remote_user;
|
|
335
|
+
auth_request_set $groups $upstream_http_remote_groups;`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const config = `server {
|
|
339
|
+
listen 443 ssl;
|
|
340
|
+
server_name ${site.fqdn};
|
|
341
|
+
|
|
342
|
+
ssl_certificate ${certDirClean}/fullchain.pem;
|
|
343
|
+
ssl_certificate_key ${certDirClean}/privkey.pem;
|
|
344
|
+
|
|
345
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
346
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
347
|
+
ssl_prefer_server_ciphers on;
|
|
348
|
+
|
|
349
|
+
root ${site.rootPath};
|
|
350
|
+
index index.html;
|
|
351
|
+
|
|
352
|
+
# Security headers
|
|
353
|
+
add_header X-Frame-Options SAMEORIGIN always;
|
|
354
|
+
add_header X-Content-Type-Options nosniff always;
|
|
355
|
+
${autheliaBlock}
|
|
356
|
+
location / {${locationAuthDirectives}
|
|
357
|
+
${tryFiles};
|
|
358
|
+
}
|
|
359
|
+
${site.autheliaProtected && domain ? `
|
|
360
|
+
# Error page for unauthenticated requests — redirect to Authelia
|
|
361
|
+
error_page 401 =302 https://auth.${domain}/?rd=$scheme://$http_host$request_uri;
|
|
362
|
+
` : ''}
|
|
363
|
+
}
|
|
364
|
+
`;
|
|
365
|
+
|
|
366
|
+
const name = `portlama-site-${site.id}`;
|
|
367
|
+
const availablePath = path.join(SITES_AVAILABLE, name);
|
|
368
|
+
const bakPath = `${availablePath}.bak`;
|
|
369
|
+
|
|
370
|
+
// 1. Backup existing vhost if present
|
|
371
|
+
const existed = await fileExistsSudo(availablePath);
|
|
372
|
+
if (existed) {
|
|
373
|
+
await execa('sudo', ['cp', availablePath, bakPath]);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
// 2. Write new vhost
|
|
378
|
+
await writeVhostFile(name, config);
|
|
379
|
+
|
|
380
|
+
// 3. Create symlink in sites-enabled
|
|
381
|
+
await enableSite(name);
|
|
382
|
+
|
|
383
|
+
// 4. Test nginx config
|
|
384
|
+
const result = await testConfig();
|
|
385
|
+
if (!result.valid) {
|
|
386
|
+
if (existed) {
|
|
387
|
+
await execa('sudo', ['mv', bakPath, availablePath]);
|
|
388
|
+
} else {
|
|
389
|
+
await execa('sudo', ['rm', '-f', availablePath]);
|
|
390
|
+
await execa('sudo', ['rm', '-f', path.join(SITES_ENABLED, name)]);
|
|
391
|
+
}
|
|
392
|
+
throw new Error(`Nginx config test failed after writing vhost for ${site.fqdn}: ${result.error}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 5. Reload nginx
|
|
396
|
+
await reload();
|
|
397
|
+
|
|
398
|
+
// Clean up backup on success
|
|
399
|
+
if (existed) {
|
|
400
|
+
await execa('sudo', ['rm', '-f', bakPath]).catch(() => {});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return availablePath;
|
|
404
|
+
} catch (err) {
|
|
405
|
+
if (err.message.includes('Nginx config test failed')) {
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Rollback on unexpected errors
|
|
410
|
+
if (existed) {
|
|
411
|
+
await execa('sudo', ['mv', bakPath, availablePath]).catch(() => {});
|
|
412
|
+
} else {
|
|
413
|
+
await execa('sudo', ['rm', '-f', availablePath]).catch(() => {});
|
|
414
|
+
await execa('sudo', ['rm', '-f', path.join(SITES_ENABLED, name)]).catch(() => {});
|
|
415
|
+
}
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Remove a static site vhost from sites-available and sites-enabled, then test and reload nginx.
|
|
422
|
+
* Idempotent: if files don't exist, proceeds silently.
|
|
423
|
+
*
|
|
424
|
+
* @param {string} siteId - The site UUID
|
|
425
|
+
*/
|
|
426
|
+
export async function removeStaticSiteVhost(siteId) {
|
|
427
|
+
const name = `portlama-site-${siteId}`;
|
|
428
|
+
try {
|
|
429
|
+
await execa('sudo', ['rm', '-f', path.join(SITES_ENABLED, name)]);
|
|
430
|
+
await execa('sudo', ['rm', '-f', path.join(SITES_AVAILABLE, name)]);
|
|
431
|
+
await execa('sudo', ['rm', '-f', `${path.join(SITES_AVAILABLE, name)}.bak`]);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
throw new Error(`Failed to remove static site vhost ${name}: ${err.stderr || err.message}`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const result = await testConfig();
|
|
437
|
+
if (!result.valid) {
|
|
438
|
+
throw new Error(`Nginx config test failed after removing vhost ${name}: ${result.error}`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
await reload();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Remove an app vhost from sites-available and sites-enabled, then test and reload nginx.
|
|
446
|
+
* Idempotent: if files don't exist, proceeds silently.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} subdomain - The subdomain name
|
|
449
|
+
*/
|
|
450
|
+
export async function removeAppVhost(subdomain) {
|
|
451
|
+
const name = `portlama-app-${subdomain}`;
|
|
452
|
+
try {
|
|
453
|
+
await execa('sudo', ['rm', '-f', path.join(SITES_ENABLED, name)]);
|
|
454
|
+
await execa('sudo', ['rm', '-f', path.join(SITES_AVAILABLE, name)]);
|
|
455
|
+
await execa('sudo', ['rm', '-f', `${path.join(SITES_AVAILABLE, name)}.bak`]);
|
|
456
|
+
} catch (err) {
|
|
457
|
+
throw new Error(`Failed to remove app vhost ${name}: ${err.stderr || err.message}`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const result = await testConfig();
|
|
461
|
+
if (!result.valid) {
|
|
462
|
+
throw new Error(`Nginx config test failed after removing vhost ${name}: ${result.error}`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
await reload();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Test the nginx configuration. Returns { valid: true } or { valid: false, error }.
|
|
470
|
+
* Does NOT throw on invalid config.
|
|
471
|
+
*/
|
|
472
|
+
export async function testConfig() {
|
|
473
|
+
try {
|
|
474
|
+
await execa('sudo', ['nginx', '-t']);
|
|
475
|
+
return { valid: true };
|
|
476
|
+
} catch (err) {
|
|
477
|
+
return { valid: false, error: err.stderr || err.message };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Reload the nginx service.
|
|
483
|
+
*/
|
|
484
|
+
export async function reload() {
|
|
485
|
+
try {
|
|
486
|
+
await execa('sudo', ['systemctl', 'reload', 'nginx']);
|
|
487
|
+
return { reloaded: true };
|
|
488
|
+
} catch (err) {
|
|
489
|
+
throw new Error(`Failed to reload nginx: ${err.stderr || err.message}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Enable a site by creating a symlink in sites-enabled.
|
|
495
|
+
*/
|
|
496
|
+
export async function enableSite(name) {
|
|
497
|
+
try {
|
|
498
|
+
await execa('sudo', [
|
|
499
|
+
'ln', '-sf',
|
|
500
|
+
path.join(SITES_AVAILABLE, name),
|
|
501
|
+
path.join(SITES_ENABLED, name),
|
|
502
|
+
]);
|
|
503
|
+
} catch (err) {
|
|
504
|
+
throw new Error(`Failed to enable site ${name}: ${err.stderr || err.message}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Disable a site by removing its symlink from sites-enabled.
|
|
510
|
+
*/
|
|
511
|
+
export async function disableSite(name) {
|
|
512
|
+
try {
|
|
513
|
+
await execa('sudo', ['rm', '-f', path.join(SITES_ENABLED, name)]);
|
|
514
|
+
} catch (err) {
|
|
515
|
+
throw new Error(`Failed to disable site ${name}: ${err.stderr || err.message}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* List all enabled Portlama sites.
|
|
521
|
+
*/
|
|
522
|
+
export async function listEnabledSites() {
|
|
523
|
+
try {
|
|
524
|
+
const entries = await readdir(SITES_ENABLED);
|
|
525
|
+
return entries.filter((name) => name.startsWith('portlama-'));
|
|
526
|
+
} catch {
|
|
527
|
+
return [];
|
|
528
|
+
}
|
|
529
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a macOS launchd plist for the Chisel client.
|
|
3
|
+
*
|
|
4
|
+
* The plist configures the Chisel client to connect to the VPS tunnel server
|
|
5
|
+
* and forward all configured tunnel ports via reverse tunneling.
|
|
6
|
+
*
|
|
7
|
+
* @param {Array<{ port: number }>} tunnels - Current tunnel list
|
|
8
|
+
* @param {string} domain - Base domain (e.g., "example.com")
|
|
9
|
+
* @returns {string} Complete plist XML content
|
|
10
|
+
*/
|
|
11
|
+
export function generatePlist(tunnels, domain) {
|
|
12
|
+
// XML-escape a string value (domain may contain special characters in edge cases)
|
|
13
|
+
const esc = (str) =>
|
|
14
|
+
String(str)
|
|
15
|
+
.replace(/&/g, '&')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, ''');
|
|
20
|
+
|
|
21
|
+
const programArgs = [
|
|
22
|
+
' <string>/usr/local/bin/chisel</string>',
|
|
23
|
+
' <string>client</string>',
|
|
24
|
+
` <string>wss://tunnel.${esc(domain)}:443</string>`,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
for (const tunnel of tunnels) {
|
|
28
|
+
programArgs.push(
|
|
29
|
+
` <string>R:0.0.0.0:${tunnel.port}:127.0.0.1:${tunnel.port}</string>`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
34
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
35
|
+
<plist version="1.0">
|
|
36
|
+
<dict>
|
|
37
|
+
<key>Label</key>
|
|
38
|
+
<string>com.portlama.chisel</string>
|
|
39
|
+
|
|
40
|
+
<key>ProgramArguments</key>
|
|
41
|
+
<array>
|
|
42
|
+
${programArgs.join('\n')}
|
|
43
|
+
</array>
|
|
44
|
+
|
|
45
|
+
<key>KeepAlive</key>
|
|
46
|
+
<true/>
|
|
47
|
+
|
|
48
|
+
<key>RunAtLoad</key>
|
|
49
|
+
<true/>
|
|
50
|
+
|
|
51
|
+
<key>StandardOutPath</key>
|
|
52
|
+
<string>/usr/local/var/log/chisel.log</string>
|
|
53
|
+
|
|
54
|
+
<key>StandardErrorPath</key>
|
|
55
|
+
<string>/usr/local/var/log/chisel.error.log</string>
|
|
56
|
+
|
|
57
|
+
<key>EnvironmentVariables</key>
|
|
58
|
+
<dict>
|
|
59
|
+
<key>PATH</key>
|
|
60
|
+
<string>/usr/local/bin:/usr/bin:/bin</string>
|
|
61
|
+
</dict>
|
|
62
|
+
</dict>
|
|
63
|
+
</plist>
|
|
64
|
+
`;
|
|
65
|
+
}
|