feat: 自动申请证书

master
xiaojunnuo 2020-12-13 23:06:17 +08:00
parent 62e3945d30
commit 458486dd6b
18 changed files with 2473 additions and 0 deletions

7
lerna.json Normal file
View File

@ -0,0 +1,7 @@
{
"packages": [
"packages/deploy/*",
"packages/*"
],
"version": "0.0.0"
}

8
package.json Normal file
View File

@ -0,0 +1,8 @@
{
"name": "root",
"private": true,
"devDependencies": {
"lerna": "^3.18.4"
},
"license": "MIT"
}

6
packages/certd/.eslintrc Normal file
View File

@ -0,0 +1,6 @@
{
"extends": "standard",
"env": {
"mocha": true
}
}

View File

@ -0,0 +1,29 @@
{
"name": "certd",
"version": "1.0.0",
"description": "",
"main": "src/index.js",
"exports": "src/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"type": "module",
"author": "Greper",
"license": "MIT",
"dependencies": {
"@alicloud/pop-core": "^1.7.10",
"acme-client": "^4.1.2",
"chai": "^4.2.0",
"dayjs": "^1.9.7",
"lodash": "^4.17.20",
"log4js": "^6.3.0"
},
"devDependencies": {
"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"
}
}

160
packages/certd/src/acme.js Normal file
View File

@ -0,0 +1,160 @@
import acme from 'acme-client'
import log from './utils/util.log.js'
import _ from 'lodash'
import sleep from './utils/util.sleep.js'
export class AcmeService {
constructor (store) {
this.store = store
}
async getAccountKey (email) {
let key = this.store.get(this.buildAccountKeyPath(email))
if (key == null) {
key = await this.createNewKey({ email })
}
return key
}
buildAccountKeyPath (email) {
return email + '/acme/account.key'
}
setAccountKey (email, privateKey) {
this.store.set(this.buildAccountKeyPath(email), privateKey)
}
async getAcmeClient (email) {
const key = await this.getAccountKey(email)
const client = new acme.Client({
directoryUrl: acme.directory.letsencrypt.staging,
accountKey: key
})
return client
}
async createNewKey ({ email }) {
const privateKey = await acme.forge.createPrivateKey()
this.setAccountKey(email, privateKey)
}
async loggerin () {
}
async challengeCreateFn (authz, challenge, keyAuthorization, dnsProvider) {
log.info('Triggered challengeCreateFn()')
/* http-01 */
if (challenge.type === 'http-01') {
const filePath = `/var/www/html/.well-known/acme-challenge/${challenge.token}`
const fileContents = keyAuthorization
log.info(`Creating challenge response for ${authz.identifier.value} at path: ${filePath}`)
/* Replace this */
log.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
log.info(`Creating TXT record for ${authz.identifier.value}: ${dnsRecord}`)
/* Replace this */
log.info(`Would create TXT record "${dnsRecord}" with value "${recordValue}"`)
try {
await dnsProvider.createRecord(dnsRecord, 'TXT', recordValue)
} catch (e) {
if (e.code === 'DomainRecordDuplicate') {
await dnsProvider.removeRecord(dnsRecord, 'TXT')
await sleep(1000)
await dnsProvider.createRecord(dnsRecord, 'TXT', recordValue)
}
}
}
}
/**
* Function used to remove an ACME challenge response
*
* @param {object} authz Authorization object
* @param {object} challenge Selected challenge
* @param {string} keyAuthorization Authorization key
* @returns {Promise}
*/
async challengeRemoveFn (authz, challenge, keyAuthorization, dnsProvider) {
log.info('Triggered challengeRemoveFn()')
/* http-01 */
if (challenge.type === 'http-01') {
const filePath = `/var/www/html/.well-known/acme-challenge/${challenge.token}`
log.info(`Removing challenge response for ${authz.identifier.value} at path: ${filePath}`)
/* Replace this */
log.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
log.info(`Removing TXT record for ${authz.identifier.value}: ${dnsRecord}`)
/* Replace this */
log.info(`Would remove TXT record "${dnsRecord}" with value "${recordValue}"`)
await dnsProvider.removeRecord(dnsRecord, 'TXT', keyAuthorization)
}
}
async order ({ email, domains, dnsProvider, csrInfo }) {
const client = await this.getAcmeClient(email)
/* Create CSR */
const { commonName, altNames } = this.buildCommonNameByDomains(domains)
const [key, csr] = await acme.forge.createCsr({
commonName,
...csrInfo,
altNames
})
/* Certificate */
const crt = await client.auto({
csr,
email: email,
termsOfServiceAgreed: true,
challengePriority: ['dns-01', 'http-01'],
challengeCreateFn: (authz, challenge, keyAuthorization) => {
return this.challengeCreateFn(authz, challenge, keyAuthorization, dnsProvider)
},
challengeRemoveFn: (authz, challenge, keyAuthorization) => {
return this.challengeRemoveFn(authz, challenge, keyAuthorization, dnsProvider)
}
})
/* Done */
log.info(`CSR:\n${csr.toString()}`)
log.info(`Private key:\n${key.toString()}`)
log.info(`Certificate:\n${crt.toString()}`)
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
}
}

