@certd/plugin-cert 1.0.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/.eslintrc +22 -0
- package/.mocharc.json +5 -0
- package/.prettierrc +3 -0
- package/LICENSE +661 -0
- package/README.md +16 -0
- package/index.ts +1 -0
- package/package.json +46 -0
- package/src/dns-provider/api.ts +23 -0
- package/src/dns-provider/decorator.ts +30 -0
- package/src/dns-provider/index.ts +3 -0
- package/src/dns-provider/registry.ts +3 -0
- package/src/index.ts +2 -0
- package/src/plugin/cert-plugin/acme.ts +205 -0
- package/src/plugin/cert-plugin/cert-reader.ts +52 -0
- package/src/plugin/cert-plugin/index.ts +276 -0
- package/src/plugin/index.ts +2 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +24 -0
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@certd/plugin-cert",
|
|
3
|
+
"private": false,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"main": "./dist/plugin-cert.umd.js",
|
|
6
|
+
"module": "./dist/plugin-cert.mjs",
|
|
7
|
+
"types": "./dist/es/plugin-cert.d.ts",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@certd/acme-client": "^1.0.0",
|
|
10
|
+
"@certd/pipeline": "^1.0.0",
|
|
11
|
+
"node-forge": "^0.10.0"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@alicloud/cs20151215": "^3.0.3",
|
|
15
|
+
"@alicloud/openapi-client": "^0.4.0",
|
|
16
|
+
"@alicloud/pop-core": "^1.7.10",
|
|
17
|
+
"@midwayjs/core": "^3.0.0",
|
|
18
|
+
"@midwayjs/decorator": "^3.0.0",
|
|
19
|
+
"@types/chai": "^4.3.3",
|
|
20
|
+
"@types/lodash": "^4.14.186",
|
|
21
|
+
"@types/mocha": "^10.0.0",
|
|
22
|
+
"@types/node-forge": "^1.3.0",
|
|
23
|
+
"@typescript-eslint/eslint-plugin": "^5.38.1",
|
|
24
|
+
"@typescript-eslint/parser": "^5.38.1",
|
|
25
|
+
"chai": "^4.3.6",
|
|
26
|
+
"dayjs": "^1.11.6",
|
|
27
|
+
"eslint": "^8.24.0",
|
|
28
|
+
"eslint-config-prettier": "^8.5.0",
|
|
29
|
+
"eslint-plugin-import": "^2.26.0",
|
|
30
|
+
"eslint-plugin-node": "^11.1.0",
|
|
31
|
+
"eslint-plugin-prettier": "^4.2.1",
|
|
32
|
+
"lodash": "^4.17.21",
|
|
33
|
+
"log4js": "^6.7.1",
|
|
34
|
+
"mocha": "^10.1.0",
|
|
35
|
+
"ts-node": "^10.9.1",
|
|
36
|
+
"typescript": "^4.8.4",
|
|
37
|
+
"vite": "^3.1.0",
|
|
38
|
+
"vue-tsc": "^0.38.9"
|
|
39
|
+
},
|
|
40
|
+
"gitHead": "5950e1cae7cf30ebfc5128c15c7d1b0d101cbbb8",
|
|
41
|
+
"scripts": {
|
|
42
|
+
"dev": "vite",
|
|
43
|
+
"build": "vue-tsc --noEmit && vite build",
|
|
44
|
+
"preview": "vite preview"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Registrable } from "@certd/pipeline";
|
|
2
|
+
|
|
3
|
+
export type DnsProviderDefine = Registrable & {
|
|
4
|
+
accessType: string;
|
|
5
|
+
autowire?: {
|
|
6
|
+
[key: string]: any;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type CreateRecordOptions = {
|
|
11
|
+
fullRecord: string;
|
|
12
|
+
type: string;
|
|
13
|
+
value: any;
|
|
14
|
+
};
|
|
15
|
+
export type RemoveRecordOptions = CreateRecordOptions & {
|
|
16
|
+
record: any;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface IDnsProvider {
|
|
20
|
+
onInstance(): Promise<void>;
|
|
21
|
+
createRecord(options: CreateRecordOptions): Promise<any>;
|
|
22
|
+
removeRecord(options: RemoveRecordOptions): Promise<any>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { dnsProviderRegistry } from "./registry";
|
|
2
|
+
import { DnsProviderDefine } from "./api";
|
|
3
|
+
import { Decorator, AUTOWIRE_KEY } from "@certd/pipeline";
|
|
4
|
+
import _ from "lodash";
|
|
5
|
+
|
|
6
|
+
// 提供一个唯一 key
|
|
7
|
+
export const DNS_PROVIDER_CLASS_KEY = "pipeline:dns-provider";
|
|
8
|
+
|
|
9
|
+
export function IsDnsProvider(define: DnsProviderDefine): ClassDecorator {
|
|
10
|
+
return (target: any) => {
|
|
11
|
+
target = Decorator.target(target);
|
|
12
|
+
const autowires: any = {};
|
|
13
|
+
const properties = Decorator.getClassProperties(target);
|
|
14
|
+
for (const property in properties) {
|
|
15
|
+
const autowire = Reflect.getMetadata(AUTOWIRE_KEY, target, property);
|
|
16
|
+
if (autowire) {
|
|
17
|
+
autowires[property] = autowire;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
_.merge(define, { autowire: autowires });
|
|
21
|
+
|
|
22
|
+
Reflect.defineMetadata(DNS_PROVIDER_CLASS_KEY, define, target);
|
|
23
|
+
|
|
24
|
+
target.define = define;
|
|
25
|
+
dnsProviderRegistry.register(define.name, {
|
|
26
|
+
define,
|
|
27
|
+
target,
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// @ts-ignore
|
|
2
|
+
import * as acme from "@certd/acme-client";
|
|
3
|
+
import _ from "lodash";
|
|
4
|
+
import { Challenge } from "@certd/acme-client/types/rfc8555";
|
|
5
|
+
import { Logger } from "log4js";
|
|
6
|
+
import { IContext } from "@certd/pipeline/src/core/context";
|
|
7
|
+
import { IDnsProvider } from "../../dns-provider";
|
|
8
|
+
export type CertInfo = {
|
|
9
|
+
crt: string;
|
|
10
|
+
key: string;
|
|
11
|
+
csr: string;
|
|
12
|
+
};
|
|
13
|
+
export class AcmeService {
|
|
14
|
+
userContext: IContext;
|
|
15
|
+
logger: Logger;
|
|
16
|
+
constructor(options: { userContext: IContext; logger: Logger }) {
|
|
17
|
+
this.userContext = options.userContext;
|
|
18
|
+
this.logger = options.logger;
|
|
19
|
+
acme.setLogger((text: string) => {
|
|
20
|
+
this.logger.info(text);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async getAccountConfig(email: string): Promise<any> {
|
|
25
|
+
return (await this.userContext.getObj(this.buildAccountKey(email))) || {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
buildAccountKey(email: string) {
|
|
29
|
+
return "acme.config." + email;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async saveAccountConfig(email: string, conf: any) {
|
|
33
|
+
await this.userContext.setObj(this.buildAccountKey(email), conf);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async getAcmeClient(email: string, isTest = false): Promise<acme.Client> {
|
|
37
|
+
const conf = await this.getAccountConfig(email);
|
|
38
|
+
if (conf.key == null) {
|
|
39
|
+
conf.key = await this.createNewKey();
|
|
40
|
+
await this.saveAccountConfig(email, conf);
|
|
41
|
+
}
|
|
42
|
+
const client = new acme.Client({
|
|
43
|
+
directoryUrl: isTest ? acme.directory.letsencrypt.staging : acme.directory.letsencrypt.production,
|
|
44
|
+
accountKey: conf.key,
|
|
45
|
+
accountUrl: conf.accountUrl,
|
|
46
|
+
backoffAttempts: 20,
|
|
47
|
+
backoffMin: 5000,
|
|
48
|
+
backoffMax: 10000,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (conf.accountUrl == null) {
|
|
52
|
+
const accountPayload = {
|
|
53
|
+
termsOfServiceAgreed: true,
|
|
54
|
+
contact: [`mailto:${email}`],
|
|
55
|
+
};
|
|
56
|
+
await client.createAccount(accountPayload);
|
|
57
|
+
conf.accountUrl = client.getAccountUrl();
|
|
58
|
+
await this.saveAccountConfig(email, conf);
|
|
59
|
+
}
|
|
60
|
+
return client;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async createNewKey() {
|
|
64
|
+
const key = await acme.forge.createPrivateKey();
|
|
65
|
+
return key.toString();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async challengeCreateFn(authz: any, challenge: any, keyAuthorization: string, dnsProvider: IDnsProvider) {
|
|
69
|
+
this.logger.info("Triggered challengeCreateFn()");
|
|
70
|
+
|
|
71
|
+
/* http-01 */
|
|
72
|
+
if (challenge.type === "http-01") {
|
|
73
|
+
const filePath = `/var/www/html/.well-known/acme-challenge/${challenge.token}`;
|
|
74
|
+
const fileContents = keyAuthorization;
|
|
75
|
+
|
|
76
|
+
this.logger.info(`Creating challenge response for ${authz.identifier.value} at path: ${filePath}`);
|
|
77
|
+
|
|
78
|
+
/* Replace this */
|
|
79
|
+
this.logger.info(`Would write "${fileContents}" to path "${filePath}"`);
|
|
80
|
+
// await fs.writeFileAsync(filePath, fileContents);
|
|
81
|
+
} else if (challenge.type === "dns-01") {
|
|
82
|
+
/* dns-01 */
|
|
83
|
+
const dnsRecord = `_acme-challenge.${authz.identifier.value}`;
|
|
84
|
+
const recordValue = keyAuthorization;
|
|
85
|
+
|
|
86
|
+
this.logger.info(`Creating TXT record for ${authz.identifier.value}: ${dnsRecord}`);
|
|
87
|
+
|
|
88
|
+
/* Replace this */
|
|
89
|
+
this.logger.info(`Would create TXT record "${dnsRecord}" with value "${recordValue}"`);
|
|
90
|
+
|
|
91
|
+
return await dnsProvider.createRecord({
|
|
92
|
+
fullRecord: dnsRecord,
|
|
93
|
+
type: "TXT",
|
|
94
|
+
value: recordValue,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Function used to remove an ACME challenge response
|
|
101
|
+
*
|
|
102
|
+
* @param {object} authz Authorization object
|
|
103
|
+
* @param {object} challenge Selected challenge
|
|
104
|
+
* @param {string} keyAuthorization Authorization key
|
|
105
|
+
* @param recordItem challengeCreateFn create record item
|
|
106
|
+
* @param dnsProvider dnsProvider
|
|
107
|
+
* @returns {Promise}
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
async challengeRemoveFn(authz: any, challenge: any, keyAuthorization: string, recordItem: any, dnsProvider: IDnsProvider) {
|
|
111
|
+
this.logger.info("Triggered challengeRemoveFn()");
|
|
112
|
+
|
|
113
|
+
/* http-01 */
|
|
114
|
+
if (challenge.type === "http-01") {
|
|
115
|
+
const filePath = `/var/www/html/.well-known/acme-challenge/${challenge.token}`;
|
|
116
|
+
|
|
117
|
+
this.logger.info(`Removing challenge response for ${authz.identifier.value} at path: ${filePath}`);
|
|
118
|
+
|
|
119
|
+
/* Replace this */
|
|
120
|
+
this.logger.info(`Would remove file on path "${filePath}"`);
|
|
121
|
+
// await fs.unlinkAsync(filePath);
|
|
122
|
+
} else if (challenge.type === "dns-01") {
|
|
123
|
+
const dnsRecord = `_acme-challenge.${authz.identifier.value}`;
|
|
124
|
+
const recordValue = keyAuthorization;
|
|
125
|
+
|
|
126
|
+
this.logger.info(`Removing TXT record for ${authz.identifier.value}: ${dnsRecord}`);
|
|
127
|
+
|
|
128
|
+
/* Replace this */
|
|
129
|
+
this.logger.info(`Would remove TXT record "${dnsRecord}" with value "${recordValue}"`);
|
|
130
|
+
try {
|
|
131
|
+
await dnsProvider.removeRecord({
|
|
132
|
+
fullRecord: dnsRecord,
|
|
133
|
+
type: "TXT",
|
|
134
|
+
value: keyAuthorization,
|
|
135
|
+
record: recordItem,
|
|
136
|
+
});
|
|
137
|
+
} catch (e) {
|
|
138
|
+
this.logger.error("删除解析记录出错:", e);
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async order(options: { email: string; domains: string | string[]; dnsProvider: any; csrInfo: any; isTest?: boolean }) {
|
|
145
|
+
const { email, isTest, domains, csrInfo, dnsProvider } = options;
|
|
146
|
+
const client: acme.Client = await this.getAcmeClient(email, isTest);
|
|
147
|
+
|
|
148
|
+
/* Create CSR */
|
|
149
|
+
const { commonName, altNames } = this.buildCommonNameByDomains(domains);
|
|
150
|
+
|
|
151
|
+
const [key, csr] = await acme.forge.createCsr({
|
|
152
|
+
commonName,
|
|
153
|
+
...csrInfo,
|
|
154
|
+
altNames,
|
|
155
|
+
});
|
|
156
|
+
if (dnsProvider == null) {
|
|
157
|
+
throw new Error("dnsProvider 不能为空");
|
|
158
|
+
}
|
|
159
|
+
/* 自动申请证书 */
|
|
160
|
+
const crt = await client.auto({
|
|
161
|
+
csr,
|
|
162
|
+
email: email,
|
|
163
|
+
termsOfServiceAgreed: true,
|
|
164
|
+
challengePriority: ["dns-01"],
|
|
165
|
+
challengeCreateFn: async (authz: acme.Authorization, challenge: Challenge, keyAuthorization: string): Promise<any> => {
|
|
166
|
+
return await this.challengeCreateFn(authz, challenge, keyAuthorization, dnsProvider);
|
|
167
|
+
},
|
|
168
|
+
challengeRemoveFn: async (authz: acme.Authorization, challenge: Challenge, keyAuthorization: string, recordItem: any): Promise<any> => {
|
|
169
|
+
return await this.challengeRemoveFn(authz, challenge, keyAuthorization, recordItem, dnsProvider);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const cert: CertInfo = {
|
|
174
|
+
crt: crt.toString(),
|
|
175
|
+
key: key.toString(),
|
|
176
|
+
csr: csr.toString(),
|
|
177
|
+
};
|
|
178
|
+
/* Done */
|
|
179
|
+
this.logger.debug(`CSR:\n${cert.csr}`);
|
|
180
|
+
this.logger.debug(`Certificate:\n${cert.crt}`);
|
|
181
|
+
this.logger.info("证书申请成功");
|
|
182
|
+
return cert;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
buildCommonNameByDomains(domains: string | string[]): {
|
|
186
|
+
commonName: string;
|
|
187
|
+
altNames: string[] | undefined;
|
|
188
|
+
} {
|
|
189
|
+
if (typeof domains === "string") {
|
|
190
|
+
domains = domains.split(",");
|
|
191
|
+
}
|
|
192
|
+
if (domains.length === 0) {
|
|
193
|
+
throw new Error("domain can not be empty");
|
|
194
|
+
}
|
|
195
|
+
const commonName = domains[0];
|
|
196
|
+
let altNames: undefined | string[] = undefined;
|
|
197
|
+
if (domains.length > 1) {
|
|
198
|
+
altNames = _.slice(domains, 1);
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
commonName,
|
|
202
|
+
altNames,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { CertInfo } from "./acme";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import forge from "node-forge";
|
|
5
|
+
import path from "path";
|
|
6
|
+
export class CertReader implements CertInfo {
|
|
7
|
+
crt: string;
|
|
8
|
+
key: string;
|
|
9
|
+
csr: string;
|
|
10
|
+
|
|
11
|
+
detail: any;
|
|
12
|
+
expires: number;
|
|
13
|
+
constructor(certInfo: CertInfo) {
|
|
14
|
+
this.crt = certInfo.crt;
|
|
15
|
+
this.key = certInfo.key;
|
|
16
|
+
this.csr = certInfo.csr;
|
|
17
|
+
|
|
18
|
+
const { detail, expires } = this.getCrtDetail(this.crt);
|
|
19
|
+
this.detail = detail;
|
|
20
|
+
this.expires = expires.getTime();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
toCertInfo(): CertInfo {
|
|
24
|
+
return {
|
|
25
|
+
crt: this.crt,
|
|
26
|
+
key: this.key,
|
|
27
|
+
csr: this.csr,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getCrtDetail(crt: string) {
|
|
32
|
+
const pki = forge.pki;
|
|
33
|
+
const detail = pki.certificateFromPem(crt.toString());
|
|
34
|
+
const expires = detail.validity.notAfter;
|
|
35
|
+
return { detail, expires };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
saveToFile(type: "crt" | "key", filepath?: string) {
|
|
39
|
+
if (filepath == null) {
|
|
40
|
+
//写入临时目录
|
|
41
|
+
filepath = path.join(os.tmpdir(), "/certd/tmp/", Math.floor(Math.random() * 1000000) + "", `cert.${type}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const dir = path.dirname(filepath);
|
|
45
|
+
if (!fs.existsSync(dir)) {
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fs.writeFileSync(filepath, this[type]);
|
|
50
|
+
return filepath;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { AbstractTaskPlugin, Autowire, HttpClient, IAccessService, IContext, IsTaskPlugin, RunStrategy, Step, TaskInput, TaskOutput } from "@certd/pipeline";
|
|
2
|
+
import dayjs from "dayjs";
|
|
3
|
+
import { AcmeService, CertInfo } from "./acme";
|
|
4
|
+
import _ from "lodash";
|
|
5
|
+
import { Logger } from "log4js";
|
|
6
|
+
import { Decorator } from "@certd/pipeline/src/decorator";
|
|
7
|
+
import { DnsProviderDefine, dnsProviderRegistry } from "../../dns-provider";
|
|
8
|
+
import { CertReader } from "./cert-reader";
|
|
9
|
+
|
|
10
|
+
export { CertReader };
|
|
11
|
+
export type { CertInfo };
|
|
12
|
+
|
|
13
|
+
@IsTaskPlugin({
|
|
14
|
+
name: "CertApply",
|
|
15
|
+
title: "证书申请",
|
|
16
|
+
desc: "免费通配符域名证书申请,支持多个域名打到同一个证书上",
|
|
17
|
+
default: {
|
|
18
|
+
input: {
|
|
19
|
+
renewDays: 20,
|
|
20
|
+
forceUpdate: false,
|
|
21
|
+
},
|
|
22
|
+
strategy: {
|
|
23
|
+
runStrategy: RunStrategy.AlwaysRun,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
export class CertApplyPlugin extends AbstractTaskPlugin {
|
|
28
|
+
@TaskInput({
|
|
29
|
+
title: "域名",
|
|
30
|
+
component: {
|
|
31
|
+
name: "a-select",
|
|
32
|
+
vModel: "value",
|
|
33
|
+
mode: "tags",
|
|
34
|
+
open: false,
|
|
35
|
+
},
|
|
36
|
+
required: true,
|
|
37
|
+
col: {
|
|
38
|
+
span: 24,
|
|
39
|
+
},
|
|
40
|
+
helper:
|
|
41
|
+
"支持通配符域名,例如: *.foo.com 、 *.test.handsfree.work\n" +
|
|
42
|
+
"支持多个域名、多个子域名、多个通配符域名打到一个证书上(域名必须是在同一个DNS提供商解析)\n" +
|
|
43
|
+
"多级子域名要分成多个域名输入(*.foo.com的证书不能用于xxx.yyy.foo.com)\n" +
|
|
44
|
+
"输入一个回车之后,再输入下一个",
|
|
45
|
+
})
|
|
46
|
+
domains!: string;
|
|
47
|
+
|
|
48
|
+
@TaskInput({
|
|
49
|
+
title: "邮箱",
|
|
50
|
+
component: {
|
|
51
|
+
name: "a-input",
|
|
52
|
+
vModel: "value",
|
|
53
|
+
},
|
|
54
|
+
required: true,
|
|
55
|
+
helper: "请输入邮箱",
|
|
56
|
+
})
|
|
57
|
+
email!: string;
|
|
58
|
+
|
|
59
|
+
@TaskInput({
|
|
60
|
+
title: "DNS提供商",
|
|
61
|
+
component: {
|
|
62
|
+
name: "pi-dns-provider-selector",
|
|
63
|
+
},
|
|
64
|
+
required: true,
|
|
65
|
+
helper: "请选择dns解析提供商",
|
|
66
|
+
})
|
|
67
|
+
dnsProviderType!: string;
|
|
68
|
+
|
|
69
|
+
@TaskInput({
|
|
70
|
+
title: "DNS解析授权",
|
|
71
|
+
component: {
|
|
72
|
+
name: "pi-access-selector",
|
|
73
|
+
},
|
|
74
|
+
required: true,
|
|
75
|
+
helper: "请选择dns解析提供商授权",
|
|
76
|
+
})
|
|
77
|
+
dnsProviderAccess!: string;
|
|
78
|
+
|
|
79
|
+
@TaskInput({
|
|
80
|
+
title: "更新天数",
|
|
81
|
+
component: {
|
|
82
|
+
name: "a-input-number",
|
|
83
|
+
vModel: "value",
|
|
84
|
+
},
|
|
85
|
+
required: true,
|
|
86
|
+
helper: "到期前多少天后更新证书",
|
|
87
|
+
})
|
|
88
|
+
renewDays!: number;
|
|
89
|
+
|
|
90
|
+
@TaskInput({
|
|
91
|
+
title: "强制更新",
|
|
92
|
+
component: {
|
|
93
|
+
name: "a-switch",
|
|
94
|
+
vModel: "checked",
|
|
95
|
+
},
|
|
96
|
+
helper: "是否强制重新申请证书",
|
|
97
|
+
})
|
|
98
|
+
forceUpdate!: string;
|
|
99
|
+
|
|
100
|
+
@TaskInput({
|
|
101
|
+
title: "CsrInfo",
|
|
102
|
+
})
|
|
103
|
+
csrInfo: any;
|
|
104
|
+
|
|
105
|
+
// @ts-ignore
|
|
106
|
+
acme: AcmeService;
|
|
107
|
+
|
|
108
|
+
@Autowire()
|
|
109
|
+
logger!: Logger;
|
|
110
|
+
|
|
111
|
+
@Autowire()
|
|
112
|
+
userContext!: IContext;
|
|
113
|
+
|
|
114
|
+
@Autowire()
|
|
115
|
+
accessService!: IAccessService;
|
|
116
|
+
|
|
117
|
+
@Autowire()
|
|
118
|
+
http!: HttpClient;
|
|
119
|
+
|
|
120
|
+
@Autowire()
|
|
121
|
+
lastStatus!: Step;
|
|
122
|
+
|
|
123
|
+
@TaskOutput({
|
|
124
|
+
title: "域名证书",
|
|
125
|
+
})
|
|
126
|
+
cert?: CertInfo;
|
|
127
|
+
|
|
128
|
+
async onInstance() {
|
|
129
|
+
this.acme = new AcmeService({ userContext: this.userContext, logger: this.logger });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async execute(): Promise<void> {
|
|
133
|
+
const oldCert = await this.condition();
|
|
134
|
+
if (oldCert != null) {
|
|
135
|
+
return this.output(oldCert);
|
|
136
|
+
}
|
|
137
|
+
const cert = await this.doCertApply();
|
|
138
|
+
if (cert != null) {
|
|
139
|
+
this.output(cert.toCertInfo());
|
|
140
|
+
//清空后续任务的状态,让后续任务能够重新执行
|
|
141
|
+
this.clearLastStatus();
|
|
142
|
+
} else {
|
|
143
|
+
throw new Error("申请证书失败");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
output(cert: CertInfo) {
|
|
148
|
+
this.cert = cert;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 是否更新证书
|
|
153
|
+
* @param input
|
|
154
|
+
*/
|
|
155
|
+
async condition() {
|
|
156
|
+
if (this.forceUpdate) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let inputChanged = false;
|
|
161
|
+
const oldInput = JSON.stringify(this.lastStatus?.input?.domains);
|
|
162
|
+
const thisInput = JSON.stringify(this.domains);
|
|
163
|
+
if (oldInput !== thisInput) {
|
|
164
|
+
inputChanged = true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let oldCert: CertReader | undefined = undefined;
|
|
168
|
+
try {
|
|
169
|
+
oldCert = await this.readLastCert();
|
|
170
|
+
} catch (e) {
|
|
171
|
+
this.logger.warn("读取cert失败:", e);
|
|
172
|
+
}
|
|
173
|
+
if (oldCert == null) {
|
|
174
|
+
this.logger.info("还未申请过,准备申请新证书");
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (inputChanged) {
|
|
179
|
+
this.logger.info("输入参数变更,申请新证书");
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const ret = this.isWillExpire(oldCert.expires, this.renewDays);
|
|
184
|
+
if (!ret.isWillExpire) {
|
|
185
|
+
this.logger.info(`证书还未过期:过期时间${dayjs(oldCert.expires).format("YYYY-MM-DD HH:mm:ss")},剩余${ret.leftDays}天`);
|
|
186
|
+
return oldCert;
|
|
187
|
+
}
|
|
188
|
+
this.logger.info("即将过期,开始更新证书");
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async doCertApply() {
|
|
193
|
+
const email = this["email"];
|
|
194
|
+
const domains = this["domains"];
|
|
195
|
+
const dnsProviderType = this["dnsProviderType"];
|
|
196
|
+
const dnsProviderAccessId = this["dnsProviderAccess"];
|
|
197
|
+
const csrInfo = _.merge(
|
|
198
|
+
{
|
|
199
|
+
country: "CN",
|
|
200
|
+
state: "GuangDong",
|
|
201
|
+
locality: "ShengZhen",
|
|
202
|
+
organization: "CertD Org.",
|
|
203
|
+
organizationUnit: "IT Department",
|
|
204
|
+
emailAddress: email,
|
|
205
|
+
},
|
|
206
|
+
this.csrInfo
|
|
207
|
+
);
|
|
208
|
+
this.logger.info("开始申请证书,", email, domains);
|
|
209
|
+
|
|
210
|
+
const dnsProviderPlugin = dnsProviderRegistry.get(dnsProviderType);
|
|
211
|
+
const DnsProviderClass = dnsProviderPlugin.target;
|
|
212
|
+
const dnsProviderDefine = dnsProviderPlugin.define as DnsProviderDefine;
|
|
213
|
+
const access = await this.accessService.getById(dnsProviderAccessId);
|
|
214
|
+
|
|
215
|
+
// @ts-ignore
|
|
216
|
+
const dnsProvider: IDnsProvider = new DnsProviderClass();
|
|
217
|
+
const context = { access, logger: this.logger, http: this.http };
|
|
218
|
+
Decorator.inject(dnsProviderDefine.autowire, dnsProvider, context);
|
|
219
|
+
await dnsProvider.onInstance();
|
|
220
|
+
|
|
221
|
+
const cert = await this.acme.order({
|
|
222
|
+
email,
|
|
223
|
+
domains,
|
|
224
|
+
dnsProvider,
|
|
225
|
+
csrInfo,
|
|
226
|
+
isTest: false,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const certInfo = this.formatCerts(cert);
|
|
230
|
+
return new CertReader(certInfo);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
formatCert(pem: string) {
|
|
234
|
+
pem = pem.replace(/\r/g, "");
|
|
235
|
+
pem = pem.replace(/\n\n/g, "\n");
|
|
236
|
+
pem = pem.replace(/\n$/g, "");
|
|
237
|
+
return pem;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
formatCerts(cert: { crt: string; key: string; csr: string }) {
|
|
241
|
+
const newCert: CertInfo = {
|
|
242
|
+
crt: this.formatCert(cert.crt),
|
|
243
|
+
key: this.formatCert(cert.key),
|
|
244
|
+
csr: this.formatCert(cert.csr),
|
|
245
|
+
};
|
|
246
|
+
return newCert;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async readLastCert(): Promise<CertReader | undefined> {
|
|
250
|
+
const cert = this.lastStatus?.status?.output?.cert;
|
|
251
|
+
if (cert == null) {
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
return new CertReader(cert);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 检查是否过期,默认提前20天
|
|
259
|
+
* @param expires
|
|
260
|
+
* @param maxDays
|
|
261
|
+
* @returns {boolean}
|
|
262
|
+
*/
|
|
263
|
+
isWillExpire(expires: number, maxDays = 20) {
|
|
264
|
+
if (expires == null) {
|
|
265
|
+
throw new Error("过期时间不能为空");
|
|
266
|
+
}
|
|
267
|
+
// 检查有效期
|
|
268
|
+
const leftDays = dayjs(expires).diff(dayjs(), "day");
|
|
269
|
+
return {
|
|
270
|
+
isWillExpire: leftDays < maxDays,
|
|
271
|
+
leftDays,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
new CertApplyPlugin();
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"noImplicitAny": true,
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"useDefineForClassFields": true,
|
|
6
|
+
"module": "commonjs",
|
|
7
|
+
"moduleResolution": "Node",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"jsx": "preserve",
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"lib": ["ESNext", "DOM"],
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"experimentalDecorators": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue","test/**/*.ts"],
|
|
19
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
// https://vitejs.dev/config/
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
plugins: [],
|
|
5
|
+
build: {
|
|
6
|
+
lib: {
|
|
7
|
+
entry: "src/index.ts",
|
|
8
|
+
name: "pipeline",
|
|
9
|
+
},
|
|
10
|
+
rollupOptions: {
|
|
11
|
+
external: ["vue", "lodash", "dayjs", "@fast-crud/fast-crud"],
|
|
12
|
+
output: {
|
|
13
|
+
// Provide global variables to use in the UMD build
|
|
14
|
+
// for externalized deps
|
|
15
|
+
globals: {
|
|
16
|
+
vue: "Vue",
|
|
17
|
+
"lodash": "_",
|
|
18
|
+
dayjs: "dayjs",
|
|
19
|
+
"@fast-crud/fast-crud": "FastCrud",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
});
|