diff --git a/packages/plugins/plugin-aliyun/package.json b/packages/plugins/plugin-aliyun/package.json index dd8de8f4..53bfc1bd 100644 --- a/packages/plugins/plugin-aliyun/package.json +++ b/packages/plugins/plugin-aliyun/package.json @@ -11,11 +11,15 @@ "preview": "vite preview" }, "dependencies": { + "@alicloud/cs20151215": "^3.0.3", + "@alicloud/openapi-client": "^0.4.0", + "@alicloud/pop-core": "^1.7.10", "@certd/acme-client": "^0.3.0", "dayjs": "^1.11.6", "lodash": "^4.17.21", "node-forge": "^0.10.0", - "@certd/pipeline": "^0.3.0" + "@certd/pipeline": "^0.3.0", + "@certd/plugin-util": "^0.3.0" }, "devDependencies": { "@types/lodash": "^4.14.186", diff --git a/packages/plugins/plugin-aliyun/src/plugin/deploy-to-ack-ingress/index.ts b/packages/plugins/plugin-aliyun/src/plugin/deploy-to-ack-ingress/index.ts new file mode 100644 index 00000000..56166cb3 --- /dev/null +++ b/packages/plugins/plugin-aliyun/src/plugin/deploy-to-ack-ingress/index.ts @@ -0,0 +1,213 @@ +import { AbstractPlugin, IsTask, RunStrategy, TaskInput, TaskOutput, TaskPlugin, utils } from "@certd/pipeline"; +import Core from "@alicloud/pop-core"; +import { AliyunAccess } from "../../access"; +import { K8sClient } from "@certd/plugin-util"; +import { appendTimeSuffix } from "../../utils"; + +const ROAClient = Core.ROAClient; +@IsTask(() => { + return { + name: "DeployCertToAliyunAckIngress", + title: "部署到阿里云AckIngress", + input: { + clusterId: { + title: "集群id", + component: { + placeholder: "集群id", + }, + }, + secretName: { + title: "保密字典Id", + component: { + placeholder: "保密字典Id", + }, + required: true, + }, + regionId: { + title: "大区", + value: "cn-shanghai", + component: { + placeholder: "集群所属大区", + }, + required: true, + }, + namespace: { + title: "命名空间", + value: "default", + component: { + placeholder: "命名空间", + }, + required: true, + }, + ingressName: { + title: "ingress名称", + value: "", + component: { + placeholder: "ingress名称", + }, + required: true, + helper: "可以传入一个数组", + }, + ingressClass: { + title: "ingress类型", + value: "nginx", + component: { + placeholder: "暂时只支持nginx类型", + }, + required: true, + }, + isPrivateIpAddress: { + title: "是否私网ip", + value: false, + component: { + placeholder: "集群连接端点是否是私网ip", + }, + helper: "如果您当前certd运行在同一个私网下,可以选择是。", + required: true, + }, + cert: { + title: "域名证书", + helper: "请选择前置任务输出的域名证书", + component: { + name: "pi-output-selector", + }, + required: true, + }, + accessId: { + title: "Access授权", + helper: "阿里云授权AccessKeyId、AccessKeySecret", + component: { + name: "pi-access-selector", + type: "aliyun", + }, + required: true, + }, + }, + output: {}, + default: { + strategy: { + runStrategy: RunStrategy.SkipWhenSucceed, + }, + }, + }; +}) +export class DeployCertToAliyunAckIngressPlugin extends AbstractPlugin implements TaskPlugin { + async execute(input: TaskInput): Promise { + console.log("开始部署证书到阿里云cdn"); + const { regionId, ingressClass, clusterId, isPrivateIpAddress, cert } = input; + const access = (await this.accessService.getById(input.accessId)) as AliyunAccess; + const client = this.getClient(access, regionId); + const kubeConfigStr = await this.getKubeConfig(client, clusterId, isPrivateIpAddress); + + this.logger.info("kubeconfig已成功获取"); + const k8sClient = new K8sClient(kubeConfigStr); + const ingressType = ingressClass || "qcloud"; + if (ingressType === "qcloud") { + throw new Error("暂未实现"); + // await this.patchQcloudCertSecret({ k8sClient, props, context }) + } else { + await this.patchNginxCertSecret({ cert, k8sClient, input }); + } + + await utils.sleep(3000); // 停留2秒,等待secret部署完成 + // await this.restartIngress({ k8sClient, props }) + return {}; + } + + async restartIngress(options: { k8sClient: any; input: TaskInput }) { + const { k8sClient, input } = options; + const { namespace } = input; + + const body = { + metadata: { + labels: { + certd: appendTimeSuffix("certd"), + }, + }, + }; + const ingressList = await k8sClient.getIngressList({ namespace }); + console.log("ingressList:", ingressList); + if (!ingressList || !ingressList.body || !ingressList.body.items) { + return; + } + const ingressNames = ingressList.body.items + .filter((item: any) => { + if (!item.spec.tls) { + return false; + } + for (const tls of item.spec.tls) { + if (tls.secretName === input.secretName) { + return true; + } + } + return false; + }) + .map((item: any) => { + return item.metadata.name; + }); + for (const ingress of ingressNames) { + await k8sClient.patchIngress({ namespace, ingressName: ingress, body }); + this.logger.info(`ingress已重启:${ingress}`); + } + } + + async patchNginxCertSecret(options: { cert: any; k8sClient: any; input: TaskInput }) { + const { cert, k8sClient, input } = options; + const crt = cert.crt; + const key = cert.key; + const crtBase64 = Buffer.from(crt).toString("base64"); + const keyBase64 = Buffer.from(key).toString("base64"); + + const { namespace, secretName } = input; + + const body = { + data: { + "tls.crt": crtBase64, + "tls.key": keyBase64, + }, + metadata: { + labels: { + certd: appendTimeSuffix("certd"), + }, + }, + }; + let secretNames = secretName; + if (typeof secretName === "string") { + secretNames = [secretName]; + } + for (const secret of secretNames) { + await k8sClient.patchSecret({ namespace, secretName: secret, body }); + this.logger.info(`CertSecret已更新:${secret}`); + } + } + + getClient(aliyunProvider: any, regionId: string) { + return new ROAClient({ + accessKeyId: aliyunProvider.accessKeyId, + accessKeySecret: aliyunProvider.accessKeySecret, + endpoint: `https://cs.${regionId}.aliyuncs.com`, + apiVersion: "2015-12-15", + }); + } + + async getKubeConfig(client: any, clusterId: string, isPrivateIpAddress = false) { + const httpMethod = "GET"; + const uriPath = `/k8s/${clusterId}/user_config`; + const queries = { + PrivateIpAddress: isPrivateIpAddress, + }; + const body = "{}"; + const headers = { + "Content-Type": "application/json", + }; + const requestOption = {}; + + try { + const res = await client.request(httpMethod, uriPath, queries, body, headers, requestOption); + return res.config; + } catch (e) { + console.error("请求出错:", e); + throw e; + } + } +} diff --git a/packages/plugins/plugin-aliyun/src/plugin/index.ts b/packages/plugins/plugin-aliyun/src/plugin/index.ts index aaae4f15..7b5a4d10 100644 --- a/packages/plugins/plugin-aliyun/src/plugin/index.ts +++ b/packages/plugins/plugin-aliyun/src/plugin/index.ts @@ -1 +1,3 @@ export * from "./deploy-to-cdn"; +export * from "./deploy-to-ack-ingress"; +export * from "./upload-to-aliyun"; diff --git a/packages/plugins/plugin-aliyun/src/plugin/upload-to-aliyun/index.ts b/packages/plugins/plugin-aliyun/src/plugin/upload-to-aliyun/index.ts new file mode 100644 index 00000000..e5362f71 --- /dev/null +++ b/packages/plugins/plugin-aliyun/src/plugin/upload-to-aliyun/index.ts @@ -0,0 +1,90 @@ +import { AbstractPlugin, IsTask, RunStrategy, TaskInput, TaskOutput, TaskPlugin } from "@certd/pipeline"; +import dayjs from "dayjs"; +import Core from "@alicloud/pop-core"; +import RPCClient from "@alicloud/pop-core"; +import { AliyunAccess } from "../../access"; +import { appendTimeSuffix, checkRet, ZoneOptions } from "../../utils"; + +@IsTask(() => { + return { + name: "uploadCertToAliyun", + title: "上传证书到阿里云", + desc: "", + input: { + name: { + title: "证书名称", + helper: "证书上传后将以此参数作为名称前缀", + }, + regionId: { + title: "大区", + value: "cn-hangzhou", + component: { + name: "a-select", + vModel: "value", + options: ZoneOptions, + }, + required: true, + }, + cert: { + title: "域名证书", + helper: "请选择前置任务输出的域名证书", + component: { + name: "pi-output-selector", + }, + required: true, + }, + accessId: { + title: "Access授权", + helper: "阿里云授权AccessKeyId、AccessKeySecret", + component: { + name: "pi-access-selector", + type: "aliyun", + }, + required: true, + }, + }, + output: { + aliyunCertId: { + title: "上传成功后的阿里云CertId", + }, + }, + default: { + strategy: { + runStrategy: RunStrategy.SkipWhenSucceed, + }, + }, + }; +}) +export class UploadCertToAliyun extends AbstractPlugin implements TaskPlugin { + async execute(input: TaskInput): Promise { + console.log("开始部署证书到阿里云cdn"); + const access = (await this.accessService.getById(input.accessId)) as AliyunAccess; + const client = this.getClient(access); + const { name, cert } = input; + const certName = appendTimeSuffix(name); + const params = { + RegionId: input.regionId || "cn-hangzhou", + Name: certName, + Cert: cert.crt, + Key: cert.key, + }; + + const requestOption = { + method: "POST", + }; + + const ret = (await client.request("CreateUserCertificate", params, requestOption)) as any; + checkRet(ret); + this.logger.info("证书上传成功:aliyunCertId=", ret.CertId); + return { aliyunCertId: ret.CertId }; + } + + getClient(aliyunProvider: AliyunAccess) { + return new Core({ + accessKeyId: aliyunProvider.accessKeyId, + accessKeySecret: aliyunProvider.accessKeySecret, + endpoint: "https://cas.aliyuncs.com", + apiVersion: "2018-07-13", + }); + } +} diff --git a/packages/plugins/plugin-aliyun/src/plugins/deploy-to-ack-ingress/index.js b/packages/plugins/plugin-aliyun/src/plugins/deploy-to-ack-ingress/index.js new file mode 100644 index 00000000..fe5738fe --- /dev/null +++ b/packages/plugins/plugin-aliyun/src/plugins/deploy-to-ack-ingress/index.js @@ -0,0 +1,199 @@ +import { AbstractAliyunPlugin } from '../abstract-aliyun.js' +import Core from '@alicloud/pop-core' +import { K8sClient } from '@certd/plugin-common' +const ROAClient = Core.ROAClient + +const define = { + name: 'deployCertToAliyunAckIngress', + title: '部署到阿里云AckIngress', + input: { + clusterId: { + title: '集群id', + component: { + placeholder: '集群id' + } + }, + secretName: { + title: '保密字典Id', + component: { + placeholder: '保密字典Id' + }, + required: true + }, + regionId: { + title: '大区', + value: 'cn-shanghai', + component: { + placeholder: '集群所属大区' + }, + required: true + }, + namespace: { + title: '命名空间', + value: 'default', + component: { + placeholder: '命名空间' + }, + required: true + }, + ingressName: { + title: 'ingress名称', + value: '', + component: { + placeholder: 'ingress名称' + }, + required: true, + helper: '可以传入一个数组' + }, + ingressClass: { + title: 'ingress类型', + value: 'nginx', + component: { + placeholder: '暂时只支持nginx类型' + }, + required: true + }, + isPrivateIpAddress: { + title: '是否私网ip', + value: false, + component: { + placeholder: '集群连接端点是否是私网ip' + }, + helper: '如果您当前certd运行在同一个私网下,可以选择是。', + required: true + }, + accessProvider: { + title: 'Access授权', + type: [String, Object], + helper: 'AccessKey、AccessSecret', + component: { + name: 'access-selector', + type: 'aliyun' + }, + required: true + } + }, + output: { + + } +} + +export class DeployCertToAliyunAckIngress extends AbstractAliyunPlugin { + static define () { + return define + } + + async execute ({ cert, props, context }) { + const accessProvider = this.getAccessProvider(props.accessProvider) + const client = this.getClient(accessProvider, props.regionId) + + const kubeConfigStr = await this.getKubeConfig(client, props.clusterId, props.isPrivateIpAddress) + + this.logger.info('kubeconfig已成功获取') + const k8sClient = new K8sClient(kubeConfigStr) + const ingressType = props.ingressClass || 'qcloud' + if (ingressType === 'qcloud') { + throw new Error('暂未实现') + // await this.patchQcloudCertSecret({ k8sClient, props, context }) + } else { + await this.patchNginxCertSecret({ cert, k8sClient, props, context }) + } + + await this.sleep(3000) // 停留2秒,等待secret部署完成 + // await this.restartIngress({ k8sClient, props }) + return true + } + + async restartIngress ({ k8sClient, props }) { + const { namespace } = props + + const body = { + metadata: { + labels: { + certd: this.appendTimeSuffix('certd') + } + } + } + const ingressList = await k8sClient.getIngressList({ namespace }) + console.log('ingressList:', ingressList) + if (!ingressList || !ingressList.body || !ingressList.body.items) { + return + } + const ingressNames = ingressList.body.items.filter(item => { + if (!item.spec.tls) { + return false + } + for (const tls of item.spec.tls) { + if (tls.secretName === props.secretName) { + return true + } + } + return false + }).map(item => { + return item.metadata.name + }) + for (const ingress of ingressNames) { + await k8sClient.patchIngress({ namespace, ingressName: ingress, body }) + this.logger.info(`ingress已重启:${ingress}`) + } + } + + async patchNginxCertSecret ({ cert, k8sClient, props, context }) { + const crt = cert.crt + const key = cert.key + const crtBase64 = Buffer.from(crt).toString('base64') + const keyBase64 = Buffer.from(key).toString('base64') + + const { namespace, secretName } = props + + const body = { + data: { + 'tls.crt': crtBase64, + 'tls.key': keyBase64 + }, + metadata: { + labels: { + certd: this.appendTimeSuffix('certd') + } + } + } + let secretNames = secretName + if (typeof secretName === 'string') { + secretNames = [secretName] + } + for (const secret of secretNames) { + await k8sClient.patchSecret({ namespace, secretName: secret, body }) + this.logger.info(`CertSecret已更新:${secret}`) + } + } + + getClient (aliyunProvider, regionId) { + return new ROAClient({ + accessKeyId: aliyunProvider.accessKeyId, + accessKeySecret: aliyunProvider.accessKeySecret, + endpoint: `https://cs.${regionId}.aliyuncs.com`, + apiVersion: '2015-12-15' + }) + } + + async getKubeConfig (client, clusterId, isPrivateIpAddress = false) { + const httpMethod = 'GET' + const uriPath = `/k8s/${clusterId}/user_config` + const queries = { + PrivateIpAddress: isPrivateIpAddress + } + const body = '{}' + const headers = { + 'Content-Type': 'application/json' + } + const requestOption = {} + + try { + const res = await client.request(httpMethod, uriPath, queries, body, headers, requestOption) + return res.config + } catch (e) { + console.error('请求出错:', e) + throw e + } + } +} diff --git a/packages/plugins/plugin-aliyun/src/plugins/upload-to-aliyun/index.js b/packages/plugins/plugin-aliyun/src/plugins/upload-to-aliyun/index.js new file mode 100644 index 00000000..e0128000 --- /dev/null +++ b/packages/plugins/plugin-aliyun/src/plugins/upload-to-aliyun/index.js @@ -0,0 +1,106 @@ +import Core from '@alicloud/pop-core' +import { AbstractAliyunPlugin } from '../abstract-aliyun.js' +import { ZoneOptions } from '../../utils/index.js' + +const define = { + name: 'uploadCertToAliyun', + title: '上传证书到阿里云', + desc: '', + input: { + name: { + title: '证书名称', + helper: '证书上传后将以此参数作为名称前缀' + }, + regionId: { + title: '大区', + value: 'cn-hangzhou', + component: { + name: 'a-select', + vModel: 'value', + options: ZoneOptions + }, + required: true + }, + accessProvider: { + title: 'Access授权', + helper: 'Access授权', + component: { + name: 'access-selector', + type: 'aliyun' + }, + required: true + } + }, + output: { + aliyunCertId: { + type: String, + desc: '上传成功后的阿里云CertId' + } + } +} + +export class UploadCertToAliyun extends AbstractAliyunPlugin { + static define () { + return define + } + + getClient (aliyunProvider) { + return new Core({ + accessKeyId: aliyunProvider.accessKeyId, + accessKeySecret: aliyunProvider.accessKeySecret, + endpoint: 'https://cas.aliyuncs.com', + apiVersion: '2018-07-13' + }) + } + + async execute ({ cert, props, context }) { + const { name, accessProvider } = props + const certName = this.appendTimeSuffix(name || cert.domain) + const params = { + RegionId: props.regionId || 'cn-hangzhou', + Name: certName, + Cert: cert.crt, + Key: cert.key + } + + const requestOption = { + method: 'POST' + } + + const provider = this.getAccessProvider(accessProvider) + const client = this.getClient(provider) + const ret = await client.request('CreateUserCertificate', params, requestOption) + this.checkRet(ret) + this.logger.info('证书上传成功:aliyunCertId=', ret.CertId) + context.aliyunCertId = ret.CertId + } + + /** + * 没用,现在阿里云证书不允许删除 + * @param accessProviders + * @param cert + * @param props + * @param context + * @returns {Promise} + */ + async rollback ({ cert, props, context }) { + const { accessProvider } = props + const { aliyunCertId } = context + this.logger.info('准备删除阿里云证书:', aliyunCertId) + const params = { + RegionId: props.regionId || 'cn-hangzhou', + CertId: aliyunCertId + } + + const requestOption = { + method: 'POST' + } + + const provider = this.getAccessProvider(accessProvider) + const client = this.getClient(provider) + const ret = await client.request('DeleteUserCertificate', params, requestOption) + this.checkRet(ret) + this.logger.info('证书删除成功:', aliyunCertId) + delete context.aliyunCertId + } +} diff --git a/packages/plugins/plugin-aliyun/src/utils/index.ts b/packages/plugins/plugin-aliyun/src/utils/index.ts new file mode 100644 index 00000000..7daa75e3 --- /dev/null +++ b/packages/plugins/plugin-aliyun/src/utils/index.ts @@ -0,0 +1,15 @@ +import dayjs from "dayjs"; + +export const ZoneOptions = [{ value: "cn-hangzhou" }]; +export function appendTimeSuffix(name: string) { + if (name == null) { + name = "certd"; + } + return name + "-" + dayjs().format("YYYYMMDD-HHmmss"); +} + +export function checkRet(ret: any) { + if (ret.code != null) { + throw new Error("执行失败:" + ret.Message); + } +} diff --git a/packages/server/certd-client b/packages/server/certd-client index e24ef9be..f508b642 160000 --- a/packages/server/certd-client +++ b/packages/server/certd-client @@ -1 +1 @@ -Subproject commit e24ef9beb0623a819cb433021e214d1c61a1b470 +Subproject commit f508b6426b5869ae0fd33d8ec8e170e097eed9d4 diff --git a/packages/server/certd-server b/packages/server/certd-server index 77fb1fa8..a59e66a1 160000 --- a/packages/server/certd-server +++ b/packages/server/certd-server @@ -1 +1 @@ -Subproject commit 77fb1fa8492d95c7f6698aaaadac34cb2de4de2b +Subproject commit a59e66a11d667dc99b425b14cd6fde7468a8725b