refactor: move

This commit is contained in:
xiaojunnuo
2021-02-08 00:21:36 +08:00
parent cfb1034450
commit 82f86d9556
150 changed files with 14691 additions and 2059 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "standard",
"env": {
"mocha": true
},
"parserOptions": {
"ecmaVersion": 2020
},
"overrides": [
{
"files": ["*.test.js", "*.spec.js"],
"rules": {
"no-unused-expressions": "off"
}
}
]
}
+2468
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@certd/api",
"version": "0.1.13",
"description": "",
"main": "src/index.js",
"type": "module",
"author": "Greper",
"license": "MIT",
"dependencies": {
"axios": "^0.21.1",
"dayjs": "^1.9.7",
"lodash-es": "^4.17.20",
"log4js": "^6.3.0",
"qs": "^6.9.4"
},
"devDependencies": {
"chai": "^4.2.0",
"eslint": "^7.15.0",
"eslint-config-standard": "^16.0.2",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"mocha": "^8.2.1"
},
"gitHead": "4a421d5b142d453203c68ce6d1036e168ea2455b"
}
@@ -0,0 +1,2 @@
import { Registry } from '../registry/registry.js'
export const accessProviderRegistry = new Registry()
@@ -0,0 +1,42 @@
import _ from 'lodash-es'
import logger from '../utils/util.log.js'
export class AbstractDnsProvider {
constructor ({ accessProviders }) {
this.logger = logger
this.accessProviders = accessProviders
}
async createRecord ({ fullRecord, type, value }) {
throw new Error('请实现 createRecord 方法')
}
async removeRecord ({ fullRecord, type, value, record }) {
throw new Error('请实现 removeRecord 方法')
}
async getDomainList () {
throw new Error('请实现 getDomainList 方法')
}
async matchDomain (dnsRecord, domainPropName) {
const list = await this.getDomainList()
let domain = null
for (const item of list) {
if (_.endsWith(dnsRecord, item[domainPropName])) {
domain = item
break
}
}
if (!domain) {
throw new Error('找不到域名,请检查域名是否正确:' + dnsRecord)
}
return domain
}
getAccessProvider (accessProvider, accessProviders = this.accessProviders) {
if (typeof accessProvider === 'string' && accessProviders) {
accessProvider = accessProviders[accessProvider]
}
return accessProvider
}
}
@@ -0,0 +1,4 @@
import { Registry } from '../registry/registry.js'
export { AbstractDnsProvider } from './abstract-dns-provider.js'
export const dnsProviderRegistry = new Registry()
+5
View File
@@ -0,0 +1,5 @@
export * from './dns-provider'
export * from './plugin'
export * from './access-provider'
export { Store } from './store/store.js'
export { util } from './utils'
@@ -0,0 +1,73 @@
import fs from 'fs'
import logger from '../utils/util.log.js'
import dayjs from 'dayjs'
import Sleep from '../utils/util.sleep.js'
export class AbstractPlugin {
constructor ({ accessProviders }) {
this.logger = logger
this.accessProviders = accessProviders
}
appendTimeSuffix (name) {
if (name == null) {
name = 'certd'
}
return name + '-' + dayjs().format('YYYYMMDD-HHmmss')
}
async executeFromContextFile (options = {}) {
const { contextPath } = options
const contextJson = fs.readFileSync(contextPath)
const context = JSON.parse(contextJson)
options.context = context
await this.doExecute(options)
fs.writeFileSync(JSON.stringify(context))
}
async doExecute (options) {
try {
return await this.execute(options)
} catch (e) {
logger.error('插件执行出错:', e)
throw e
}
}
/**
* 执行
* @param options
* @returns {Promise<void>}
*/
async execute (options) {
console.error('请实现此方法,context:', options.context)
}
async doRollback (options) {
try {
return await this.rollback(options)
} catch (e) {
logger.error('插件rollback出错:', e)
throw e
}
}
/**
* 回退,用于单元测试
* @param options
*/
async rollback (options) {
console.error('请实现此方法,rollback:', options.context)
}
getAccessProvider (accessProvider, accessProviders = this.accessProviders) {
if (typeof accessProvider === 'string' && accessProviders) {
accessProvider = accessProviders[accessProvider]
}
return accessProvider
}
async sleep (time) {
await Sleep(time)
}
}
+3
View File
@@ -0,0 +1,3 @@
import { Registry } from '../registry/registry.js'
export { AbstractPlugin } from './abstract-plugin.js'
export const pluginRegistry = new Registry()
@@ -0,0 +1,39 @@
export class Registry {
constructor () {
this.collection = {}
}
install (target) {
if (target == null) {
return
}
if (this.collection == null) {
this.collection = {}
}
let defineName = target.define ? target.define().name : null
if (defineName == null) {
defineName = target.name
}
this.register(defineName, target)
}
register (key, value) {
if (!key || value == null) {
return
}
this.collection[key] = value
}
get (name) {
if (name) {
return this.collection[name]
}
throw new Error(`${name} cant blank`)
}
getCollection () {
return this.collection
}
}
+33
View File
@@ -0,0 +1,33 @@
export class Store {
set (key, value) {
}
get (key) {
}
buildKey (...keyItem) {
}
linkExists (linkPath) {
}
link (targetPath, linkPath) {
}
unlink (linkPath) {
}
/**
* 全路径
* @param key
*/
getActualKey (key) {
// return 前缀+key
}
}
+7
View File
@@ -0,0 +1,7 @@
import logger from './util.log.js'
import path from './util.path.js'
import { request } from './util.request.js'
import sleep from './util.sleep.js'
export const util = {
logger, path, request, sleep
}
+7
View File
@@ -0,0 +1,7 @@
import log4js from 'log4js'
log4js.configure({
appenders: { std: { type: 'stdout' } },
categories: { default: { appenders: ['std'], level: 'info' } }
})
const logger = log4js.getLogger('certd')
export default logger
+9
View File
@@ -0,0 +1,9 @@
import path from 'path'
function getUserBasePath () {
const userHome = process.env.USERPROFILE || process.env.HOME
return path.resolve(userHome, './.certd')
}
export default {
getUserBasePath
}
@@ -0,0 +1,57 @@
import axios from 'axios'
import qs from 'qs'
import logger from './util.log.js'
/**
* @description 创建请求实例
*/
function createService () {
// 创建一个 axios 实例
const service = axios.create()
// 请求拦截
service.interceptors.request.use(
config => {
if (config.formData) {
config.data = qs.stringify(config.formData, {
arrayFormat: 'indices',
allowDots: true
}) // 序列化请求参数
delete config.formData
}
return config
},
error => {
// 发送失败
logger.error(error)
return Promise.reject(error)
}
)
// 响应拦截
service.interceptors.response.use(
response => {
logger.info('http response:', JSON.stringify(response.data))
return response.data
},
error => {
// const status = _.get(error, 'response.status')
// switch (status) {
// case 400: error.message = '请求错误'; break
// case 401: error.message = '未授权,请登录'; break
// case 403: error.message = '拒绝访问'; break
// case 404: error.message = `请求地址出错: ${error.response.config.url}`; break
// case 408: error.message = '请求超时'; break
// case 500: error.message = '服务器内部错误'; break
// case 501: error.message = '服务未实现'; break
// case 502: error.message = '网关错误'; break
// case 503: error.message = '服务不可用'; break
// case 504: error.message = '网关超时'; break
// case 505: error.message = 'HTTP版本不受支持'; break
// default: break
// }
logger.error('请求出错:', error.response.config.url, error)
return Promise.reject(error)
}
)
return service
}
export const request = createService()
@@ -0,0 +1,7 @@
export default function (timeout) {
return new Promise(resolve => {
setTimeout(() => {
resolve()
}, timeout)
})
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "standard",
"env": {
"mocha": true
},
"overrides": [
{
"files": ["*.test.js", "*.spec.js"],
"rules": {
"no-unused-expressions": "off"
}
}
]
}
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@certd/certd",
"version": "0.1.13",
"description": "",
"main": "src/index.js",
"scripts": {
"test": "echo \\\"Error: no test specified\\\" && exit 1"
},
"type": "module",
"author": "Greper",
"license": "MIT",
"dependencies": {
"@certd/acme-client": "^0.1.6",
"@certd/api": "^0.1.13",
"dayjs": "^1.9.7",
"lodash-es": "^4.17.20",
"node-forge": "^0.10.0"
},
"devDependencies": {
"chai": "^4.2.0",
"eslint": "^7.15.0",
"eslint-config-standard": "^16.0.2",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"mocha": "^8.2.1"
},
"gitHead": "4a421d5b142d453203c68ce6d1036e168ea2455b"
}
+199
View File
@@ -0,0 +1,199 @@
import acme from '@certd/acme-client'
import _ from 'lodash-es'
import { util } from '@certd/api'
const logger = util.logger
export class AcmeService {
constructor (store) {
this.store = store
}
async getAccountConfig (email) {
let conf = this.store.get(this.buildAccountPath(email))
if (conf == null) {
conf = {}
} else {
conf = JSON.parse(conf)
}
return conf
}
buildAccountPath (email) {
return this.store.buildKey(email, 'account.json')
}
saveAccountConfig (email, conf) {
this.store.set(this.buildAccountPath(email), JSON.stringify(conf))
}
async getAcmeClient (email, isTest) {
const conf = await this.getAccountConfig(email)
if (conf.key == null) {
conf.key = await this.createNewKey()
this.saveAccountConfig(email, conf)
}
if (isTest == null) {
isTest = process.env.CERTD_MODE === 'test'
}
const client = new acme.Client({
directoryUrl: isTest ? acme.directory.letsencrypt.staging : acme.directory.letsencrypt.production,
accountKey: conf.key,
accountUrl: conf.accountUrl,
backoffAttempts: 20,
backoffMin: 5000,
backoffMax: 10000
})
if (conf.accountUrl == null) {
const accountPayload = { termsOfServiceAgreed: true, contact: [`mailto:${email}`] }
await client.createAccount(accountPayload)
conf.accountUrl = client.getAccountUrl()
this.saveAccountConfig(email, conf)
}
return client
}
async createNewKey () {
const key = await acme.forge.createPrivateKey()
return key.toString()
}
async challengeCreateFn (authz, challenge, keyAuthorization, dnsProvider) {
logger.info('Triggered challengeCreateFn()')
/* http-01 */
if (challenge.type === 'http-01') {
const filePath = `/var/www/html/.well-known/acme-challenge/${challenge.token}`
const fileContents = keyAuthorization
logger.info(`Creating challenge response for ${authz.identifier.value} at path: ${filePath}`)
/* Replace this */
logger.info(`Would write "${fileContents}" to path "${filePath}"`)
// await fs.writeFileAsync(filePath, fileContents);
} else if (challenge.type === 'dns-01') {
/* dns-01 */
const dnsRecord = `_acme-challenge.${authz.identifier.value}`
const recordValue = keyAuthorization
logger.info(`Creating TXT record for ${authz.identifier.value}: ${dnsRecord}`)
/* Replace this */
logger.info(`Would create TXT record "${dnsRecord}" with value "${recordValue}"`)
return await dnsProvider.createRecord({
fullRecord: dnsRecord,
type: 'TXT',
value: recordValue
})
}
}
/**
* Function used to remove an ACME challenge response
*
* @param {object} authz Authorization object
* @param {object} challenge Selected challenge
* @param {string} keyAuthorization Authorization key
* @param recordItem challengeCreateFn create record item
* @param dnsProvider dnsProvider
* @returns {Promise}
*/
async challengeRemoveFn (authz, challenge, keyAuthorization, recordItem, dnsProvider) {
logger.info('Triggered challengeRemoveFn()')
/* http-01 */
if (challenge.type === 'http-01') {
const filePath = `/var/www/html/.well-known/acme-challenge/${challenge.token}`
logger.info(`Removing challenge response for ${authz.identifier.value} at path: ${filePath}`)
/* Replace this */
logger.info(`Would remove file on path "${filePath}"`)
// await fs.unlinkAsync(filePath);
} else if (challenge.type === 'dns-01') {
const dnsRecord = `_acme-challenge.${authz.identifier.value}`
const recordValue = keyAuthorization
logger.info(`Removing TXT record for ${authz.identifier.value}: ${dnsRecord}`)
/* Replace this */
logger.info(`Would remove TXT record "${dnsRecord}" with value "${recordValue}"`)
await dnsProvider.removeRecord({
fullRecord: dnsRecord,
type: 'TXT',
value: keyAuthorization,
record: recordItem
})
}
}
async order ({ email, domains, dnsProvider, dnsProviderCreator, csrInfo, isTest }) {
const client = await this.getAcmeClient(email, isTest)
let accountUrl
try {
accountUrl = client.getAccountUrl()
} catch (e) {
}
/* Create CSR */
const { commonName, altNames } = this.buildCommonNameByDomains(domains)
const [key, csr] = await acme.forge.createCsr({
commonName,
...csrInfo,
altNames
})
if (dnsProvider == null && dnsProviderCreator) {
dnsProvider = await dnsProviderCreator()
}
if (dnsProvider == null) {
throw new Error('dnsProvider 不能为空')
}
/* 自动申请证书 */
const crt = await client.auto({
csr,
email: email,
termsOfServiceAgreed: true,
challengePriority: ['dns-01'],
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
return await this.challengeCreateFn(authz, challenge, keyAuthorization, dnsProvider)
},
challengeRemoveFn: async (authz, challenge, keyAuthorization, recordItem) => {
return await this.challengeRemoveFn(authz, challenge, keyAuthorization, recordItem, dnsProvider)
}
})
// 保存账号url
if (!accountUrl) {
try {
accountUrl = client.getAccountUrl()
this.setAccountUrl(email, accountUrl)
} catch (e) {
logger.warn('保存accountUrl出错', e)
}
}
/* Done */
logger.debug(`CSR:\n${csr.toString()}`)
logger.debug(`Certificate:\n${crt.toString()}`)
logger.info('证书申请成功')
return { key, crt, csr }
}
buildCommonNameByDomains (domains) {
if (typeof domains === 'string') {
domains = domains.split(',')
}
if (domains.length === 0) {
throw new Error('domain can not be empty')
}
const ret = {
commonName: domains[0]
}
if (domains.length > 1) {
ret.altNames = _.slice(domains, 1)
}
return ret
}
}
+135
View File
@@ -0,0 +1,135 @@
import { util, Store, dnsProviderRegistry } from '@certd/api'
import { AcmeService } from './acme.js'
import { FileStore } from './store/file-store.js'
import { CertStore } from './store/cert-store.js'
import dayjs from 'dayjs'
import forge from 'node-forge'
import DefaultDnsProviders from '@certd/dns-providers'
const logger = util.logger
DefaultDnsProviders.install()
export class Certd {
constructor (options) {
this.options = options
this.email = options.cert.email
this.domains = options.cert.domains
if (!(options.store instanceof Store)) {
this.store = new FileStore(options.store || {})
}
this.certStore = new CertStore({
store: this.store,
email: options.cert.email,
domains: this.domains
})
this.acme = new AcmeService(this.store)
}
async certApply () {
let oldCert
try {
oldCert = await this.readCurrentCert()
} catch (e) {
logger.warn('读取cert失败:', e)
}
if (oldCert == null) {
logger.info('还未申请过,准备申请新证书')
} else {
const ret = this.isWillExpire(oldCert.expires, this.options.cert.renewDays)
if (!ret.isWillExpire) {
logger.info('证书还未过期:', oldCert.expires, ',剩余', ret.leftDays, '天')
if (this.options.args.forceCert) {
logger.info('准备强制更新证书')
} else {
logger.info('暂不更新证书')
oldCert.isNew = false
return oldCert
}
} else {
logger.info('即将过期,准备更新证书')
}
}
// 执行证书申请步骤
return await this.doCertApply()
}
async doCertApply () {
const options = this.options
const dnsProvider = this.createDnsProvider(options)
const cert = await this.acme.order({
email: options.cert.email,
domains: options.cert.domains,
dnsProvider,
csrInfo: options.cert.csrInfo,
isTest: options.args.test
})
await this.writeCert(cert)
const certRet = await this.readCurrentCert()
certRet.isNew = true
return certRet
}
createDnsProvider (options) {
return this.createProviderByType(options.cert.dnsProvider, options.accessProviders)
}
async writeCert (cert) {
const newPath = await this.certStore.writeCert(cert)
return {
realPath: this.certStore.store.getActualKey(newPath),
currentPath: this.certStore.store.getActualKey(this.certStore.currentMarkPath)
}
}
async readCurrentCert () {
const cert = await this.certStore.readCert()
if (cert == null) {
return null
}
const { detail, expires } = this.getCrtDetail(cert.crt)
const domain = this.certStore.getMainDomain(this.options.cert.domains)
return {
...cert, detail, expires, domain, domains: this.domains, email: this.email
}
}
getCrtDetail (crt) {
const pki = forge.pki
const detail = pki.certificateFromPem(crt.toString())
const expires = detail.validity.notAfter
return { detail, expires }
}
/**
* 检查是否过期,默认提前20天
* @param expires
* @param maxDays
* @returns {boolean}
*/
isWillExpire (expires, maxDays = 20) {
if (expires == null) {
throw new Error('过期时间不能为空')
}
// 检查有效期
const leftDays = dayjs(expires).diff(dayjs(), 'day')
return {
isWillExpire: leftDays < maxDays,
leftDays
}
}
createProviderByType (props, accessProviders) {
const { type } = props
const Provider = dnsProviderRegistry.get(type)
if (Provider == null) {
throw new Error('暂不支持此dnsProvider,请先注册该provider' + type)
}
return new Provider({ accessProviders, props })
}
}
+127
View File
@@ -0,0 +1,127 @@
import dayjs from 'dayjs'
import crypto from 'crypto'
// eslint-disable-next-line no-unused-vars
function md5 (content) {
return crypto.createHash('md5').update(content).digest('hex')
}
export class CertStore {
constructor ({ store, email, domains }) {
this.store = store
this.email = email
this.domains = domains
this.domain = this.getMainDomain(this.domains)
this.safetyDomain = this.getSafetyDomain(this.domain)
// this.domainDir = this.safetyDomain + '-' + md5(this.getDomainStr(this.domains))
this.domainDir = this.safetyDomain
this.certsRootPath = this.store.buildKey(this.email, 'certs')
this.currentMarkPath = this.store.buildKey(this.certsRootPath, this.domainDir, 'current.json')
}
getMainDomain (domains) {
if (domains == null) {
return null
}
if (typeof domains === 'string') {
return domains
}
if (domains.length > 0) {
return domains[0]
}
}
getDomainStr (domains) {
if (domains == null) {
return null
}
if (typeof domains === 'string') {
return domains
}
return domains.join(',')
}
buildNewCertRootPath (dir) {
if (dir == null) {
dir = dayjs().format('YYYY.MM.DD.HHmmss')
}
return this.store.buildKey(this.certsRootPath, this.domainDir, dir)
}
formatCert (pem) {
pem = pem.replace(/\r/g, '')
pem = pem.replace(/\n\n/g, '\n')
pem = pem.replace(/\n$/g, '')
return pem
}
async writeCert (cert) {
const newDir = this.buildNewCertRootPath()
const crtKey = this.buildKey(newDir, this.safetyDomain + '.crt')
const priKey = this.buildKey(newDir, this.safetyDomain + '.key')
const csrKey = this.buildKey(newDir, this.safetyDomain + '.csr')
await this.store.set(crtKey, this.formatCert(cert.crt.toString()))
await this.store.set(priKey, this.formatCert(cert.key.toString()))
await this.store.set(csrKey, cert.csr.toString())
await this.store.set(this.currentMarkPath, JSON.stringify({ latest: newDir }))
return newDir
}
async readCert (dir) {
if (dir == null) {
dir = await this.getCurrentDir()
}
if (dir == null) {
return
}
const crtKey = this.buildKey(dir, this.safetyDomain + '.crt')
const priKey = this.buildKey(dir, this.safetyDomain + '.key')
const csrKey = this.buildKey(dir, this.safetyDomain + '.csr')
const crt = await this.store.get(crtKey)
if (crt == null) {
return null
}
const key = await this.store.get(priKey)
const csr = await this.store.get(csrKey)
return {
crt: this.formatCert(crt),
key: this.formatCert(key),
csr,
crtPath: this.store.getActualKey(crtKey),
keyPath: this.store.getActualKey(priKey),
certDir: this.store.getActualKey(dir)
}
}
buildKey (...keyItem) {
return this.store.buildKey(...keyItem)
}
getSafetyDomain (domain) {
return domain.replace(/\*/g, '_')
}
async getCurrentDir () {
const current = await this.store.get(this.currentMarkPath)
if (current == null) {
return null
}
return JSON.parse(current).latest
}
async getCurrentFile (file) {
const currentDir = await this.getCurrentDir()
const key = this.buildKey(currentDir, file)
return this.store.get(key)
}
async setCurrentFile (file, value) {
const currentDir = await this.getCurrentDir()
const key = this.buildKey(currentDir, file)
return this.store.set(key, value)
}
}
@@ -0,0 +1,66 @@
import { Store, util } from '@certd/api'
import path from 'path'
import fs from 'fs'
const logger = util.logger
export class FileStore extends Store {
constructor (opts) {
super()
if (opts.rootDir != null) {
this.rootDir = opts.rootDir
} else {
this.rootDir = util.path.getUserBasePath()
}
if (opts.test) {
this.rootDir = path.join(this.rootDir, '/test/')
}
}
getActualKey (key) {
// return 前缀+key
return this.getPathByKey(key)
}
buildKey (...keyItem) {
return path.join(...keyItem)
}
getPathByKey (key) {
return path.join(this.rootDir, key)
}
set (key, value) {
const filePath = this.getPathByKey(key)
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
fs.writeFileSync(filePath, value)
return filePath
}
get (key) {
const filePath = this.getPathByKey(key)
if (!fs.existsSync(filePath)) {
return null
}
return fs.readFileSync(filePath).toString()
}
link (targetPath, linkPath) {
targetPath = this.getPathByKey(targetPath)
linkPath = this.getPathByKey(linkPath)
if (fs.existsSync(linkPath)) {
try {
fs.unlinkSync(linkPath)
} catch (e) {
logger.error('unlink error:', e)
}
}
fs.symlinkSync(targetPath, linkPath, 'dir')
}
unlink (linkPath) {
linkPath = this.getPathByKey(linkPath)
fs.unlinkSync(linkPath)
}
}
@@ -0,0 +1,18 @@
import pkg from 'chai'
import { createOptions } from '../../../../../test/options.js'
import { Certd } from '../../src'
const { expect } = pkg
describe('AliyunDnsProvider', function () {
it('#申请证书-aliyun', async function () {
this.timeout(300000)
const options = createOptions()
options.args = { forceCert: true, test: false }
const certd = new Certd(options)
const cert = await certd.certApply()
expect(cert).ok
expect(cert.crt).ok
expect(cert.key).ok
expect(cert.detail).ok
expect(cert.expires).ok
})
})
@@ -0,0 +1,20 @@
import pkg from 'chai'
import { Certd } from '../../src'
import { createOptions } from '../../../../../test/options.js'
const { expect } = pkg
describe('DnspodDnsProvider', function () {
it('#申请证书', async function () {
this.timeout(300000)
const options = createOptions()
options.cert.domains = ['*.certd.xyz', '*.test.certd.xyz', '*.base.certd.xyz', 'certd.xyz']
options.cert.dnsProvider = 'dnspod'
options.args = { forceCert: true }
const certd = new Certd(options)
const cert = await certd.certApply()
expect(cert).ok
expect(cert.crt).ok
expect(cert.key).ok
expect(cert.detail).ok
expect(cert.expires).ok
})
})
+88
View File
@@ -0,0 +1,88 @@
import chai from 'chai'
import { Certd } from '../src'
import { createOptions } from '../../../../test/options.js'
const { expect } = chai
const fakeCrt = `-----BEGIN CERTIFICATE-----
MIIFSTCCBDGgAwIBAgITAPoZZk/LhVIyXoic2NnJyxubezANBgkqhkiG9w0BAQsF
ADAiMSAwHgYDVQQDDBdGYWtlIExFIEludGVybWVkaWF0ZSBYMTAeFw0yMDEyMTQx
NjA1NTFaFw0yMTAzMTQxNjA1NTFaMBsxGTAXBgNVBAMMECouZG9jbWlycm9yLmNs
dWIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC75tGrYjly+RpcZehQ
my1EpaXElT4L60pINKV2YDKnBrcSSo1c6rO7nFh12eC/ju4WwYUep0RVmBDF8xD0
I1Sd1uuDTQWP0UT1X9yqdXtjvxpUqoCHAzG633f3sJRFul7mDLuC9tRCuae9o7qP
EZ827XOmjBR35dso9I2GEE4828J3YE3tSKtobZlM+30jozLEcsO0PTyM5mq5PPjP
VI3fGLcEaBmLZf5ixz4XkcY9IAhyAMYf03cT2wRoYPBaDdXblgCYL6sFtIMbzl3M
Di94PB8NyoNSsC2nmBdWi54wFOgBvY/4ljsX/q7X3EqlSvcA0/M6/c/J9kJ3eupv
jV8nAgMBAAGjggJ9MIICeTAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYB
BQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFAkdTjSCV3KD
x28sf98MrwVfyFYgMB8GA1UdIwQYMBaAFMDMA0a5WCDMXHJw8+EuyyCm9Wg6MHcG
CCsGAQUFBwEBBGswaTAyBggrBgEFBQcwAYYmaHR0cDovL29jc3Auc3RnLWludC14
MS5sZXRzZW5jcnlwdC5vcmcwMwYIKwYBBQUHMAKGJ2h0dHA6Ly9jZXJ0LnN0Zy1p
bnQteDEubGV0c2VuY3J5cHQub3JnLzArBgNVHREEJDAighAqLmRvY21pcnJvci5j
bHVigg5kb2NtaXJyb3IuY2x1YjBMBgNVHSAERTBDMAgGBmeBDAECATA3BgsrBgEE
AYLfEwEBATAoMCYGCCsGAQUFBwIBFhpodHRwOi8vY3BzLmxldHNlbmNyeXB0Lm9y
ZzCCAQQGCisGAQQB1nkCBAIEgfUEgfIA8AB1ABboacHRlerXw/iXGuPwdgH3jOG2
nTGoUhi2g38xqBUIAAABdmI3LM4AAAQDAEYwRAIgaiNqXSEq+sxp8eqlJXp/KFdO
so5mT50MoRsLF8Inu0ACIDP46+ekng7I0BlmyIPmbqFcZgnZFVWLLCdLYijhVyOL
AHcA3Zk0/KXnJIDJVmh9gTSZCEmySfe1adjHvKs/XMHzbmQAAAF2YjcuxwAABAMA
SDBGAiEAxpeB8/w4YkHZ62nH20h128VtuTSmYDCnF7EK2fQyeZYCIQDbJlF2wehZ
sF1BeE7qnYYqCTP0dYIrQ9HWtBa/MbGOKTANBgkqhkiG9w0BAQsFAAOCAQEAL2di
HKh6XcZtGk0BFxJa51sCZ3MLu9+Zy90kCRD4ooP5x932WxVM25+LBRd+xSzx+TRL
UVrlKp9GdMYX1JXL4Vf2NwzuFO3snPDe/qizD/3+D6yo8eKJ/LD82t5kLWAD2rto
YfVSTKwfNIBBJwHUnjviBPJmheHHCKmz8Ct6/6QxFAeta9TAMn0sFeVCQnmAq7HL
jrunq0tNHR/EKG0ITPLf+6P7MxbmpYNnq918766l0tKsW8oo8ZSGEwKU2LMaSiAa
hasyl/2gMnYXjtKOjDcnR8oLpbrOg0qpVbynmJin1HP835oHPPAZ1gLsqYTTizNz
AHxTaXliTVvS83dogw==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEqzCCApOgAwIBAgIRAIvhKg5ZRO08VGQx8JdhT+UwDQYJKoZIhvcNAQELBQAw
GjEYMBYGA1UEAwwPRmFrZSBMRSBSb290IFgxMB4XDTE2MDUyMzIyMDc1OVoXDTM2
MDUyMzIyMDc1OVowIjEgMB4GA1UEAwwXRmFrZSBMRSBJbnRlcm1lZGlhdGUgWDEw
ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDtWKySDn7rWZc5ggjz3ZB0
8jO4xti3uzINfD5sQ7Lj7hzetUT+wQob+iXSZkhnvx+IvdbXF5/yt8aWPpUKnPym
oLxsYiI5gQBLxNDzIec0OIaflWqAr29m7J8+NNtApEN8nZFnf3bhehZW7AxmS1m0
ZnSsdHw0Fw+bgixPg2MQ9k9oefFeqa+7Kqdlz5bbrUYV2volxhDFtnI4Mh8BiWCN
xDH1Hizq+GKCcHsinDZWurCqder/afJBnQs+SBSL6MVApHt+d35zjBD92fO2Je56
dhMfzCgOKXeJ340WhW3TjD1zqLZXeaCyUNRnfOmWZV8nEhtHOFbUCU7r/KkjMZO9
AgMBAAGjgeMwgeAwDgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAw
HQYDVR0OBBYEFMDMA0a5WCDMXHJw8+EuyyCm9Wg6MHoGCCsGAQUFBwEBBG4wbDA0
BggrBgEFBQcwAYYoaHR0cDovL29jc3Auc3RnLXJvb3QteDEubGV0c2VuY3J5cHQu
b3JnLzA0BggrBgEFBQcwAoYoaHR0cDovL2NlcnQuc3RnLXJvb3QteDEubGV0c2Vu
Y3J5cHQub3JnLzAfBgNVHSMEGDAWgBTBJnSkikSg5vogKNhcI5pFiBh54DANBgkq
hkiG9w0BAQsFAAOCAgEABYSu4Il+fI0MYU42OTmEj+1HqQ5DvyAeyCA6sGuZdwjF
UGeVOv3NnLyfofuUOjEbY5irFCDtnv+0ckukUZN9lz4Q2YjWGUpW4TTu3ieTsaC9
AFvCSgNHJyWSVtWvB5XDxsqawl1KzHzzwr132bF2rtGtazSqVqK9E07sGHMCf+zp
DQVDVVGtqZPHwX3KqUtefE621b8RI6VCl4oD30Olf8pjuzG4JKBFRFclzLRjo/h7
IkkfjZ8wDa7faOjVXx6n+eUQ29cIMCzr8/rNWHS9pYGGQKJiY2xmVC9h12H99Xyf
zWE9vb5zKP3MVG6neX1hSdo7PEAb9fqRhHkqVsqUvJlIRmvXvVKTwNCP3eCjRCCI
PTAvjV+4ni786iXwwFYNz8l3PmPLCyQXWGohnJ8iBm+5nk7O2ynaPVW0U2W+pt2w
SVuvdDM5zGv2f9ltNWUiYZHJ1mmO97jSY/6YfdOUH66iRtQtDkHBRdkNBsMbD+Em
2TgBldtHNSJBfB3pm9FblgOcJ0FSWcUDWJ7vO0+NTXlgrRofRT6pVywzxVo6dND0
WzYlTWeUVsO40xJqhgUQRER9YLOLxJ0O6C8i0xFxAMKOtSdodMB3RIwt7RFQ0uyt
n5Z5MqkYhlMI3J1tPRTp1nEt9fyGspBOO05gi148Qasp+3N+svqKomoQglNoAxU=
-----END CERTIFICATE-----`
describe('Certd', function () {
it('#buildCertDir', function () {
const options = createOptions()
options.cert.email = 'xiaojunnuo@qq.com'
options.cert.domains = ['*.docmirror.club']
const certd = new Certd(options)
const currentRootPath = certd.certStore.currentMarkPath
console.log('rootDir', currentRootPath)
expect(currentRootPath).match(/xiaojunnuo@qq.com\\certs\\_.docmirror.club\w*\\current.json/)
})
it('#writeAndReadCert', async function () {
const options = createOptions()
options.cert.email = 'xiaojunnuo@qq.com'
options.cert.domains = ['*.domain.cn']
const certd = new Certd(options)
await certd.writeCert({ csr: 'csr', crt: fakeCrt, key: 'bbb' })
const cert = await certd.readCurrentCert()
expect(cert).to.be.ok
expect(cert.crt).ok
expect(cert.key).to.be.ok
expect(cert.detail).to.be.ok
expect(cert.expires).to.be.ok
console.log('cert:', JSON.stringify(cert))
})
})
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "standard",
"env": {
"mocha": true
},
"overrides": [
{
"files": ["*.test.js", "*.spec.js"],
"rules": {
"no-unused-expressions": "off"
}
}
]
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@certd/executor",
"version": "0.1.14",
"description": "",
"main": "src/index.js",
"scripts": {
"test": "echo \\\"Error: no test specified\\\" && exit 1",
"build": "webpack --config webpack.config.cjs ",
"rollup": "rollup --config rollup.config.js"
},
"type": "module",
"dependencies": {
"@certd/api": "^0.1.13",
"@certd/certd": "^0.1.13",
"@certd/dns-providers": "^0.1.13",
"@certd/plugins": "^0.1.13",
"dayjs": "^1.9.7",
"lodash-es": "^4.17.20"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^11.0.1",
"chai": "^4.2.0",
"eslint": "^7.15.0",
"eslint-config-standard": "^16.0.2",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"mocha": "^8.2.1",
"rollup": "^2.35.1",
"rollup-plugin-terser": "^7.0.2"
},
"author": "Greper",
"license": "MIT",
"sideEffects": false,
"gitHead": "4a421d5b142d453203c68ce6d1036e168ea2455b"
}
+21
View File
@@ -0,0 +1,21 @@
import json from '@rollup/plugin-json'
import { terser } from 'rollup-plugin-terser'
import commonjs from '@rollup/plugin-commonjs'
import { nodeResolve } from '@rollup/plugin-node-resolve'
export default {
input: 'src/index.js',
output: [
{
file: 'bundle.js',
format: 'es'
},
{
file: 'bundle.min.js',
format: 'iife',
name: 'version',
plugins: [terser()]
}
],
plugins: [json(), commonjs(), nodeResolve()]
}
+173
View File
@@ -0,0 +1,173 @@
import { Certd } from '@certd/certd'
import { pluginRegistry, util } from '@certd/api'
import _ from 'lodash-es'
import dayjs from 'dayjs'
import { Trace } from './trace.js'
import PluginAliyun from '@certd/plugin-aliyun'
import PluginTencent from '@certd/plugin-tencent'
import PluginHost from '@certd/plugin-host'
// 安装默认插件和授权提供者
PluginAliyun.install()
PluginTencent.install()
PluginHost.install()
const logger = util.logger
function createDefaultOptions () {
return {
args: {
forceCert: false,
forceDeploy: true,
forceRedeploy: false,
doNotThrowError: false // 部署流程执行有错误时,不抛异常,此时整个任务执行完毕后,可以返回结果,你可以在返回结果中处理
}
}
}
export class Executor {
constructor () {
this.trace = new Trace()
}
async run (options) {
logger.info('------------------- Cert-D ---------------------')
try {
this.transferToSafetyOptions(options)
options = _.merge(createDefaultOptions(), options)
return await this.doRun(options)
} catch (e) {
logger.error('任务执行出错:' + e.message, e)
throw e
}
}
async doRun (options) {
// 申请证书
logger.info('任务开始')
const certd = new Certd(options)
const cert = await this.runCertd(certd)
if (cert == null) {
throw new Error('申请证书失败')
}
logger.info('证书保存路径:', cert.certDir)
logger.info('----------------------')
if (!cert.isNew) {
// 如果没有更新
if (!options.args.forceDeploy && !options.args.forceRedeploy) {
// 且不需要强制运行deploy
logger.info('证书无更新,无需重新部署')
logger.info('任务完成')
return { cert }
}
}
// 读取上次执行进度
let context = {}
const contextJson = await certd.certStore.getCurrentFile('context.json')
if (contextJson) {
context = JSON.parse(contextJson)
}
context.certIsNew = !!cert.isNew
const trace = new Trace(context)
const resultTrace = trace.getInstance({ type: 'result' })
// 运行部署任务
try {
await this.runDeploys({ options, cert, context, trace })
} finally {
await certd.certStore.setCurrentFile('context.json', JSON.stringify(context))
}
logger.info('任务完成')
trace.print()
const result = resultTrace.get({ })
const returnData = {
cert,
context,
result
}
if (result.status === 'error' && options.args.doNotThrowError === false) {
throw new Error(result.remark)
}
return returnData
}
async runCertd (certd) {
logger.info(`证书任务 ${JSON.stringify(certd.options.cert.domains)} 开始`)
const cert = await certd.certApply()
logger.info(`证书任务 ${JSON.stringify(certd.options.cert.domains)} 完成`)
return cert
}
async runDeploys ({ options, cert, context, trace }) {
if (cert == null) {
const certd = new Certd(options)
cert = await certd.readCurrentCert()
}
logger.info('部署任务开始')
for (const deploy of options.deploy) {
const deployName = deploy.deployName
logger.info(`------------【${deployName}】-----------`)
const deployTrace = trace.getInstance({ type: 'deploy', deployName })
if (deploy.disabled === true) {
logger.info('此流程已被禁用,跳过')
logger.info('')
deployTrace.set({ value: { current: 'skip', status: 'disabled', remark: '流程禁用' } })
continue
}
try {
for (const task of deploy.tasks) {
if (context[deployName] == null) {
context[deployName] = {}
}
const taskContext = context[deployName]
// 开始执行任务列表
await this.runTask({ options, cert, task, context: taskContext, deploy, trace })
}
deployTrace.set({ value: { status: 'success', remark: '执行成功' } })
trace.set({ type: 'result', value: { status: 'success', remark: '执行成功' } })
} catch (e) {
deployTrace.set({ value: { status: 'error', remark: '执行失败:' + e.message } })
trace.set({ type: 'result', value: { status: 'error', remark: deployName + '执行失败:' + e.message } })
logger.error('流程执行失败', e)
}
logger.info('')
}
}
async runTask ({ options, task, cert, context, deploy, trace }) {
const taskType = task.type
const Plugin = pluginRegistry.get(taskType)
const deployName = deploy.deployName
const taskName = task.taskName
if (Plugin == null) {
throw new Error(`插件:${taskType}还未安装`)
}
let instance = Plugin
if (Plugin instanceof Function) {
instance = new Plugin({ accessProviders: options.accessProviders })
}
const taskTrace = trace.getInstance({ type: 'deploy', deployName, taskName })
const traceStatus = taskTrace.get({})
if (traceStatus && traceStatus.status === 'success' && !options.args.forceRedeploy) {
logger.info(`----【${taskName}】已经执行完成,跳过此任务`)
taskTrace.set({ value: { current: 'skip', status: 'success', remark: '已执行成功过,本次跳过' } })
return
}
logger.info(`----【${taskName}】开始执行`)
try {
// 执行任务
await instance.execute({ cert, props: task.props, context })
taskTrace.set({ value: { current: 'success', status: 'success', remark: '执行成功', time: dayjs().format() } })
} catch (e) {
taskTrace.set({ value: { current: 'error', status: 'error', remark: e.message, time: dayjs().format() } })
throw e
}
logger.info(`----任务【${taskName}】执行完成`)
logger.info('')
}
}
+94
View File
@@ -0,0 +1,94 @@
import { util } from '@certd/api'
import _ from 'lodash-es'
const logger = util.logger
export class Trace {
constructor (context) {
this.context = context
}
getInstance ({ type, deployName, taskName }) {
return {
get: ({ prop }) => {
return this.get({ type, deployName, taskName, prop })
},
set: ({ prop, value }) => {
this.set({ type, deployName, taskName, prop, value })
}
}
}
set ({ type, deployName, taskName, prop, value }) {
const key = this.buildTraceKey({ type, deployName, taskName, prop })
const oldValue = _.get(this.context, key) || {}
_.merge(oldValue, value)
_.set(this.context, key, oldValue)
}
get ({ type, deployName, taskName, prop }) {
return _.get(this.context, this.buildTraceKey({ type, deployName, taskName, prop }))
}
buildTraceKey ({ type = 'default', deployName, taskName, prop }) {
let key = '__trace__.' + type
if (deployName) {
key += '.'
key += deployName.replace(/\./g, '_')
}
if (taskName) {
key += '.tasks.'
key += taskName.replace(/\./g, '_')
}
if (prop) {
key += '.' + prop
}
return key
}
getStringLength (str) {
const enLength = str.replace(/[\u0391-\uFFE5]/g, '').length // 先把中文替换成两个字节的英文,再计算长度
return Math.floor((str.length - enLength) * 1.5) + enLength
}
print () {
const context = this.context
logger.info('---------------------------任务结果总览--------------------------')
if (context.certIsNew) {
this.printTraceLine({ current: 'success', remark: '证书更新成功' }, '更新证书')
} else {
this.printTraceLine({ current: 'skip', remark: '还未到过期时间,跳过' }, '更新证书')
}
const trace = this.get({ type: 'deploy' })
// logger.info('trace', trace)
for (const deployName in trace) {
if (trace[deployName] == null) {
trace[deployName] = {}
}
const traceStatus = this.printTraceLine(trace[deployName], deployName)
const tasks = traceStatus.tasks
if (tasks) {
for (const taskName in tasks) {
if (tasks[taskName] == null) {
tasks[taskName] = {}
}
this.printTraceLine(tasks[taskName], taskName, ' └')
}
}
}
const result = this.get({ type: 'result' })
this.printTraceLine(result, 'result', '')
const mainContext = {}
_.merge(mainContext, context)
delete mainContext.__trace__
logger.info('【context】', JSON.stringify(mainContext))
}
printTraceLine (traceStatus, name, prefix = '') {
const length = this.getStringLength(name)
const endPad = _.repeat('-', 45 - prefix.length - length) + '\t'
const status = traceStatus.current || traceStatus.status || ''
const remark = traceStatus.remark || ''
logger.info(`${prefix}${name}${endPad}[${status}] \t${remark}`)
return traceStatus
}
}
+34
View File
@@ -0,0 +1,34 @@
import pkg from 'chai'
import { Executor } from '../src'
import { createOptions } from '../../../../test/options.js'
const { expect } = pkg
describe('AutoDeploy', function () {
it('#run', async function () {
this.timeout(120000)
const options = createOptions()
const executor = new Executor()
const ret = await executor.run(options)
expect(ret).ok
expect(ret.cert).ok
})
it('#forceCert', async function () {
this.timeout(120000)
const executor = new Executor()
const options = createOptions()
options.args.forceCert = true
options.args.forceDeploy = true
const ret = await executor.run(options)
expect(ret).ok
expect(ret.cert).ok
})
it('#forceDeploy', async function () {
this.timeout(120000)
const executor = new Executor()
const options = createOptions()
const ret = await executor.run(options, { forceCert: false, forceDeploy: true, forceRedeploy: true })
expect(ret).ok
expect(ret.cert).ok
})
})
+23
View File
@@ -0,0 +1,23 @@
const path = require('path')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
console.log(CleanWebpackPlugin)
module.exports = {
devtool: 'source-map',
target: 'node',
entry: './src/index.js',
output: {
filename: 'executor.js',
path: path.resolve(__dirname, 'dist'),
library: 'certdExecutor',
libraryTarget: 'umd'
},
plugins: [
new CleanWebpackPlugin()
],
mode: 'production'
// mode: 'development',
// optimization: {
// usedExports: true
// }
}
File diff suppressed because it is too large Load Diff