easy-devops 0.1.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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +325 -0
  3. package/cli/index.js +91 -0
  4. package/cli/managers/domain-manager.js +451 -0
  5. package/cli/managers/nginx-manager.js +329 -0
  6. package/cli/managers/node-manager.js +275 -0
  7. package/cli/managers/ssl-manager.js +397 -0
  8. package/cli/menus/.gitkeep +0 -0
  9. package/cli/menus/dashboard.js +223 -0
  10. package/cli/menus/domains.js +5 -0
  11. package/cli/menus/nginx.js +5 -0
  12. package/cli/menus/nodejs.js +5 -0
  13. package/cli/menus/settings.js +83 -0
  14. package/cli/menus/ssl.js +5 -0
  15. package/core/config.js +37 -0
  16. package/core/db.js +30 -0
  17. package/core/detector.js +257 -0
  18. package/core/nginx-conf-generator.js +309 -0
  19. package/core/shell.js +151 -0
  20. package/dashboard/lib/.gitkeep +0 -0
  21. package/dashboard/lib/cert-reader.js +59 -0
  22. package/dashboard/lib/domains-db.js +51 -0
  23. package/dashboard/lib/nginx-conf-generator.js +16 -0
  24. package/dashboard/lib/nginx-service.js +282 -0
  25. package/dashboard/public/js/app.js +486 -0
  26. package/dashboard/routes/.gitkeep +0 -0
  27. package/dashboard/routes/auth.js +30 -0
  28. package/dashboard/routes/domains.js +300 -0
  29. package/dashboard/routes/nginx.js +151 -0
  30. package/dashboard/routes/settings.js +78 -0
  31. package/dashboard/routes/ssl.js +105 -0
  32. package/dashboard/server.js +79 -0
  33. package/dashboard/views/index.ejs +327 -0
  34. package/dashboard/views/partials/domain-form.ejs +229 -0
  35. package/dashboard/views/partials/domains-panel.ejs +66 -0
  36. package/dashboard/views/partials/login.ejs +50 -0
  37. package/dashboard/views/partials/nginx-panel.ejs +90 -0
  38. package/dashboard/views/partials/overview.ejs +67 -0
  39. package/dashboard/views/partials/settings-panel.ejs +37 -0
  40. package/dashboard/views/partials/sidebar.ejs +45 -0
  41. package/dashboard/views/partials/ssl-panel.ejs +53 -0
  42. package/data/.gitkeep +0 -0
  43. package/install.bat +41 -0
  44. package/install.ps1 +653 -0
  45. package/install.sh +452 -0
  46. package/lib/installer/.gitkeep +0 -0
  47. package/lib/installer/detect.sh +88 -0
  48. package/lib/installer/node-versions.sh +109 -0
  49. package/lib/installer/nvm-bootstrap.sh +77 -0
  50. package/lib/installer/picker.sh +163 -0
  51. package/lib/installer/progress.sh +25 -0
  52. package/package.json +67 -0