View File

@ -0,0 +1,7 @@
export class DnsProviderFactory {
static async createByType (type, options) {
const ProviderModule = await import('./impl/' + type + '.js')
const Provider = ProviderModule.default
return new Provider(options)
}
}

View File

@ -0,0 +1,9 @@
export class DnsProvider {
createRecord (dnsRecord, type, recordValue) {
}
removeRecord (dnsRecord, type) {
}
}

View File

@ -0,0 +1,107 @@
import { DnsProvider } from '../dns-provider.js'
import Core from '@alicloud/pop-core'
import _ from 'lodash'
import log from '../../utils/util.log.js'
export default class AliyunDnsProvider extends DnsProvider {
constructor (dnsProviderConfig) {
super()
this.client = new Core({
accessKeyId: dnsProviderConfig.accessKeyId,
accessKeySecret: dnsProviderConfig.accessKeySecret,
endpoint: 'https://alidns.aliyuncs.com',
apiVersion: '2015-01-09'
})
}
async getDomainList () {
const params = {
RegionId: 'cn-hangzhou'
}
const requestOption = {
method: 'POST'
}
const ret = await this.client.request('DescribeDomains', params, requestOption)
return ret.Domains.Domain
}
async matchDomain (dnsRecord) {
const list = await this.getDomainList()
let domain = null
for (const item of list) {
if (_.endsWith(dnsRecord, item.DomainName)) {
domain = item.DomainName
break
}
}
if (!domain) {
throw new Error('can not find Domain ,' + dnsRecord)
}
return domain
}
async getRecords (domain, rr, value) {
const params = {
RegionId: 'cn-hangzhou',
DomainName: domain,
RRKeyWord: rr
}
if (value) {
params.ValueKeyWord = value
}
const requestOption = {
method: 'POST'
}
const ret = await this.client.request('DescribeDomainRecords', params, requestOption)
return ret.DomainRecords.Record
}
async createRecord (dnsRecord, type, recordValue) {
const domain = await this.matchDomain(dnsRecord)
const rr = dnsRecord.replace('.' + domain, '')
const params = {
RegionId: 'cn-hangzhou',
DomainName: domain,
RR: rr,
Type: type,
Value: recordValue
}
const requestOption = {
method: 'POST'
}
try {
const ret = await this.client.request('AddDomainRecord', params, requestOption)
return ret.RecordId
} catch (e) {
// e.code === 'DomainRecordDuplicate'
console.log('添加域名解析出错', e)
throw e
}
}
async removeRecord (dnsRecord, type, value) {
const domain = await this.matchDomain(dnsRecord)
const rr = dnsRecord.replace('.' + domain, '')
const record = await this.getRecords(domain, rr, value)
const params = {
RegionId: 'cn-hangzhou',
RecordId: record[0].RecordId
}
const requestOption = {
method: 'POST'
}
const ret = await this.client.request('DeleteDomainRecord', params, requestOption)
log.info('delete record success:', ret.RecordId)
return ret.RecordId
}
}

View File

