@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.
Files changed (47) hide show
  1. package/bin/create-portlama.js +15 -0
  2. package/package.json +50 -0
  3. package/scripts/bundle-vendor.js +60 -0
  4. package/src/index.js +484 -0
  5. package/src/lib/cert-help-page.js +160 -0
  6. package/src/lib/env.js +136 -0
  7. package/src/lib/secrets.js +19 -0
  8. package/src/lib/summary.js +101 -0
  9. package/src/tasks/harden.js +302 -0
  10. package/src/tasks/mtls.js +195 -0
  11. package/src/tasks/nginx.js +184 -0
  12. package/src/tasks/node.js +110 -0
  13. package/src/tasks/panel.js +434 -0
  14. package/vendor/panel-client/dist/assets/index-BDOylgUN.js +323 -0
  15. package/vendor/panel-client/dist/assets/index-BZTMcuQt.css +1 -0
  16. package/vendor/panel-client/dist/index.html +13 -0
  17. package/vendor/panel-server/package.json +31 -0
  18. package/vendor/panel-server/src/index.js +86 -0
  19. package/vendor/panel-server/src/lib/app-error.js +14 -0
  20. package/vendor/panel-server/src/lib/authelia.js +482 -0
  21. package/vendor/panel-server/src/lib/certbot.js +328 -0
  22. package/vendor/panel-server/src/lib/chisel.js +357 -0
  23. package/vendor/panel-server/src/lib/config.js +100 -0
  24. package/vendor/panel-server/src/lib/files.js +251 -0
  25. package/vendor/panel-server/src/lib/mtls.js +197 -0
  26. package/vendor/panel-server/src/lib/nginx.js +529 -0
  27. package/vendor/panel-server/src/lib/plist.js +65 -0
  28. package/vendor/panel-server/src/lib/services.js +128 -0
  29. package/vendor/panel-server/src/lib/state.js +95 -0
  30. package/vendor/panel-server/src/lib/system-stats.js +58 -0
  31. package/vendor/panel-server/src/middleware/errors.js +58 -0
  32. package/vendor/panel-server/src/middleware/mtls.js +30 -0
  33. package/vendor/panel-server/src/middleware/onboarding-guard.js +30 -0
  34. package/vendor/panel-server/src/routes/health.js +22 -0
  35. package/vendor/panel-server/src/routes/management/certs.js +225 -0
  36. package/vendor/panel-server/src/routes/management/logs.js +132 -0
  37. package/vendor/panel-server/src/routes/management/services.js +51 -0
  38. package/vendor/panel-server/src/routes/management/sites.js +448 -0
  39. package/vendor/panel-server/src/routes/management/system.js +12 -0
  40. package/vendor/panel-server/src/routes/management/tunnels.js +225 -0
  41. package/vendor/panel-server/src/routes/management/users.js +237 -0
  42. package/vendor/panel-server/src/routes/management.js +20 -0
  43. package/vendor/panel-server/src/routes/onboarding/dns.js +73 -0
  44. package/vendor/panel-server/src/routes/onboarding/domain.js +35 -0
  45. package/vendor/panel-server/src/routes/onboarding/index.js +18 -0
  46. package/vendor/panel-server/src/routes/onboarding/provision.js +291 -0
  47. 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, '&amp;')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;')
19
+ .replace(/'/g, '&apos;');
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
+ }