vk-ssl-auto-deploy 0.8.3 → 0.8.5
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/README.md +34 -4
- package/package.json +1 -1
- package/public/stylesheets/style.css +8 -8
- package/routes/admin.js +144 -0
- package/views/admin.ejs +103 -5
- package/views/error.ejs +3 -3
- package/views/index.ejs +10 -10
package/README.md
CHANGED
|
@@ -1,18 +1,48 @@
|
|
|
1
1
|
# vk-ssl-auto-deploy
|
|
2
2
|
|
|
3
|
-
SSL
|
|
3
|
+
无忧SSL证书平台自动部署工具 - 提供HTTP API接口,支持证书文件自动上传和部署
|
|
4
|
+
|
|
5
|
+
### 支持系统
|
|
6
|
+
|
|
7
|
+
| 系统 | 最低版本 |
|
|
8
|
+
|------|----------|
|
|
9
|
+
| Alibaba Cloud Linux | 3+ |
|
|
10
|
+
| CentOS | 7+ |
|
|
11
|
+
| Ubuntu | 20+ |
|
|
12
|
+
| Debian | 12+ |
|
|
13
|
+
| Fedora | 40+ |
|
|
14
|
+
| OpenSUSE | 15+ |
|
|
15
|
+
| Rocky Linux | 8+ |
|
|
16
|
+
| CentOS Stream | 9+ |
|
|
17
|
+
| AlmaLinux | 8+ |
|
|
18
|
+
| Anolis OS | 不支持 |
|
|
19
|
+
| Windows | 10 / 11 / Server 2016+ |
|
|
4
20
|
|
|
5
21
|
## 一键安装(推荐)
|
|
6
22
|
|
|
23
|
+
### Linux 系统
|
|
24
|
+
|
|
7
25
|
Linux 服务器执行以下命令,自动完成安装、部署和开机自启配置:
|
|
8
26
|
|
|
9
27
|
```bash
|
|
10
28
|
curl -fsSL https://gitee.com/vk1688/vk-ssl-auto-deploy/raw/master/install.sh | sudo bash
|
|
11
29
|
```
|
|
12
30
|
|
|
13
|
-
|
|
31
|
+
### Windows 系统
|
|
32
|
+
|
|
33
|
+
**PowerShell (推荐):**
|
|
34
|
+
|
|
35
|
+
```powershell
|
|
36
|
+
iwr -useb https://gitee.com/vk1688/vk-ssl-auto-deploy/raw/master/install.ps1 | iex
|
|
37
|
+
```
|
|
14
38
|
|
|
15
|
-
|
|
39
|
+
**CMD (命令提示符):**
|
|
40
|
+
|
|
41
|
+
```cmd
|
|
42
|
+
powershell -c "iwr -useb https://gitee.com/vk1688/vk-ssl-auto-deploy/raw/master/install.ps1 | iex"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
安装完成后,访问 `http://服务器IP:6001/admin`,默认密码:`admin@123456`
|
|
16
46
|
|
|
17
47
|
---
|
|
18
48
|
|
|
@@ -30,7 +60,7 @@ curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash - && sudo yum insta
|
|
|
30
60
|
|
|
31
61
|
#### Windows系统
|
|
32
62
|
|
|
33
|
-
```
|
|
63
|
+
```powershell
|
|
34
64
|
winget install --id OpenJS.NodeJS.LTS -e
|
|
35
65
|
```
|
|
36
66
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
body {
|
|
2
|
-
padding: 50px;
|
|
3
|
-
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
a {
|
|
7
|
-
color: #00B7FF;
|
|
8
|
-
}
|
|
1
|
+
body {
|
|
2
|
+
padding: 50px;
|
|
3
|
+
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
a {
|
|
7
|
+
color: #00B7FF;
|
|
8
|
+
}
|
package/routes/admin.js
CHANGED
|
@@ -3,6 +3,11 @@ const router = express.Router();
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const crypto = require('crypto');
|
|
6
|
+
const { exec } = require('child_process');
|
|
7
|
+
const { promisify } = require('util');
|
|
8
|
+
const AdmZip = require('adm-zip');
|
|
9
|
+
|
|
10
|
+
const execAsync = promisify(exec);
|
|
6
11
|
|
|
7
12
|
const CONFIG_FILE = path.join(__dirname, '..', 'config.json');
|
|
8
13
|
|
|
@@ -266,4 +271,143 @@ router.get('/api/certs/local', verifyPassword, (req, res) => {
|
|
|
266
271
|
}
|
|
267
272
|
});
|
|
268
273
|
|
|
274
|
+
/**
|
|
275
|
+
* POST /api/test-nginx
|
|
276
|
+
* 测试 Nginx 路径是否正确
|
|
277
|
+
*/
|
|
278
|
+
router.post('/api/test-nginx', verifyPassword, async (req, res) => {
|
|
279
|
+
try {
|
|
280
|
+
const { nginxPath } = req.body;
|
|
281
|
+
|
|
282
|
+
if (!nginxPath) {
|
|
283
|
+
return res.json({
|
|
284
|
+
code: 400,
|
|
285
|
+
msg: 'Nginx 路径不能为空',
|
|
286
|
+
data: null
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 检查文件是否存在
|
|
291
|
+
if (!fs.existsSync(nginxPath)) {
|
|
292
|
+
return res.json({
|
|
293
|
+
code: 404,
|
|
294
|
+
msg: 'Nginx 文件不存在',
|
|
295
|
+
data: null
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 执行 nginx -t 命令测试配置
|
|
300
|
+
try {
|
|
301
|
+
// 获取 nginx 所在目录作为工作目录
|
|
302
|
+
const nginxDir = path.dirname(nginxPath);
|
|
303
|
+
|
|
304
|
+
const { stdout, stderr } = await execAsync(`"${nginxPath}" -t`, {
|
|
305
|
+
timeout: 10000, // 10秒超时
|
|
306
|
+
cwd: nginxDir // 设置工作目录为 nginx 所在目录
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// nginx -t 的输出通常在 stderr 中
|
|
310
|
+
const output = stderr || stdout;
|
|
311
|
+
|
|
312
|
+
// 检查是否包含 "syntax is ok" 和 "test is successful"
|
|
313
|
+
if (output.includes('syntax is ok') || output.includes('test is successful')) {
|
|
314
|
+
return res.json({
|
|
315
|
+
code: 0,
|
|
316
|
+
msg: 'Nginx 配置测试通过\n' + output,
|
|
317
|
+
data: { output }
|
|
318
|
+
});
|
|
319
|
+
} else {
|
|
320
|
+
return res.json({
|
|
321
|
+
code: 500,
|
|
322
|
+
msg: 'Nginx 配置测试失败\n' + output,
|
|
323
|
+
data: { output }
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
} catch (execError) {
|
|
327
|
+
// 执行命令失败
|
|
328
|
+
return res.json({
|
|
329
|
+
code: 500,
|
|
330
|
+
msg: 'Nginx 配置测试失败: ' + (execError.stderr || execError.message),
|
|
331
|
+
data: null
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
} catch (error) {
|
|
335
|
+
res.json({
|
|
336
|
+
code: 500,
|
|
337
|
+
msg: '测试失败: ' + error.message,
|
|
338
|
+
data: null
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* GET /api/cert/download/:fileName
|
|
345
|
+
* 下载证书(包含 cert.pem 和 cert.key 的 zip 压缩包)
|
|
346
|
+
*/
|
|
347
|
+
router.get('/api/cert/download/:fileName', verifyPassword, (req, res) => {
|
|
348
|
+
try {
|
|
349
|
+
const { fileName } = req.params;
|
|
350
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
351
|
+
const certDir = path.isAbsolute(config.certSaveDir) ?
|
|
352
|
+
config.certSaveDir :
|
|
353
|
+
path.join(__dirname, '..', config.certSaveDir);
|
|
354
|
+
|
|
355
|
+
// 检查目录是否存在
|
|
356
|
+
if (!fs.existsSync(certDir)) {
|
|
357
|
+
return res.status(404).json({
|
|
358
|
+
code: 404,
|
|
359
|
+
msg: '证书目录不存在',
|
|
360
|
+
data: null
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 构建证书文件路径
|
|
365
|
+
const crtFile = path.join(certDir, `${fileName}.crt`);
|
|
366
|
+
const keyFile = path.join(certDir, `${fileName}.key`);
|
|
367
|
+
|
|
368
|
+
// 检查文件是否存在
|
|
369
|
+
if (!fs.existsSync(crtFile)) {
|
|
370
|
+
return res.status(404).json({
|
|
371
|
+
code: 404,
|
|
372
|
+
msg: '证书文件不存在',
|
|
373
|
+
data: null
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!fs.existsSync(keyFile)) {
|
|
378
|
+
return res.status(404).json({
|
|
379
|
+
code: 404,
|
|
380
|
+
msg: '私钥文件不存在',
|
|
381
|
+
data: null
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// 创建 ZIP 压缩包
|
|
386
|
+
const zip = new AdmZip();
|
|
387
|
+
|
|
388
|
+
// 添加证书文件(重命名为 cert.pem)
|
|
389
|
+
zip.addLocalFile(crtFile, '', 'cert.pem');
|
|
390
|
+
|
|
391
|
+
// 添加私钥文件(重命名为 cert.key)
|
|
392
|
+
zip.addLocalFile(keyFile, '', 'cert.key');
|
|
393
|
+
|
|
394
|
+
// 生成 ZIP 文件
|
|
395
|
+
const zipBuffer = zip.toBuffer();
|
|
396
|
+
|
|
397
|
+
// 设置响应头
|
|
398
|
+
res.setHeader('Content-Type', 'application/zip');
|
|
399
|
+
res.setHeader('Content-Disposition', `attachment; filename="${fileName}.zip"`);
|
|
400
|
+
res.setHeader('Content-Length', zipBuffer.length);
|
|
401
|
+
|
|
402
|
+
// 发送文件
|
|
403
|
+
res.send(zipBuffer);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
res.status(500).json({
|
|
406
|
+
code: 500,
|
|
407
|
+
msg: '下载失败: ' + error.message,
|
|
408
|
+
data: null
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
269
413
|
module.exports = router;
|
package/views/admin.ejs
CHANGED
|
@@ -101,8 +101,8 @@
|
|
|
101
101
|
border-radius: 8px;
|
|
102
102
|
color: #e0e0e0;
|
|
103
103
|
font-size: 0.95em;
|
|
104
|
-
transition: all 0.3s;
|
|
105
104
|
font-family: 'Consolas', 'Monaco', monospace;
|
|
105
|
+
will-change: border-color, box-shadow;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
input[type="text"]:focus,
|
|
@@ -158,8 +158,9 @@
|
|
|
158
158
|
font-size: 1em;
|
|
159
159
|
font-weight: 600;
|
|
160
160
|
cursor: pointer;
|
|
161
|
-
transition:
|
|
161
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
162
162
|
box-shadow: 0 4px 15px rgba(0, 255, 136, 0.3);
|
|
163
|
+
will-change: transform, box-shadow;
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
button:hover {
|
|
@@ -448,7 +449,8 @@
|
|
|
448
449
|
align-items: center;
|
|
449
450
|
justify-content: center;
|
|
450
451
|
border-radius: 8px;
|
|
451
|
-
transition:
|
|
452
|
+
transition: background-color 0.2s, color 0.2s;
|
|
453
|
+
will-change: background-color, color;
|
|
452
454
|
}
|
|
453
455
|
|
|
454
456
|
.close-btn:hover {
|
|
@@ -470,6 +472,8 @@
|
|
|
470
472
|
border-radius: 8px;
|
|
471
473
|
color: #e0e0e0;
|
|
472
474
|
font-size: 0.95em;
|
|
475
|
+
transition: border-color 0.2s, box-shadow 0.2s;
|
|
476
|
+
will-change: border-color, box-shadow;
|
|
473
477
|
}
|
|
474
478
|
|
|
475
479
|
.search-box input:focus {
|
|
@@ -660,7 +664,7 @@
|
|
|
660
664
|
|
|
661
665
|
<!-- 本地证书列表 -->
|
|
662
666
|
<div class="card full-width">
|
|
663
|
-
<h2 class="card-title"
|
|
667
|
+
<h2 class="card-title">本地证书列表 <span id="certCount" style="color: #888; font-size: 0.8em; font-weight: normal;">(0)</span></h2>
|
|
664
668
|
<div class="search-box">
|
|
665
669
|
<input type="text" id="certSearch" placeholder="🔍 搜索证书域名...">
|
|
666
670
|
</div>
|
|
@@ -742,7 +746,10 @@
|
|
|
742
746
|
</div>
|
|
743
747
|
<div class="form-group">
|
|
744
748
|
<label for="nginxDir">Nginx路径</label>
|
|
745
|
-
<
|
|
749
|
+
<div style="display: flex; gap: 10px; align-items: flex-start;">
|
|
750
|
+
<input type="text" id="nginxDir" name="nginxDir" placeholder="请输入Nginx路径" style="flex: 1;">
|
|
751
|
+
<button type="button" onclick="testNginxPath()" style="flex: none; padding: 12px 20px; min-width: auto;">测试</button>
|
|
752
|
+
</div>
|
|
746
753
|
<div class="hint">Nginx可执行文件路径(留空则不重载)</div>
|
|
747
754
|
</div>
|
|
748
755
|
<div class="form-group">
|
|
@@ -943,6 +950,39 @@
|
|
|
943
950
|
document.getElementById('configModal').classList.remove('show');
|
|
944
951
|
}
|
|
945
952
|
|
|
953
|
+
// 测试Nginx路径
|
|
954
|
+
async function testNginxPath() {
|
|
955
|
+
const nginxPath = document.getElementById('nginxDir').value.trim();
|
|
956
|
+
if (!nginxPath) {
|
|
957
|
+
showAlert('提示', '请先输入Nginx路径', 'warning');
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
addLog('→ 开始测试 Nginx 路径...', 'info');
|
|
962
|
+
|
|
963
|
+
try {
|
|
964
|
+
const response = await authenticatedFetch('/admin/api/test-nginx', {
|
|
965
|
+
method: 'POST',
|
|
966
|
+
headers: { 'Content-Type': 'application/json' },
|
|
967
|
+
body: JSON.stringify({ nginxPath })
|
|
968
|
+
});
|
|
969
|
+
const result = await response.json();
|
|
970
|
+
|
|
971
|
+
if (result.code === 0) {
|
|
972
|
+
addLog('✓ Nginx 路径测试成功', 'success');
|
|
973
|
+
showAlert('测试成功', result.msg || 'Nginx 配置测试通过', 'success');
|
|
974
|
+
} else {
|
|
975
|
+
addLog('✗ Nginx 路径测试失败: ' + result.msg, 'error');
|
|
976
|
+
showAlert('测试失败', result.msg, 'error');
|
|
977
|
+
}
|
|
978
|
+
} catch (error) {
|
|
979
|
+
if (error.message !== '未设置密码' && error.message !== '密码验证失败') {
|
|
980
|
+
addLog('✗ Nginx 路径测试失败: ' + error.message, 'error');
|
|
981
|
+
showAlert('测试失败', error.message, 'error');
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
946
986
|
// 加载配置
|
|
947
987
|
async function loadConfig() {
|
|
948
988
|
try {
|
|
@@ -1028,6 +1068,9 @@
|
|
|
1028
1068
|
function renderCerts(certs) {
|
|
1029
1069
|
const container = document.getElementById('certListContainer');
|
|
1030
1070
|
|
|
1071
|
+
// 更新证书数量显示
|
|
1072
|
+
document.getElementById('certCount').textContent = `(${certs.length})`;
|
|
1073
|
+
|
|
1031
1074
|
if (certs.length === 0) {
|
|
1032
1075
|
container.innerHTML = '<div class="empty-state">暂无证书文件</div>';
|
|
1033
1076
|
return;
|
|
@@ -1041,6 +1084,7 @@
|
|
|
1041
1084
|
html += '<th>过期时间</th>';
|
|
1042
1085
|
html += '<th>剩余天数</th>';
|
|
1043
1086
|
html += '<th>状态</th>';
|
|
1087
|
+
html += '<th style="width: 100px;">操作</th>';
|
|
1044
1088
|
html += '</tr></thead><tbody>';
|
|
1045
1089
|
|
|
1046
1090
|
sortedCerts.forEach(cert => {
|
|
@@ -1056,6 +1100,7 @@
|
|
|
1056
1100
|
html += `<td>${formatDate(cert.notAfter)}</td>`;
|
|
1057
1101
|
html += `<td>${cert.remainingDays} 天</td>`;
|
|
1058
1102
|
html += `<td><span class="status-badge ${statusClass}">${statusText}</span></td>`;
|
|
1103
|
+
html += `<td><button onclick="downloadCert('${escapeHtml(cert.fileName)}')" style="padding: 6px 12px; font-size: 0.85em;">下载</button></td>`;
|
|
1059
1104
|
html += '</tr>';
|
|
1060
1105
|
});
|
|
1061
1106
|
|
|
@@ -1063,6 +1108,59 @@
|
|
|
1063
1108
|
container.innerHTML = html;
|
|
1064
1109
|
}
|
|
1065
1110
|
|
|
1111
|
+
// 下载证书
|
|
1112
|
+
async function downloadCert(fileName) {
|
|
1113
|
+
try {
|
|
1114
|
+
addLog(`→ 开始下载证书: ${fileName}`, 'info');
|
|
1115
|
+
|
|
1116
|
+
const password = getStoredPassword();
|
|
1117
|
+
const response = await fetch(`/admin/api/cert/download/${encodeURIComponent(fileName)}`, {
|
|
1118
|
+
method: 'GET',
|
|
1119
|
+
headers: {
|
|
1120
|
+
'X-Password': password
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
if (response.status === 401) {
|
|
1125
|
+
clearPassword();
|
|
1126
|
+
showPasswordModal();
|
|
1127
|
+
addLog('✗ 口令验证失败,请重新输入', 'error');
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (!response.ok) {
|
|
1132
|
+
const result = await response.json();
|
|
1133
|
+
throw new Error(result.msg || '下载失败');
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// 获取文件名
|
|
1137
|
+
const contentDisposition = response.headers.get('Content-Disposition');
|
|
1138
|
+
let downloadFileName = `${fileName}.zip`;
|
|
1139
|
+
if (contentDisposition) {
|
|
1140
|
+
const match = contentDisposition.match(/filename[*]?=['"]?([^'";\n]+)['"]?/);
|
|
1141
|
+
if (match && match[1]) {
|
|
1142
|
+
downloadFileName = match[1];
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// 下载文件
|
|
1147
|
+
const blob = await response.blob();
|
|
1148
|
+
const url = window.URL.createObjectURL(blob);
|
|
1149
|
+
const a = document.createElement('a');
|
|
1150
|
+
a.href = url;
|
|
1151
|
+
a.download = downloadFileName;
|
|
1152
|
+
document.body.appendChild(a);
|
|
1153
|
+
a.click();
|
|
1154
|
+
window.URL.revokeObjectURL(url);
|
|
1155
|
+
document.body.removeChild(a);
|
|
1156
|
+
|
|
1157
|
+
addLog(`✓ 证书下载成功: ${fileName}`, 'success');
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
addLog(`✗ 证书下载失败: ${error.message}`, 'error');
|
|
1160
|
+
showAlert('下载失败', error.message, 'error');
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1066
1164
|
// 过滤证书
|
|
1067
1165
|
function filterCerts() {
|
|
1068
1166
|
const keyword = document.getElementById('certSearch').value.toLowerCase();
|
package/views/error.ejs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
<h1><%= message %></h1>
|
|
2
|
-
<h2><%= error.status %></h2>
|
|
3
|
-
<pre><%= error.stack %></pre>
|
|
1
|
+
<h1><%= message %></h1>
|
|
2
|
+
<h2><%= error.status %></h2>
|
|
3
|
+
<pre><%= error.stack %></pre>
|
package/views/index.ejs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html>
|
|
3
|
-
<head>
|
|
4
|
-
<title><%= title %></title>
|
|
5
|
-
<link rel='stylesheet' href='/stylesheets/style.css' />
|
|
6
|
-
</head>
|
|
7
|
-
<body>
|
|
8
|
-
<h1><%= title %></h1>
|
|
9
|
-
</body>
|
|
10
|
-
</html>
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title><%= title %></title>
|
|
5
|
+
<link rel='stylesheet' href='/stylesheets/style.css' />
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<h1><%= title %></h1>
|
|
9
|
+
</body>
|
|
10
|
+
</html>
|