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.
- package/LICENSE +21 -0
- package/README.md +325 -0
- package/cli/index.js +91 -0
- package/cli/managers/domain-manager.js +451 -0
- package/cli/managers/nginx-manager.js +329 -0
- package/cli/managers/node-manager.js +275 -0
- package/cli/managers/ssl-manager.js +397 -0
- package/cli/menus/.gitkeep +0 -0
- package/cli/menus/dashboard.js +223 -0
- package/cli/menus/domains.js +5 -0
- package/cli/menus/nginx.js +5 -0
- package/cli/menus/nodejs.js +5 -0
- package/cli/menus/settings.js +83 -0
- package/cli/menus/ssl.js +5 -0
- package/core/config.js +37 -0
- package/core/db.js +30 -0
- package/core/detector.js +257 -0
- package/core/nginx-conf-generator.js +309 -0
- package/core/shell.js +151 -0
- package/dashboard/lib/.gitkeep +0 -0
- package/dashboard/lib/cert-reader.js +59 -0
- package/dashboard/lib/domains-db.js +51 -0
- package/dashboard/lib/nginx-conf-generator.js +16 -0
- package/dashboard/lib/nginx-service.js +282 -0
- package/dashboard/public/js/app.js +486 -0
- package/dashboard/routes/.gitkeep +0 -0
- package/dashboard/routes/auth.js +30 -0
- package/dashboard/routes/domains.js +300 -0
- package/dashboard/routes/nginx.js +151 -0
- package/dashboard/routes/settings.js +78 -0
- package/dashboard/routes/ssl.js +105 -0
- package/dashboard/server.js +79 -0
- package/dashboard/views/index.ejs +327 -0
- package/dashboard/views/partials/domain-form.ejs +229 -0
- package/dashboard/views/partials/domains-panel.ejs +66 -0
- package/dashboard/views/partials/login.ejs +50 -0
- package/dashboard/views/partials/nginx-panel.ejs +90 -0
- package/dashboard/views/partials/overview.ejs +67 -0
- package/dashboard/views/partials/settings-panel.ejs +37 -0
- package/dashboard/views/partials/sidebar.ejs +45 -0
- package/dashboard/views/partials/ssl-panel.ejs +53 -0
- package/data/.gitkeep +0 -0
- package/install.bat +41 -0
- package/install.ps1 +653 -0
- package/install.sh +452 -0
- package/lib/installer/.gitkeep +0 -0
- package/lib/installer/detect.sh +88 -0
- package/lib/installer/node-versions.sh +109 -0
- package/lib/installer/nvm-bootstrap.sh +77 -0
- package/lib/installer/picker.sh +163 -0
- package/lib/installer/progress.sh +25 -0
- 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;
|