vk-ssl-auto-deploy 0.3.0 → 0.6.1

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 CHANGED
@@ -189,7 +189,6 @@ pm2-startup uninstall
189
189
  ```json
190
190
  {
191
191
  "port": 6001,
192
- "routerName": "api",
193
192
  "certSaveDir": "/vk-cert",
194
193
  "ipWhitelist": [],
195
194
  "callbackCommand": [
@@ -199,7 +198,6 @@ pm2-startup uninstall
199
198
  ```
200
199
 
201
200
  - `port`: 服务监听端口(默认 6001)(一般无需改动)
202
- - `routerName`: API 路由前缀(默认 "api",即访问路径为 `/api/...`)(一般无需改动)
203
201
  - `certSaveDir`: 证书存储目录,最终证书路径为 `${certSaveDir}/${证书id}/`(一般无需改动)
204
202
  - `ipWhitelist`: IP白名单,默认为空,表示不限制IP,如果需要限制IP,可以填写IP地址,多个IP地址用逗号分隔
205
203
  - `callbackCommand`: 证书部署成功后执行的服务器命令,一般用于重载nginx配置等
@@ -353,91 +351,6 @@ npm run delete
353
351
  npm run list
354
352
  ```
355
353
 
356
- ## 证书自动部署API
357
-
358
- ### 接口说明
359
-
360
- 证书续期后台可以通过HTTP POST请求将证书文件自动部署到本项目。
361
-
362
- ### 请求地址
363
-
364
- ```
365
- POST http://your-domain:3000/api/deploy-cert
366
- ```
367
-
368
- ### 请求参数
369
-
370
- - Content-Type: `multipart/form-data`
371
- - `name`: 证书名称(必填),用于创建子目录,例如 `aaa` 会创建 `cert/aaa` 目录
372
- - `file`: zip格式的证书文件(必填)
373
-
374
- ### 返回格式
375
-
376
- 成功响应:
377
- ```json
378
- {
379
- "code": 0,
380
- "msg": "证书部署成功",
381
- "path": "cert/aaa",
382
- "fileCount": 11
383
- }
384
- ```
385
-
386
- 失败响应:
387
- ```json
388
- {
389
- "code": 错误码,
390
- "msg": "错误信息"
391
- }
392
- ```
393
-
394
- ### 错误码说明
395
-
396
- - `1001`: 缺少参数 name
397
- - `1002`: 缺少文件
398
- - `1003`: name参数包含非法字符
399
- - `2001`: 解压zip文件失败
400
- - `5000`: 服务器内部错误
401
-
402
- ### 证书文件说明
403
-
404
- 上传的zip文件解压后应包含以下内容:
405
-
406
- - 私钥.pem(私钥文件)
407
- - 证书.pem(证书文件,包含证书链)
408
- - 中间证书.pem(中间证书文件,pem格式)
409
- - cert.crt(证书文件,包含证书链,与证书.pem内容一致)
410
- - cert.key(私钥文件,pem格式,与私钥.pem内容一致)
411
- - intermediate.crt(中间证书文件,pem格式,中间证书.pem内容一致)
412
- - cert.der(der格式证书文件)
413
- - cert.jks(jks格式证书文件,java服务器使用)
414
- - cert.p7b(p7b格式证书文件)
415
- - cert.pfx(pfx格式证书文件,iis服务器使用)
416
- - one.pem(证书和私钥简单合并成一个文件,pem格式,crt正文+key正文)
417
-
418
- ### 使用示例
419
-
420
- 使用curl命令:
421
- ```bash
422
- curl -X POST http://localhost:3000/api/deploy-cert \
423
- -F "name=example" \
424
- -F "file=@/path/to/cert.zip"
425
- ```
426
-
427
- 使用JavaScript(fetch):
428
- ```javascript
429
- const formData = new FormData();
430
- formData.append('name', 'example');
431
- formData.append('file', fileBlob);
432
-
433
- fetch('http://localhost:3000/api/deploy-cert', {
434
- method: 'POST',
435
- body: formData
436
- })
437
- .then(res => res.json())
438
- .then(data => console.log(data));
439
- ```
440
-
441
354
  ## License
442
355
 
443
356
  MIT
package/app.js CHANGED
@@ -25,7 +25,7 @@ app.use(express.static(path.join(__dirname, 'public')));
25
25
 
26
26
  app.use('/', indexRouter);
27
27
  app.use('/test', testRouter);
28
- app.use(`/${config.routerName}`, certRouter);
28
+ app.use(`/api/cert`, certRouter);
29
29
 
30
30
  // catch 404 and forward to error handler
31
31
  app.use((req, res, next) => {
package/config.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "port": 6001,
3
- "routerName": "api",
4
- "ipWhitelist": [],
5
3
  "certSaveDir": "/vk-cert",
6
- "callbackCommand": [
7
- "/www/server/nginx/sbin/nginx -s reload"
8
- ]
4
+ "nginxDir": "/www/server/nginx/sbin/nginx",
5
+ "key": "",
6
+ "domains": [],
7
+ "callbackCommand": []
9
8
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vk-ssl-auto-deploy",
3
- "version": "0.3.0",
3
+ "version": "0.6.1",
4
4
  "description": "SSL证书自动部署工具 - 提供HTTP API接口,支持证书文件自动上传和部署",
5
5
  "main": "app.js",
6
6
  "bin": {
@@ -49,13 +49,16 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "adm-zip": "^0.5.16",
52
+ "axios": "^1.6.0",
53
+ "chalk": "^4.1.2",
52
54
  "cookie-parser": "~1.4.4",
53
55
  "debug": "~2.6.9",
54
56
  "ejs": "~2.6.1",
55
57
  "express": "~4.16.1",
56
58
  "http-errors": "~1.6.3",
57
59
  "morgan": "~1.9.1",
58
- "multer": "^2.0.2"
60
+ "multer": "^2.0.2",
61
+ "node-forge": "^1.3.1"
59
62
  },
60
63
  "optionalDependencies": {
61
64
  "pm2": "^5.3.0"
package/routes/cert.js CHANGED
@@ -1,240 +1,740 @@
1
1
  const express = require('express');
2
2
  const router = express.Router();
3
- const multer = require('multer');
4
- const AdmZip = require('adm-zip');
5
3
  const fs = require('fs');
6
4
  const path = require('path');
7
5
  const { exec } = require('child_process');
8
6
  const { promisify } = require('util');
7
+ const crypto = require('crypto');
8
+ const axios = require('axios');
9
+ const forge = require('node-forge');
10
+ const chalk = require('chalk');
9
11
  const config = require('../config.json');
10
- const { validateCertFile } = require('../utils/certValidator');
11
- const { getClientIP, isIPInWhitelist } = require('../utils/ipValidator');
12
12
 
13
13
  const execAsync = promisify(exec);
14
14
 
15
- // 使用内存存储
16
- const upload = multer({
17
- storage: multer.memoryStorage(),
18
- limits: {
19
- fileSize: 10 * 1024 * 1024 // 限制文件大小为10MB
20
- }
21
- });
15
+ // 日志工具 - 带颜色输出(仅使用5种颜色:白色、灰色、黄色、绿色、红色)
16
+ const logger = {
17
+ // 错误 - 红色
18
+ error: (...args) => console.log(chalk.red(...args)),
19
+ // 警告 - 黄色
20
+ warn: (...args) => console.log(chalk.yellow(...args)),
21
+ // 成功 - 绿色
22
+ success: (...args) => console.log(chalk.green(...args)),
23
+ // 信息 - 白色
24
+ info: (...args) => console.log(...args),
25
+ // 次要信息 - 灰色
26
+ dim: (...args) => console.log(chalk.gray(...args)),
27
+ // 普通日志 - 白色
28
+ log: (...args) => console.log(...args),
29
+ // 步骤标题 - 白色
30
+ step: (...args) => console.log(...args),
31
+ // 分隔线 - 灰色
32
+ divider: () => console.log(chalk.gray('─'.repeat(60)))
33
+ };
34
+
35
+ // 无忧SSL API配置
36
+ const WUYOU_API_BASE = 'https://openapi-ssl.vk168.top/http'; // 请勿修改
37
+ const API_KEY = config.key;
38
+
39
+ // QPS限制:每次API调用后等待时间(毫秒)
40
+ const API_CALL_DELAY = 1000; // 确保QPS不超过1
41
+
42
+ // 定时任务状态
43
+ let scheduledTaskTimer = null;
44
+ let lastExecutionDate = null;
45
+ let isTaskRunning = false;
46
+ let scheduledExecutionTime = null; // 固定的执行时间 { hour, minute }
47
+
48
+ // 文件锁路径
49
+ const LOCK_FILE = path.join(__dirname, '..', '.cert-deploy.lock');
50
+ const LOCK_TIMEOUT = 10 * 60 * 1000; // 锁超时时间:10分钟
51
+
52
+ /**
53
+ * 延迟函数
54
+ */
55
+ function sleep(ms) {
56
+ return new Promise(resolve => setTimeout(resolve, ms));
57
+ }
22
58
 
23
59
  /**
24
- * POST /api/deploy-cert
25
- * 接收证书zip文件并自动部署
26
- *
27
- * 参数:
28
- * - name: 证书名称(用于创建子目录)
29
- * - file: zip文件
30
- *
31
- * 返回:
32
- * - 成功: { code: 0, msg: '证书部署成功', path: 'cert/xxx' }
33
- * - 失败: { code: 错误码, msg: '错误信息' }
60
+ * 尝试获取文件锁
61
+ * @returns {boolean} 是否成功获取锁
34
62
  */
35
- router.post('/deploy-cert', upload.single('file'), async (req, res, next) => {
63
+ function acquireLock() {
36
64
  try {
37
- // 0. IP白名单验证
38
- const clientIP = getClientIP(req);
39
- if (!isIPInWhitelist(clientIP, config.ipWhitelist)) {
40
- console.warn(`[IP白名单拦截] IP: ${clientIP} 尝试部署证书但不在白名单中`);
41
- return res.json({
42
- code: 4030,
43
- msg: 'IP地址不在白名单中,访问被拒绝',
44
- ip: clientIP
45
- });
65
+ // 检查锁文件是否存在
66
+ if (fs.existsSync(LOCK_FILE)) {
67
+ // 检查锁文件是否超时
68
+ const lockStat = fs.statSync(LOCK_FILE);
69
+ const lockAge = Date.now() - lockStat.mtimeMs;
70
+
71
+ if (lockAge > LOCK_TIMEOUT) {
72
+ // 锁已超时,删除旧锁
73
+ logger.warn('[文件锁] 检测到超时的锁文件,已清理');
74
+ fs.unlinkSync(LOCK_FILE);
75
+ } else {
76
+ // 锁仍然有效,其他实例正在执行
77
+ logger.dim('[文件锁] 其他实例正在执行任务,跳过');
78
+ return false;
79
+ }
46
80
  }
47
81
 
48
- console.log(`[证书部署请求] 来源IP: ${clientIP}`);
82
+ // 创建锁文件
83
+ const lockData = {
84
+ pid: process.pid,
85
+ startTime: new Date().toISOString(),
86
+ hostname: require('os').hostname()
87
+ };
88
+ fs.writeFileSync(LOCK_FILE, JSON.stringify(lockData, null, 2), 'utf8');
89
+ logger.dim('[文件锁] 成功获取锁');
90
+ return true;
91
+ } catch (error) {
92
+ logger.error('[文件锁] 获取锁失败:', error.message);
93
+ return false;
94
+ }
95
+ }
49
96
 
50
- // 1. 验证参数
51
- if (!req.body.name) {
52
- return res.json({ code: 1001, msg: '缺少参数: name' });
97
+ /**
98
+ * 释放文件锁
99
+ */
100
+ function releaseLock() {
101
+ try {
102
+ if (fs.existsSync(LOCK_FILE)) {
103
+ fs.unlinkSync(LOCK_FILE);
104
+ logger.dim('[文件锁] 锁已释放');
53
105
  }
106
+ } catch (error) {
107
+ logger.error('[文件锁] 释放锁失败:', error.message);
108
+ }
109
+ }
54
110
 
55
- if (!req.file) {
56
- return res.json({ code: 1002, msg: '缺少文件: file' });
111
+ /**
112
+ * 调用无忧SSL API - 获取证书列表
113
+ * @param {number} pageIndex - 页码
114
+ * @param {number} pageSize - 每页数量
115
+ * @returns {Promise<Object>} API响应
116
+ */
117
+ async function getCertList(pageIndex = 1, pageSize = 10000) {
118
+ try {
119
+ logger.info(`[API调用] 获取证书列表 - 页码: ${pageIndex}, 每页: ${pageSize}`);
120
+
121
+ const params = {
122
+ pageIndex,
123
+ pageSize,
124
+ status: 3,
125
+ key: API_KEY
126
+ };
127
+
128
+ // 添加domains过滤条件
129
+ if (config.domains && Array.isArray(config.domains) && config.domains.length > 0) {
130
+ params.domains = config.domains.join(',');
57
131
  }
58
132
 
59
- const certName = req.body.name;
133
+ const response = await axios.post(
134
+ `${WUYOU_API_BASE}/api/listCert`, params, {
135
+ timeout: 60000
136
+ }
137
+ );
138
+
139
+ logger.success(`[API响应] 获取证书列表成功 - 共 ${response.data?.data?.total || 0} 条记录`);
140
+
141
+ // QPS限制:调用后等待
142
+ await sleep(API_CALL_DELAY);
60
143
 
61
- // 验证name参数,防止路径注入
62
- if (certName.includes('..') || certName.includes('/') || certName.includes('\\')) {
63
- return res.json({ code: 1003, msg: 'name参数包含非法字符' });
144
+ return response.data;
145
+ } catch (error) {
146
+ logger.error('[API错误] 获取证书列表失败:', error.message);
147
+ throw error;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * 调用无忧SSL API - 批量下载证书
153
+ * @param {Array<string>} certIds - 证书ID列表(最多100个)
154
+ * @returns {Promise<Object>} API响应
155
+ */
156
+ async function downloadCerts(certIds) {
157
+ try {
158
+ logger.info(`[API调用] 批量下载证书 - 数量: ${certIds.length}`);
159
+ const params = {
160
+ certIds,
161
+ type: "nginx",
162
+ key: API_KEY
163
+ };
164
+
165
+ const response = await axios.post(
166
+ `${WUYOU_API_BASE}/api/certDownload`, params, {
167
+ timeout: 60000
168
+ }
169
+ );
170
+
171
+ logger.success(`[API响应] 批量下载证书成功 - ${response.data?.data?.total} 个证书`);
172
+
173
+ // QPS限制:调用后等待
174
+ await sleep(API_CALL_DELAY);
175
+
176
+ return response.data;
177
+ } catch (error) {
178
+ logger.error('[API错误] 批量下载证书失败:', error.message);
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * 解析证书内容,获取有效期等信息
185
+ * 支持 RSA、EC (ECDSA) 等多种算法
186
+ * @param {string} certContent - 证书内容(PEM格式)
187
+ * @returns {Object} 证书信息
188
+ */
189
+ function parseCertificate(certContent) {
190
+ try {
191
+ // 优先使用 Node.js 内置的 crypto 模块,支持 RSA、EC 等多种算法
192
+ // X509Certificate 在 Node.js 15.6.0+ 可用
193
+ if (crypto.X509Certificate) {
194
+ const cert = new crypto.X509Certificate(certContent);
195
+
196
+ // 解析 subject 和 issuer 中的 CN
197
+ const parseCN = (dn) => {
198
+ const cnMatch = dn.match(/CN=([^,]+)/);
199
+ return cnMatch ? cnMatch[1] : '';
200
+ };
201
+
202
+ return {
203
+ notBefore: new Date(cert.validFrom),
204
+ notAfter: new Date(cert.validTo),
205
+ subject: parseCN(cert.subject),
206
+ issuer: parseCN(cert.issuer)
207
+ };
64
208
  }
65
209
 
66
- // 2. 创建cert目录和子目录
67
- const projectRoot = path.join(__dirname, '..');
68
- // 使用配置文件中的 certSaveDir,支持绝对路径和相对路径
69
- const certBaseDir = path.isAbsolute(config.certSaveDir) ?
70
- config.certSaveDir :
71
- path.join(projectRoot, config.certSaveDir);
72
- const certDir = path.resolve(certBaseDir, certName);
73
- const userCertDir = certDir.replace(/\\/g, '/');
210
+ // 降级方案:尝试使用 node-forge(可能不支持 EC 算法)
211
+ // 如果证书是 EC 算法,这里可能会失败,但至少尝试一下
212
+ try {
213
+ const cert = forge.pki.certificateFromPem(certContent);
214
+ return {
215
+ notBefore: cert.validity.notBefore,
216
+ notAfter: cert.validity.notAfter,
217
+ subject: cert.subject.attributes.find(attr => attr.name === 'commonName')?.value || '',
218
+ issuer: cert.issuer.attributes.find(attr => attr.name === 'commonName')?.value || ''
219
+ };
220
+ } catch (forgeError) {
221
+ // node-forge 不支持 EC 算法,提示需要升级 Node.js
222
+ if (forgeError.message && forgeError.message.includes('OID is not RSA')) {
223
+ logger.error('[证书解析错误] 证书使用 EC 算法,需要 Node.js 15.6.0+ 版本支持');
224
+ } else {
225
+ logger.error('[证书解析错误]', forgeError.message);
226
+ }
227
+ return null;
228
+ }
229
+ } catch (error) {
230
+ logger.error('[证书解析错误]', error.message);
231
+ return null;
232
+ }
233
+ }
74
234
 
75
- // 确保cert根目录存在
76
- if (!fs.existsSync(certBaseDir)) {
77
- fs.mkdirSync(certBaseDir, { recursive: true });
235
+ /**
236
+ * 检查本地证书文件的有效期
237
+ * @param {string} certFilePath - 证书文件路径(.crt或.pem)
238
+ * @returns {number} 剩余天数,如果文件不存在或解析失败返回-1
239
+ */
240
+ function getCertRemainingDays(certFilePath) {
241
+ try {
242
+ if (!fs.existsSync(certFilePath)) {
243
+ return -1;
78
244
  }
79
245
 
80
- // 如果目标目录已存在,先删除(实现覆盖更新)
81
- if (fs.existsSync(certDir)) {
82
- fs.rmSync(certDir, { recursive: true, force: true });
246
+ const certContent = fs.readFileSync(certFilePath, 'utf8');
247
+ const certInfo = parseCertificate(certContent);
248
+
249
+ if (!certInfo) {
250
+ return -1;
83
251
  }
84
252
 
85
- // 创建目标目录
253
+ const now = new Date();
254
+ const expiryDate = new Date(certInfo.notAfter);
255
+ const remainingMs = expiryDate - now;
256
+ const remainingDays = Math.floor(remainingMs / (1000 * 60 * 60 * 24));
257
+
258
+ return remainingDays;
259
+ } catch (error) {
260
+ logger.error('[证书检查错误]', error.message);
261
+ return -1;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * 根据域名列表生成文件名
267
+ * @param {Array<string>} domains - 域名列表
268
+ * @returns {string} 文件名(不含扩展名)
269
+ */
270
+ function generateFileName(domains) {
271
+ if (!domains || domains.length === 0) {
272
+ return 'unknown';
273
+ }
274
+
275
+ // 移除通配符并排序,找出主域名
276
+ const cleanDomains = domains.map(d => d.replace(/^\*\./, '')).sort();
277
+ const mainDomain = cleanDomains[0];
278
+
279
+ // 统计有多少个不同的主域名
280
+ const uniqueDomains = [...new Set(cleanDomains)];
281
+
282
+ if (uniqueDomains.length === 1) {
283
+ return mainDomain;
284
+ } else {
285
+ return `${mainDomain}-${uniqueDomains.length}`;
286
+ }
287
+ }
288
+
289
+ /**
290
+ * 保存证书文件
291
+ * @param {string} fileName - 文件名(不含扩展名)
292
+ * @param {string} certContent - 证书内容
293
+ * @param {string} keyContent - 私钥内容
294
+ */
295
+ function saveCertFiles(fileName, certContent, keyContent) {
296
+ const certDir = path.isAbsolute(config.certSaveDir) ?
297
+ config.certSaveDir :
298
+ path.join(__dirname, '..', config.certSaveDir);
299
+
300
+ // 确保目录存在
301
+ if (!fs.existsSync(certDir)) {
86
302
  fs.mkdirSync(certDir, { recursive: true });
303
+ logger.dim(`[目录创建] ${certDir}`);
304
+ }
87
305
 
88
- // 3. 解压zip文件并进行安全验证
89
- try {
90
- const zip = new AdmZip(req.file.buffer);
91
- const zipEntries = zip.getEntries();
92
-
93
- // 统计信息
94
- const stats = {
95
- total: 0, // 总文件数
96
- saved: 0, // 成功保存的文件数
97
- filtered: 0, // 被过滤的文件数
98
- savedFiles: [], // 保存的文件列表
99
- filteredFiles: [] // 被过滤的文件列表(含原因)
100
- };
306
+ // 保存三个文件
307
+ const crtFile = path.join(certDir, `${fileName}.crt`);
308
+ const keyFile = path.join(certDir, `${fileName}.key`);
309
+ const pemFile = path.join(certDir, `${fileName}.pem`);
101
310
 
102
- // 遍历并验证每个文件
103
- zipEntries.forEach((entry) => {
104
- if (!entry.isDirectory) {
105
- stats.total++;
106
-
107
- const filename = path.basename(entry.entryName);
108
- const entryData = entry.getData();
109
-
110
- // 安全验证
111
- const validation = validateCertFile(filename, entryData, entry.entryName);
112
-
113
- if (validation.valid) {
114
- // 验证通过,保存文件
115
- try {
116
- const entryPath = path.join(certDir, entry.entryName);
117
-
118
- // 确保父目录存在
119
- const entryDir = path.dirname(entryPath);
120
- if (!fs.existsSync(entryDir)) {
121
- fs.mkdirSync(entryDir, { recursive: true });
122
- }
123
-
124
- // 写入文件
125
- fs.writeFileSync(entryPath, entryData);
126
-
127
- stats.saved++;
128
- stats.savedFiles.push(entry.entryName);
129
-
130
- console.log(`[文件已保存] ${entry.entryName} (${entryData.length} bytes)`);
131
- } catch (writeError) {
132
- console.error(`[文件写入失败] ${entry.entryName}:`, writeError.message);
133
- stats.filtered++;
134
- stats.filteredFiles.push({
135
- filename: entry.entryName,
136
- reason: '文件写入失败: ' + writeError.message
137
- });
138
- }
139
- } else {
140
- // 验证失败,过滤该文件
141
- stats.filtered++;
142
- stats.filteredFiles.push({
143
- filename: entry.entryName,
144
- reason: validation.reason
145
- });
311
+ fs.writeFileSync(crtFile, certContent, 'utf8');
312
+ fs.writeFileSync(keyFile, keyContent, 'utf8');
313
+ fs.writeFileSync(pemFile, certContent, 'utf8'); // pem内容与crt相同
146
314
 
147
- console.warn(`[文件已过滤] ${entry.entryName}: ${validation.reason}`);
148
- }
149
- }
315
+ logger.success(`[证书保存] ${fileName}.crt, ${fileName}.key, ${fileName}.pem`);
316
+ }
317
+
318
+ /**
319
+ * 重载Nginx配置
320
+ */
321
+ async function reloadNginx() {
322
+ if (!config.nginxDir || !config.nginxDir.trim()) {
323
+ logger.dim('[Nginx重载] 未配置nginxDir,跳过重载');
324
+ return { success: true, message: '未配置nginxDir' };
325
+ }
326
+
327
+ const nginxCommand = `${config.nginxDir} -s reload`;
328
+
329
+ try {
330
+ logger.info(`[Nginx重载] 执行命令: ${nginxCommand}`);
331
+ const { stdout, stderr } = await execAsync(nginxCommand, {
332
+ timeout: 30000
333
+ });
334
+
335
+ logger.success('[Nginx重载] 成功');
336
+ if (stdout) logger.dim(`[输出] ${stdout.trim()}`);
337
+ if (stderr) logger.warn(`[错误输出] ${stderr.trim()}`);
338
+
339
+ return { success: true, message: 'Nginx重载成功', output: stdout, error: stderr };
340
+ } catch (error) {
341
+ logger.error('[Nginx重载] 失败:', error.message);
342
+ return { success: false, message: 'Nginx重载失败', error: error.message };
343
+ }
344
+ }
345
+
346
+ /**
347
+ * 执行证书自动部署任务
348
+ */
349
+ async function executeDeployTask() {
350
+ if (isTaskRunning) {
351
+ logger.warn('[任务执行] 已有任务正在执行,跳过本次执行');
352
+ return { success: false, message: '任务正在执行中' };
353
+ }
354
+
355
+ // 尝试获取文件锁
356
+ if (!acquireLock()) {
357
+ return { success: false, message: '无法获取文件锁,其他实例正在执行' };
358
+ }
359
+
360
+ isTaskRunning = true;
361
+ const startTime = new Date();
362
+ logger.divider();
363
+ logger.step(`\n[任务开始] ${startTime.toLocaleString()}\n`);
364
+ logger.divider();
365
+
366
+ const result = {
367
+ success: false,
368
+ message: '',
369
+ startTime,
370
+ endTime: null,
371
+ duration: 0,
372
+ stats: {
373
+ totalCerts: 0,
374
+ checkedCerts: 0,
375
+ needUpdateCerts: 0,
376
+ downloadedCerts: 0,
377
+ failedCerts: 0
378
+ },
379
+ details: []
380
+ };
381
+
382
+ try {
383
+ // 步骤1:获取证书列表
384
+ logger.step('\n[步骤1] 获取证书列表...');
385
+ const certListResponse = await getCertList(1, 10000);
386
+
387
+ if (!certListResponse || certListResponse.code !== 0) {
388
+ throw new Error(`获取证书列表失败: ${certListResponse?.message || '未知错误'}`);
389
+ }
390
+
391
+ const certList = certListResponse.data?.rows || [];
392
+ result.stats.totalCerts = certList.length;
393
+ logger.success(`[步骤1] 共获取 ${certList.length} 个证书\n`);
394
+
395
+ if (certList.length === 0) {
396
+ result.success = true;
397
+ result.message = '没有需要处理的证书';
398
+ return result;
399
+ }
400
+
401
+ // 步骤2:检查本地证书,筛选需要更新的证书
402
+ logger.step('[步骤2] 检查本地证书有效期...');
403
+ const certsToUpdate = [];
404
+ const certDir = path.isAbsolute(config.certSaveDir) ?
405
+ config.certSaveDir :
406
+ path.join(__dirname, '..', config.certSaveDir);
407
+
408
+ // 先收集所有证书的有效期信息
409
+ const certStatusList = [];
410
+ for (const cert of certList) {
411
+ result.stats.checkedCerts++;
412
+
413
+ const fileName = generateFileName(cert.domains);
414
+ const certFilePath = path.join(certDir, `${fileName}.crt`);
415
+ const remainingDays = getCertRemainingDays(certFilePath);
416
+
417
+ certStatusList.push({
418
+ cert,
419
+ fileName,
420
+ remainingDays
150
421
  });
422
+ }
423
+
424
+ // 按有效期降序排序(不存在的证书排在最后)
425
+ certStatusList.sort((a, b) => {
426
+ if (a.remainingDays === -1 && b.remainingDays === -1) return 0;
427
+ if (a.remainingDays === -1) return 1; // 不存在的排后面
428
+ if (b.remainingDays === -1) return -1; // 不存在的排后面
429
+ return b.remainingDays - a.remainingDays; // 降序排列
430
+ });
431
+
432
+ // 按排序后的顺序打印日志
433
+ for (const { cert, fileName, remainingDays } of certStatusList) {
434
+ if (remainingDays === -1) {
435
+ // 证书文件不存在
436
+ logger.warn(`[需要下载] ${fileName} - 本地证书不存在`);
437
+ certsToUpdate.push(cert);
438
+ result.details.push({
439
+ certId: cert._id,
440
+ domains: cert.domains,
441
+ fileName,
442
+ reason: '本地证书不存在',
443
+ remainingDays: -1
444
+ });
445
+ } else if (remainingDays < 14) {
446
+ // 证书有效期不足14天
447
+ logger.warn(`[需要更新] ${fileName} - 剩余有效期: ${remainingDays} 天`);
448
+ certsToUpdate.push(cert);
449
+ result.details.push({
450
+ certId: cert._id,
451
+ domains: cert.domains,
452
+ fileName,
453
+ reason: `有效期不足14天(剩余${remainingDays}天)`,
454
+ remainingDays
455
+ });
456
+ } else {
457
+ logger.dim(`[无需更新] ${fileName} - 剩余有效期: ${remainingDays} 天`);
458
+ }
459
+ }
460
+
461
+ result.stats.needUpdateCerts = certsToUpdate.length;
462
+ logger.success(`[步骤2] 需要更新 ${certsToUpdate.length} 个证书\n`);
463
+
464
+ if (certsToUpdate.length === 0) {
465
+ result.success = true;
466
+ result.message = '所有证书均在有效期内,无需更新';
467
+ return result;
468
+ }
469
+
470
+ // 步骤3:分批下载证书(每批最多100个)
471
+ logger.step('[步骤3] 开始下载证书...');
472
+ const batchSize = 100;
473
+ const batches = [];
474
+
475
+ for (let i = 0; i < certsToUpdate.length; i += batchSize) {
476
+ batches.push(certsToUpdate.slice(i, i + batchSize));
477
+ }
478
+
479
+ logger.info(`[步骤3] 共分 ${batches.length} 批下载`);
151
480
 
152
- // 检查是否至少保存了一个文件
153
- if (stats.saved === 0) {
154
- // 如果没有任何有效文件,删除创建的目录
155
- if (fs.existsSync(certDir)) {
156
- fs.rmSync(certDir, { recursive: true, force: true });
481
+ for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
482
+ const batch = batches[batchIndex];
483
+ logger.info(`\n[批次 ${batchIndex + 1}/${batches.length}] 下载 ${batch.length} 个证书...`);
484
+
485
+ const certIds = batch.map(cert => cert._id);
486
+
487
+ try {
488
+ const downloadResponse = await downloadCerts(certIds);
489
+
490
+ if (!downloadResponse || downloadResponse.code !== 0) {
491
+ throw new Error(`下载失败: ${downloadResponse?.message || '未知错误'}`);
157
492
  }
158
493
 
159
- return res.json({
160
- code: 2002,
161
- msg: 'zip文件中没有有效的证书文件',
162
- stats: stats
494
+ const downloadedCerts = downloadResponse.data?.certs || [];
495
+ logger.success(`[批次 ${batchIndex + 1}/${batches.length}] 成功下载 ${downloadedCerts.length} 个证书`);
496
+
497
+ // 步骤4:保存证书文件
498
+ for (const certData of downloadedCerts) {
499
+ try {
500
+ const fileName = generateFileName(certData.domains);
501
+ saveCertFiles(fileName, certData.cert_data.pem, certData.cert_data.key);
502
+ result.stats.downloadedCerts++;
503
+
504
+ // 更新详情
505
+ const detail = result.details.find(d => d.certId === certData._id);
506
+ if (detail) {
507
+ detail.success = true;
508
+ detail.savedFiles = [`${fileName}.crt`, `${fileName}.key`, `${fileName}.pem`];
509
+ }
510
+ } catch (saveError) {
511
+ logger.error(`[保存失败] 证书ID: ${certData._id}`, saveError.message);
512
+ result.stats.failedCerts++;
513
+
514
+ const detail = result.details.find(d => d.certId === certData._id);
515
+ if (detail) {
516
+ detail.success = false;
517
+ detail.error = saveError.message;
518
+ }
519
+ }
520
+ }
521
+ } catch (batchError) {
522
+ logger.error(`[批次 ${batchIndex + 1}/${batches.length}] 下载失败:`, batchError.message);
523
+ result.stats.failedCerts += batch.length;
524
+
525
+ // 标记该批次的所有证书为失败
526
+ batch.forEach(cert => {
527
+ const detail = result.details.find(d => d.certId === cert._id);
528
+ if (detail) {
529
+ detail.success = false;
530
+ detail.error = batchError.message;
531
+ }
163
532
  });
164
533
  }
534
+ }
165
535
 
166
- console.log(`[证书部署成功] 名称: ${certName}, 路径: ${userCertDir}, 保存: ${stats.saved}个, 过滤: ${stats.filtered}个`);
536
+ logger.success(`\n[步骤3] 证书下载完成 - 成功: ${result.stats.downloadedCerts}, 失败: ${result.stats.failedCerts}\n`);
537
+
538
+ // 步骤5:重载Nginx
539
+ if (result.stats.downloadedCerts > 0) {
540
+ logger.step('[步骤4] 重载Nginx配置...');
541
+ const nginxResult = await reloadNginx();
542
+ result.nginxReload = nginxResult;
543
+ if (nginxResult.success) {
544
+ logger.success(`[步骤4] Nginx重载成功\n`);
545
+ } else {
546
+ logger.error(`[步骤4] Nginx重载失败\n`);
547
+ }
548
+ }
167
549
 
168
- // 4. 执行回调命令
169
- const commandResults = [];
170
- if (config.callbackCommand && Array.isArray(config.callbackCommand) && config.callbackCommand.length > 0) {
171
- console.log(`[开始执行回调命令] 共${config.callbackCommand.length}个命令`);
550
+ result.success = true;
551
+ result.message = `任务完成 - 成功更新 ${result.stats.downloadedCerts} 个证书`;
172
552
 
173
- for (let i = 0; i < config.callbackCommand.length; i++) {
174
- const command = config.callbackCommand[i];
175
- const commandResult = {
176
- command: command,
177
- index: i + 1,
178
- success: false,
179
- output: '',
180
- error: ''
181
- };
553
+ } catch (error) {
554
+ logger.error('[任务执行错误]', error);
555
+ result.success = false;
556
+ result.message = `任务失败: ${error.message}`;
557
+ result.error = error.stack;
558
+ } finally {
559
+ isTaskRunning = false;
560
+ releaseLock(); // 释放文件锁
561
+ result.endTime = new Date();
562
+ result.duration = Math.floor((result.endTime - result.startTime) / 1000);
563
+
564
+ logger.divider();
565
+ logger.step(`\n[任务结束] ${result.endTime.toLocaleString()}`);
566
+ if (result.success) {
567
+ logger.success(`[任务结果] ✓ 成功 - ${result.message}`);
568
+ } else {
569
+ logger.error(`[任务结果] ✗ 失败 - ${result.message}`);
570
+ }
571
+ logger.info(`[执行时长] ${result.duration} 秒`);
572
+ logger.info(
573
+ `[统计信息] 总数: ${result.stats.totalCerts}, 检查: ${result.stats.checkedCerts}, 需更新: ${result.stats.needUpdateCerts}, 已下载: ${result.stats.downloadedCerts}, 失败: ${result.stats.failedCerts}`
574
+ );
575
+ logger.dim(`[证书保存目录] ${config.certSaveDir}`);
576
+ logger.divider();
577
+ console.log('');
578
+ }
182
579
 
183
- try {
184
- console.log(`[执行命令 ${i + 1}/${config.callbackCommand.length}] ${command}`);
185
- const { stdout, stderr } = await execAsync(command, {
186
- timeout: 30000, // 30秒超时
187
- maxBuffer: 1024 * 1024 // 1MB buffer
188
- });
580
+ return result;
581
+ }
189
582
 
190
- commandResult.success = true;
191
- commandResult.output = stdout ? stdout.trim() : '';
192
- if (stderr) {
193
- commandResult.error = stderr.trim();
194
- }
583
+ /**
584
+ * 初始化定时任务
585
+ * 服务启动时随机确定一个执行时间(9:00-10:59之间),每天在该固定时间执行
586
+ */
587
+ function initScheduledTask() {
588
+ logger.info('[定时任务] 初始化定时任务...');
195
589
 
196
- console.log(`[命令执行成功 ${i + 1}/${config.callbackCommand.length}] ${command}`);
197
- if (stdout) console.log(`[命令输出] ${stdout.trim()}`);
198
- if (stderr) console.warn(`[命令stderr] ${stderr.trim()}`);
590
+ // 清除旧的定时器
591
+ if (scheduledTaskTimer) {
592
+ clearInterval(scheduledTaskTimer);
593
+ }
199
594
 
200
- } catch (cmdError) {
201
- commandResult.success = false;
202
- commandResult.error = cmdError.message;
203
- if (cmdError.stdout) commandResult.output = cmdError.stdout.trim();
204
- if (cmdError.stderr) commandResult.error += '\n' + cmdError.stderr.trim();
595
+ // 生成固定的随机执行时间(只在服务启动时生成一次)
596
+ if (!scheduledExecutionTime) {
597
+ // 生成9:00-10:59之间的随机分钟数(0-119分钟)
598
+ const randomMinute = Math.floor(Math.random() * 120);
599
+ scheduledExecutionTime = {
600
+ hour: 9 + Math.floor(randomMinute / 60),
601
+ minute: randomMinute % 60
602
+ };
603
+
604
+ // scheduledExecutionTime = {
605
+ // hour: 20,
606
+ // minute: 42
607
+ // }
608
+
609
+ logger.success(
610
+ `[定时任务] 已设定执行时间为每天 ${scheduledExecutionTime.hour}:${scheduledExecutionTime.minute.toString().padStart(2, '0')}`
611
+ );
612
+ }
205
613
 
206
- console.error(`[命令执行失败 ${i + 1}/${config.callbackCommand.length}] ${command}:`, cmdError.message);
207
- }
614
+ // 每秒检查一次,覆盖式输出当前时间
615
+ scheduledTaskTimer = setInterval(() => {
616
+ try {
617
+ const now = new Date();
618
+ const currentHour = now.getHours();
619
+ const currentMinute = now.getMinutes();
620
+ const currentSecond = now.getSeconds();
621
+ const currentDate = now.toDateString();
622
+
623
+ // 只在任务未执行时,覆盖式输出倒计时(不换行)
624
+ if (!isTaskRunning) {
625
+ // 计算下次执行时间
626
+ const targetTime = new Date(now);
627
+ targetTime.setHours(scheduledExecutionTime.hour, scheduledExecutionTime.minute, 0, 0);
628
+
629
+ // 如果今天的执行时间已过,则计算明天的执行时间
630
+ if (now >= targetTime) {
631
+ targetTime.setDate(targetTime.getDate() + 1);
632
+ }
208
633
 
209
- commandResults.push(commandResult);
634
+ // 计算剩余时间(毫秒)
635
+ const remainingMs = targetTime - now;
636
+ const remainingSeconds = Math.floor(remainingMs / 1000);
637
+ const hours = Math.floor(remainingSeconds / 3600);
638
+ const minutes = Math.floor((remainingSeconds % 3600) / 60);
639
+ const seconds = remainingSeconds % 60;
640
+
641
+ // 格式化倒计时文本
642
+ let countdownText = '';
643
+ if (hours > 0) {
644
+ countdownText = `${hours}小时${minutes}分${seconds}秒 后执行`;
645
+ } else if (minutes > 0) {
646
+ countdownText = `${minutes}分${seconds}秒 后执行`;
647
+ } else {
648
+ countdownText = `${seconds}秒 后执行`;
210
649
  }
211
650
 
212
- console.log(`[回调命令执行完成] 成功: ${commandResults.filter(r => r.success).length}个, 失败: ${commandResults.filter(r => !r.success).length}个`);
651
+ process.stdout.write(`\r[定时任务] 将在 ${countdownText}${' '.repeat(5)}`);
213
652
  }
214
653
 
215
- return res.json({
216
- code: 0,
217
- msg: '证书部署成功',
218
- path: userCertDir,
219
- stats: {
220
- total: stats.total,
221
- saved: stats.saved,
222
- filtered: stats.filtered,
223
- savedFiles: stats.savedFiles,
224
- filteredFiles: stats.filteredFiles
225
- },
226
- commandResults: commandResults.length > 0 ? commandResults : undefined
227
- });
654
+ // 只在秒数为 0 时检查是否到达执行时间
655
+ if (currentSecond === 0) {
656
+ if (currentHour === scheduledExecutionTime.hour && currentMinute === scheduledExecutionTime.minute) {
657
+ // 检查今天是否已经执行过
658
+ if (lastExecutionDate !== currentDate) {
659
+ // 换行后输出触发信息
660
+ console.log('');
661
+ logger.success(
662
+ `[定时任务] 触发执行 - 当前时间: ${currentHour}:${currentMinute.toString().padStart(2, '0')}`
663
+ );
664
+ lastExecutionDate = currentDate;
665
+
666
+ // 异步执行任务,使用try-catch确保异常不会影响定时器
667
+ executeDeployTask().catch(error => {
668
+ logger.error('[定时任务] 执行失败:', error);
669
+ });
670
+ }
671
+ }
672
+ }
673
+ } catch (error) {
674
+ // 捕获定时器内的所有异常,确保定时器不会停止
675
+ console.log(''); // 换行
676
+ logger.error('[定时任务] 检查时发生异常:', error);
677
+ }
678
+ }, 1000); // 每秒检查一次
679
+
680
+ logger.success(
681
+ `[定时任务] 定时任务已启动 - 每天 ${scheduledExecutionTime.hour}:${scheduledExecutionTime.minute.toString().padStart(2, '0')} 执行`
682
+ );
683
+ }
228
684
 
229
- } catch (zipError) {
230
- console.error('[解压失败]', zipError);
231
- return res.json({ code: 2001, msg: '解压zip文件失败: ' + zipError.message });
685
+ /**
686
+ * GET /api/cert/status
687
+ * 获取任务状态
688
+ */
689
+ router.get('/status', (req, res) => {
690
+ res.json({
691
+ code: 0,
692
+ msg: 'success',
693
+ data: {
694
+ isTaskRunning,
695
+ lastExecutionDate,
696
+ scheduledTaskEnabled: !!scheduledTaskTimer,
697
+ scheduledExecutionTime: scheduledExecutionTime ?
698
+ `${scheduledExecutionTime.hour}:${scheduledExecutionTime.minute.toString().padStart(2, '0')}` : null,
699
+ config: {
700
+ certSaveDir: config.certSaveDir,
701
+ nginxDir: config.nginxDir,
702
+ domainsFilter: config.domains
703
+ }
232
704
  }
705
+ });
706
+ });
233
707
 
234
- } catch (error) {
235
- console.error('[证书部署错误]', error);
236
- return res.json({ code: 5000, msg: '服务器内部错误: ' + error.message });
708
+ /**
709
+ * GET /api/cert/execute
710
+ * 手动触发任务执行
711
+ */
712
+ router.get('/execute', async (req, res) => {
713
+ logger.info('[手动执行] 收到手动执行请求');
714
+
715
+ if (isTaskRunning) {
716
+ return res.json({
717
+ code: 1001,
718
+ msg: '任务正在执行中,请稍后再试',
719
+ data: null
720
+ });
237
721
  }
722
+
723
+ // 异步执行任务,立即返回响应
724
+ executeDeployTask().catch(error => {
725
+ logger.error('[手动执行] 任务执行失败:', error);
726
+ });
727
+
728
+ res.json({
729
+ code: 0,
730
+ msg: '任务已开始执行,请通过 /api/cert/status 查看执行状态',
731
+ data: {
732
+ startTime: new Date().toISOString()
733
+ }
734
+ });
238
735
  });
239
736
 
737
+ // 启动时初始化定时任务
738
+ initScheduledTask();
739
+
240
740
  module.exports = router;