@@ -0,0 +1,300 @@
1
+ import express from 'express';
2
+ import fs from 'fs/promises';
3
+ import { run } from '../../core/shell.js';
4
+ import { loadConfig } from '../../core/config.js';
5
+ import { getDomains, saveDomains, findDomain, createDomain, DOMAIN_DEFAULTS } from '../lib/domains-db.js';
6
+ import { generateConf, getDefaultCertPaths } from '../lib/nginx-conf-generator.js';
7
+ import { getCertExpiry } from '../lib/cert-reader.js';
8
+
9
+ const router = express.Router();
10
+
11
+ // ─── shared nginx helpers ─────────────────────────────────────────────────────
12
+
13
+ const isWindows = process.platform === 'win32';
14
+
15
+ function getNginxExe(nginxDir) {
16
+ return isWindows ? `${nginxDir}\\nginx.exe` : 'nginx';
17
+ }
18
+
19
+ function nginxTestCmd(nginxDir) {
20
+ const exe = getNginxExe(nginxDir);
21
+ return isWindows ? `& "${exe}" -t` : 'nginx -t';
22
+ }
23
+
24
+ function nginxReloadCmd(nginxDir) {
25
+ const exe = getNginxExe(nginxDir);
26
+ return isWindows ? `& "${exe}" -s reload` : 'nginx -s reload';
27
+ }
28
+
29
+ // ─── Validation helpers ───────────────────────────────────────────────────────
30
+
31
+ function validateDomainName(name) {
32
+ if (!name || typeof name !== 'string') {
33
+ return 'name is required';
34
+ }
35
+ // Allow wildcards and standard hostnames
36
+ const validPattern = /^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
37
+ if (!validPattern.test(name) || name.includes('/') || name.includes(' ')) {
38
+ return 'Invalid domain name format';
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function validatePort(port) {
44
+ const portNum = Number(port);
45
+ if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
46
+ return 'Invalid port: must be 1-65535';
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function validateUpstreamType(type) {
52
+ if (type && !['http', 'https', 'ws'].includes(type)) {
53
+ return 'Invalid upstreamType: must be http, https, or ws';
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function validateMaxBodySize(size) {
59
+ if (size && !/^\d+[kmgKMG]?$/.test(size)) {
60
+ return 'Invalid maxBodySize format (e.g., 10m, 1g)';
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function validatePositiveInteger(val, field) {
66
+ if (val !== undefined && (!Number.isInteger(Number(val)) || Number(val) < 1)) {
67
+ return `Invalid ${field}: must be a positive integer`;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // ─── GET /api/domains ─────────────────────────────────────────────────────────
73
+
74
+ router.get('/', async (_req, res) => {
75
+ const domains = getDomains();
76
+ const result = await Promise.all(
77
+ domains.map(async (domain) => {
78
+ const { expiry, daysLeft } = await getCertExpiry(domain.name);
79
+ return { ...domain, certExpiry: expiry, daysLeft };
80
+ })
81
+ );
82
+ res.json(result);
83
+ });
84
+
85
+ // ─── POST /api/domains ────────────────────────────────────────────────────────
86
+
87
+ router.post('/', async (req, res) => {
88
+ const body = req.body ?? {};
89
+
90
+ // Validate required fields
91
+ const nameError = validateDomainName(body.name);
92
+ if (nameError) {
93
+ return res.status(400).json({ error: nameError });
94
+ }
95
+
96
+ const portError = validatePort(body.port);
97
+ if (portError) {
98
+ return res.status(400).json({ error: portError });
99
+ }
100
+
101
+ // Validate optional fields
102
+ const upstreamError = validateUpstreamType(body.upstreamType);
103
+ if (upstreamError) {
104
+ return res.status(400).json({ error: upstreamError });
105
+ }
106
+
107
+ const maxSizeError = validateMaxBodySize(body.performance?.maxBodySize);
108
+ if (maxSizeError) {
109
+ return res.status(400).json({ error: maxSizeError });
110
+ }
111
+
112
+ // Check for duplicate
113
+ if (findDomain(body.name)) {
114
+ return res.status(409).json({ error: `Domain already exists: ${body.name}` });
115
+ }
116
+
117
+ // Build domain object with defaults
118
+ const domain = createDomain({
119
+ name: body.name,
120
+ port: Number(body.port),
121
+ backendHost: body.backendHost ?? DOMAIN_DEFAULTS.backendHost,
122
+ upstreamType: body.upstreamType ?? DOMAIN_DEFAULTS.upstreamType,
123
+ www: body.www ?? false,
124
+ ssl: {
125
+ enabled: body.ssl?.enabled ?? false,
126
+ certPath: body.ssl?.certPath ?? '',
127
+ keyPath: body.ssl?.keyPath ?? '',
128
+ redirect: body.ssl?.redirect ?? true,
129
+ hsts: body.ssl?.hsts ?? false,
130
+ hstsMaxAge: body.ssl?.hstsMaxAge ?? DOMAIN_DEFAULTS.ssl.hstsMaxAge,
131
+ },
132
+ performance: {
133
+ maxBodySize: body.performance?.maxBodySize ?? DOMAIN_DEFAULTS.performance.maxBodySize,
134
+ readTimeout: body.performance?.readTimeout ?? DOMAIN_DEFAULTS.performance.readTimeout,
135
+ connectTimeout: body.performance?.connectTimeout ?? DOMAIN_DEFAULTS.performance.connectTimeout,
136
+ proxyBuffers: body.performance?.proxyBuffers ?? false,
137
+ gzip: body.performance?.gzip ?? true,
138
+ gzipTypes: body.performance?.gzipTypes ?? DOMAIN_DEFAULTS.performance.gzipTypes,
139
+ },
140
+ security: {
141
+ rateLimit: body.security?.rateLimit ?? false,
142
+ rateLimitRate: body.security?.rateLimitRate ?? DOMAIN_DEFAULTS.security.rateLimitRate,
143
+ rateLimitBurst: body.security?.rateLimitBurst ?? DOMAIN_DEFAULTS.security.rateLimitBurst,
144
+ securityHeaders: body.security?.securityHeaders ?? false,
145
+ custom404: body.security?.custom404 ?? false,
146
+ custom50x: body.security?.custom50x ?? false,
147
+ },
148
+ advanced: {
149
+ accessLog: body.advanced?.accessLog ?? true,
150
+ customLocations: body.advanced?.customLocations ?? '',
151
+ },
152
+ });
153
+
154
+ const { nginxDir } = loadConfig();
155
+
156
+ try {
157
+ await generateConf(domain);
158
+ } catch (err) {
159
+ return res.status(500).json({ error: 'Failed to write nginx conf', details: err.message });
160
+ }
161
+
162
+ const testResult = await run(nginxTestCmd(nginxDir), { cwd: nginxDir });
163
+ if (!testResult.success) {
164
+ try { await fs.unlink(domain.configFile); } catch { /* ignore */ }
165
+ return res.status(500).json({ error: 'nginx config test failed', output: testResult.stderr || testResult.stdout });
166
+ }
167
+
168
+ const domains = getDomains();
169
+ domains.push(domain);
170
+ saveDomains(domains);
171
+
172
+ res.status(201).json(domain);
173
+ });
174
+
175
+ // ─── PUT /api/domains/:name ───────────────────────────────────────────────────
176
+
177
+ router.put('/:name', async (req, res) => {
178
+ const { name } = req.params;
179
+ const existing = findDomain(name);
180
+ if (!existing) {
181
+ return res.status(404).json({ error: `Domain not found: ${name}` });
182
+ }
183
+
184
+ const body = req.body ?? {};
185
+
186
+ // Validate port if provided
187
+ if (body.port !== undefined) {
188
+ const portError = validatePort(body.port);
189
+ if (portError) {
190
+ return res.status(400).json({ error: portError });
191
+ }
192
+ body.port = Number(body.port);
193
+ }
194
+
195
+ // Validate upstreamType if provided
196
+ if (body.upstreamType !== undefined) {
197
+ const upstreamError = validateUpstreamType(body.upstreamType);
198
+ if (upstreamError) {
199
+ return res.status(400).json({ error: upstreamError });
200
+ }
201
+ }
202
+
203
+ // name is immutable — merge everything except name and configFile
204
+ const { name: _ignored, configFile: _cf, ...updates } = body;
205
+
206
+ // Deep merge nested objects
207
+ const updatedDomain = {
208
+ ...existing,
209
+ ...updates,
210
+ ssl: { ...existing.ssl, ...updates.ssl },
211
+ performance: { ...existing.performance, ...updates.performance },
212
+ security: { ...existing.security, ...updates.security },
213
+ advanced: { ...existing.advanced, ...updates.advanced },
214
+ updatedAt: new Date().toISOString(),
215
+ };
216
+
217
+ const { nginxDir } = loadConfig();
218
+ const bakPath = existing.configFile ? `${existing.configFile}.bak` : null;
219
+
220
+ // Backup existing conf
221
+ if (bakPath && existing.configFile) {
222
+ try {
223
+ await fs.copyFile(existing.configFile, bakPath);
224
+ } catch { /* file absent — skip backup */ }
225
+ }
226
+
227
+ try {
228
+ await generateConf(updatedDomain);
229
+ } catch (err) {
230
+ if (bakPath) {
231
+ try { await fs.copyFile(bakPath, existing.configFile); } catch { /* ignore restore failure */ }
232
+ }
233
+ return res.status(500).json({ error: 'Failed to write nginx conf', details: err.message });
234
+ }
235
+
236
+ const testResult = await run(nginxTestCmd(nginxDir), { cwd: nginxDir });
237
+ if (!testResult.success) {
238
+ if (bakPath) {
239
+ try { await fs.rename(bakPath, existing.configFile); } catch { /* ignore */ }
240
+ }
241
+ return res.status(500).json({ error: 'nginx config test failed', output: testResult.stderr || testResult.stdout });
242
+ }
243
+
244
+ const domains = getDomains();
245
+ const idx = domains.findIndex((d) => d.name === name);
246
+ domains[idx] = updatedDomain;
247
+ saveDomains(domains);
248
+
249
+ res.json(updatedDomain);
250
+ });
251
+
252
+ // ─── DELETE /api/domains/:name ────────────────────────────────────────────────
253
+
254
+ router.delete('/:name', async (req, res) => {
255
+ const { name } = req.params;
256
+ const domain = findDomain(name);
257
+ if (!domain) {
258
+ return res.status(404).json({ error: `Domain not found: ${name}` });
259
+ }
260
+
261
+ if (domain.configFile) {
262
+ try {
263
+ await fs.unlink(domain.configFile);
264
+ } catch (err) {
265
+ if (err.code !== 'ENOENT') {
266
+ return res.status(500).json({ error: 'Failed to delete conf file', details: err.message });
267
+ }
268
+ }
269
+ }
270
+
271
+ const domains = getDomains().filter((d) => d.name !== name);
272
+ saveDomains(domains);
273
+
274
+ res.json({ message: `Domain deleted: ${name}` });
275
+ });
276
+
277
+ // ─── POST /api/domains/:name/reload ──────────────────────────────────────────
278
+
279
+ router.post('/:name/reload', async (req, res) => {
280
+ const { name } = req.params;
281
+ if (!findDomain(name)) {
282
+ return res.status(404).json({ error: `Domain not found: ${name}` });
283
+ }
284
+
285
+ const { nginxDir } = loadConfig();
286
+
287
+ const testResult = await run(nginxTestCmd(nginxDir), { cwd: nginxDir });
288
+ if (!testResult.success) {
289
+ return res.status(500).json({ error: 'nginx config test failed', output: testResult.stderr || testResult.stdout });
290
+ }
291
+
292
+ const reloadResult = await run(nginxReloadCmd(nginxDir), { cwd: nginxDir });
293
+ if (!reloadResult.success) {
294
+ return res.status(500).json({ error: 'nginx reload failed', output: reloadResult.stderr || reloadResult.stdout });
295
+ }
296
+
297
+ res.json({ message: 'nginx reloaded successfully' });
298
+ });
299
+
300
+ export default router;
@@ -0,0 +1,151 @@
1
+ import express from 'express';
2
+ import {
3
+ getStatus,
4
+ reload,
5
+ restart,
6
+ start,
7
+ stop,
8
+ test,
9
+ listConfigs,
10
+ getConfig,
11
+ saveConfig,
12
+ getLogs,
13
+ NginxNotFoundError,
14
+ NginxConfigError,
15
+ InvalidFilenameError,
16
+ } from '../lib/nginx-service.js';
17
+
18
+ const router = express.Router();
19
+
20
+ // ─── Error Handler Helper ─────────────────────────────────────────────────────
21
+
22
+ function handleError(err, res) {
23
+ if (err instanceof NginxNotFoundError) {
24
+ return res.status(503).json({ error: 'nginx not installed' });
25
+ }
26
+ if (err instanceof NginxConfigError) {
27
+ return res.status(400).json({ success: false, output: err.output });
28
+ }
29
+ if (err instanceof InvalidFilenameError) {
30
+ return res.status(400).json({ error: 'Invalid filename' });
31
+ }
32
+ if (err.code === 'ENOENT') {
33
+ return res.status(404).json({ error: 'Config file not found' });
34
+ }
35
+ return res.status(500).json({ error: 'Internal server error' });
36
+ }
37
+
38
+ // ─── US1: GET /api/nginx/status ───────────────────────────────────────────────
39
+
40
+ router.get('/status', async (req, res) => {
41
+ try {
42
+ const status = await getStatus();
43
+ res.json(status);
44
+ } catch (err) {
45
+ handleError(err, res);
46
+ }
47
+ });
48
+
49
+ // ─── US2: POST /api/nginx/reload ─────────────────────────────────────────────
50
+
51
+ router.post('/reload', async (req, res) => {
52
+ try {
53
+ const result = await reload();
54
+ res.json(result);
55
+ } catch (err) {
56
+ handleError(err, res);
57
+ }
58
+ });
59
+
60
+ // ─── US2: POST /api/nginx/restart ────────────────────────────────────────────
61
+
62
+ router.post('/restart', async (req, res) => {
63
+ try {
64
+ const result = await restart();
65
+ res.json(result);
66
+ } catch (err) {
67
+ handleError(err, res);
68
+ }
69
+ });
70
+
71
+ // ─── US1: POST /api/nginx/start ──────────────────────────────────────────────
72
+
73
+ router.post('/start', async (req, res) => {
74
+ try {
75
+ const result = await start();
76
+ res.json(result);
77
+ } catch (err) {
78
+ handleError(err, res);
79
+ }
80
+ });
81
+
82
+ // ─── US1: POST /api/nginx/stop ───────────────────────────────────────────────
83
+
84
+ router.post('/stop', async (req, res) => {
85
+ try {
86
+ const result = await stop();
87
+ res.json(result);
88
+ } catch (err) {
89
+ handleError(err, res);
90
+ }
91
+ });
92
+
93
+ // ─── US3: POST /api/nginx/test ───────────────────────────────────────────────
94
+
95
+ router.post('/test', async (req, res) => {
96
+ try {
97
+ const result = await test();
98
+ res.json(result);
99
+ } catch (err) {
100
+ handleError(err, res);
101
+ }
102
+ });
103
+
104
+ // ─── US4: GET /api/nginx/configs ─────────────────────────────────────────────
105
+
106
+ router.get('/configs', async (req, res) => {
107
+ try {
108
+ const configs = await listConfigs();
109
+ res.json(configs);
110
+ } catch (err) {
111
+ handleError(err, res);
112
+ }
113
+ });
114
+
115
+ // ─── US4: GET /api/nginx/config/:filename ────────────────────────────────────
116
+
117
+ router.get('/config/:filename', async (req, res) => {
118
+ try {
119
+ const result = await getConfig(req.params.filename);
120
+ res.json(result);
121
+ } catch (err) {
122
+ handleError(err, res);
123
+ }
124
+ });
125
+
126
+ // ─── US4: POST /api/nginx/config/:filename ───────────────────────────────────
127
+
128
+ router.post('/config/:filename', async (req, res) => {
129
+ if (typeof req.body.content !== 'string') {
130
+ return res.status(400).json({ error: 'content must be a string' });
131
+ }
132
+ try {
133
+ const result = await saveConfig(req.params.filename, req.body.content);
134
+ res.json(result);
135
+ } catch (err) {
136
+ handleError(err, res);
137
+ }
138
+ });
139
+
140
+ // ─── US5: GET /api/nginx/logs ────────────────────────────────────────────────
141
+
142
+ router.get('/logs', async (req, res) => {
143
+ try {
144
+ const result = await getLogs(100);
145
+ res.json(result);
146
+ } catch (err) {
147
+ handleError(err, res);
148
+ }
149
+ });
150
+
151
+ export default router;
@@ -0,0 +1,78 @@
1
+ import express from 'express';
2
+ import { loadConfig, saveConfig } from '../../core/config.js';
3
+
4
+ const router = express.Router();
5
+
6
+ // ─── GET /api/settings ─────────────────────────────────────────────────────
7
+
8
+ router.get('/settings', (req, res) => {
9
+ try {
10
+ const config = loadConfig();
11
+ // Never return the password (write-only field)
12
+ const { dashboardPort, nginxDir, certbotDir } = config;
13
+ res.json({
14
+ dashboardPort,
15
+ nginxDir,
16
+ certbotDir,
17
+ platform: process.platform === 'win32' ? 'win32' : 'linux',
18
+ });
19
+ } catch (err) {
20
+ res.status(500).json({ error: 'Internal server error' });
21
+ }
22
+ });
23
+
24
+ // ─── POST /api/settings ────────────────────────────────────────────────────
25
+
26
+ router.post('/settings', (req, res) => {
27
+ try {
28
+ const { dashboardPort, dashboardPassword, nginxDir, certbotDir } = req.body;
29
+
30
+ // Validate port if provided
31
+ if (dashboardPort !== undefined) {
32
+ const port = parseInt(dashboardPort, 10);
33
+ if (isNaN(port) || port < 1 || port > 65535) {
34
+ return res.status(400).json({ error: 'Invalid port number' });
35
+ }
36
+ }
37
+
38
+ // Validate password if provided
39
+ if (dashboardPassword !== undefined && typeof dashboardPassword !== 'string') {
40
+ return res.status(400).json({ error: 'Password must be a string' });
41
+ }
42
+
43
+ // Validate directories if provided
44
+ if (nginxDir !== undefined && (typeof nginxDir !== 'string' || nginxDir.trim() === '')) {
45
+ return res.status(400).json({ error: 'Nginx directory must be a non-empty string' });
46
+ }
47
+
48
+ if (certbotDir !== undefined && (typeof certbotDir !== 'string' || certbotDir.trim() === '')) {
49
+ return res.status(400).json({ error: 'Certbot directory must be a non-empty string' });
50
+ }
51
+
52
+ // Load current config and merge updates
53
+ const currentConfig = loadConfig();
54
+ const updates = {};
55
+
56
+ if (dashboardPort !== undefined) {
57
+ updates.dashboardPort = parseInt(dashboardPort, 10);
58
+ }
59
+ if (dashboardPassword !== undefined) {
60
+ updates.dashboardPassword = dashboardPassword;
61
+ }
62
+ if (nginxDir !== undefined) {
63
+ updates.nginxDir = nginxDir.trim();
64
+ }
65
+ if (certbotDir !== undefined) {
66
+ updates.certbotDir = certbotDir.trim();
67
+ }
68
+
69
+ const newConfig = { ...currentConfig, ...updates };
70
+ saveConfig(newConfig);
71
+
72
+ res.json({ success: true });
73
+ } catch (err) {
74
+ res.status(500).json({ error: 'Internal server error' });
75
+ }
76
+ });
77
+
78
+ export default router;
@@ -0,0 +1,105 @@
1
+ import express from 'express';
2
+ import { run } from '../../core/shell.js';
3
+ import { listAllCerts } from '../lib/cert-reader.js';
4
+
5
+ const router = express.Router();
6
+
7
+ const CERTBOT_WIN_EXE = 'C:\\Program Files\\Certbot\\bin\\certbot.exe';
8
+
9
+ // Returns the PS-safe certbot invocation string, or null if not installed.
10
+ async function getCertbotCmd() {
11
+ if (process.platform !== 'win32') {
12
+ const r = await run('certbot --version', { timeout: 10000 });
13
+ return r.success ? 'certbot' : null;
14
+ }
15
+
16
+ // Try PATH first
17
+ const whereResult = await run('where.exe certbot', { timeout: 5000 });
18
+ if (whereResult.success && whereResult.stdout.trim()) return 'certbot';
19
+
20
+ // Check known Windows install location
21
+ const testResult = await run(`Test-Path "${CERTBOT_WIN_EXE}"`, { timeout: 5000 });
22
+ if (testResult.stdout.trim() === 'True') return `& "${CERTBOT_WIN_EXE}"`;
23
+
24
+ return null;
25
+ }
26
+
27
+ async function renewDomain(domain, certbotCmd) {
28
+ if (process.platform === 'win32') {
29
+ const certResult = await run(
30
+ `${certbotCmd} certonly --standalone --non-interactive --agree-tos -d ${domain}`,
31
+ { timeout: 120000 }
32
+ );
33
+ const output = [certResult.stdout, certResult.stderr].filter(Boolean).join('\n').trim();
34
+ return { success: certResult.success, output };
35
+ }
36
+
37
+ await run('systemctl stop nginx', { timeout: 15000 });
38
+ let certResult;
39
+ try {
40
+ certResult = await run(
41
+ `${certbotCmd} certonly --standalone --non-interactive --agree-tos -d ${domain}`,
42
+ { timeout: 120000 }
43
+ );
44
+ } finally {
45
+ await run('systemctl start nginx', { timeout: 15000 });
46
+ }
47
+ const output = [certResult.stdout, certResult.stderr].filter(Boolean).join('\n').trim();
48
+ return { success: certResult.success, output };
49
+ }
50
+
51
+ router.get('/', async (req, res) => {
52
+ const certbotCmd = await getCertbotCmd();
53
+ if (!certbotCmd) {
54
+ return res.status(503).json({
55
+ error: 'certbot not installed',
56
+ instructions: 'Install certbot: https://certbot.eff.org/instructions',
57
+ });
58
+ }
59
+ const certs = await listAllCerts();
60
+ res.json(certs);
61
+ });
62
+
63
+ router.post('/renew/:domain', async (req, res) => {
64
+ const certbotCmd = await getCertbotCmd();
65
+ if (!certbotCmd) {
66
+ return res.status(503).json({
67
+ error: 'certbot not installed',
68
+ instructions: 'Install certbot: https://certbot.eff.org/instructions',
69
+ });
70
+ }
71
+ const domain = req.params.domain;
72
+ const certs = await listAllCerts();
73
+ const found = certs.find(cert => cert.domain === domain);
74
+ if (!found) {
75
+ return res.status(404).json({ error: `Domain '${domain}' not found in certbot` });
76
+ }
77
+ const result = await renewDomain(domain, certbotCmd);
78
+ if (result.output.includes('binding to port 80') || result.output.includes('Address already in use')) {
79
+ return res.status(409).json({
80
+ error: 'Port 80 is busy',
81
+ message: 'stop nginx first or use --webroot',
82
+ });
83
+ }
84
+ res.json({ success: result.success, output: result.output });
85
+ });
86
+
87
+ router.post('/renew-all', async (req, res) => {
88
+ const certbotCmd = await getCertbotCmd();
89
+ if (!certbotCmd) {
90
+ return res.status(503).json({
91
+ error: 'certbot not installed',
92
+ instructions: 'Install certbot: https://certbot.eff.org/instructions',
93
+ });
94
+ }
95
+ const certs = await listAllCerts();
96
+ const expiring = certs.filter(c => c.daysLeft !== null && c.daysLeft < 30);
97
+ const results = [];
98
+ for (const cert of expiring) {
99
+ const r = await renewDomain(cert.domain, certbotCmd);
100
+ results.push({ domain: cert.domain, success: r.success, output: r.output });
101
+ }
102
+ res.json(results);
103
+ });
104
+
105
+ export default router;