ante-erp-cli 1.7.0 ā 1.7.2
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/ante-cli.js +19 -0
- package/package.json +1 -1
- package/src/commands/ssl-enable.js +349 -0
- package/src/utils/nginx.js +138 -1
- package/src/utils/ssl.js +329 -0
package/bin/ante-cli.js
CHANGED
|
@@ -33,6 +33,7 @@ import { uninstall } from '../src/commands/uninstall.js';
|
|
|
33
33
|
import { migrate, seed, shell, optimize, reset as dbReset, info } from '../src/commands/database.js';
|
|
34
34
|
import { cloneDb } from '../src/commands/clone-db.js';
|
|
35
35
|
import { setDomain } from '../src/commands/set-domain.js';
|
|
36
|
+
import { sslEnable, sslStatus } from '../src/commands/ssl-enable.js';
|
|
36
37
|
|
|
37
38
|
// Installation & Setup
|
|
38
39
|
program
|
|
@@ -214,6 +215,24 @@ program
|
|
|
214
215
|
.option('--no-interactive', 'Non-interactive mode')
|
|
215
216
|
.action(setDomain);
|
|
216
217
|
|
|
218
|
+
// SSL/HTTPS Management
|
|
219
|
+
const sslCmd = program
|
|
220
|
+
.command('ssl')
|
|
221
|
+
.description('SSL/HTTPS certificate management');
|
|
222
|
+
|
|
223
|
+
sslCmd
|
|
224
|
+
.command('enable')
|
|
225
|
+
.description('Enable SSL/HTTPS with automatic certificate from Let\'s Encrypt')
|
|
226
|
+
.option('--email <email>', 'Email for certificate notifications')
|
|
227
|
+
.option('--staging', 'Use Let\'s Encrypt staging environment (for testing)')
|
|
228
|
+
.option('--no-interactive', 'Non-interactive mode')
|
|
229
|
+
.action(sslEnable);
|
|
230
|
+
|
|
231
|
+
sslCmd
|
|
232
|
+
.command('status')
|
|
233
|
+
.description('Check SSL certificate status and expiry')
|
|
234
|
+
.action(sslStatus);
|
|
235
|
+
|
|
217
236
|
// Help
|
|
218
237
|
program
|
|
219
238
|
.command('help [command]')
|
package/package.json
CHANGED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { getInstallDir } from '../utils/config.js';
|
|
7
|
+
import { isNginxInstalled } from '../utils/nginx.js';
|
|
8
|
+
import {
|
|
9
|
+
obtainSSLCertificate,
|
|
10
|
+
updateNginxForSSL,
|
|
11
|
+
setupAutoRenewal,
|
|
12
|
+
checkSSLStatus,
|
|
13
|
+
extractDomain
|
|
14
|
+
} from '../utils/ssl.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Validate email format
|
|
18
|
+
* @param {string} email - Email to validate
|
|
19
|
+
* @returns {boolean|string} True if valid, error message if invalid
|
|
20
|
+
*/
|
|
21
|
+
function validateEmail(email) {
|
|
22
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
23
|
+
if (!email || email.trim() === '') {
|
|
24
|
+
return 'Email is required';
|
|
25
|
+
}
|
|
26
|
+
if (!emailRegex.test(email)) {
|
|
27
|
+
return 'Please enter a valid email address';
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read environment variable from .env file
|
|
34
|
+
* @param {string} envPath - Path to .env file
|
|
35
|
+
* @param {string} key - Environment variable key
|
|
36
|
+
* @returns {string} Value or empty string
|
|
37
|
+
*/
|
|
38
|
+
function getEnvValue(envPath, key) {
|
|
39
|
+
try {
|
|
40
|
+
const envContent = readFileSync(envPath, 'utf8');
|
|
41
|
+
const match = envContent.match(new RegExp(`^${key}=(.*)$`, 'm'));
|
|
42
|
+
return match ? match[1] : '';
|
|
43
|
+
} catch {
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Enable SSL/HTTPS for ANTE installation
|
|
50
|
+
*/
|
|
51
|
+
export async function sslEnable(options) {
|
|
52
|
+
try {
|
|
53
|
+
console.log(chalk.bold('\nš Enable SSL/HTTPS for ANTE\n'));
|
|
54
|
+
|
|
55
|
+
// Check if NGINX is installed
|
|
56
|
+
const nginxInstalled = await isNginxInstalled();
|
|
57
|
+
if (!nginxInstalled) {
|
|
58
|
+
console.log(chalk.red('ā NGINX is not installed'));
|
|
59
|
+
console.log(chalk.yellow('\nā SSL requires NGINX to be configured first.'));
|
|
60
|
+
console.log(chalk.gray(' Run "ante set-domain" to configure domains and NGINX.\n'));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Get installation directory
|
|
65
|
+
const installDir = getInstallDir();
|
|
66
|
+
const envPath = join(installDir, '.env');
|
|
67
|
+
|
|
68
|
+
console.log(chalk.gray(`Installation: ${installDir}\n`));
|
|
69
|
+
|
|
70
|
+
// Read current configuration
|
|
71
|
+
const frontendUrl = getEnvValue(envPath, 'FRONTEND_URL');
|
|
72
|
+
const apiUrl = getEnvValue(envPath, 'API_URL');
|
|
73
|
+
|
|
74
|
+
if (!frontendUrl || !apiUrl) {
|
|
75
|
+
console.log(chalk.red('ā Domain configuration not found'));
|
|
76
|
+
console.log(chalk.yellow('\nā Please configure domains first using:'));
|
|
77
|
+
console.log(chalk.gray(' ante set-domain\n'));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Extract domains from URLs
|
|
82
|
+
const frontendDomain = extractDomain(frontendUrl);
|
|
83
|
+
const apiDomain = extractDomain(apiUrl);
|
|
84
|
+
|
|
85
|
+
// Check if domains are already using HTTPS
|
|
86
|
+
if (frontendUrl.startsWith('https://') && apiUrl.startsWith('https://')) {
|
|
87
|
+
console.log(chalk.yellow('ā Domains are already configured with HTTPS:\n'));
|
|
88
|
+
console.log(chalk.gray(` Frontend: ${frontendUrl}`));
|
|
89
|
+
console.log(chalk.gray(` API: ${apiUrl}\n`));
|
|
90
|
+
|
|
91
|
+
// Check certificate status
|
|
92
|
+
const frontendStatus = await checkSSLStatus(frontendDomain);
|
|
93
|
+
const apiStatus = await checkSSLStatus(apiDomain);
|
|
94
|
+
|
|
95
|
+
if (frontendStatus.installed) {
|
|
96
|
+
console.log(chalk.green(`ā Frontend SSL: Valid (expires in ${frontendStatus.daysUntilExpiry} days)`));
|
|
97
|
+
}
|
|
98
|
+
if (apiStatus.installed) {
|
|
99
|
+
console.log(chalk.green(`ā API SSL: Valid (expires in ${apiStatus.daysUntilExpiry} days)`));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
console.log();
|
|
103
|
+
|
|
104
|
+
const { proceed } = await inquirer.prompt([
|
|
105
|
+
{
|
|
106
|
+
type: 'confirm',
|
|
107
|
+
name: 'proceed',
|
|
108
|
+
message: 'Do you want to renew or reconfigure SSL certificates?',
|
|
109
|
+
default: false
|
|
110
|
+
}
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
if (!proceed) {
|
|
114
|
+
console.log(chalk.gray('\nSSL configuration cancelled.\n'));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Show current configuration
|
|
120
|
+
console.log(chalk.cyan('Current Configuration:'));
|
|
121
|
+
console.log(chalk.gray(` Frontend: ${frontendUrl}`));
|
|
122
|
+
console.log(chalk.gray(` API: ${apiUrl}\n`));
|
|
123
|
+
|
|
124
|
+
// Detect domains to configure
|
|
125
|
+
const domains = [];
|
|
126
|
+
|
|
127
|
+
if (frontendDomain !== 'localhost' && !frontendDomain.match(/^\d+\.\d+\.\d+\.\d+$/)) {
|
|
128
|
+
domains.push({
|
|
129
|
+
name: 'Frontend',
|
|
130
|
+
domain: frontendDomain,
|
|
131
|
+
port: frontendUrl.match(/:(\d+)/) ? frontendUrl.match(/:(\d+)/)[1] : 8080,
|
|
132
|
+
url: frontendUrl
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (apiDomain !== 'localhost' && !apiDomain.match(/^\d+\.\d+\.\d+\.\d+$/) && apiDomain !== frontendDomain) {
|
|
137
|
+
domains.push({
|
|
138
|
+
name: 'API',
|
|
139
|
+
domain: apiDomain,
|
|
140
|
+
port: apiUrl.match(/:(\d+)/) ? apiUrl.match(/:(\d+)/)[1] : 3001,
|
|
141
|
+
url: apiUrl
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (domains.length === 0) {
|
|
146
|
+
console.log(chalk.red('ā No valid domains found for SSL configuration'));
|
|
147
|
+
console.log(chalk.yellow('\nā SSL certificates require domain names (not localhost or IP addresses).'));
|
|
148
|
+
console.log(chalk.gray(' Configure a domain using "ante set-domain" first.\n'));
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Interactive mode: ask for email and confirmation
|
|
153
|
+
if (options.interactive !== false) {
|
|
154
|
+
console.log(chalk.yellow('SSL certificates will be obtained for:\n'));
|
|
155
|
+
domains.forEach(d => {
|
|
156
|
+
console.log(chalk.white(` ⢠${d.domain} (${d.name})`));
|
|
157
|
+
});
|
|
158
|
+
console.log();
|
|
159
|
+
|
|
160
|
+
const answers = await inquirer.prompt([
|
|
161
|
+
{
|
|
162
|
+
type: 'input',
|
|
163
|
+
name: 'email',
|
|
164
|
+
message: 'Email address for certificate notifications:',
|
|
165
|
+
validate: validateEmail,
|
|
166
|
+
default: options.email || ''
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
type: 'confirm',
|
|
170
|
+
name: 'confirm',
|
|
171
|
+
message: 'Proceed with SSL certificate installation?',
|
|
172
|
+
default: true
|
|
173
|
+
}
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
if (!answers.confirm) {
|
|
177
|
+
console.log(chalk.gray('\nSSL configuration cancelled.\n'));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
options.email = answers.email;
|
|
182
|
+
} else {
|
|
183
|
+
// Non-interactive mode: require email
|
|
184
|
+
if (!options.email) {
|
|
185
|
+
console.log(chalk.red('ā Email is required in non-interactive mode'));
|
|
186
|
+
console.log(chalk.gray(' Use: ante ssl enable --email your@email.com\n'));
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const emailValidation = validateEmail(options.email);
|
|
191
|
+
if (emailValidation !== true) {
|
|
192
|
+
console.log(chalk.red(`ā ${emailValidation}\n`));
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.log(chalk.bold('\nš Starting SSL Configuration...\n'));
|
|
198
|
+
|
|
199
|
+
// Step 1: Obtain SSL certificates for all domains
|
|
200
|
+
console.log(chalk.cyan('š Obtaining SSL certificates...\n'));
|
|
201
|
+
|
|
202
|
+
for (const domainConfig of domains) {
|
|
203
|
+
try {
|
|
204
|
+
console.log(chalk.gray(` Obtaining certificate for ${domainConfig.domain}...`));
|
|
205
|
+
await obtainSSLCertificate({
|
|
206
|
+
domain: domainConfig.domain,
|
|
207
|
+
email: options.email,
|
|
208
|
+
staging: options.staging || false
|
|
209
|
+
});
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.log(chalk.red(`\nā Failed to obtain SSL certificate for ${domainConfig.domain}:`), error.message);
|
|
212
|
+
console.log(chalk.yellow('\nā Troubleshooting:'));
|
|
213
|
+
console.log(chalk.gray(' 1. Verify DNS points to this server'));
|
|
214
|
+
console.log(chalk.gray(' 2. Ensure ports 80 and 443 are open'));
|
|
215
|
+
console.log(chalk.gray(' 3. Check NGINX is running: systemctl status nginx'));
|
|
216
|
+
console.log(chalk.gray(' 4. View certbot logs: /var/log/letsencrypt/letsencrypt.log\n'));
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log(chalk.green('\nā All SSL certificates obtained\n'));
|
|
222
|
+
|
|
223
|
+
// Step 2: Update NGINX configuration with SSL
|
|
224
|
+
console.log(chalk.cyan('š§ Configuring NGINX for HTTPS...\n'));
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
// Extract frontend and API ports
|
|
228
|
+
const frontendPort = frontendUrl.match(/:(\d+)/) ? parseInt(frontendUrl.match(/:(\d+)/)[1]) : 8080;
|
|
229
|
+
const apiPort = apiUrl.match(/:(\d+)/) ? parseInt(apiUrl.match(/:(\d+)/)[1]) : 3001;
|
|
230
|
+
|
|
231
|
+
await updateNginxForSSL({
|
|
232
|
+
frontendDomain: frontendUrl,
|
|
233
|
+
apiDomain: apiUrl,
|
|
234
|
+
frontendPort,
|
|
235
|
+
apiPort
|
|
236
|
+
});
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.log(chalk.red('\nā Failed to configure NGINX:'), error.message);
|
|
239
|
+
console.log(chalk.yellow('\nā Troubleshooting:'));
|
|
240
|
+
console.log(chalk.gray(' 1. Check NGINX configuration: nginx -t'));
|
|
241
|
+
console.log(chalk.gray(' 2. View NGINX logs: journalctl -u nginx'));
|
|
242
|
+
console.log(chalk.gray(' 3. Restore backup: cp /etc/nginx/sites-enabled/ante.backup /etc/nginx/sites-enabled/ante\n'));
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Setup automatic renewal
|
|
247
|
+
console.log(chalk.cyan('\nš Setting up automatic certificate renewal...\n'));
|
|
248
|
+
await setupAutoRenewal();
|
|
249
|
+
|
|
250
|
+
// Success summary
|
|
251
|
+
console.log(chalk.bold.green('\nā SSL/HTTPS Configuration Complete!\n'));
|
|
252
|
+
|
|
253
|
+
console.log(chalk.cyan('Secured Domains:'));
|
|
254
|
+
domains.forEach(d => {
|
|
255
|
+
const httpsUrl = d.url.replace('http://', 'https://').replace(/:\d+$/, '');
|
|
256
|
+
console.log(chalk.white(` ⢠${d.domain}`));
|
|
257
|
+
console.log(chalk.gray(` ${httpsUrl}\n`));
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
console.log(chalk.cyan('Certificate Details:'));
|
|
261
|
+
console.log(chalk.gray(' Provider: Let\'s Encrypt'));
|
|
262
|
+
console.log(chalk.gray(' Validity: 90 days'));
|
|
263
|
+
console.log(chalk.gray(' Auto-renewal: Enabled (daily check at 3:00 AM)'));
|
|
264
|
+
|
|
265
|
+
console.log(chalk.yellow('\nā Important:'));
|
|
266
|
+
console.log(chalk.gray(' ⢠Update your .env file to use HTTPS URLs'));
|
|
267
|
+
console.log(chalk.gray(' ⢠Restart services: ante restart'));
|
|
268
|
+
console.log(chalk.gray(' ⢠Update DNS if not already configured'));
|
|
269
|
+
|
|
270
|
+
console.log(chalk.cyan('\nš” Next Steps:'));
|
|
271
|
+
console.log(chalk.white(' 1. Update environment variables:'));
|
|
272
|
+
console.log(chalk.gray(' ante set-domain --frontend https://your-domain --api https://api-domain'));
|
|
273
|
+
console.log(chalk.white(' 2. Restart services:'));
|
|
274
|
+
console.log(chalk.gray(' ante restart'));
|
|
275
|
+
console.log(chalk.white(' 3. Test HTTPS access:'));
|
|
276
|
+
domains.forEach(d => {
|
|
277
|
+
const httpsUrl = d.url.replace('http://', 'https://').replace(/:\d+$/, '');
|
|
278
|
+
console.log(chalk.gray(` ${httpsUrl}`));
|
|
279
|
+
});
|
|
280
|
+
console.log();
|
|
281
|
+
|
|
282
|
+
} catch (error) {
|
|
283
|
+
console.error(chalk.red('\nā SSL configuration failed:'), error.message);
|
|
284
|
+
console.log(chalk.yellow('\nā Common Issues:'));
|
|
285
|
+
console.log(chalk.gray(' ⢠Domain does not resolve to this server'));
|
|
286
|
+
console.log(chalk.gray(' ⢠Ports 80/443 are not accessible'));
|
|
287
|
+
console.log(chalk.gray(' ⢠NGINX is not properly configured'));
|
|
288
|
+
console.log(chalk.gray(' ⢠Firewall is blocking traffic\n'));
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Check SSL certificate status
|
|
295
|
+
*/
|
|
296
|
+
export async function sslStatus() {
|
|
297
|
+
try {
|
|
298
|
+
console.log(chalk.bold('\nš SSL Certificate Status\n'));
|
|
299
|
+
|
|
300
|
+
const installDir = getInstallDir();
|
|
301
|
+
const envPath = join(installDir, '.env');
|
|
302
|
+
|
|
303
|
+
const frontendUrl = getEnvValue(envPath, 'FRONTEND_URL');
|
|
304
|
+
const apiUrl = getEnvValue(envPath, 'API_URL');
|
|
305
|
+
|
|
306
|
+
if (!frontendUrl || !apiUrl) {
|
|
307
|
+
console.log(chalk.red('ā Domain configuration not found\n'));
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const frontendDomain = extractDomain(frontendUrl);
|
|
312
|
+
const apiDomain = extractDomain(apiUrl);
|
|
313
|
+
|
|
314
|
+
const domains = [
|
|
315
|
+
{ name: 'Frontend', domain: frontendDomain, url: frontendUrl },
|
|
316
|
+
{ name: 'API', domain: apiDomain, url: apiUrl }
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
for (const { name, domain, url } of domains) {
|
|
320
|
+
console.log(chalk.cyan(`${name}:`));
|
|
321
|
+
console.log(chalk.gray(` Domain: ${domain}`));
|
|
322
|
+
console.log(chalk.gray(` URL: ${url}`));
|
|
323
|
+
|
|
324
|
+
const spinner = ora('Checking certificate...').start();
|
|
325
|
+
const status = await checkSSLStatus(domain);
|
|
326
|
+
|
|
327
|
+
if (status.installed) {
|
|
328
|
+
spinner.succeed('Certificate found');
|
|
329
|
+
console.log(chalk.gray(` Expiry: ${new Date(status.expiryDate).toLocaleDateString()}`));
|
|
330
|
+
console.log(chalk.gray(` Days remaining: ${status.daysUntilExpiry}`));
|
|
331
|
+
|
|
332
|
+
if (status.daysUntilExpiry < 30) {
|
|
333
|
+
console.log(chalk.yellow(` Status: Expiring soon (renew recommended)`));
|
|
334
|
+
} else {
|
|
335
|
+
console.log(chalk.green(` Status: Valid`));
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
spinner.fail('No certificate found');
|
|
339
|
+
console.log(chalk.gray(` Run "ante ssl enable" to obtain a certificate`));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
console.log();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
} catch (error) {
|
|
346
|
+
console.error(chalk.red('\nā Failed to check SSL status:'), error.message);
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
}
|
package/src/utils/nginx.js
CHANGED
|
@@ -50,15 +50,20 @@ export async function installNginx(spinner) {
|
|
|
50
50
|
* @param {string} config.apiDomain - API domain (e.g., api.ante.example.com)
|
|
51
51
|
* @param {number} config.frontendPort - Frontend Docker port (default: 8080)
|
|
52
52
|
* @param {number} config.apiPort - API Docker port (default: 3001)
|
|
53
|
+
* @param {boolean} config.ssl - Enable SSL configuration (default: false)
|
|
53
54
|
* @returns {string} NGINX configuration content
|
|
54
55
|
*/
|
|
55
56
|
export function generateNginxConfig(config) {
|
|
56
|
-
const { frontendDomain, apiDomain, frontendPort = 8080, apiPort = 3001 } = config;
|
|
57
|
+
const { frontendDomain, apiDomain, frontendPort = 8080, apiPort = 3001, ssl = false } = config;
|
|
57
58
|
|
|
58
59
|
// Extract domain without protocol
|
|
59
60
|
const frontendHost = frontendDomain.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
|
|
60
61
|
const apiHost = apiDomain.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
|
|
61
62
|
|
|
63
|
+
if (ssl) {
|
|
64
|
+
return generateSslNginxConfig({ frontendHost, apiHost, frontendPort, apiPort });
|
|
65
|
+
}
|
|
66
|
+
|
|
62
67
|
return `# ANTE Frontend Configuration
|
|
63
68
|
server {
|
|
64
69
|
listen 80;
|
|
@@ -127,6 +132,138 @@ server {
|
|
|
127
132
|
`;
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Generate NGINX configuration with SSL support
|
|
137
|
+
* @param {Object} config - Configuration object
|
|
138
|
+
* @param {string} config.frontendHost - Frontend hostname
|
|
139
|
+
* @param {string} config.apiHost - API hostname
|
|
140
|
+
* @param {number} config.frontendPort - Frontend port
|
|
141
|
+
* @param {number} config.apiPort - API port
|
|
142
|
+
* @returns {string} NGINX configuration with SSL
|
|
143
|
+
*/
|
|
144
|
+
function generateSslNginxConfig(config) {
|
|
145
|
+
const { frontendHost, apiHost, frontendPort, apiPort } = config;
|
|
146
|
+
|
|
147
|
+
return `# ANTE Frontend Configuration (HTTPS)
|
|
148
|
+
server {
|
|
149
|
+
listen 443 ssl;
|
|
150
|
+
listen [::]:443 ssl;
|
|
151
|
+
http2 on;
|
|
152
|
+
server_name ${frontendHost};
|
|
153
|
+
|
|
154
|
+
# SSL Certificate paths
|
|
155
|
+
ssl_certificate /etc/letsencrypt/live/${frontendHost}/fullchain.pem;
|
|
156
|
+
ssl_certificate_key /etc/letsencrypt/live/${frontendHost}/privkey.pem;
|
|
157
|
+
|
|
158
|
+
# SSL Configuration
|
|
159
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
160
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
161
|
+
ssl_prefer_server_ciphers on;
|
|
162
|
+
ssl_session_cache shared:SSL:10m;
|
|
163
|
+
ssl_session_timeout 10m;
|
|
164
|
+
|
|
165
|
+
# Security headers
|
|
166
|
+
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
167
|
+
add_header X-Frame-Options SAMEORIGIN always;
|
|
168
|
+
add_header X-Content-Type-Options nosniff always;
|
|
169
|
+
add_header X-XSS-Protection "1; mode=block" always;
|
|
170
|
+
|
|
171
|
+
# Increase buffer sizes for large headers
|
|
172
|
+
client_header_buffer_size 16k;
|
|
173
|
+
large_client_header_buffers 4 16k;
|
|
174
|
+
|
|
175
|
+
# Increase body size for file uploads
|
|
176
|
+
client_max_body_size 100M;
|
|
177
|
+
|
|
178
|
+
location / {
|
|
179
|
+
proxy_pass http://localhost:${frontendPort};
|
|
180
|
+
proxy_http_version 1.1;
|
|
181
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
182
|
+
proxy_set_header Connection 'upgrade';
|
|
183
|
+
proxy_set_header Host $host;
|
|
184
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
185
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
186
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
187
|
+
proxy_cache_bypass $http_upgrade;
|
|
188
|
+
|
|
189
|
+
# Timeout settings
|
|
190
|
+
proxy_connect_timeout 60s;
|
|
191
|
+
proxy_send_timeout 60s;
|
|
192
|
+
proxy_read_timeout 60s;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# HTTP to HTTPS redirect for ${frontendHost}
|
|
197
|
+
server {
|
|
198
|
+
listen 80;
|
|
199
|
+
listen [::]:80;
|
|
200
|
+
server_name ${frontendHost};
|
|
201
|
+
return 301 https://$server_name$request_uri;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# ANTE API Configuration (HTTPS)
|
|
205
|
+
server {
|
|
206
|
+
listen 443 ssl;
|
|
207
|
+
listen [::]:443 ssl;
|
|
208
|
+
http2 on;
|
|
209
|
+
server_name ${apiHost};
|
|
210
|
+
|
|
211
|
+
# SSL Certificate paths
|
|
212
|
+
ssl_certificate /etc/letsencrypt/live/${apiHost}/fullchain.pem;
|
|
213
|
+
ssl_certificate_key /etc/letsencrypt/live/${apiHost}/privkey.pem;
|
|
214
|
+
|
|
215
|
+
# SSL Configuration
|
|
216
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
217
|
+
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
218
|
+
ssl_prefer_server_ciphers on;
|
|
219
|
+
ssl_session_cache shared:SSL:10m;
|
|
220
|
+
ssl_session_timeout 10m;
|
|
221
|
+
|
|
222
|
+
# Security headers
|
|
223
|
+
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
224
|
+
add_header X-Frame-Options SAMEORIGIN always;
|
|
225
|
+
add_header X-Content-Type-Options nosniff always;
|
|
226
|
+
add_header X-XSS-Protection "1; mode=block" always;
|
|
227
|
+
|
|
228
|
+
# Increase buffer sizes for large headers
|
|
229
|
+
client_header_buffer_size 16k;
|
|
230
|
+
large_client_header_buffers 4 16k;
|
|
231
|
+
|
|
232
|
+
# Increase body size for file uploads
|
|
233
|
+
client_max_body_size 100M;
|
|
234
|
+
|
|
235
|
+
location / {
|
|
236
|
+
proxy_pass http://localhost:${apiPort};
|
|
237
|
+
proxy_http_version 1.1;
|
|
238
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
239
|
+
proxy_set_header Connection 'upgrade';
|
|
240
|
+
proxy_set_header Host $host;
|
|
241
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
242
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
243
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
244
|
+
proxy_cache_bypass $http_upgrade;
|
|
245
|
+
|
|
246
|
+
# Timeout settings
|
|
247
|
+
proxy_connect_timeout 60s;
|
|
248
|
+
proxy_send_timeout 60s;
|
|
249
|
+
proxy_read_timeout 60s;
|
|
250
|
+
|
|
251
|
+
# WebSocket support
|
|
252
|
+
proxy_set_header X-Forwarded-Host $host;
|
|
253
|
+
proxy_set_header X-Forwarded-Server $host;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
# HTTP to HTTPS redirect for ${apiHost}
|
|
258
|
+
server {
|
|
259
|
+
listen 80;
|
|
260
|
+
listen [::]:80;
|
|
261
|
+
server_name ${apiHost};
|
|
262
|
+
return 301 https://$server_name$request_uri;
|
|
263
|
+
}
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
|
|
130
267
|
/**
|
|
131
268
|
* Configure NGINX for ANTE domains
|
|
132
269
|
* @param {Object} config - Configuration object
|
package/src/utils/ssl.js
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { execa } from 'execa';
|
|
4
|
+
import { writeFileSync, existsSync, copyFileSync } from 'fs';
|
|
5
|
+
import { generateNginxConfig } from './nginx.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check if certbot is installed
|
|
9
|
+
* @returns {Promise<boolean>} True if certbot is installed
|
|
10
|
+
*/
|
|
11
|
+
export async function isCertbotInstalled() {
|
|
12
|
+
try {
|
|
13
|
+
await execa('which', ['certbot']);
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Install certbot on Ubuntu/Debian
|
|
22
|
+
* @param {Object} spinner - Ora spinner instance
|
|
23
|
+
* @returns {Promise<void>}
|
|
24
|
+
*/
|
|
25
|
+
export async function installCertbot(spinner) {
|
|
26
|
+
try {
|
|
27
|
+
spinner.text = 'Installing certbot...';
|
|
28
|
+
|
|
29
|
+
// Update package list
|
|
30
|
+
await execa('apt-get', ['update', '-qq'], { stdio: 'pipe' });
|
|
31
|
+
|
|
32
|
+
// Install certbot
|
|
33
|
+
await execa('apt-get', ['install', '-y', '-qq', 'certbot', 'python3-certbot-nginx'], { stdio: 'pipe' });
|
|
34
|
+
|
|
35
|
+
spinner.succeed('Certbot installed successfully');
|
|
36
|
+
return true;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
spinner.fail('Failed to install certbot');
|
|
39
|
+
throw new Error(`Certbot installation failed: ${error.message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if a domain has valid DNS resolution
|
|
45
|
+
* @param {string} domain - Domain to check
|
|
46
|
+
* @returns {Promise<boolean>} True if domain resolves
|
|
47
|
+
*/
|
|
48
|
+
export async function checkDomainDNS(domain) {
|
|
49
|
+
try {
|
|
50
|
+
const { stdout } = await execa('host', [domain], { stdio: 'pipe' });
|
|
51
|
+
return stdout.includes('has address') || stdout.includes('has IPv6 address');
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Obtain SSL certificate using certbot
|
|
59
|
+
* @param {Object} config - Configuration object
|
|
60
|
+
* @param {string} config.domain - Domain name (e.g., ante.example.com)
|
|
61
|
+
* @param {string} config.email - Email for certificate notifications
|
|
62
|
+
* @param {boolean} [config.staging=false] - Use staging environment for testing
|
|
63
|
+
* @returns {Promise<void>}
|
|
64
|
+
*/
|
|
65
|
+
export async function obtainSSLCertificate(config) {
|
|
66
|
+
const { domain, email, staging = false } = config;
|
|
67
|
+
const spinner = ora(`Obtaining SSL certificate for ${domain}...`).start();
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// Check if certbot is installed
|
|
71
|
+
const certbotInstalled = await isCertbotInstalled();
|
|
72
|
+
if (!certbotInstalled) {
|
|
73
|
+
spinner.text = 'Certbot not found, installing...';
|
|
74
|
+
await installCertbot(spinner);
|
|
75
|
+
spinner.start(`Obtaining SSL certificate for ${domain}...`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check DNS resolution
|
|
79
|
+
spinner.text = `Checking DNS resolution for ${domain}...`;
|
|
80
|
+
const dnsResolved = await checkDomainDNS(domain);
|
|
81
|
+
if (!dnsResolved) {
|
|
82
|
+
spinner.warn(`Warning: ${domain} does not resolve to an IP address`);
|
|
83
|
+
console.log(chalk.yellow('\nā DNS Check Failed:'));
|
|
84
|
+
console.log(chalk.gray(' The domain does not currently resolve. Certbot may fail.'));
|
|
85
|
+
console.log(chalk.gray(' Make sure your DNS is properly configured before continuing.\n'));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
spinner.text = `Obtaining SSL certificate for ${domain}...`;
|
|
89
|
+
|
|
90
|
+
// Build certbot command
|
|
91
|
+
const certbotArgs = [
|
|
92
|
+
'certonly',
|
|
93
|
+
'--nginx',
|
|
94
|
+
'-d', domain,
|
|
95
|
+
'--email', email,
|
|
96
|
+
'--agree-tos',
|
|
97
|
+
'--no-eff-email',
|
|
98
|
+
'--non-interactive'
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
// Add staging flag if testing
|
|
102
|
+
if (staging) {
|
|
103
|
+
certbotArgs.push('--staging');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Run certbot
|
|
107
|
+
try {
|
|
108
|
+
await execa('certbot', certbotArgs, { stdio: 'pipe' });
|
|
109
|
+
spinner.succeed(`SSL certificate obtained for ${domain}`);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
spinner.fail(`Failed to obtain SSL certificate for ${domain}`);
|
|
112
|
+
throw new Error(`Certbot failed: ${error.message}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return true;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
spinner.fail('Failed to obtain SSL certificate');
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Update NGINX configuration to use SSL
|
|
124
|
+
* @param {Object} config - Configuration object
|
|
125
|
+
* @param {string} config.frontendDomain - Frontend domain URL
|
|
126
|
+
* @param {string} config.apiDomain - API domain URL
|
|
127
|
+
* @param {number} [config.frontendPort=8080] - Frontend port
|
|
128
|
+
* @param {number} [config.apiPort=3001] - API port
|
|
129
|
+
* @returns {Promise<void>}
|
|
130
|
+
*/
|
|
131
|
+
export async function updateNginxForSSL(config) {
|
|
132
|
+
const { frontendDomain, apiDomain, frontendPort = 8080, apiPort = 3001 } = config;
|
|
133
|
+
const spinner = ora('Updating NGINX configuration for HTTPS...').start();
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const configPath = '/etc/nginx/sites-enabled/ante';
|
|
137
|
+
const backupPath = `${configPath}.backup`;
|
|
138
|
+
|
|
139
|
+
// Check if config exists
|
|
140
|
+
if (!existsSync(configPath)) {
|
|
141
|
+
spinner.fail('NGINX configuration not found');
|
|
142
|
+
throw new Error('Please run "ante set-domain" first to configure NGINX');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Backup existing configuration
|
|
146
|
+
spinner.text = 'Backing up current NGINX configuration...';
|
|
147
|
+
copyFileSync(configPath, backupPath);
|
|
148
|
+
|
|
149
|
+
// Generate new SSL-enabled configuration
|
|
150
|
+
spinner.text = 'Generating SSL configuration...';
|
|
151
|
+
const sslConfig = generateNginxConfig({
|
|
152
|
+
frontendDomain,
|
|
153
|
+
apiDomain,
|
|
154
|
+
frontendPort,
|
|
155
|
+
apiPort,
|
|
156
|
+
ssl: true
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Write new configuration
|
|
160
|
+
writeFileSync(configPath, sslConfig);
|
|
161
|
+
|
|
162
|
+
// Test NGINX configuration
|
|
163
|
+
spinner.text = 'Testing NGINX configuration...';
|
|
164
|
+
try {
|
|
165
|
+
const { stderr } = await execa('nginx', ['-t'], { stdio: 'pipe' });
|
|
166
|
+
|
|
167
|
+
// Check for warnings
|
|
168
|
+
if (stderr && !stderr.includes('syntax is ok')) {
|
|
169
|
+
console.log(chalk.yellow('\nā NGINX warnings:'));
|
|
170
|
+
console.log(chalk.gray(stderr));
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
spinner.fail('NGINX configuration test failed');
|
|
174
|
+
|
|
175
|
+
// Restore backup
|
|
176
|
+
console.log(chalk.yellow('\nā Restoring previous configuration...'));
|
|
177
|
+
copyFileSync(backupPath, configPath);
|
|
178
|
+
|
|
179
|
+
throw new Error(`Invalid NGINX configuration: ${error.stderr || error.message}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Reload NGINX
|
|
183
|
+
spinner.text = 'Reloading NGINX...';
|
|
184
|
+
try {
|
|
185
|
+
await execa('systemctl', ['reload', 'nginx'], { stdio: 'pipe' });
|
|
186
|
+
} catch {
|
|
187
|
+
// If reload fails, try restart
|
|
188
|
+
await execa('systemctl', ['restart', 'nginx'], { stdio: 'pipe' });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Verify NGINX is running
|
|
192
|
+
const { stdout } = await execa('systemctl', ['is-active', 'nginx'], { stdio: 'pipe' });
|
|
193
|
+
if (stdout.trim() !== 'active') {
|
|
194
|
+
throw new Error('NGINX failed to start after configuration update');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
spinner.succeed('NGINX configured for HTTPS');
|
|
198
|
+
|
|
199
|
+
// Show configuration info
|
|
200
|
+
const frontendHost = frontendDomain.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
|
|
201
|
+
const apiHost = apiDomain.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
|
|
202
|
+
console.log(chalk.gray('\nš NGINX Configuration:'));
|
|
203
|
+
console.log(chalk.gray(` Frontend: https://${frontendHost} ā localhost:${frontendPort}`));
|
|
204
|
+
console.log(chalk.gray(` API: https://${apiHost} ā localhost:${apiPort}`));
|
|
205
|
+
console.log(chalk.gray(` HTTP redirects to HTTPS: Enabled\n`));
|
|
206
|
+
|
|
207
|
+
} catch (error) {
|
|
208
|
+
spinner.fail('Failed to update NGINX configuration');
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Setup automatic SSL certificate renewal via cron
|
|
215
|
+
* @returns {Promise<void>}
|
|
216
|
+
*/
|
|
217
|
+
export async function setupAutoRenewal() {
|
|
218
|
+
const spinner = ora('Setting up automatic SSL renewal...').start();
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
// Check if certbot timer is enabled (systemd-based auto-renewal)
|
|
222
|
+
try {
|
|
223
|
+
const { stdout } = await execa('systemctl', ['is-enabled', 'certbot.timer'], { stdio: 'pipe' });
|
|
224
|
+
if (stdout.trim() === 'enabled') {
|
|
225
|
+
spinner.succeed('Automatic SSL renewal already configured (systemd timer)');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// Timer not enabled, continue with cron setup
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Try to enable systemd timer first (modern approach)
|
|
233
|
+
try {
|
|
234
|
+
await execa('systemctl', ['enable', 'certbot.timer'], { stdio: 'pipe' });
|
|
235
|
+
await execa('systemctl', ['start', 'certbot.timer'], { stdio: 'pipe' });
|
|
236
|
+
spinner.succeed('Automatic SSL renewal configured (systemd timer)');
|
|
237
|
+
return;
|
|
238
|
+
} catch {
|
|
239
|
+
// Systemd timer not available, fallback to cron
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Fallback: Setup cron job
|
|
243
|
+
const cronJob = '0 3 * * * certbot renew --quiet --nginx --post-hook "systemctl reload nginx"';
|
|
244
|
+
const cronFile = '/etc/cron.d/certbot-renewal';
|
|
245
|
+
|
|
246
|
+
// Write cron job
|
|
247
|
+
writeFileSync(cronFile, `# Certbot automatic renewal\nSHELL=/bin/bash\nPATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n\n${cronJob}\n`);
|
|
248
|
+
|
|
249
|
+
// Set proper permissions
|
|
250
|
+
await execa('chmod', ['644', cronFile]);
|
|
251
|
+
|
|
252
|
+
spinner.succeed('Automatic SSL renewal configured (cron)');
|
|
253
|
+
|
|
254
|
+
console.log(chalk.gray('\nš SSL Renewal Details:'));
|
|
255
|
+
console.log(chalk.gray(' Renewal check: Daily at 3:00 AM'));
|
|
256
|
+
console.log(chalk.gray(' Method: Cron job'));
|
|
257
|
+
console.log(chalk.gray(' Certificates will auto-renew when within 30 days of expiry\n'));
|
|
258
|
+
|
|
259
|
+
} catch (error) {
|
|
260
|
+
spinner.fail('Failed to setup automatic renewal');
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Check SSL certificate status for a domain
|
|
267
|
+
* @param {string} domain - Domain name
|
|
268
|
+
* @returns {Promise<Object>} Certificate status information
|
|
269
|
+
*/
|
|
270
|
+
export async function checkSSLStatus(domain) {
|
|
271
|
+
try {
|
|
272
|
+
const certPath = `/etc/letsencrypt/live/${domain}/cert.pem`;
|
|
273
|
+
|
|
274
|
+
if (!existsSync(certPath)) {
|
|
275
|
+
return {
|
|
276
|
+
installed: false,
|
|
277
|
+
message: 'No SSL certificate found'
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Get certificate expiry date
|
|
282
|
+
const { stdout } = await execa('openssl', [
|
|
283
|
+
'x509',
|
|
284
|
+
'-enddate',
|
|
285
|
+
'-noout',
|
|
286
|
+
'-in',
|
|
287
|
+
certPath
|
|
288
|
+
], { stdio: 'pipe' });
|
|
289
|
+
|
|
290
|
+
const expiryMatch = stdout.match(/notAfter=(.+)/);
|
|
291
|
+
const expiryDate = expiryMatch ? new Date(expiryMatch[1]) : null;
|
|
292
|
+
|
|
293
|
+
if (expiryDate) {
|
|
294
|
+
const daysUntilExpiry = Math.floor((expiryDate - new Date()) / (1000 * 60 * 60 * 24));
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
installed: true,
|
|
298
|
+
expiryDate: expiryDate.toISOString(),
|
|
299
|
+
daysUntilExpiry,
|
|
300
|
+
status: daysUntilExpiry > 30 ? 'valid' : 'expiring-soon'
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
installed: true,
|
|
306
|
+
message: 'Certificate found but unable to parse expiry date'
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return {
|
|
311
|
+
installed: false,
|
|
312
|
+
error: error.message
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Extract domain from URL
|
|
319
|
+
* @param {string} url - URL string
|
|
320
|
+
* @returns {string} Domain name
|
|
321
|
+
*/
|
|
322
|
+
export function extractDomain(url) {
|
|
323
|
+
try {
|
|
324
|
+
const parsed = new URL(url);
|
|
325
|
+
return parsed.hostname;
|
|
326
|
+
} catch {
|
|
327
|
+
return url;
|
|
328
|
+
}
|
|
329
|
+
}
|