@ -0,0 +1,82 @@
import { AcmeService } from './acme.js'
import { FileStore } from './store/file-store.js'
import { DnsProviderFactory } from './dns-provider/dns-provider-factory.js'
import dayjs from 'dayjs'
import path from 'path'
import _ from 'lodash'
import fs from 'fs'
import util from './utils/util.js'
import forge from 'node-forge'
export class Certd {
constructor () {
this.store = new FileStore()
this.acme = new AcmeService(this.store)
}
buildCertDir (email, domains) {
let domainStr = _.join(domains)
domainStr = domainStr.replace(/\*/g, '')
const dir = path.join(email, '/certs/', domainStr)
return dir
}
async certApply (options) {
const certOptions = options.cert
const providers = options.providers
const providerOptions = providers[certOptions.challenge.dnsProvider]
const dnsProvider = await DnsProviderFactory.createByType(providerOptions.providerType, providerOptions)
const cert = await this.acme.order({
email: certOptions.email,
domains: certOptions.domains,
dnsProvider: dnsProvider,
csrInfo: certOptions.csrInfo
})
this.writeCert(certOptions.email, certOptions.domains, cert)
const { detail, expires } = this.getDetailFromCrt(cert.crt)
return {
...cert,
detail,
expires
}
}
writeCert (email, domains, cert) {
const certFilesRootDir = this.buildCertDir(email, domains)
const dirPath = path.join(certFilesRootDir, dayjs().format('YYYY.MM.DD.HHmmss'))
this.store.set(path.join(dirPath, '/cert.crt'), cert.crt)
this.store.set(path.join(dirPath, '/cert.key'), cert.key)
this.store.set(path.join(dirPath, '/cert.csr'), cert.csr)
const linkPath = path.join(util.getUserBasePath(), certFilesRootDir, 'current')
const lastPath = path.join(util.getUserBasePath(), dirPath)
// if (!fs.existsSync(linkPath)) {
// fs.mkdirSync(linkPath)
// }
fs.symlinkSync(lastPath, linkPath)
}
readCurrentCert (email, domains) {
const certFilesRootDir = this.buildCertDir(email, domains)
const currentPath = path.join(certFilesRootDir, 'current')
const crt = this.store.get(currentPath + '/cert.crt')
const key = this.store.get(currentPath + '/cert.key')
const csr = this.store.get(currentPath + '/cert.csr')
const { detail, expires } = this.getDetailFromCrt(crt)
const cert = {
crt, key, csr, detail, expires
}
return cert
}
getDetailFromCrt (crt) {
const pki = forge.pki
const detail = pki.certificateFromPem(crt.toString())
const expires = detail.validity.notAfter
return { detail, expires }
}
}

View File

@ -0,0 +1,33 @@
import { Store } from './store.js'
import util from '../utils/util.js'
import path from 'path'
import fs from 'fs'
export class FileStore extends Store {
constructor () {
super()
this.rootDir = util.getUserBasePath()
}
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)
}
}

View File

@ -0,0 +1,9 @@
export class Store {
set (key, value) {
}
get (key) {
}
}

View File

@ -0,0 +1,9 @@
import path from 'path'
function getUserBasePath () {
const userHome = process.env.USERPROFILE
return path.resolve(userHome, './.certd')
}
export default {
getUserBasePath
}

View File

@ -0,0 +1,11 @@
import util from './util.js'
import log4js from 'log4js'
import path from 'path'
const level = process.env.NODE_ENV === 'development' ? 'debug' : 'info'
const filename = path.join(util.getUserBasePath(), '/logs/certd.log')
log4js.configure({
appenders: { std: { type: 'stdout' }, file: { type: 'file', pattern: 'yyyy-MM-dd', daysToKeep: 3, filename } },
categories: { default: { appenders: ['file', 'std'], level: level } }
})
const logger = log4js.getLogger('certd')
export default logger

View File

@ -0,0 +1,7 @@
export default function (timeout) {
return new Promise(resolve => {
setTimeout(() => {
resolve()
}, timeout)
})
}

View File

