@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,15 @@
|
|
|
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 installation failed.');
|
|
10
|
+
console.error(` Error: ${error.message}`);
|
|
11
|
+
console.error('\n');
|
|
12
|
+
console.error(' You can safely re-run this installer to retry.');
|
|
13
|
+
console.error('\n');
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lamalibre/create-portlama",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "One-command setup for secure reverse tunnels with a management dashboard",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Code Lama Software",
|
|
8
|
+
"bin": {
|
|
9
|
+
"create-portlama": "bin/create-portlama.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"scripts",
|
|
15
|
+
"vendor"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"lint": "eslint .",
|
|
19
|
+
"prepublishOnly": "npm run lint && node scripts/bundle-vendor.js"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/codelama/portlama.git",
|
|
24
|
+
"directory": "packages/create-portlama"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/codelama/portlama#readme",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/codelama/portlama/issues"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"tunnel",
|
|
32
|
+
"reverse-tunnel",
|
|
33
|
+
"chisel",
|
|
34
|
+
"nginx",
|
|
35
|
+
"management",
|
|
36
|
+
"dashboard",
|
|
37
|
+
"vps",
|
|
38
|
+
"self-hosted",
|
|
39
|
+
"authelia",
|
|
40
|
+
"mtls"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"chalk": "^5.3.0",
|
|
44
|
+
"execa": "^9.0.0",
|
|
45
|
+
"listr2": "^8.0.0"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundles sibling monorepo packages into vendor/ so that create-portlama
|
|
3
|
+
* works standalone when installed via npx (outside the monorepo).
|
|
4
|
+
*
|
|
5
|
+
* Run automatically via the "prepublishOnly" npm script.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cp, mkdir, rm } from 'node:fs/promises';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const packageRoot = join(thisDir, '..');
|
|
15
|
+
const monorepoRoot = join(packageRoot, '..', '..');
|
|
16
|
+
const vendorDir = join(packageRoot, 'vendor');
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
// Clean vendor/ if it exists, then recreate
|
|
20
|
+
await rm(vendorDir, { recursive: true, force: true });
|
|
21
|
+
await mkdir(vendorDir, { recursive: true });
|
|
22
|
+
|
|
23
|
+
// --- panel-server: package.json + src/ ---
|
|
24
|
+
const serverSrc = join(monorepoRoot, 'packages', 'panel-server');
|
|
25
|
+
if (!existsSync(serverSrc)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`panel-server not found at ${serverSrc}. Run from the monorepo root.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const serverDest = join(vendorDir, 'panel-server');
|
|
32
|
+
await mkdir(serverDest, { recursive: true });
|
|
33
|
+
await cp(join(serverSrc, 'package.json'), join(serverDest, 'package.json'));
|
|
34
|
+
await cp(join(serverSrc, 'src'), join(serverDest, 'src'), {
|
|
35
|
+
recursive: true,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
console.log('Bundled vendor/panel-server (package.json + src/)');
|
|
39
|
+
|
|
40
|
+
// --- panel-client: dist/ (pre-built assets) ---
|
|
41
|
+
const clientDist = join(monorepoRoot, 'packages', 'panel-client', 'dist');
|
|
42
|
+
if (!existsSync(clientDist)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`panel-client/dist/ not found at ${clientDist}. Run "npm run build" first.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const clientDest = join(vendorDir, 'panel-client');
|
|
49
|
+
await mkdir(join(clientDest, 'dist'), { recursive: true });
|
|
50
|
+
await cp(clientDist, join(clientDest, 'dist'), { recursive: true });
|
|
51
|
+
|
|
52
|
+
console.log('Bundled vendor/panel-client (dist/)');
|
|
53
|
+
|
|
54
|
+
console.log('Vendor bundling complete.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
main().catch((err) => {
|
|
58
|
+
console.error(err.message);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
});
|
package/src/index.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { Listr } from 'listr2';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { execa } from 'execa';
|
|
7
|
+
import { detectOS, detectIP, checkRoot } from './lib/env.js';
|
|
8
|
+
import { printSummary } from './lib/summary.js';
|
|
9
|
+
import { hardenTasks } from './tasks/harden.js';
|
|
10
|
+
import { nodeTasks } from './tasks/node.js';
|
|
11
|
+
import { mtlsTasks } from './tasks/mtls.js';
|
|
12
|
+
import { nginxTasks } from './tasks/nginx.js';
|
|
13
|
+
import { panelTasks } from './tasks/panel.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse minimal CLI flags from process.argv.
|
|
17
|
+
* @returns {{ skipHarden: boolean, uninstall: boolean, dev: boolean, help: boolean, yes: boolean }}
|
|
18
|
+
*/
|
|
19
|
+
function parseFlags() {
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
return {
|
|
22
|
+
skipHarden: args.includes('--skip-harden'),
|
|
23
|
+
uninstall: args.includes('--uninstall'),
|
|
24
|
+
dev: args.includes('--dev'),
|
|
25
|
+
help: args.includes('--help') || args.includes('-h'),
|
|
26
|
+
yes: args.includes('--yes') || args.includes('-y'),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Print help message describing Portlama and what the installer does,
|
|
32
|
+
* then exit with code 0.
|
|
33
|
+
*/
|
|
34
|
+
function printHelp() {
|
|
35
|
+
const b = chalk.bold;
|
|
36
|
+
const c = chalk.cyan;
|
|
37
|
+
const y = chalk.yellow;
|
|
38
|
+
const d = chalk.dim;
|
|
39
|
+
|
|
40
|
+
console.log(`
|
|
41
|
+
${b('Portlama')} — A self-hosted secure tunneling platform
|
|
42
|
+
|
|
43
|
+
${b('DESCRIPTION')}
|
|
44
|
+
|
|
45
|
+
Portlama exposes web apps running behind a firewall through a VPS
|
|
46
|
+
via WebSocket-over-HTTPS tunnels. This installer provisions a fresh
|
|
47
|
+
Ubuntu 24.04 droplet with nginx, mTLS client certificates, and a
|
|
48
|
+
browser-based management panel — all in a single command.
|
|
49
|
+
|
|
50
|
+
${b('USAGE')}
|
|
51
|
+
|
|
52
|
+
${c('npx create-portlama')} ${d('[flags]')}
|
|
53
|
+
|
|
54
|
+
${b('SYSTEM MODIFICATIONS')}
|
|
55
|
+
|
|
56
|
+
This installer makes the following changes to the machine:
|
|
57
|
+
|
|
58
|
+
${y('Swap & Memory')}
|
|
59
|
+
• Creates a 1GB swap file
|
|
60
|
+
|
|
61
|
+
${y('Firewall & Security')}
|
|
62
|
+
• Resets UFW firewall (allows only ports 22, 443, 9292)
|
|
63
|
+
• Installs fail2ban with SSH and nginx jails
|
|
64
|
+
• Hardens SSH (disables password authentication)
|
|
65
|
+
|
|
66
|
+
${y('Packages')}
|
|
67
|
+
• Installs Node.js 20, nginx, certbot
|
|
68
|
+
|
|
69
|
+
${y('Certificates')}
|
|
70
|
+
• Generates mTLS CA, server, and client certificates
|
|
71
|
+
• Creates a PKCS12 (.p12) bundle for browser-based access
|
|
72
|
+
|
|
73
|
+
${y('Users & Services')}
|
|
74
|
+
• Creates ${c('portlama')} system user
|
|
75
|
+
• Creates systemd service ${c('portlama-panel')}
|
|
76
|
+
• Deploys panel server + client to ${c('/opt/portlama/')}
|
|
77
|
+
|
|
78
|
+
${y('Directories')}
|
|
79
|
+
• ${c('/etc/portlama/')} — configuration and PKI certificates
|
|
80
|
+
• ${c('/opt/portlama/')} — panel server and client files
|
|
81
|
+
• ${c('/var/www/portlama/')} — static web assets
|
|
82
|
+
|
|
83
|
+
${b('REQUIREMENTS')}
|
|
84
|
+
|
|
85
|
+
• Ubuntu 24.04
|
|
86
|
+
• Root access
|
|
87
|
+
• Public IP address (unless --dev is used)
|
|
88
|
+
|
|
89
|
+
${b('FLAGS')}
|
|
90
|
+
|
|
91
|
+
${c('--help')}, ${c('-h')} Show this help message and exit
|
|
92
|
+
${c('--yes')}, ${c('-y')} Skip the confirmation prompt
|
|
93
|
+
${c('--skip-harden')} Skip OS hardening (swap, UFW, fail2ban, SSH)
|
|
94
|
+
${c('--dev')} Allow private/non-routable IP addresses
|
|
95
|
+
${c('--uninstall')} Show manual removal guide for Portlama
|
|
96
|
+
`);
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Print detailed uninstall guide listing all components installed by Portlama,
|
|
102
|
+
* then exit with code 0.
|
|
103
|
+
*/
|
|
104
|
+
function printUninstallGuide() {
|
|
105
|
+
const b = chalk.bold;
|
|
106
|
+
const c = chalk.cyan;
|
|
107
|
+
const y = chalk.yellow;
|
|
108
|
+
const d = chalk.dim;
|
|
109
|
+
|
|
110
|
+
console.log(`
|
|
111
|
+
${b('Portlama — Manual Removal Guide')}
|
|
112
|
+
|
|
113
|
+
${y('⚠ Automated uninstall is not yet implemented.')}
|
|
114
|
+
${y(' Follow the steps below to fully remove Portlama from this machine.')}
|
|
115
|
+
|
|
116
|
+
${b('1. Stop and disable services')}
|
|
117
|
+
|
|
118
|
+
${c('sudo systemctl stop portlama-panel')}
|
|
119
|
+
${c('sudo systemctl disable portlama-panel')}
|
|
120
|
+
${c('sudo rm /etc/systemd/system/portlama-panel.service')}
|
|
121
|
+
${c('sudo systemctl daemon-reload')}
|
|
122
|
+
|
|
123
|
+
${b('2. Remove nginx configuration')}
|
|
124
|
+
|
|
125
|
+
${c('sudo rm /etc/nginx/sites-enabled/portlama-*')}
|
|
126
|
+
${c('sudo rm /etc/nginx/sites-available/portlama-*')}
|
|
127
|
+
${c('sudo rm /etc/nginx/snippets/portlama-mtls.conf')}
|
|
128
|
+
${c('sudo nginx -t && sudo systemctl reload nginx')}
|
|
129
|
+
|
|
130
|
+
${b('3. Remove Portlama directories')}
|
|
131
|
+
|
|
132
|
+
${c('sudo rm -rf /etc/portlama/')} ${d('# Configuration, PKI certificates, state')}
|
|
133
|
+
${c('sudo rm -rf /opt/portlama/')} ${d('# Panel server and client files')}
|
|
134
|
+
${c('sudo rm -rf /var/www/portlama/')} ${d('# Static site files')}
|
|
135
|
+
|
|
136
|
+
${b('4. Remove portlama user')}
|
|
137
|
+
|
|
138
|
+
${c('sudo userdel -r portlama')}
|
|
139
|
+
|
|
140
|
+
${b('5. Remove sudoers rules')}
|
|
141
|
+
|
|
142
|
+
${c('sudo rm /etc/sudoers.d/portlama')}
|
|
143
|
+
|
|
144
|
+
${b('6. Remove fail2ban config (optional)')}
|
|
145
|
+
|
|
146
|
+
${c('sudo rm /etc/fail2ban/jail.d/portlama.conf')}
|
|
147
|
+
${c('sudo systemctl restart fail2ban')}
|
|
148
|
+
|
|
149
|
+
${b('7. Revert SSH hardening (optional)')}
|
|
150
|
+
|
|
151
|
+
${d('If a backup was created during install:')}
|
|
152
|
+
${c('sudo cp /etc/ssh/sshd_config.pre-portlama /etc/ssh/sshd_config')}
|
|
153
|
+
${c('sudo sshd -t && sudo systemctl restart sshd')}
|
|
154
|
+
|
|
155
|
+
${b('8. Revert firewall changes (optional)')}
|
|
156
|
+
|
|
157
|
+
${d('Remove Portlama-specific UFW rules:')}
|
|
158
|
+
${c('sudo ufw delete allow 9292/tcp')}
|
|
159
|
+
|
|
160
|
+
${b('9. Remove swap file (optional)')}
|
|
161
|
+
|
|
162
|
+
${d('Only if Portlama created it:')}
|
|
163
|
+
${c('sudo swapoff /swapfile')}
|
|
164
|
+
${c('sudo rm /swapfile')}
|
|
165
|
+
${d('Remove the /swapfile line from /etc/fstab')}
|
|
166
|
+
|
|
167
|
+
${b('10. Remove Let\'s Encrypt certificates (optional)')}
|
|
168
|
+
|
|
169
|
+
${d('List Portlama-issued certs:')}
|
|
170
|
+
${c('sudo certbot certificates')}
|
|
171
|
+
${d('Delete specific ones:')}
|
|
172
|
+
${c('sudo certbot delete --cert-name <domain>')}
|
|
173
|
+
|
|
174
|
+
${d('Note: Steps 6-10 are optional. They revert OS hardening changes that')}
|
|
175
|
+
${d('may be useful to keep even after removing Portlama.')}
|
|
176
|
+
`);
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Detect existing system state to surface warnings before installation.
|
|
182
|
+
* All checks are wrapped in try/catch — detection failures never block the installer.
|
|
183
|
+
* @returns {Promise<{
|
|
184
|
+
* portlamaExists: boolean,
|
|
185
|
+
* onboardingStatus: string | null,
|
|
186
|
+
* existingNginxSites: string[],
|
|
187
|
+
* port3100InUse: boolean,
|
|
188
|
+
* ufwActive: boolean,
|
|
189
|
+
* ufwRuleCount: number,
|
|
190
|
+
* }>}
|
|
191
|
+
*/
|
|
192
|
+
async function detectExistingState() {
|
|
193
|
+
const state = {
|
|
194
|
+
portlamaExists: false,
|
|
195
|
+
onboardingStatus: null,
|
|
196
|
+
existingNginxSites: [],
|
|
197
|
+
port3100InUse: false,
|
|
198
|
+
ufwActive: false,
|
|
199
|
+
ufwRuleCount: 0,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// 1. Check for existing Portlama installation
|
|
203
|
+
try {
|
|
204
|
+
if (existsSync('/etc/portlama/panel.json')) {
|
|
205
|
+
state.portlamaExists = true;
|
|
206
|
+
const raw = await readFile('/etc/portlama/panel.json', 'utf8');
|
|
207
|
+
const config = JSON.parse(raw);
|
|
208
|
+
state.onboardingStatus = config.onboardingStatus || 'FRESH';
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
// If panel.json exists but is unreadable/invalid, still flag it
|
|
212
|
+
if (existsSync('/etc/portlama/panel.json')) {
|
|
213
|
+
state.portlamaExists = true;
|
|
214
|
+
state.onboardingStatus = 'UNKNOWN';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 2. Check for existing nginx sites (non-portlama, non-default)
|
|
219
|
+
try {
|
|
220
|
+
const entries = await readdir('/etc/nginx/sites-enabled');
|
|
221
|
+
state.existingNginxSites = entries.filter(
|
|
222
|
+
(name) => !name.startsWith('portlama-') && name !== 'default',
|
|
223
|
+
);
|
|
224
|
+
} catch {
|
|
225
|
+
// nginx not installed or sites-enabled doesn't exist — nothing to report
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 3. Check if port 3100 is in use
|
|
229
|
+
try {
|
|
230
|
+
const { stdout } = await execa('ss', ['-tlnp', 'sport', '=', ':3100']);
|
|
231
|
+
// ss always prints a header line; if there are more lines, the port is in use
|
|
232
|
+
const lines = stdout.trim().split('\n');
|
|
233
|
+
state.port3100InUse = lines.length > 1;
|
|
234
|
+
} catch {
|
|
235
|
+
// ss not available or command failed — assume port is free
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 4. Check UFW status
|
|
239
|
+
try {
|
|
240
|
+
const { stdout } = await execa('ufw', ['status']);
|
|
241
|
+
state.ufwActive = stdout.includes('Status: active');
|
|
242
|
+
if (state.ufwActive) {
|
|
243
|
+
// Count rule lines: each rule line starts with a port number or an action keyword
|
|
244
|
+
// Skip the header lines (Status, blank lines, header dividers)
|
|
245
|
+
const lines = stdout.split('\n');
|
|
246
|
+
let ruleCount = 0;
|
|
247
|
+
let pastHeader = false;
|
|
248
|
+
for (const line of lines) {
|
|
249
|
+
if (line.startsWith('--')) {
|
|
250
|
+
pastHeader = true;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (pastHeader && line.trim().length > 0) {
|
|
254
|
+
ruleCount++;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
state.ufwRuleCount = ruleCount;
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
// ufw not installed — nothing to report
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return state;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Print a confirmation banner and optionally wait for user input.
|
|
268
|
+
* @param {{ yes: boolean }} flags - Parsed CLI flags.
|
|
269
|
+
* @param {{ portlamaExists: boolean, onboardingStatus: string | null, existingNginxSites: string[], port3100InUse: boolean, ufwActive: boolean, ufwRuleCount: number }} existingState - Detection results.
|
|
270
|
+
*/
|
|
271
|
+
async function confirmInstallation(flags, existingState) {
|
|
272
|
+
const banner = `
|
|
273
|
+
${chalk.cyan.bold('┌─────────────────────────────────────────────────────────────┐')}
|
|
274
|
+
${chalk.cyan.bold('│')} ${chalk.white.bold('Portlama Installer')} ${chalk.cyan.bold('│')}
|
|
275
|
+
${chalk.cyan.bold('├─────────────────────────────────────────────────────────────┤')}
|
|
276
|
+
${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
|
|
277
|
+
${chalk.cyan.bold('│')} This will install Portlama on this machine. ${chalk.cyan.bold('│')}
|
|
278
|
+
${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
|
|
279
|
+
${chalk.cyan.bold('│')} The following changes will be made: ${chalk.cyan.bold('│')}
|
|
280
|
+
${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
|
|
281
|
+
${chalk.cyan.bold('│')} ${chalk.yellow('•')} Reset UFW firewall (allow ports 22, 443, 9292 only) ${chalk.cyan.bold('│')}
|
|
282
|
+
${chalk.cyan.bold('│')} ${chalk.yellow('•')} Harden SSH (disable password authentication) ${chalk.cyan.bold('│')}
|
|
283
|
+
${chalk.cyan.bold('│')} ${chalk.yellow('•')} Install fail2ban, Node.js 20, nginx, certbot ${chalk.cyan.bold('│')}
|
|
284
|
+
${chalk.cyan.bold('│')} ${chalk.yellow('•')} Generate mTLS certificates for browser access ${chalk.cyan.bold('│')}
|
|
285
|
+
${chalk.cyan.bold('│')} ${chalk.yellow('•')} Create portlama user and systemd service ${chalk.cyan.bold('│')}
|
|
286
|
+
${chalk.cyan.bold('│')} ${chalk.yellow('•')} Deploy panel to /opt/portlama/ ${chalk.cyan.bold('│')}
|
|
287
|
+
${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
|
|
288
|
+
${chalk.cyan.bold('│')} ${chalk.dim('Designed for a fresh Ubuntu 24.04 droplet.')} ${chalk.cyan.bold('│')}
|
|
289
|
+
${chalk.cyan.bold('│')} ${chalk.cyan.bold('│')}
|
|
290
|
+
${chalk.cyan.bold('└─────────────────────────────────────────────────────────────┘')}`;
|
|
291
|
+
|
|
292
|
+
console.log(banner);
|
|
293
|
+
|
|
294
|
+
// Display detection warnings below the banner
|
|
295
|
+
const warnings = [];
|
|
296
|
+
|
|
297
|
+
if (existingState.portlamaExists) {
|
|
298
|
+
const status = existingState.onboardingStatus || 'UNKNOWN';
|
|
299
|
+
warnings.push(
|
|
300
|
+
`An existing Portlama installation was detected (onboarding: ${status}). Re-running will update the installation but preserve your configuration.`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (existingState.existingNginxSites.length > 0) {
|
|
305
|
+
warnings.push(
|
|
306
|
+
`Existing nginx sites will be affected: ${existingState.existingNginxSites.join(', ')}`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (existingState.port3100InUse) {
|
|
311
|
+
warnings.push('Port 3100 is currently in use. The panel may fail to start.');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (existingState.ufwActive && existingState.ufwRuleCount > 0) {
|
|
315
|
+
warnings.push(
|
|
316
|
+
`Existing UFW firewall rules (${existingState.ufwRuleCount} rules) will be reset.`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (warnings.length > 0) {
|
|
321
|
+
console.log('');
|
|
322
|
+
for (const warning of warnings) {
|
|
323
|
+
console.log(` ${chalk.yellow('!')} ${chalk.yellow(warning)}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (flags.yes) {
|
|
328
|
+
console.log(`\n ${chalk.dim('Skipping confirmation (--yes)')}\n`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const rl = createInterface({
|
|
333
|
+
input: process.stdin,
|
|
334
|
+
output: process.stdout,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await new Promise((resolve) => {
|
|
338
|
+
rl.question(
|
|
339
|
+
`\n ${chalk.white.bold('Press Enter to continue or Ctrl+C to abort...')} `,
|
|
340
|
+
() => {
|
|
341
|
+
rl.close();
|
|
342
|
+
resolve();
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Main installer orchestrator. Creates a shared context, runs all installation
|
|
350
|
+
* tasks through Listr2, and prints a summary on completion.
|
|
351
|
+
*/
|
|
352
|
+
export async function main() {
|
|
353
|
+
const flags = parseFlags();
|
|
354
|
+
|
|
355
|
+
if (flags.help) {
|
|
356
|
+
printHelp();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (flags.uninstall) {
|
|
360
|
+
printUninstallGuide();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const ctx = {
|
|
364
|
+
ip: null,
|
|
365
|
+
osRelease: null,
|
|
366
|
+
skipHarden: flags.skipHarden,
|
|
367
|
+
nodeAlreadyInstalled: false,
|
|
368
|
+
nodeVersion: null,
|
|
369
|
+
npmVersion: null,
|
|
370
|
+
p12Password: null,
|
|
371
|
+
pkiDir: '/etc/portlama/pki',
|
|
372
|
+
configDir: '/etc/portlama',
|
|
373
|
+
installDir: '/opt/portlama',
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// Phase 1: Environment checks
|
|
377
|
+
const envTasks = new Listr(
|
|
378
|
+
[
|
|
379
|
+
{
|
|
380
|
+
title: 'Checking environment',
|
|
381
|
+
task: async (_ctx, task) => {
|
|
382
|
+
return task.newListr([
|
|
383
|
+
{
|
|
384
|
+
title: 'Verifying root access',
|
|
385
|
+
task: async () => {
|
|
386
|
+
checkRoot();
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
title: 'Detecting operating system',
|
|
391
|
+
task: async (_ctx, subtask) => {
|
|
392
|
+
ctx.osRelease = await detectOS();
|
|
393
|
+
subtask.output = ctx.osRelease.prettyName;
|
|
394
|
+
},
|
|
395
|
+
rendererOptions: { persistentOutput: true },
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
title: 'Detecting IP address',
|
|
399
|
+
task: async (_ctx, subtask) => {
|
|
400
|
+
ctx.ip = await detectIP({ allowPrivate: flags.dev });
|
|
401
|
+
if (flags.dev) {
|
|
402
|
+
subtask.output = `${ctx.ip} (dev mode — private IP accepted)`;
|
|
403
|
+
} else {
|
|
404
|
+
subtask.output = ctx.ip;
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
rendererOptions: { persistentOutput: true },
|
|
408
|
+
},
|
|
409
|
+
]);
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
{
|
|
414
|
+
renderer: 'default',
|
|
415
|
+
rendererOptions: { collapseSubtasks: false },
|
|
416
|
+
exitOnError: true,
|
|
417
|
+
},
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// Phase 2: Installation tasks
|
|
421
|
+
const installTasks = new Listr(
|
|
422
|
+
[
|
|
423
|
+
{
|
|
424
|
+
title: 'Hardening operating system',
|
|
425
|
+
task: (_ctx, task) => hardenTasks(ctx, task),
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
title: 'Installing Node.js 20',
|
|
429
|
+
task: (_ctx, task) => nodeTasks(ctx, task),
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
title: 'Generating mTLS certificates',
|
|
433
|
+
task: (_ctx, task) => mtlsTasks(ctx, task),
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
title: 'Configuring nginx',
|
|
437
|
+
task: (_ctx, task) => nginxTasks(ctx, task),
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
title: 'Deploying Portlama panel',
|
|
441
|
+
task: (_ctx, task) => panelTasks(ctx, task),
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
title: 'Installation complete',
|
|
445
|
+
task: async () => {
|
|
446
|
+
// Summary will be printed after Listr finishes
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
{
|
|
451
|
+
renderer: 'default',
|
|
452
|
+
rendererOptions: { collapseSubtasks: false },
|
|
453
|
+
exitOnError: true,
|
|
454
|
+
},
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
// Run environment checks first
|
|
459
|
+
await envTasks.run();
|
|
460
|
+
|
|
461
|
+
// Detect existing system state for the confirmation banner
|
|
462
|
+
const existingState = await detectExistingState();
|
|
463
|
+
|
|
464
|
+
// Show confirmation banner and wait for user input
|
|
465
|
+
await confirmInstallation(flags, existingState);
|
|
466
|
+
|
|
467
|
+
// Run installation tasks
|
|
468
|
+
await installTasks.run();
|
|
469
|
+
} catch (error) {
|
|
470
|
+
console.error('\n');
|
|
471
|
+
console.error(' ┌─────────────────────────────────────────────┐');
|
|
472
|
+
console.error(' │ Portlama installation failed. │');
|
|
473
|
+
console.error(` │ ${(error.message || 'Unknown error').slice(0, 43).padEnd(43)} │`);
|
|
474
|
+
console.error(' │ │');
|
|
475
|
+
console.error(' │ You can safely re-run this installer │');
|
|
476
|
+
console.error(' │ to retry. │');
|
|
477
|
+
console.error(' └─────────────────────────────────────────────┘');
|
|
478
|
+
console.error('\n');
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Print summary after Listr2 finishes rendering
|
|
483
|
+
await printSummary(ctx);
|
|
484
|
+
}
|