@ -0,0 +1,33 @@
import pkg from 'chai'
import options from '../options.js'
import AliyunDnsProvider from '../../src/dns-provider/impl/aliyun.js'
const { expect } = pkg
describe('AliyunDnsProvider', function () {
it('#getDomainList', async function () {
const aliyunDnsProvider = new AliyunDnsProvider(options.providers.aliyun)
const domainList = await aliyunDnsProvider.getDomainList()
console.log('domainList', domainList)
expect(domainList.length).gt(0)
})
it('#getRecords', async function () {
const aliyunDnsProvider = new AliyunDnsProvider(options.providers.aliyun)
const recordList = await aliyunDnsProvider.getRecords('docmirror.cn', '*')
console.log('recordList', recordList)
expect(recordList.length).gt(0)
})
it('#createRecord', async function () {
const aliyunDnsProvider = new AliyunDnsProvider(options.providers.aliyun)
const recordId = await aliyunDnsProvider.createRecord('___certd___.__test__.docmirror.cn', 'TXT', 'aaaa')
console.log('recordId', recordId)
expect(recordId != null).ok
})
it('#removeRecord', async function () {
const aliyunDnsProvider = new AliyunDnsProvider(options.providers.aliyun)
const recordId = await aliyunDnsProvider.removeRecord('___certd___.__test__.docmirror.cn', 'TXT', 'aaaa')
console.log('recordId', recordId)
expect(recordId != null).ok
})
})

View File

@ -0,0 +1,36 @@
import pkg from 'chai'
import { Certd } from '../src/index.js'
import options from './options.js'
import forge from 'node-forge'
const { expect } = pkg
describe('Certd', function () {
it('#buildCertDir', function () {
const certd = new Certd()
const rootDir = certd.buildCertDir('xiaojunnuo@qq.com', options.cert.domains)
console.log('rootDir', rootDir)
expect(rootDir).match(/xiaojunnuo@qq.com\\cert\\(.*)\\(.*)/)
})
it('#writeCert', async function () {
const certd = new Certd()
certd.writeCert('xiaojunnuo@qq.com', ['*.domain.cn'], { csr: 'csr', crt: 'aaa', key: 'bbb' })
})
it('#certApply', async function () {
this.timeout(80000)
const certd = new Certd()
const cert = await certd.certApply(options)
expect(cert).ok
expect(cert.cert).ok
expect(cert.key).to.be.ok
})
it('#readCurrentCert', async function () {
const certd = new Certd()
const cert = certd.readCurrentCert('xiaojunnuo@qq.com', ['*.docmirror.cn'])
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('expires:', cert.expires)
})
})

View File

@ -0,0 +1,87 @@
import _ from 'lodash'
import optionsPrivate from './options.private.js'
const defaultOptions = {
providers: {
aliyun: {
providerType: 'aliyun',
accessKeyId: '',
accessKeySecret: ''
},
myLinux: {
providerType: 'SSH',
username: 'xxx',
password: 'xxx',
host: '1111.com',
port: 22,
publicKey: ''
}
},
cert: {
domains: ['*.docmirror.cn', 'docmirror.cn'],
email: 'xiaojunnuo@qq.com',
challenge: {
challengeType: 'dns',
dnsProvider: 'aliyun'
},
csrInfo: {
country: 'CN',
state: 'GuangDong',
locality: 'ShengZhen',
organization: 'CertD Org.',
organizationUnit: 'IT Department',
emailAddress: 'xiaojunnuo@qq.com'
}
},
deploy: [
{
deployName: '流程1-部署到阿里云系列产品',
tasks: [
{
name: '上传证书到云',
taskType: 'uploadCertToCloud',
certStore: 'aliyun'
},
{
name: '部署证书到SLB',
taskType: 'deployCertToAliyunSLB',
certStore: 'aliyun'
},
{
name: '部署证书到阿里云集群Ingress',
taskType: 'deployCertToAliyunK8sIngress',
certStore: 'aliyun'
}
]
},
{
deployName: '流程2-部署到nginx服务器',
tasks: [
{
name: '上传证书到服务器,并重启nginx',
taskType: 'sshAndExecute',
ssh: 'myLinux',
upload: [
{ from: '{certPath}', to: '/xxx/xxx/xxx.cert.pem' },
{ from: '{keyPath}', to: '/xxx/xxx/xxx.key' }
],
script: 'sudo systemctl restart nginx'
}
]
},
{
deployName: '流程3-触发jenkins任务',
tasks: [
{
name: '触发jenkins任务',
taskType: 'sshAndExecute',
ssh: 'myLinux',
script: 'sudo systemctl restart nginx'
}
]
}
]
}
_.merge(defaultOptions, optionsPrivate)
export default defaultOptions

1833
packages/certd/yarn.lock Normal file

File diff suppressed because it is too large Load Diff