mirror of
https://github.com/certd/certd.git
synced 2026-06-23 02:07:32 +08:00
Merge branch 'codex/v2-persist-01' into v2-invite
This commit is contained in:
@@ -3,6 +3,22 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复暗黑模式下注册页面验证码看不清的问题 ([5ba33be](https://github.com/certd/certd/commit/5ba33be30f765f06cafbfcc04f5e25320db01449))
|
||||
|
||||
## [1.40.2](https://github.com/certd/certd/compare/v1.40.1...v1.40.2) (2026-05-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **certd-server:** 调整首页缓存控制头的判断逻辑 ([0499347](https://github.com/certd/certd/commit/0499347588ee544862420ab9a5afd2546d61bc6c))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **controller:** 更换版本获取源并添加版本标准化处理 ([cb08e06](https://github.com/certd/certd/commit/cb08e061d257ba23a0fefdbfb046a8c759def828))
|
||||
|
||||
## [1.40.1](https://github.com/certd/certd/compare/v1.40.0...v1.40.1) (2026-05-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
ALTER TABLE cd_access ADD COLUMN subtype varchar(100);
|
||||
CREATE INDEX "index_access_subtype" ON "cd_access" ("subtype");
|
||||
|
||||
CREATE TABLE "cd_dns_persist_record"
|
||||
(
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"project_id" integer,
|
||||
"domain" varchar(255) NOT NULL,
|
||||
"main_domain" varchar(255) NOT NULL,
|
||||
"ca_type" varchar(50) NOT NULL,
|
||||
"acme_account_access_id" integer NOT NULL,
|
||||
"account_uri" varchar(512) NOT NULL,
|
||||
"host_record" varchar(255) NOT NULL,
|
||||
"record_value" text NOT NULL,
|
||||
"policy" varchar(50),
|
||||
"persist_until" integer,
|
||||
"status" varchar(50) NOT NULL DEFAULT 'pending',
|
||||
"dns_provider_type" varchar(50),
|
||||
"dns_provider_access" integer,
|
||||
"record_res" text,
|
||||
"disabled" integer NOT NULL DEFAULT 0,
|
||||
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
||||
);
|
||||
|
||||
CREATE INDEX "index_dns_persist_user_id" ON "cd_dns_persist_record" ("user_id");
|
||||
CREATE INDEX "index_dns_persist_project_id" ON "cd_dns_persist_record" ("project_id");
|
||||
CREATE INDEX "index_dns_persist_domain" ON "cd_dns_persist_record" ("domain");
|
||||
CREATE INDEX "index_dns_persist_main_domain" ON "cd_dns_persist_record" ("main_domain");
|
||||
CREATE INDEX "index_dns_persist_account" ON "cd_dns_persist_record" ("acme_account_access_id");
|
||||
@@ -21,10 +21,10 @@ input:
|
||||
options:
|
||||
- label: QQ
|
||||
value: qq
|
||||
icon: cib:tencent-qq:#007AFF
|
||||
icon: svg:icon-qq
|
||||
- label: 微信
|
||||
value: wx
|
||||
icon: simple-icons:wechat:#34C759
|
||||
icon: svg:icon-wechat
|
||||
- label: 支付宝
|
||||
value: alipay
|
||||
icon: simple-icons:alipay:#0099ff
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@certd/ui-server",
|
||||
"version": "1.40.1",
|
||||
"version": "1.40.3",
|
||||
"description": "fast-server base midway",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -53,20 +53,20 @@
|
||||
"@aws-sdk/client-sts": "^3.990.0",
|
||||
"@azure/arm-dns": "^5.1.0",
|
||||
"@azure/identity": "^4.13.1",
|
||||
"@certd/acme-client": "^1.40.1",
|
||||
"@certd/basic": "^1.40.1",
|
||||
"@certd/commercial-core": "^1.40.1",
|
||||
"@certd/acme-client": "^1.40.3",
|
||||
"@certd/basic": "^1.40.3",
|
||||
"@certd/commercial-core": "^1.40.3",
|
||||
"@certd/cv4pve-api-javascript": "^8.4.2",
|
||||
"@certd/jdcloud": "^1.40.1",
|
||||
"@certd/lib-huawei": "^1.40.1",
|
||||
"@certd/lib-k8s": "^1.40.1",
|
||||
"@certd/lib-server": "^1.40.1",
|
||||
"@certd/midway-flyway-js": "^1.40.1",
|
||||
"@certd/pipeline": "^1.40.1",
|
||||
"@certd/plugin-cert": "^1.40.1",
|
||||
"@certd/plugin-lib": "^1.40.1",
|
||||
"@certd/plugin-plus": "^1.40.1",
|
||||
"@certd/plus-core": "^1.40.1",
|
||||
"@certd/jdcloud": "^1.40.3",
|
||||
"@certd/lib-huawei": "^1.40.3",
|
||||
"@certd/lib-k8s": "^1.40.3",
|
||||
"@certd/lib-server": "^1.40.3",
|
||||
"@certd/midway-flyway-js": "^1.40.3",
|
||||
"@certd/pipeline": "^1.40.3",
|
||||
"@certd/plugin-cert": "^1.40.3",
|
||||
"@certd/plugin-lib": "^1.40.3",
|
||||
"@certd/plugin-plus": "^1.40.3",
|
||||
"@certd/plus-core": "^1.40.3",
|
||||
"@google-cloud/dns": "^5.3.1",
|
||||
"@google-cloud/publicca": "^1.3.0",
|
||||
"@huaweicloud/huaweicloud-sdk-cdn": "3.1.185",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export function shouldSetDefaultNoCache(path: string, cacheControl?: string) {
|
||||
if(path === '/' || path === '/index.html' ){
|
||||
//首页不管怎样都不要缓存
|
||||
return true;
|
||||
}
|
||||
if (cacheControl) {
|
||||
return false;
|
||||
}
|
||||
return path === '/' || path === '/index.html' || path.startsWith('/api');
|
||||
// api也不要缓存,如果他本身有设置缓存除外
|
||||
return path.startsWith('/api');
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ describe("shouldSetDefaultNoCache", () => {
|
||||
});
|
||||
|
||||
it("keeps explicit cache headers from file responses", () => {
|
||||
assert.equal(shouldSetDefaultNoCache("/api/basic/file/download", "public,max-age=259200"), false);
|
||||
assert.equal(shouldSetDefaultNoCache("/", "public,max-age=259200"), true);
|
||||
assert.equal(shouldSetDefaultNoCache("/index.html", "public,max-age=259200"), true);
|
||||
assert.equal(shouldSetDefaultNoCache("/api/basic/file/download", "public,max-age=259200"), false);
|
||||
});
|
||||
|
||||
it("ignores non-html and non-api paths", () => {
|
||||
|
||||
@@ -126,7 +126,7 @@ export class MainConfiguration {
|
||||
await next();
|
||||
const path = ctx.path;
|
||||
// 如果是首页则强制设置为不缓存
|
||||
if (path === '/' || path === '/index.html' || shouldSetDefaultNoCache(path, ctx.response.get('Cache-Control')) ) {
|
||||
if (shouldSetDefaultNoCache(path, ctx.response.get('Cache-Control')) ) {
|
||||
ctx.response.set('Cache-Control', 'public,max-age=0');
|
||||
}
|
||||
});
|
||||
@@ -138,6 +138,5 @@ export class MainConfiguration {
|
||||
|
||||
logger.info('当前环境:', this.app.getEnv()); // prod
|
||||
// throw new Error("address family not supported")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ALL, Body, Controller, Inject, Post, Provide, Query } from "@midwayjs/core";
|
||||
import { Constants, CrudController } from "@certd/lib-server";
|
||||
import { ApiTags } from "@midwayjs/swagger";
|
||||
import { DnsPersistRecordService } from "../../../modules/cert/service/dns-persist-record-service.js";
|
||||
|
||||
@Provide()
|
||||
@Controller("/api/cert/dns-persist")
|
||||
@ApiTags(["cert"])
|
||||
export class DnsPersistRecordController extends CrudController<DnsPersistRecordService> {
|
||||
@Inject()
|
||||
service: DnsPersistRecordService;
|
||||
|
||||
getService(): DnsPersistRecordService {
|
||||
return this.service;
|
||||
}
|
||||
|
||||
@Post("/page", { description: Constants.per.authOnly, summary: "查询DNS持久验证记录分页列表" })
|
||||
async page(@Body(ALL) body: any) {
|
||||
const { projectId, userId } = await this.getProjectUserIdRead();
|
||||
body.query = body.query ?? {};
|
||||
body.query.projectId = projectId;
|
||||
body.query.userId = userId;
|
||||
return super.page(body);
|
||||
}
|
||||
|
||||
@Post("/add", { description: Constants.per.authOnly, summary: "添加DNS持久验证记录" })
|
||||
async add(@Body(ALL) bean: any) {
|
||||
const { projectId, userId } = await this.getProjectUserIdWrite();
|
||||
bean.projectId = projectId;
|
||||
bean.userId = userId;
|
||||
return super.add(bean);
|
||||
}
|
||||
|
||||
@Post("/update", { description: Constants.per.authOnly, summary: "更新DNS持久验证记录" })
|
||||
async update(@Body(ALL) bean: any) {
|
||||
await this.checkOwner(this.getService(), bean.id, "write");
|
||||
delete bean.userId;
|
||||
delete bean.projectId;
|
||||
return super.update(bean);
|
||||
}
|
||||
|
||||
@Post("/info", { description: Constants.per.authOnly, summary: "查询DNS持久验证记录详情" })
|
||||
async info(@Query("id") id: number) {
|
||||
await this.checkOwner(this.getService(), id, "read");
|
||||
return super.info(id);
|
||||
}
|
||||
|
||||
@Post("/delete", { description: Constants.per.authOnly, summary: "删除DNS持久验证记录" })
|
||||
async delete(@Query("id") id: number) {
|
||||
await this.checkOwner(this.getService(), id, "write");
|
||||
await this.service.delete(id as any);
|
||||
return this.ok({
|
||||
message: this.service.lastDeleteMessage,
|
||||
});
|
||||
}
|
||||
|
||||
@Post("/build", { description: Constants.per.authOnly, summary: "生成DNS持久验证记录值" })
|
||||
async build(@Body(ALL) body: { domain: string; accountUri: string; wildcard?: boolean; persistUntil?: number }) {
|
||||
const { projectId, userId } = await this.getProjectUserIdRead();
|
||||
return this.ok(await this.service.buildRecord({ ...body, userId, projectId }));
|
||||
}
|
||||
|
||||
@Post("/getByDomain", { description: Constants.per.authOnly, summary: "根据域名获取或创建DNS持久验证记录" })
|
||||
async getByDomain(@Body(ALL) body: { domain: string; caType?: string; acmeAccountAccessId?: number; commonAcmeAccountAccessId?: number; wildcard?: boolean; persistUntil?: number; createOnNotFound?: boolean }) {
|
||||
const { projectId, userId } = await this.getProjectUserIdWrite();
|
||||
return this.ok(await this.service.getByDomain({ ...body, userId, projectId }));
|
||||
}
|
||||
|
||||
@Post("/check", { description: Constants.per.authOnly, summary: "校验DNS持久验证记录" })
|
||||
async check(@Body(ALL) body: { hostRecord: string; recordValue: string }) {
|
||||
return this.ok(await this.service.checkRecord(body));
|
||||
}
|
||||
|
||||
@Post("/verify", { description: Constants.per.authOnly, summary: "验证DNS持久验证记录" })
|
||||
async verify(@Body(ALL) body: { id: number }) {
|
||||
await this.checkOwner(this.getService(), body.id, "write");
|
||||
return this.ok(await this.service.verify(body.id));
|
||||
}
|
||||
|
||||
@Post("/triggerVerify", { description: Constants.per.authOnly, summary: "后台验证DNS持久验证记录" })
|
||||
async triggerVerify(@Body(ALL) body: { id: number }) {
|
||||
await this.checkOwner(this.getService(), body.id, "write");
|
||||
return this.ok(await this.service.triggerVerify(body.id));
|
||||
}
|
||||
|
||||
@Post("/createTxt", { description: Constants.per.authOnly, summary: "一键创建DNS持久验证TXT记录" })
|
||||
async createTxt(@Body(ALL) body: { id: number; dnsProviderType?: string; dnsProviderAccess?: number }) {
|
||||
await this.checkOwner(this.getService(), body.id, "write");
|
||||
return this.ok(await this.service.createDnsTxt(body));
|
||||
}
|
||||
}
|
||||
@@ -23,31 +23,49 @@ describe("AutoFix", () => {
|
||||
autoFix.googleCommonEabAccountKeyFix = {
|
||||
async init() {
|
||||
calls.push("google");
|
||||
return true;
|
||||
},
|
||||
} as any;
|
||||
autoFix.oauthSubtypeBoundTypeFix = {
|
||||
async init() {
|
||||
calls.push("oauth");
|
||||
return true;
|
||||
},
|
||||
} as any;
|
||||
autoFix.certInfoWildcardDomainCountFix = {
|
||||
async init() {
|
||||
calls.push("cert");
|
||||
return true;
|
||||
},
|
||||
} as any;
|
||||
autoFix.suiteContentWildcardDomainCountFix = {
|
||||
async init() {
|
||||
calls.push("suite");
|
||||
return true;
|
||||
},
|
||||
} as any;
|
||||
autoFix.legacyAcmeAccountAccessFix = {
|
||||
async init() {
|
||||
calls.push("legacy-acme");
|
||||
return true;
|
||||
},
|
||||
} as any;
|
||||
autoFix.commonEabToAcmeAccountFix = {
|
||||
async init() {
|
||||
calls.push("common-eab-acme");
|
||||
return true;
|
||||
},
|
||||
} as any;
|
||||
|
||||
await autoFix.init();
|
||||
|
||||
assert.deepEqual(calls, ["google", "cert", "suite"]);
|
||||
assert.deepEqual(calls, ["google", "cert", "suite", "legacy-acme", "common-eab-acme"]);
|
||||
assert.equal(savedSetting.fixed["google-common-eab-account-key"], true);
|
||||
assert.equal(savedSetting.fixed["oauth-subtype-bound-type"], true);
|
||||
assert.equal(savedSetting.fixed["cert-info-wildcard-domain-count"], true);
|
||||
assert.equal(savedSetting.fixed["suite-content-wildcard-domain-count"], true);
|
||||
assert.equal(savedSetting.fixed["legacy-acme-account-access"], true);
|
||||
assert.equal(savedSetting.fixed["common-eab-to-acme-account"], true);
|
||||
});
|
||||
|
||||
it("initializes missing fixed map", async () => {
|
||||
@@ -62,6 +80,8 @@ describe("AutoFix", () => {
|
||||
autoFix.oauthSubtypeBoundTypeFix = { async init() {} } as any;
|
||||
autoFix.certInfoWildcardDomainCountFix = { async init() {} } as any;
|
||||
autoFix.suiteContentWildcardDomainCountFix = { async init() {} } as any;
|
||||
autoFix.legacyAcmeAccountAccessFix = { async init() {} } as any;
|
||||
autoFix.commonEabToAcmeAccountFix = { async init() {} } as any;
|
||||
|
||||
await autoFix.init();
|
||||
});
|
||||
|
||||
@@ -4,11 +4,13 @@ import { GoogleCommonEabAccountKeyFix } from "./google-common-eab-account-key-fi
|
||||
import { OauthSubtypeBoundTypeFix } from "./oauth-subtype-bound-type-fix.js";
|
||||
import { CertInfoWildcardDomainCountFix } from "./cert-info-wildcard-domain-count-fix.js";
|
||||
import { SuiteContentWildcardDomainCountFix } from "./suite-content-wildcard-domain-count-fix.js";
|
||||
import { LegacyAcmeAccountAccessFix } from "./legacy-acme-account-access-fix.js";
|
||||
import { CommonEabToAcmeAccountFix } from "./common-eab-to-acme-account-fix.js";
|
||||
|
||||
type AutoFixTask = {
|
||||
key: string;
|
||||
fix: {
|
||||
init(): Promise<void>;
|
||||
init(): Promise<boolean>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -30,6 +32,12 @@ export class AutoFix {
|
||||
@Inject()
|
||||
suiteContentWildcardDomainCountFix: SuiteContentWildcardDomainCountFix;
|
||||
|
||||
@Inject()
|
||||
legacyAcmeAccountAccessFix: LegacyAcmeAccountAccessFix;
|
||||
|
||||
@Inject()
|
||||
commonEabToAcmeAccountFix: CommonEabToAcmeAccountFix;
|
||||
|
||||
async init() {
|
||||
const setting = await this.sysSettingsService.getSetting<SysAutoFixSetting>(SysAutoFixSetting);
|
||||
setting.fixed = setting.fixed || {};
|
||||
@@ -50,14 +58,22 @@ export class AutoFix {
|
||||
key: "suite-content-wildcard-domain-count",
|
||||
fix: this.suiteContentWildcardDomainCountFix,
|
||||
},
|
||||
{
|
||||
key: "legacy-acme-account-access",
|
||||
fix: this.legacyAcmeAccountAccessFix,
|
||||
},
|
||||
{
|
||||
key: "common-eab-to-acme-account",
|
||||
fix: this.commonEabToAcmeAccountFix,
|
||||
},
|
||||
];
|
||||
|
||||
for (const task of tasks) {
|
||||
if (setting.fixed?.[task.key]) {
|
||||
continue;
|
||||
}
|
||||
await task.fix.init();
|
||||
setting.fixed[task.key] = true;
|
||||
const ret = await task.fix.init();
|
||||
setting.fixed[task.key] = ret;
|
||||
await this.sysSettingsService.saveSetting(setting);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,10 @@ export class CertInfoWildcardDomainCountFix {
|
||||
if (fixedCount > 0) {
|
||||
logger.info(`已修复证书泛域名数量历史数据,数量=${fixedCount}`);
|
||||
}
|
||||
return true
|
||||
} catch (e: any) {
|
||||
logger.error("修复证书泛域名数量历史数据失败", e);
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from "assert";
|
||||
import { buildLegacyCommonEabAccountStorageWhere, CommonEabToAcmeAccountFix, parseEabAccountKey } from "./common-eab-to-acme-account-fix.js";
|
||||
import { AcmeService } from "../../../plugins/plugin-cert/plugin/cert-plugin/acme.js";
|
||||
|
||||
describe("CommonEabToAcmeAccountFix", () => {
|
||||
it("parses legacy EAB account key payload", () => {
|
||||
assert.equal(
|
||||
parseEabAccountKey(
|
||||
JSON.stringify({
|
||||
kid: "kid-1",
|
||||
privateKey: "private-key",
|
||||
})
|
||||
),
|
||||
"private-key"
|
||||
);
|
||||
});
|
||||
|
||||
it("builds legacy common EAB account storage query", () => {
|
||||
assert.deepEqual(buildLegacyCommonEabAccountStorageWhere("google", 12), {
|
||||
userId: 0,
|
||||
scope: "user",
|
||||
namespace: "0",
|
||||
key: "acme.config.google.access.12",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates common acme account from common eab and legacy storage", async () => {
|
||||
let addParam: any;
|
||||
const fix = new CommonEabToAcmeAccountFix();
|
||||
fix.accessService = {
|
||||
async getAccessById(id: number) {
|
||||
assert.equal(id, 12);
|
||||
return {
|
||||
accountKey: JSON.stringify({
|
||||
privateKey: "private-key",
|
||||
}),
|
||||
email: "common@example.com",
|
||||
};
|
||||
},
|
||||
async findOne() {
|
||||
return null;
|
||||
},
|
||||
async add(param: any) {
|
||||
addParam = param;
|
||||
return { id: 99 };
|
||||
},
|
||||
} as any;
|
||||
fix.storageService = {
|
||||
getRepository() {
|
||||
return {
|
||||
async findOne(options: any) {
|
||||
assert.deepEqual(options.where, buildLegacyCommonEabAccountStorageWhere("google", 12));
|
||||
return {
|
||||
value: JSON.stringify({
|
||||
value: {
|
||||
accountUrl: "https://example.com/acct/1",
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
|
||||
const id = await fix.createCommonAcmeAccountFromEab("google", 12);
|
||||
|
||||
assert.equal(id, 99);
|
||||
assert.equal(addParam.userId, 0);
|
||||
assert.equal(addParam.type, "acmeAccount");
|
||||
const setting = JSON.parse(addParam.setting);
|
||||
const account = JSON.parse(setting.account);
|
||||
assert.equal(account.accountKey, "private-key");
|
||||
assert.equal(account.accountUri, "https://example.com/acct/1");
|
||||
});
|
||||
|
||||
it("creates common acme account by resolving account uri from eab private key", async () => {
|
||||
const original = AcmeService.prototype.getAcmeClient;
|
||||
const calls: string[] = [];
|
||||
AcmeService.prototype.getAcmeClient = async function (email: string) {
|
||||
calls.push(email);
|
||||
return {
|
||||
getAccountUrl() {
|
||||
return "https://example.com/acct/generated";
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
try {
|
||||
let addParam: any;
|
||||
const fix = new CommonEabToAcmeAccountFix();
|
||||
fix.accessService = {
|
||||
async getAccessById(id: number) {
|
||||
assert.equal(id, 12);
|
||||
return {
|
||||
id: 12,
|
||||
kid: "kid-1",
|
||||
hmacKey: "hmac-1",
|
||||
accountKey: JSON.stringify({
|
||||
kid: "kid-1",
|
||||
privateKey: "private-key",
|
||||
}),
|
||||
email: "common@example.com",
|
||||
};
|
||||
},
|
||||
async findOne() {
|
||||
return null;
|
||||
},
|
||||
async add(param: any) {
|
||||
addParam = param;
|
||||
return { id: 100 };
|
||||
},
|
||||
} as any;
|
||||
fix.storageService = {
|
||||
getRepository() {
|
||||
return {
|
||||
async findOne() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
|
||||
const id = await fix.createCommonAcmeAccountFromEab("google", 12);
|
||||
|
||||
assert.equal(id, 100);
|
||||
assert.deepEqual(calls, ["common@example.com"]);
|
||||
const setting = JSON.parse(addParam.setting);
|
||||
const account = JSON.parse(setting.account);
|
||||
assert.equal(account.accountKey, "private-key");
|
||||
assert.equal(account.accountUri, "https://example.com/acct/generated");
|
||||
} finally {
|
||||
AcmeService.prototype.getAcmeClient = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { logger } from "@certd/basic";
|
||||
import { AccessService } from "@certd/lib-server";
|
||||
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { PluginConfigService } from "../../plugin/service/plugin-config-service.js";
|
||||
import { StorageService } from "../../pipeline/service/storage-service.js";
|
||||
import { AcmeService } from "../../../plugins/plugin-cert/plugin/cert-plugin/acme.js";
|
||||
import { buildAcmeAccountSetting, LegacyAcmeAccountConfig } from "./legacy-acme-account-access-fix.js";
|
||||
import { parseStorageValue } from "./google-common-eab-account-key-fix.js";
|
||||
|
||||
const COMMON_EAB_TO_ACME_ACCOUNT_FIELDS = [
|
||||
{
|
||||
caType: "google",
|
||||
eabField: "googleCommonEabAccessId",
|
||||
acmeField: "googleCommonAcmeAccountAccessId",
|
||||
},
|
||||
{
|
||||
caType: "zerossl",
|
||||
eabField: "zerosslCommonEabAccessId",
|
||||
acmeField: "zerosslCommonAcmeAccountAccessId",
|
||||
},
|
||||
{
|
||||
caType: "sslcom",
|
||||
eabField: "sslcomCommonEabAccessId",
|
||||
acmeField: "sslcomCommonAcmeAccountAccessId",
|
||||
},
|
||||
{
|
||||
caType: "litessl",
|
||||
eabField: "litesslCommonEabAccessId",
|
||||
acmeField: "litesslCommonAcmeAccountAccessId",
|
||||
},
|
||||
];
|
||||
|
||||
export function parseEabAccountKey(accountKey?: string) {
|
||||
if (!accountKey) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(accountKey);
|
||||
return parsed?.privateKey || parsed?.accountKey || parsed?.key || accountKey;
|
||||
} catch {
|
||||
return accountKey;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLegacyCommonEabAccountStorageWhere(caType: string, accessId: number) {
|
||||
return {
|
||||
userId: 0,
|
||||
scope: "user",
|
||||
namespace: "0",
|
||||
key: `acme.config.${caType}.access.${accessId}`,
|
||||
};
|
||||
}
|
||||
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class CommonEabToAcmeAccountFix {
|
||||
@Inject()
|
||||
pluginConfigService: PluginConfigService;
|
||||
|
||||
@Inject()
|
||||
accessService: AccessService;
|
||||
|
||||
@Inject()
|
||||
storageService: StorageService;
|
||||
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const certApplyConfig = await this.pluginConfigService.getPluginConfig({
|
||||
name: "CertApply",
|
||||
type: "builtIn",
|
||||
});
|
||||
const input = certApplyConfig.sysSetting.input || {};
|
||||
let changed = false;
|
||||
for (const item of COMMON_EAB_TO_ACME_ACCOUNT_FIELDS) {
|
||||
if (input[item.acmeField]) {
|
||||
continue;
|
||||
}
|
||||
const eabAccessId = input[item.eabField];
|
||||
if (!eabAccessId) {
|
||||
continue;
|
||||
}
|
||||
const acmeAccessId = await this.createCommonAcmeAccountFromEab(item.caType, eabAccessId);
|
||||
if (acmeAccessId) {
|
||||
input[item.acmeField] = acmeAccessId;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
await this.pluginConfigService.savePluginConfig({
|
||||
name: "CertApply",
|
||||
disabled: certApplyConfig.disabled,
|
||||
sysSetting: {
|
||||
...certApplyConfig.sysSetting,
|
||||
input,
|
||||
},
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logger.error("公共EAB迁移为公共ACME账号失败", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async createCommonAcmeAccountFromEab(caType: string, eabAccessId: number) {
|
||||
const eabAccess = await this.accessService.getAccessById(eabAccessId, false);
|
||||
const privateKey = parseEabAccountKey(eabAccess.accountKey);
|
||||
const accountConfig = await this.getLegacyCommonEabAccountConfig(caType, eabAccessId);
|
||||
const accountUri = await this.resolveAccountUriByPrivateKey(caType, eabAccess, accountConfig?.accountUri || accountConfig?.accountUrl);
|
||||
if (!privateKey || !accountUri) {
|
||||
logger.info(`公共${caType} EAB缺少可迁移的accountKey或无法获取accountUri,跳过生成公共ACME账号`);
|
||||
return null;
|
||||
}
|
||||
const email = eabAccess.email || `${caType}@common.certd.local`;
|
||||
const exists = await this.accessService.findOne({
|
||||
where: {
|
||||
userId: 0,
|
||||
projectId: null,
|
||||
type: "acmeAccount",
|
||||
subtype: caType,
|
||||
name: `公共${caType} ACME账号`,
|
||||
} as any,
|
||||
});
|
||||
if (exists) {
|
||||
return exists.id;
|
||||
}
|
||||
const setting = buildAcmeAccountSetting({
|
||||
caType,
|
||||
email,
|
||||
config: {
|
||||
privateKey,
|
||||
accountUri,
|
||||
},
|
||||
});
|
||||
const { id } = await this.accessService.add({
|
||||
userId: 0,
|
||||
projectId: null,
|
||||
type: "acmeAccount",
|
||||
name: `公共${caType} ACME账号`,
|
||||
setting: JSON.stringify(setting),
|
||||
});
|
||||
logger.info(`已根据公共${caType} EAB生成公共ACME账号,accessId=${id}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
async resolveAccountUriByPrivateKey(caType: string, eabAccess: any, accountUri?: string | null) {
|
||||
if (accountUri) {
|
||||
return accountUri;
|
||||
}
|
||||
const privateKey = parseEabAccountKey(eabAccess.accountKey);
|
||||
if (!privateKey || !eabAccess?.kid) {
|
||||
return null;
|
||||
}
|
||||
const acmeService = new AcmeService({
|
||||
userId: 0,
|
||||
userContext: {
|
||||
async getObj() {
|
||||
return null;
|
||||
},
|
||||
async setObj() {},
|
||||
} as any,
|
||||
logger: logger as any,
|
||||
sslProvider: caType as any,
|
||||
eab: {
|
||||
id: eabAccess.id || eabAccess.accessId || eabAccess.eabAccessId || 0,
|
||||
kid: eabAccess.kid,
|
||||
hmacKey: eabAccess.hmacKey,
|
||||
accountKey: JSON.stringify({
|
||||
kid: eabAccess.kid,
|
||||
privateKey,
|
||||
}),
|
||||
} as any,
|
||||
domainParser: {} as any,
|
||||
privateKeyType: "rsa_2048",
|
||||
});
|
||||
const client = await acmeService.getAcmeClient(eabAccess.email || `${caType}@common.certd.local`);
|
||||
return client.getAccountUrl() || null;
|
||||
}
|
||||
|
||||
async getLegacyCommonEabAccountConfig(caType: string, accessId: number): Promise<LegacyAcmeAccountConfig | null> {
|
||||
const repository = this.storageService.getRepository();
|
||||
const record = await repository.findOne({
|
||||
where: buildLegacyCommonEabAccountStorageWhere(caType, accessId),
|
||||
});
|
||||
return parseStorageValue(record?.value) as LegacyAcmeAccountConfig;
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export class GoogleCommonEabAccountKeyFix {
|
||||
|
||||
async init() {
|
||||
if (!isComm()) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const certApplyConfig = await this.pluginConfigService.getPluginConfig({
|
||||
@@ -56,31 +56,33 @@ export class GoogleCommonEabAccountKeyFix {
|
||||
});
|
||||
const googleCommonEabAccessId = certApplyConfig?.sysSetting?.input?.googleCommonEabAccessId;
|
||||
if (!googleCommonEabAccessId) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const eabAccess = await this.accessService.getAccessById(googleCommonEabAccessId, false);
|
||||
if (eabAccess.accountKey) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (!eabAccess.kid) {
|
||||
logger.info("公共Google EAB授权缺少KID,跳过历史ACME账号私钥修复");
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const accountConfig = await this.getLegacyGoogleAccountConfig(eabAccess.email);
|
||||
const privateKey = accountConfig?.privateKey || accountConfig?.key || accountConfig?.accountKey;
|
||||
if (!privateKey) {
|
||||
logger.info("未找到可迁移到公共Google EAB授权的历史ACME账号私钥");
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const accountKey = buildEabAccountKeyValue(eabAccess.kid, privateKey);
|
||||
await this.accessService.updateAccess({ id: googleCommonEabAccessId, eabType: "google", accountKey });
|
||||
logger.info(`已修复公共Google EAB授权的ACME账号私钥,accessId=${googleCommonEabAccessId}`);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logger.error("修复公共Google EAB授权ACME账号私钥失败", e);
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async getLegacyGoogleAccountConfig(email?: string) {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "assert";
|
||||
import { buildAcmeAccountAccessName, buildAcmeAccountSetting, maskAcmeAccountEmail, parseLegacyAcmeStorageKey } from "./legacy-acme-account-access-fix.js";
|
||||
|
||||
describe("LegacyAcmeAccountAccessFix", () => {
|
||||
it("parses legacy storage account key", () => {
|
||||
assert.deepEqual(parseLegacyAcmeStorageKey("acme.config.letsencrypt.user@example.com"), {
|
||||
caType: "letsencrypt",
|
||||
email: "user@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips EAB access cache keys", () => {
|
||||
assert.equal(parseLegacyAcmeStorageKey("acme.config.google.access.12"), null);
|
||||
});
|
||||
|
||||
it("builds acme account access setting from legacy config", () => {
|
||||
const setting = buildAcmeAccountSetting({
|
||||
caType: "letsencrypt",
|
||||
email: "user@example.com",
|
||||
config: {
|
||||
key: "private-key",
|
||||
accountUrl: "https://example.com/acct/1",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(setting.caType, "letsencrypt");
|
||||
const account = JSON.parse(setting.account);
|
||||
assert.equal(account.accountKey, "private-key");
|
||||
assert.equal(account.accountUri, "https://example.com/acct/1");
|
||||
});
|
||||
|
||||
it("builds masked acme account access name", () => {
|
||||
assert.equal(maskAcmeAccountEmail("xiaojunnuo@qq.com"), "xi*******qq.com");
|
||||
assert.equal(buildAcmeAccountAccessName("zerossl", "xiaojunnuo@qq.com"), "zerossl-acme-xi*******qq.com");
|
||||
});
|
||||
|
||||
it("skips incomplete legacy config", () => {
|
||||
const setting = buildAcmeAccountSetting({
|
||||
caType: "letsencrypt",
|
||||
email: "user@example.com",
|
||||
config: {
|
||||
key: "private-key",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(setting, null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { logger } from "@certd/basic";
|
||||
import { AccessService } from "@certd/lib-server";
|
||||
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { Like } from "typeorm";
|
||||
import { StorageService } from "../../pipeline/service/storage-service.js";
|
||||
import { parseStorageValue } from "./google-common-eab-account-key-fix.js";
|
||||
|
||||
export type LegacyAcmeAccountConfig = {
|
||||
key?: string;
|
||||
privateKey?: string;
|
||||
accountKey?: string;
|
||||
accountUrl?: string;
|
||||
accountUri?: string;
|
||||
};
|
||||
|
||||
export function parseLegacyAcmeStorageKey(key: string) {
|
||||
const match = /^acme\.config\.([^.]+)\.(.+)$/.exec(key);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
if (match[2].startsWith("access.")) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
caType: match[1],
|
||||
email: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAcmeAccountSetting(req: { caType: string; email: string; config: LegacyAcmeAccountConfig }) {
|
||||
const accountKey = req.config.privateKey || req.config.key || req.config.accountKey;
|
||||
const accountUri = req.config.accountUri || req.config.accountUrl;
|
||||
if (!accountKey || !accountUri) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
caType: req.caType,
|
||||
email: req.email,
|
||||
account: JSON.stringify({
|
||||
accountKey,
|
||||
accountUri,
|
||||
caType: req.caType,
|
||||
email: req.email,
|
||||
directoryUrl: "",
|
||||
migratedFrom: "legacy-storage",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function maskAcmeAccountEmail(email: string) {
|
||||
if (!email) {
|
||||
return "unknown";
|
||||
}
|
||||
const atIndex = email.indexOf("@");
|
||||
if (atIndex < 0) {
|
||||
return email.length <= 2 ? `${email[0] || ""}*******` : `${email.substring(0, 2)}*******`;
|
||||
}
|
||||
const name = email.substring(0, atIndex);
|
||||
const domain = email.substring(atIndex + 1);
|
||||
const prefix = name.substring(0, Math.min(2, name.length));
|
||||
return `${prefix}*******${domain}`;
|
||||
}
|
||||
|
||||
export function buildAcmeAccountAccessName(caType: string, email: string) {
|
||||
return `${caType}-acme-${maskAcmeAccountEmail(email)}`;
|
||||
}
|
||||
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class LegacyAcmeAccountAccessFix {
|
||||
@Inject()
|
||||
storageService: StorageService;
|
||||
|
||||
@Inject()
|
||||
accessService: AccessService;
|
||||
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const repository = this.storageService.getRepository();
|
||||
const records = await repository.find({
|
||||
where: {
|
||||
scope: "user",
|
||||
key: Like("acme.config.%"),
|
||||
},
|
||||
});
|
||||
let count = 0;
|
||||
for (const record of records) {
|
||||
const parsedKey = parseLegacyAcmeStorageKey(record.key);
|
||||
if (!parsedKey) {
|
||||
continue;
|
||||
}
|
||||
const config = parseStorageValue(record.value) as LegacyAcmeAccountConfig;
|
||||
const setting = buildAcmeAccountSetting({
|
||||
...parsedKey,
|
||||
config,
|
||||
});
|
||||
if (!setting) {
|
||||
continue;
|
||||
}
|
||||
const name = buildAcmeAccountAccessName(parsedKey.caType, parsedKey.email);
|
||||
const exists = await this.accessService.findOne({
|
||||
where: {
|
||||
userId: record.userId,
|
||||
projectId: record.projectId,
|
||||
type: "acmeAccount",
|
||||
subtype: parsedKey.caType,
|
||||
name,
|
||||
} as any,
|
||||
});
|
||||
if (exists) {
|
||||
continue;
|
||||
}
|
||||
await this.accessService.add({
|
||||
userId: record.userId,
|
||||
projectId: record.projectId,
|
||||
type: "acmeAccount",
|
||||
subtype: parsedKey.caType,
|
||||
name,
|
||||
setting: JSON.stringify(setting),
|
||||
});
|
||||
count++;
|
||||
}
|
||||
logger.info(`旧ACME账号迁移完成,生成${count}个ACME账号授权`);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
logger.error("旧ACME账号迁移失败", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export class OauthSubtypeBoundTypeFix {
|
||||
await this.convertLegacyAddonLoginTypeToArray(addonEntity, legacyLoginType, manager);
|
||||
}
|
||||
});
|
||||
return true
|
||||
} catch (e: any) {
|
||||
logger.error("修复OAuth subtype绑定历史数据失败", e);
|
||||
}
|
||||
|
||||
+2
@@ -33,9 +33,11 @@ export class SuiteContentWildcardDomainCountFix {
|
||||
if (fixedCount > 0) {
|
||||
logger.info(`已修复套餐最大泛域名数量历史数据,数量=${fixedCount}`);
|
||||
}
|
||||
return true
|
||||
} catch (e: any) {
|
||||
logger.error("修复套餐最大泛域名数量历史数据失败", e);
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async fixSuiteContentWildcardDomainCountByTable(entityManager: any, tableName: string) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||
|
||||
@Entity("cd_dns_persist_record")
|
||||
export class DnsPersistRecordEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: "user_id" })
|
||||
userId: number;
|
||||
|
||||
@Column({ name: "project_id", nullable: true })
|
||||
projectId: number;
|
||||
|
||||
@Column({ length: 255 })
|
||||
domain: string;
|
||||
|
||||
@Column({ name: "main_domain", length: 255 })
|
||||
mainDomain: string;
|
||||
|
||||
@Column({ name: "ca_type", length: 50 })
|
||||
caType: string;
|
||||
|
||||
@Column({ name: "acme_account_access_id" })
|
||||
acmeAccountAccessId: number;
|
||||
|
||||
@Column({ name: "account_uri", length: 512 })
|
||||
accountUri: string;
|
||||
|
||||
@Column({ name: "host_record", length: 255 })
|
||||
hostRecord: string;
|
||||
|
||||
@Column({ name: "record_value", type: "text" })
|
||||
recordValue: string;
|
||||
|
||||
@Column({ length: 50, nullable: true })
|
||||
policy: string;
|
||||
|
||||
@Column({ name: "persist_until", nullable: true })
|
||||
persistUntil: number;
|
||||
|
||||
@Column({ length: 50 })
|
||||
status: string;
|
||||
|
||||
@Column({ name: "dns_provider_type", length: 50, nullable: true })
|
||||
dnsProviderType: string;
|
||||
|
||||
@Column({ name: "dns_provider_access", nullable: true })
|
||||
dnsProviderAccess: number;
|
||||
|
||||
@Column({ name: "record_res", type: "text", nullable: true })
|
||||
recordRes: string;
|
||||
|
||||
@Column()
|
||||
disabled: boolean;
|
||||
|
||||
@Column({ name: "create_time", default: () => "CURRENT_TIMESTAMP" })
|
||||
createTime: Date;
|
||||
|
||||
@Column({ name: "update_time", default: () => "CURRENT_TIMESTAMP" })
|
||||
updateTime: Date;
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import assert from "assert";
|
||||
import { buildDnsPersistRecordValue, DnsPersistRecordService } from "./dns-persist-record-service.js";
|
||||
|
||||
describe("DnsPersistRecordService", () => {
|
||||
it("builds dns-persist-01 record value", () => {
|
||||
const value = buildDnsPersistRecordValue({
|
||||
accountUri: "https://example.com/acct/1",
|
||||
wildcard: true,
|
||||
persistUntil: 1893456000,
|
||||
});
|
||||
|
||||
assert.equal(value, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard; persistUntil=1893456000");
|
||||
});
|
||||
|
||||
it("builds validation host from wildcard domain", async () => {
|
||||
const service = new DnsPersistRecordService();
|
||||
|
||||
const record = await service.buildRecord({
|
||||
domain: "*.example.com",
|
||||
accountUri: "https://example.com/acct/1",
|
||||
wildcard: true,
|
||||
});
|
||||
|
||||
assert.equal(record.hostRecord, "_validation-persist");
|
||||
});
|
||||
|
||||
it("builds relative validation host from subdomain", async () => {
|
||||
const service = new DnsPersistRecordService();
|
||||
|
||||
const record = await service.buildRecord({
|
||||
domain: "aaa.handsfree.work",
|
||||
accountUri: "https://example.com/acct/1",
|
||||
});
|
||||
|
||||
assert.equal(record.hostRecord, "_validation-persist.aaa");
|
||||
assert.equal(record.mainDomain, "handsfree.work");
|
||||
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
|
||||
});
|
||||
|
||||
it("builds dns-persist record from acme account access", async () => {
|
||||
const service = new DnsPersistRecordService();
|
||||
(service as any).accessService = {
|
||||
async getAccessById(id: number, checkUser: boolean, userId?: number) {
|
||||
assert.equal(id, 12);
|
||||
assert.equal(checkUser, true);
|
||||
assert.equal(userId, 1);
|
||||
return {
|
||||
account: JSON.stringify({
|
||||
accountKey: "private-key",
|
||||
accountUri: "https://example.com/acct/1",
|
||||
caType: "zerossl",
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const record = await service.buildRecordByAcmeAccount({
|
||||
domain: "*.example.com",
|
||||
caType: "zerossl",
|
||||
acmeAccountAccessId: 12,
|
||||
userId: 1,
|
||||
projectId: 2,
|
||||
});
|
||||
|
||||
assert.equal(record.domain, "example.com");
|
||||
assert.equal(record.caType, "zerossl");
|
||||
assert.equal(record.acmeAccountAccessId, 12);
|
||||
assert.equal(record.accountUri, "https://example.com/acct/1");
|
||||
assert.equal(record.hostRecord, "_validation-persist");
|
||||
assert.equal(record.mainDomain, "example.com");
|
||||
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
|
||||
assert.equal(record.policy, "wildcard");
|
||||
assert.equal(record.status, "pending");
|
||||
});
|
||||
|
||||
it("rejects mismatched ca type", async () => {
|
||||
const service = new DnsPersistRecordService();
|
||||
(service as any).accessService = {
|
||||
async getAccessById() {
|
||||
return {
|
||||
account: JSON.stringify({
|
||||
accountKey: "private-key",
|
||||
accountUri: "https://example.com/acct/1",
|
||||
caType: "google",
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
service.buildRecordByAcmeAccount({
|
||||
domain: "example.com",
|
||||
caType: "zerossl",
|
||||
acmeAccountAccessId: 12,
|
||||
userId: 1,
|
||||
}),
|
||||
/颁发机构不匹配/
|
||||
);
|
||||
});
|
||||
|
||||
it("returns full local record after add and triggers auto create hook", async () => {
|
||||
const service = new DnsPersistRecordService();
|
||||
let saved: any = null;
|
||||
let autoCreateId: number | null = null;
|
||||
(service as any).repository = {
|
||||
async save(param: any) {
|
||||
param.id = 77;
|
||||
saved = { ...param };
|
||||
},
|
||||
async findOneBy(where: any) {
|
||||
return where.id === 77 ? saved : null;
|
||||
},
|
||||
async findOne() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
(service as any).accessService = {
|
||||
async getAccessById() {
|
||||
return {
|
||||
account: JSON.stringify({
|
||||
accountKey: "private-key",
|
||||
accountUri: "https://example.com/acct/1",
|
||||
caType: "letsencrypt",
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
(service as any).tryAutoCreateDnsTxt = async (id: number) => {
|
||||
autoCreateId = id;
|
||||
};
|
||||
|
||||
const record: any = await service.add({
|
||||
domain: "example.com",
|
||||
mainDomain: "example.com",
|
||||
caType: "letsencrypt",
|
||||
acmeAccountAccessId: 1,
|
||||
userId: 1,
|
||||
projectId: 2,
|
||||
});
|
||||
|
||||
assert.equal(autoCreateId, 77);
|
||||
assert.equal(record.id, 77);
|
||||
assert.equal(record.hostRecord, "_validation-persist");
|
||||
assert.equal(record.mainDomain, "example.com");
|
||||
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
|
||||
assert.equal(record.status, "pending");
|
||||
});
|
||||
|
||||
it("reuses existing record for the same domain and acme account", async () => {
|
||||
const service = new DnsPersistRecordService();
|
||||
let saveCount = 0;
|
||||
const exists = {
|
||||
id: 88,
|
||||
domain: "example.com",
|
||||
mainDomain: "example.com",
|
||||
caType: "letsencrypt",
|
||||
acmeAccountAccessId: 1,
|
||||
userId: 1,
|
||||
projectId: 2,
|
||||
hostRecord: "_validation-persist",
|
||||
recordValue: "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard",
|
||||
policy: "wildcard",
|
||||
status: "valid",
|
||||
};
|
||||
(service as any).repository = {
|
||||
async save() {
|
||||
saveCount++;
|
||||
},
|
||||
async findOne(options: any) {
|
||||
return options.where.domain === "example.com" && options.where.acmeAccountAccessId === 1 ? exists : null;
|
||||
},
|
||||
};
|
||||
(service as any).accessService = {
|
||||
async getAccessById() {
|
||||
return {
|
||||
account: JSON.stringify({
|
||||
accountKey: "private-key",
|
||||
accountUri: "https://example.com/acct/1",
|
||||
caType: "letsencrypt",
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const record: any = await service.add({
|
||||
domain: "example.com",
|
||||
mainDomain: "example.com",
|
||||
caType: "letsencrypt",
|
||||
acmeAccountAccessId: 1,
|
||||
userId: 1,
|
||||
projectId: 2,
|
||||
});
|
||||
|
||||
assert.equal(saveCount, 0);
|
||||
assert.equal(record.id, 88);
|
||||
assert.equal(record.status, "valid");
|
||||
});
|
||||
|
||||
it("upgrades existing non-wildcard record to wildcard and pending", async () => {
|
||||
const service = new DnsPersistRecordService();
|
||||
let saved: any = {
|
||||
id: 89,
|
||||
domain: "example.com",
|
||||
mainDomain: "example.com",
|
||||
caType: "letsencrypt",
|
||||
acmeAccountAccessId: 1,
|
||||
userId: 1,
|
||||
projectId: 2,
|
||||
hostRecord: "_validation-persist",
|
||||
recordValue: "letsencrypt.org; accounturi=https://example.com/acct/1",
|
||||
policy: null,
|
||||
status: "valid",
|
||||
recordRes: JSON.stringify({ old: true }),
|
||||
};
|
||||
(service as any).repository = {
|
||||
async save(param: any) {
|
||||
saved = { ...saved, ...param };
|
||||
},
|
||||
async findOne(options: any) {
|
||||
return options.where.domain === "example.com" && options.where.acmeAccountAccessId === 1 ? saved : null;
|
||||
},
|
||||
async findOneBy(where: any) {
|
||||
return where.id === 89 ? saved : null;
|
||||
},
|
||||
};
|
||||
(service as any).accessService = {
|
||||
async getAccessById() {
|
||||
return {
|
||||
account: JSON.stringify({
|
||||
accountKey: "private-key",
|
||||
accountUri: "https://example.com/acct/1",
|
||||
caType: "letsencrypt",
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const record: any = await service.add({
|
||||
domain: "example.com",
|
||||
caType: "letsencrypt",
|
||||
acmeAccountAccessId: 1,
|
||||
userId: 1,
|
||||
projectId: 2,
|
||||
});
|
||||
|
||||
assert.equal(record.id, 89);
|
||||
assert.equal(record.policy, "wildcard");
|
||||
assert.equal(record.status, "pending");
|
||||
assert.equal(record.mainDomain, "example.com");
|
||||
assert.equal(record.recordValue, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard");
|
||||
assert.equal(record.recordRes, null);
|
||||
});
|
||||
|
||||
it("returns manual cleanup hint when deleting record", async () => {
|
||||
const service = new DnsPersistRecordService();
|
||||
let deletedIds: any = null;
|
||||
(service as any).repository = {
|
||||
async findOneBy(where: any) {
|
||||
return where.id === 90
|
||||
? {
|
||||
id: 90,
|
||||
domain: "example.com",
|
||||
mainDomain: "example.com",
|
||||
hostRecord: "_validation-persist",
|
||||
recordValue: "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard",
|
||||
recordRes: null,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
async delete(ids: any) {
|
||||
deletedIds = ids;
|
||||
},
|
||||
};
|
||||
|
||||
await service.delete([90]);
|
||||
|
||||
assert.deepEqual(deletedIds.id._value, [90]);
|
||||
assert.match(service.lastDeleteMessage, /请到域名供应商删除TXT记录/);
|
||||
assert.match(service.lastDeleteMessage, /_validation-persist/);
|
||||
});
|
||||
|
||||
it("triggers dns-persist verification asynchronously", async () => {
|
||||
const service = new DnsPersistRecordService();
|
||||
const savedStatuses: string[] = [];
|
||||
let saved: any = {
|
||||
id: 91,
|
||||
domain: "example.com",
|
||||
mainDomain: "example.com",
|
||||
hostRecord: "_validation-persist",
|
||||
recordValue: "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard",
|
||||
status: "pending",
|
||||
};
|
||||
(service as any).repository = {
|
||||
async findOneBy(where: any) {
|
||||
return where.id === 91 ? saved : null;
|
||||
},
|
||||
async save(param: any) {
|
||||
saved = { ...saved, ...param };
|
||||
savedStatuses.push(saved.status);
|
||||
},
|
||||
};
|
||||
(service as any).checkRecord = async () => true;
|
||||
|
||||
const triggered = await service.triggerVerify(91);
|
||||
|
||||
assert.equal(triggered, true);
|
||||
assert.equal(saved.status, "validating");
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
assert.deepEqual(savedStatuses, ["validating", "valid"]);
|
||||
assert.equal(saved.status, "valid");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,438 @@
|
||||
import { BaseService } from "@certd/lib-server";
|
||||
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { InjectEntityModel } from "@midwayjs/typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
import { createChallengeFn } from "@certd/acme-client";
|
||||
import { AccessService } from "@certd/lib-server";
|
||||
import { http, logger, utils } from "@certd/basic";
|
||||
import { createDnsProvider, DomainParser } from "@certd/plugin-lib";
|
||||
import { DnsPersistRecordEntity } from "../entity/dns-persist-record.js";
|
||||
import { TaskServiceBuilder } from "../../pipeline/service/getter/task-service-getter.js";
|
||||
import { DomainEntity } from "../entity/domain.js";
|
||||
|
||||
export function buildDnsPersistRecordValue(req: { issuer?: string; accountUri: string; wildcard?: boolean; persistUntil?: number }) {
|
||||
const parts = [req.issuer || "letsencrypt.org", `accounturi=${req.accountUri}`];
|
||||
if (req.wildcard !== false) {
|
||||
parts.push("policy=wildcard");
|
||||
}
|
||||
if (req.persistUntil) {
|
||||
parts.push(`persistUntil=${req.persistUntil}`);
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
export type DnsPersistRecordBuildReq = {
|
||||
domain: string;
|
||||
caType?: string;
|
||||
acmeAccountAccessId?: number;
|
||||
commonAcmeAccountAccessId?: number;
|
||||
wildcard?: boolean;
|
||||
persistUntil?: number;
|
||||
};
|
||||
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class DnsPersistRecordService extends BaseService<DnsPersistRecordEntity> {
|
||||
@InjectEntityModel(DnsPersistRecordEntity)
|
||||
repository: Repository<DnsPersistRecordEntity>;
|
||||
|
||||
@InjectEntityModel(DomainEntity)
|
||||
domainRepository: Repository<DomainEntity>;
|
||||
|
||||
@Inject()
|
||||
accessService: AccessService;
|
||||
|
||||
@Inject()
|
||||
taskServiceBuilder: TaskServiceBuilder;
|
||||
|
||||
lastDeleteMessage = "";
|
||||
|
||||
//@ts-ignore
|
||||
getRepository() {
|
||||
return this.repository;
|
||||
}
|
||||
|
||||
normalizeDomain(domain: string) {
|
||||
return domain?.replace(/^\*\./, "");
|
||||
}
|
||||
|
||||
private async parseMainDomain(domain: string, userId?: number, projectId?: number) {
|
||||
if (this.taskServiceBuilder && userId != null) {
|
||||
const taskService = this.taskServiceBuilder.create({ userId, projectId });
|
||||
const subDomainsGetter = await taskService.getSubDomainsGetter();
|
||||
const domainParser = new DomainParser(subDomainsGetter, logger);
|
||||
return await domainParser.parse(domain);
|
||||
}
|
||||
const parts = domain.split(".");
|
||||
return parts.length > 2 ? parts.slice(-2).join(".") : domain;
|
||||
}
|
||||
|
||||
private buildRelativeHostRecord(domain: string, mainDomain: string) {
|
||||
let prefix = domain;
|
||||
if (domain === mainDomain) {
|
||||
prefix = "";
|
||||
} else if (domain.endsWith(`.${mainDomain}`)) {
|
||||
prefix = domain.substring(0, domain.length - mainDomain.length - 1);
|
||||
}
|
||||
return prefix ? `_validation-persist.${prefix}` : "_validation-persist";
|
||||
}
|
||||
|
||||
private async buildFullHostRecord(record: Pick<DnsPersistRecordEntity, "domain" | "hostRecord" | "userId" | "projectId" | "mainDomain">) {
|
||||
if (record.hostRecord === `_validation-persist.${record.domain}` || record.hostRecord.endsWith(`.${record.domain}`)) {
|
||||
return record.hostRecord;
|
||||
}
|
||||
const mainDomain = record.mainDomain || (await this.parseMainDomain(record.domain, record.userId, record.projectId));
|
||||
return `${record.hostRecord}.${mainDomain}`;
|
||||
}
|
||||
|
||||
private parseAcmeAccount(account: string | any) {
|
||||
if (!account) {
|
||||
throw new Error("ACME账号授权缺少账号信息,请重新生成ACME账号");
|
||||
}
|
||||
const parsed = typeof account === "string" ? JSON.parse(account) : account;
|
||||
if (!parsed.accountKey || !parsed.accountUri) {
|
||||
throw new Error("ACME账号授权无效,请重新生成ACME账号");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async getAcmeAccount(req: DnsPersistRecordBuildReq & { userId: number; projectId?: number }) {
|
||||
const accessId = req.acmeAccountAccessId || req.commonAcmeAccountAccessId;
|
||||
if (!accessId) {
|
||||
throw new Error("请选择ACME账号授权");
|
||||
}
|
||||
let access: any;
|
||||
if (req.commonAcmeAccountAccessId) {
|
||||
const entity = await this.accessService.info(accessId);
|
||||
if (!entity || entity.userId !== 0 || entity.type !== "acmeAccount") {
|
||||
throw new Error("公共ACME账号授权不存在");
|
||||
}
|
||||
access = await this.accessService.getAccessById(accessId, false);
|
||||
} else {
|
||||
access = await this.accessService.getAccessById(accessId, true, req.userId, req.projectId);
|
||||
}
|
||||
const account = this.parseAcmeAccount(access.account);
|
||||
const caType = req.caType || account.caType;
|
||||
if (caType && account.caType && caType !== account.caType) {
|
||||
throw new Error("ACME账号授权与颁发机构不匹配");
|
||||
}
|
||||
return {
|
||||
accessId,
|
||||
caType,
|
||||
account,
|
||||
};
|
||||
}
|
||||
|
||||
async buildRecord(req: { domain: string; accountUri: string; wildcard?: boolean; persistUntil?: number; userId?: number; projectId?: number }) {
|
||||
const domain = this.normalizeDomain(req.domain);
|
||||
const mainDomain = await this.parseMainDomain(domain, req.userId, req.projectId);
|
||||
return {
|
||||
mainDomain,
|
||||
hostRecord: this.buildRelativeHostRecord(domain, mainDomain),
|
||||
recordValue: buildDnsPersistRecordValue({
|
||||
...req,
|
||||
wildcard: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async buildRecordByAcmeAccount(req: DnsPersistRecordBuildReq & { userId: number; projectId?: number }) {
|
||||
const { account, caType, accessId } = await this.getAcmeAccount(req);
|
||||
const record = await this.buildRecord({
|
||||
domain: req.domain,
|
||||
accountUri: account.accountUri,
|
||||
wildcard: true,
|
||||
persistUntil: req.persistUntil,
|
||||
userId: req.userId,
|
||||
projectId: req.projectId,
|
||||
});
|
||||
return {
|
||||
...record,
|
||||
domain: this.normalizeDomain(req.domain),
|
||||
mainDomain: record.mainDomain,
|
||||
caType,
|
||||
acmeAccountAccessId: accessId,
|
||||
accountUri: account.accountUri,
|
||||
policy: "wildcard",
|
||||
persistUntil: req.persistUntil,
|
||||
status: "pending",
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
async add(param: any) {
|
||||
const record = await this.buildRecordByAcmeAccount(param);
|
||||
const exists = await this.findOne({
|
||||
where: {
|
||||
domain: record.domain,
|
||||
caType: record.caType,
|
||||
acmeAccountAccessId: record.acmeAccountAccessId,
|
||||
userId: param.userId,
|
||||
projectId: param.projectId,
|
||||
},
|
||||
});
|
||||
if (exists) {
|
||||
if (exists.policy !== "wildcard") {
|
||||
await this.upgradeToWildcardRecord(exists, record);
|
||||
return await this.info(exists.id);
|
||||
}
|
||||
if (exists.status !== "valid" && !exists.dnsProviderAccess) {
|
||||
await this.tryAutoCreateDnsTxt(exists.id);
|
||||
return await this.info(exists.id);
|
||||
}
|
||||
return exists;
|
||||
}
|
||||
const result = await super.add({
|
||||
...param,
|
||||
...record,
|
||||
});
|
||||
await this.tryAutoCreateDnsTxt(result.id);
|
||||
return await this.info(result.id);
|
||||
}
|
||||
|
||||
async update(param: any) {
|
||||
const old = await this.info(param.id);
|
||||
if (!old) {
|
||||
throw new Error("DNS持久验证记录不存在");
|
||||
}
|
||||
if (param.domain || param.caType || param.acmeAccountAccessId || param.commonAcmeAccountAccessId || param.persistUntil != null || param.wildcard != null) {
|
||||
const record = await this.buildRecordByAcmeAccount({
|
||||
domain: param.domain || old.domain,
|
||||
caType: param.caType || old.caType,
|
||||
acmeAccountAccessId: param.acmeAccountAccessId || old.acmeAccountAccessId,
|
||||
commonAcmeAccountAccessId: param.commonAcmeAccountAccessId,
|
||||
wildcard: true,
|
||||
persistUntil: param.persistUntil ?? old.persistUntil,
|
||||
userId: old.userId,
|
||||
projectId: old.projectId,
|
||||
});
|
||||
param = {
|
||||
...param,
|
||||
...record,
|
||||
};
|
||||
}
|
||||
await super.update(param);
|
||||
if (param.domain || param.caType || param.acmeAccountAccessId || param.commonAcmeAccountAccessId || param.persistUntil != null || param.wildcard != null) {
|
||||
await this.tryAutoCreateDnsTxt(param.id);
|
||||
}
|
||||
}
|
||||
|
||||
async checkRecord(req: { hostRecord: string; recordValue: string }) {
|
||||
const { walkTxtRecord } = createChallengeFn();
|
||||
const values = await walkTxtRecord(req.hostRecord);
|
||||
return values.includes(req.recordValue);
|
||||
}
|
||||
|
||||
async getByDomain(req: DnsPersistRecordBuildReq & { userId: number; projectId?: number; createOnNotFound?: boolean }) {
|
||||
const account = await this.getAcmeAccount(req);
|
||||
const domain = this.normalizeDomain(req.domain);
|
||||
let record = await this.findOne({
|
||||
where: {
|
||||
domain,
|
||||
caType: account.caType,
|
||||
acmeAccountAccessId: account.accessId,
|
||||
userId: req.userId,
|
||||
projectId: req.projectId,
|
||||
},
|
||||
});
|
||||
if (!record && req.createOnNotFound) {
|
||||
record = await this.add({
|
||||
...req,
|
||||
domain,
|
||||
caType: account.caType,
|
||||
acmeAccountAccessId: account.accessId,
|
||||
});
|
||||
} else if (record && record.policy !== "wildcard") {
|
||||
const wildcardRecord = await this.buildRecordByAcmeAccount({
|
||||
...req,
|
||||
domain,
|
||||
caType: account.caType,
|
||||
acmeAccountAccessId: account.accessId,
|
||||
persistUntil: req.persistUntil ?? record.persistUntil,
|
||||
});
|
||||
await this.upgradeToWildcardRecord(record, wildcardRecord);
|
||||
record = await this.info(record.id);
|
||||
} else if (record && record.status !== "valid" && !record.dnsProviderAccess) {
|
||||
await this.tryAutoCreateDnsTxt(record.id);
|
||||
record = await this.info(record.id);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private async upgradeToWildcardRecord(exists: DnsPersistRecordEntity, wildcardRecord: Partial<DnsPersistRecordEntity>) {
|
||||
await super.update({
|
||||
id: exists.id,
|
||||
hostRecord: wildcardRecord.hostRecord,
|
||||
recordValue: wildcardRecord.recordValue,
|
||||
mainDomain: wildcardRecord.mainDomain,
|
||||
policy: "wildcard",
|
||||
persistUntil: wildcardRecord.persistUntil ?? exists.persistUntil,
|
||||
status: "pending",
|
||||
recordRes: null,
|
||||
});
|
||||
}
|
||||
|
||||
async verify(id: number) {
|
||||
const record = await this.info(id);
|
||||
if (!record) {
|
||||
throw new Error("DNS持久验证记录不存在");
|
||||
}
|
||||
const ok = await this.checkRecord({
|
||||
hostRecord: await this.buildFullHostRecord(record),
|
||||
recordValue: record.recordValue,
|
||||
});
|
||||
await this.update({
|
||||
id: record.id,
|
||||
status: ok ? "valid" : "failed",
|
||||
});
|
||||
return ok;
|
||||
}
|
||||
|
||||
async triggerVerify(id: number) {
|
||||
await super.update({
|
||||
id,
|
||||
status: "validating",
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.verify(id).catch(async (e: any) => {
|
||||
logger.error(`DNS持久验证记录后台校验失败:${e.message || e}`);
|
||||
await super.update({
|
||||
id,
|
||||
status: "failed",
|
||||
});
|
||||
});
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async findDomainDnsProvider(record: DnsPersistRecordEntity) {
|
||||
const taskService = this.taskServiceBuilder.create({ userId: record.userId, projectId: record.projectId });
|
||||
const subDomainsGetter = await taskService.getSubDomainsGetter();
|
||||
const domainParser = new DomainParser(subDomainsGetter, logger);
|
||||
const mainDomain = record.mainDomain || (await domainParser.parse(record.domain));
|
||||
const domains = [...new Set([record.domain, mainDomain].filter(Boolean))];
|
||||
const list = await this.domainRepository.find({
|
||||
where: {
|
||||
domain: In(domains),
|
||||
userId: record.userId,
|
||||
projectId: record.projectId,
|
||||
challengeType: "dns",
|
||||
disabled: false,
|
||||
},
|
||||
});
|
||||
const matched = list.find(item => item.domain === record.domain) || list.find(item => item.domain === mainDomain);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
dnsProviderType: matched.dnsProviderType,
|
||||
dnsProviderAccess: matched.dnsProviderAccess,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveDnsProvider(record: DnsPersistRecordEntity, req: { dnsProviderType?: string; dnsProviderAccess?: number }) {
|
||||
if (req.dnsProviderType && req.dnsProviderAccess) {
|
||||
return {
|
||||
dnsProviderType: req.dnsProviderType,
|
||||
dnsProviderAccess: req.dnsProviderAccess,
|
||||
};
|
||||
}
|
||||
const provider = await this.findDomainDnsProvider(record);
|
||||
if (!provider) {
|
||||
throw new Error("未找到该域名在域名管理中的DNS授权配置,请手动选择DNS服务商和授权");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
private async tryAutoCreateDnsTxt(id: number) {
|
||||
const record = await this.info(id);
|
||||
if (!record || record.status === "valid") {
|
||||
return;
|
||||
}
|
||||
const provider = await this.findDomainDnsProvider(record);
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.createDnsTxt({
|
||||
id,
|
||||
...provider,
|
||||
});
|
||||
} catch (e: any) {
|
||||
await super.update({
|
||||
id,
|
||||
status: "failed",
|
||||
recordRes: JSON.stringify({
|
||||
autoCreateError: e.message || `${e}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createDnsTxt(req: { id: number; dnsProviderType?: string; dnsProviderAccess?: number }) {
|
||||
const record = await this.info(req.id);
|
||||
if (!record) {
|
||||
throw new Error("DNS持久验证记录不存在");
|
||||
}
|
||||
const provider = await this.resolveDnsProvider(record, req);
|
||||
const taskService = this.taskServiceBuilder.create({ userId: record.userId, projectId: record.projectId });
|
||||
const subDomainsGetter = await taskService.getSubDomainsGetter();
|
||||
const domainParser = new DomainParser(subDomainsGetter, logger);
|
||||
const access = await this.accessService.getAccessById(provider.dnsProviderAccess, true, record.userId, record.projectId);
|
||||
const dnsProvider = await createDnsProvider({
|
||||
dnsProviderType: provider.dnsProviderType,
|
||||
context: {
|
||||
access,
|
||||
logger,
|
||||
http,
|
||||
utils,
|
||||
domainParser,
|
||||
serviceGetter: taskService,
|
||||
},
|
||||
});
|
||||
const mainDomain = record.mainDomain || (await domainParser.parse(record.domain));
|
||||
const fullRecordRaw = await this.buildFullHostRecord(record);
|
||||
const fullRecord = dnsProvider.usePunyCode() ? fullRecordRaw : dnsProvider.punyCodeDecode(fullRecordRaw);
|
||||
let hostRecord = fullRecord.replace(`${mainDomain}`, "");
|
||||
if (hostRecord.endsWith(".")) {
|
||||
hostRecord = hostRecord.substring(0, hostRecord.length - 1);
|
||||
}
|
||||
const recordReq = {
|
||||
domain: mainDomain,
|
||||
fullRecord,
|
||||
hostRecord,
|
||||
type: "TXT",
|
||||
value: record.recordValue,
|
||||
};
|
||||
const recordRes = await dnsProvider.createRecord(recordReq);
|
||||
const verified = await this.checkRecord({
|
||||
hostRecord: await this.buildFullHostRecord(record),
|
||||
recordValue: record.recordValue,
|
||||
});
|
||||
await this.update({
|
||||
id: record.id,
|
||||
dnsProviderType: provider.dnsProviderType,
|
||||
dnsProviderAccess: provider.dnsProviderAccess,
|
||||
recordRes: JSON.stringify({ recordReq, recordRes }),
|
||||
status: verified ? "valid" : "validating",
|
||||
});
|
||||
return {
|
||||
recordReq,
|
||||
recordRes,
|
||||
verified,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(ids: string | any[], where?: any) {
|
||||
const idList = this.resolveIdArr(ids);
|
||||
const messages: string[] = [];
|
||||
for (const id of idList) {
|
||||
const record = await this.info(id);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
messages.push(`DNS持久验证记录已删除,请到域名供应商删除TXT记录:${record.hostRecord} => ${record.recordValue}`);
|
||||
}
|
||||
this.lastDeleteMessage = messages.join("\n");
|
||||
return await super.delete(ids, where);
|
||||
}
|
||||
}
|
||||
@@ -363,7 +363,6 @@ export class PipelineService extends BaseService<PipelineEntity> {
|
||||
if (!old && userSuite?.pipelineCount.max != -1 && userSuite?.pipelineCount.used + 1 > userSuite?.pipelineCount.max) {
|
||||
throw new NeedSuiteException(`对不起,您最多只能创建${userSuite?.pipelineCount.max}条流水线,请购买或升级套餐`);
|
||||
}
|
||||
|
||||
let oldDomainCount = 0;
|
||||
let oldWildcardDomainCount = 0;
|
||||
if (old?.id) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { AbstractTaskPlugin, IsTaskPlugin, PageSearch, pluginGroups, RunStrategy, TaskInput } from '@certd/pipeline';
|
||||
import { CertApplyPluginNames } from '@certd/plugin-cert';
|
||||
import { CertInfo, createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from '@certd/plugin-lib';
|
||||
import { AliyunAccess } from '../../../plugin-lib/aliyun/access/index.js';
|
||||
import { AliyunSslClient, CasCertId } from '../../../plugin-lib/aliyun/lib/index.js';
|
||||
|
||||
@IsTaskPlugin({
|
||||
name: 'DeployCertToAliyunLive',
|
||||
title: '阿里云-部署至直播(Live)',
|
||||
icon: 'svg:icon-aliyun',
|
||||
group: pluginGroups.aliyun.key,
|
||||
desc: '部署证书到阿里云视频直播(Live)域名',
|
||||
needPlus: false,
|
||||
default: {
|
||||
strategy: {
|
||||
runStrategy: RunStrategy.SkipWhenSucceed,
|
||||
},
|
||||
},
|
||||
})
|
||||
export class DeployCertToAliyunLive extends AbstractTaskPlugin {
|
||||
|
||||
|
||||
@TaskInput({
|
||||
title: '域名证书',
|
||||
helper: '请选择前置任务输出的域名证书',
|
||||
component: {
|
||||
name: 'output-selector',
|
||||
from: [...CertApplyPluginNames, 'uploadCertToAliyun'],
|
||||
},
|
||||
template: false,
|
||||
required: true,
|
||||
})
|
||||
cert!: CertInfo | CasCertId;
|
||||
|
||||
@TaskInput(createCertDomainGetterInputDefine({ props: { required: false } }))
|
||||
certDomains!: string[];
|
||||
|
||||
|
||||
@TaskInput({
|
||||
title: 'Access授权',
|
||||
helper: '阿里云授权AccessKeyId、AccessKeySecret',
|
||||
component: {
|
||||
name: 'access-selector',
|
||||
type: 'aliyun',
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
accessId!: string;
|
||||
|
||||
@TaskInput({
|
||||
title: '证书服务接入点',
|
||||
helper: '不会选就按默认',
|
||||
value: 'cas.aliyuncs.com',
|
||||
component: {
|
||||
name: 'a-select',
|
||||
options: [
|
||||
{ value: 'cas.aliyuncs.com', label: '中国大陆' },
|
||||
{ value: 'cas.ap-southeast-1.aliyuncs.com', label: '新加坡' },
|
||||
{ value: 'cas.eu-central-1.aliyuncs.com', label: '德国(法兰克福)' },
|
||||
],
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
endpoint!: string;
|
||||
|
||||
@TaskInput(
|
||||
createRemoteSelectInputDefine({
|
||||
title: '直播域名',
|
||||
helper: '请选择要部署证书的直播域名',
|
||||
typeName: 'DeployCertToAliyunLive',
|
||||
action: DeployCertToAliyunLive.prototype.onGetDomainList.name,
|
||||
watches: ['certDomains', 'accessId'],
|
||||
pager: true,
|
||||
search: true,
|
||||
})
|
||||
)
|
||||
domainList!: string[];
|
||||
|
||||
async onInstance() {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
this.logger.info('开始部署证书到阿里云直播');
|
||||
const access = await this.getAccess<AliyunAccess>(this.accessId);
|
||||
|
||||
if (this.cert == null) {
|
||||
throw new Error('域名证书参数为空,请检查前置任务');
|
||||
}
|
||||
|
||||
const client = await this.getClient(access);
|
||||
const sslClient = new AliyunSslClient({
|
||||
access,
|
||||
logger: this.logger,
|
||||
endpoint: this.endpoint || 'cas.aliyuncs.com',
|
||||
});
|
||||
|
||||
// 确保证书已上传到 CAS,统一使用 cas 方式部署
|
||||
const casCert = await sslClient.uploadCertOrGet(this.cert);
|
||||
// const certName = this.appendTimeSuffix(this.certName || casCert.certName);
|
||||
for (const domain of this.domainList) {
|
||||
const res = await client.doRequest({
|
||||
action: 'SetLiveDomainCertificate',
|
||||
version: '2016-11-01',
|
||||
protocol: 'HTTPS',
|
||||
data: {
|
||||
query: {
|
||||
DomainName: domain,
|
||||
CertName: casCert.certName,
|
||||
CertType: 'cas',
|
||||
SSLProtocol: 'on',
|
||||
CertId: casCert.certId,
|
||||
},
|
||||
},
|
||||
});
|
||||
this.logger.info('部署直播域名[' + domain + ']证书成功:' + JSON.stringify(res));
|
||||
}
|
||||
}
|
||||
|
||||
async getClient(access: AliyunAccess) {
|
||||
const endpoint = 'live.aliyuncs.com';
|
||||
return access.getClient(endpoint);
|
||||
}
|
||||
|
||||
async onGetDomainList(data: PageSearch) {
|
||||
if (!this.accessId) {
|
||||
throw new Error('请选择Access授权');
|
||||
}
|
||||
const access = await this.getAccess<AliyunAccess>(this.accessId);
|
||||
const client = await this.getClient(access);
|
||||
|
||||
const res = await client.doRequest({
|
||||
action: 'DescribeLiveUserDomains',
|
||||
version: '2016-11-01',
|
||||
protocol: 'HTTPS',
|
||||
data: {
|
||||
query: {
|
||||
DomainName: data.searchKey || undefined,
|
||||
PageNumber: data.pageNo || 1,
|
||||
PageSize: data.pageSize || 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const list = res?.Domains?.PageData;
|
||||
if (!list || list.length === 0) {
|
||||
throw new Error('没有找到直播域名,请先在阿里云添加直播域名');
|
||||
}
|
||||
|
||||
const options = list.map((item: any) => {
|
||||
return {
|
||||
label: item.DomainName,
|
||||
value: item.DomainName,
|
||||
domain: item.DomainName,
|
||||
};
|
||||
});
|
||||
return this.ctx.utils.options.buildGroupOptions(options, this.certDomains);
|
||||
}
|
||||
}
|
||||
|
||||
new DeployCertToAliyunLive();
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './deploy-to-cdn/index.js';
|
||||
export * from './deploy-to-cdn/index.js';
|
||||
export * from './deploy-to-dcdn/index.js';
|
||||
export * from './deploy-to-oss/index.js';
|
||||
export * from './upload-to-aliyun/index.js';
|
||||
@@ -10,8 +10,9 @@ export * from './deploy-to-fc/index.js';
|
||||
export * from './deploy-to-esa/index.js';
|
||||
export * from './deploy-to-ga/index.js';
|
||||
export * from './deploy-to-vod/index.js';
|
||||
export * from './deploy-to-live/index.js';
|
||||
export * from './deploy-to-apigateway/index.js';
|
||||
export * from './deploy-to-apig/index.js';
|
||||
export * from './deploy-to-ack/index.js';
|
||||
export * from './deploy-to-all/index.js';
|
||||
export * from './delete-expiring-cert/index.js';
|
||||
export * from './delete-expiring-cert/index.js';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "assert";
|
||||
import { AcmeAccountAccess } from "./acme-account-access.js";
|
||||
import { AcmeService } from "../plugin/cert-plugin/acme.js";
|
||||
|
||||
describe("AcmeAccountAccess", () => {
|
||||
it("requires generated account payload before use", () => {
|
||||
const access = new AcmeAccountAccess();
|
||||
|
||||
assert.throws(() => access.getAccount(), /ACME账号信息无效/);
|
||||
});
|
||||
|
||||
it("parses generated account payload", () => {
|
||||
const access = new AcmeAccountAccess();
|
||||
access.account = JSON.stringify({
|
||||
accountKey: "private-key",
|
||||
accountUri: "https://example.com/acct/1",
|
||||
caType: "letsencrypt",
|
||||
email: "user@example.com",
|
||||
directoryUrl: "https://example.com/directory",
|
||||
});
|
||||
|
||||
const account = access.getAccount();
|
||||
|
||||
assert.equal(account.accountKey, "private-key");
|
||||
assert.equal(account.accountUri, "https://example.com/acct/1");
|
||||
});
|
||||
|
||||
it("generates account payload through acme service", async () => {
|
||||
const original = AcmeService.prototype.getAcmeClient;
|
||||
const calls: string[] = [];
|
||||
AcmeService.prototype.getAcmeClient = async function (email: string) {
|
||||
calls.push(email);
|
||||
await this.userContext.setObj(this.buildAccountKey(email), { key: "generated-key" });
|
||||
return {
|
||||
getAccountUrl() {
|
||||
return "https://example.com/acct/2";
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
try {
|
||||
const access = new AcmeAccountAccess();
|
||||
access.caType = "google";
|
||||
access.email = "user@example.com";
|
||||
access.eabKid = "kid-1";
|
||||
access.eabHmacKey = "hmac-1";
|
||||
|
||||
const account = JSON.parse(await access.onGenerateAccount());
|
||||
|
||||
assert.equal(calls[0], "user@example.com");
|
||||
assert.equal(account.accountKey, "generated-key");
|
||||
assert.equal(account.accountUri, "https://example.com/acct/2");
|
||||
assert.equal(account.caType, "google");
|
||||
assert.equal(account.email, "user@example.com");
|
||||
} finally {
|
||||
AcmeService.prototype.getAcmeClient = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
|
||||
import * as acme from "@certd/acme-client";
|
||||
import { AcmeService } from "../plugin/cert-plugin/acme.js";
|
||||
|
||||
export type AcmeAccountInfo = {
|
||||
accountKey: string;
|
||||
accountUri: string;
|
||||
caType: string;
|
||||
email: string;
|
||||
directoryUrl: string;
|
||||
eab?: {
|
||||
kid?: string;
|
||||
hmacKey?: string;
|
||||
usedAt: number;
|
||||
};
|
||||
};
|
||||
|
||||
function parseAccount(account?: string | AcmeAccountInfo): AcmeAccountInfo | null {
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
if (typeof account !== "string") {
|
||||
return account;
|
||||
}
|
||||
return JSON.parse(account);
|
||||
}
|
||||
|
||||
@IsAccess({
|
||||
name: "acmeAccount",
|
||||
title: "ACME账号",
|
||||
desc: "用于复用ACME账号私钥和账号地址,证书申请时不再临时创建账号",
|
||||
icon: "ph:certificate",
|
||||
subtype: "caType",
|
||||
} as any)
|
||||
export class AcmeAccountAccess extends BaseAccess {
|
||||
@AccessInput({
|
||||
title: "颁发机构",
|
||||
component: {
|
||||
name: "a-select",
|
||||
options: [
|
||||
{ value: "letsencrypt", label: "Let's Encrypt" },
|
||||
{ value: "letsencrypt_staging", label: "Let's Encrypt测试环境" },
|
||||
{ value: "google", label: "Google" },
|
||||
{ value: "zerossl", label: "ZeroSSL" },
|
||||
{ value: "litessl", label: "litessl" },
|
||||
{ value: "sslcom", label: "SSL.com" },
|
||||
],
|
||||
},
|
||||
required: true,
|
||||
mergeScript: `
|
||||
return {
|
||||
component: {
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account)
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
caType = "letsencrypt";
|
||||
|
||||
@AccessInput({
|
||||
title: "邮箱",
|
||||
component: {
|
||||
placeholder: "user@example.com",
|
||||
},
|
||||
rules: [{ type: "email", message: "请输入正确的邮箱" }],
|
||||
required: true,
|
||||
mergeScript: `
|
||||
return {
|
||||
component: {
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account)
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
email = "";
|
||||
|
||||
@AccessInput({
|
||||
title: "ACME Directory URL",
|
||||
component: {
|
||||
placeholder: "自定义ACME服务端点",
|
||||
},
|
||||
helper: "自定义ACME时必填,其他颁发机构默认自动使用内置端点",
|
||||
required: false,
|
||||
mergeScript: `
|
||||
return {
|
||||
show: false,
|
||||
}
|
||||
`,
|
||||
})
|
||||
directoryUrl = "";
|
||||
|
||||
@AccessInput({
|
||||
title: "EAB KID",
|
||||
component: {
|
||||
placeholder: "需要EAB的颁发机构生成账号时填写",
|
||||
},
|
||||
helper:
|
||||
"需要提供EAB授权" +
|
||||
"\nZeroSSL:请前往[zerossl开发者中心](https://app.zerossl.com/developer),生成 'EAB Credentials'" +
|
||||
"\nGoogle:请查看[google获取eab帮助文档](https://certd.docmirror.cn/guide/use/google/),用过一次后会绑定邮箱,后续复用EAB要用同一个邮箱" +
|
||||
"\nSSL.com:[SSL.com账号页面](https://secure.ssl.com/account),然后点击api credentials链接,然后点击编辑按钮,查看Secret key和HMAC key" +
|
||||
"\nlitessl:[litesslEAB页面](https://freessl.cn/automation/eab-manager),然后点击新增EAB",
|
||||
required: false,
|
||||
encrypt: true,
|
||||
mergeScript: `
|
||||
return {
|
||||
show: ctx.compute(({form})=>{
|
||||
const caType = form.access?.caType;
|
||||
return ['google','zerossl','sslcom','litessl'].includes(caType);
|
||||
}),
|
||||
component: {
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account)
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
eabKid = "";
|
||||
|
||||
@AccessInput({
|
||||
title: "EAB HMAC Key",
|
||||
component: {
|
||||
placeholder: "需要EAB的颁发机构生成账号时填写",
|
||||
},
|
||||
required: false,
|
||||
encrypt: true,
|
||||
mergeScript: `
|
||||
return {
|
||||
show: ctx.compute(({form})=>{
|
||||
const caType = form.access?.caType;
|
||||
return ['google','zerossl','sslcom','litessl'].includes(caType);
|
||||
}),
|
||||
component: {
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account)
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
eabHmacKey = "";
|
||||
|
||||
@AccessInput({
|
||||
title: "ACME账号信息",
|
||||
component: {
|
||||
name: "refresh-input",
|
||||
action: "GenerateAccount",
|
||||
buttonText: "生成ACME账号",
|
||||
successMessage: "ACME账号已生成,请保存授权配置",
|
||||
},
|
||||
required: true,
|
||||
helper: "请生成ACME账号,账号一旦生成不允许修改",
|
||||
encrypt: true,
|
||||
mergeScript: `
|
||||
return {
|
||||
component: {
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account)
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
account = "";
|
||||
|
||||
getDirectoryUrl() {
|
||||
if (this.caType === "custom") {
|
||||
if (!this.directoryUrl) {
|
||||
throw new Error("自定义ACME需要填写Directory URL");
|
||||
}
|
||||
return this.directoryUrl;
|
||||
}
|
||||
return acme.getDirectoryUrl({ sslProvider: this.caType, pkType: "rsa_2048" });
|
||||
}
|
||||
|
||||
async onGenerateAccount() {
|
||||
if (!this.caType) {
|
||||
throw new Error("请先选择颁发机构");
|
||||
}
|
||||
if (!this.email) {
|
||||
throw new Error("请先填写邮箱");
|
||||
}
|
||||
const needEab = ["google", "zerossl", "sslcom", "litessl"].includes(this.caType);
|
||||
if (needEab && (!this.eabKid || !this.eabHmacKey)) {
|
||||
throw new Error("该颁发机构需要填写EAB KID和EAB HMAC Key后才能生成账号");
|
||||
}
|
||||
const account = await this.createAccountInfo();
|
||||
return JSON.stringify(account);
|
||||
}
|
||||
|
||||
private async createAccountInfo(): Promise<AcmeAccountInfo> {
|
||||
const directoryUrl = this.getDirectoryUrl();
|
||||
const externalAccountBinding = this.eabKid && this.eabHmacKey ? { kid: this.eabKid, hmacKey: this.eabHmacKey } : undefined;
|
||||
const memoryStore = new Map<string, any>();
|
||||
const userContext = {
|
||||
async getObj(key: string) {
|
||||
return memoryStore.get(key);
|
||||
},
|
||||
async setObj(key: string, value: any) {
|
||||
memoryStore.set(key, value);
|
||||
},
|
||||
};
|
||||
const acmeService = new AcmeService({
|
||||
userId: 0,
|
||||
userContext: userContext as any,
|
||||
logger: (this.ctx?.logger || console) as any,
|
||||
sslProvider: this.caType as any,
|
||||
eab: externalAccountBinding ? ({ ...externalAccountBinding, id: 0 } as any) : undefined,
|
||||
privateKeyType: "rsa_2048",
|
||||
signal: (this.ctx as any)?.signal,
|
||||
maxCheckRetryCount: 20,
|
||||
domainParser: {} as any,
|
||||
});
|
||||
const client = await acmeService.getAcmeClient(this.email);
|
||||
const conf = await userContext.getObj(acmeService.buildAccountKey(this.email));
|
||||
if (!conf?.key || !client.getAccountUrl()) {
|
||||
throw new Error("ACME账号生成失败,请稍后重试");
|
||||
}
|
||||
const account: AcmeAccountInfo = {
|
||||
accountKey: conf.key,
|
||||
accountUri: client.getAccountUrl(),
|
||||
caType: this.caType,
|
||||
email: this.email,
|
||||
directoryUrl,
|
||||
};
|
||||
if (externalAccountBinding) {
|
||||
account.eab = {
|
||||
...externalAccountBinding,
|
||||
usedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
getAccount(): AcmeAccountInfo {
|
||||
const account = parseAccount(this.account);
|
||||
if (!account?.accountKey || !account?.accountUri) {
|
||||
throw new Error("ACME账号信息无效,请重新生成ACME账号");
|
||||
}
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
new AcmeAccountAccess();
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./eab-access.js";
|
||||
export * from "./google-access.js";
|
||||
export * from "./acme-account-access.js";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "assert";
|
||||
import { utils } from "@certd/basic";
|
||||
import { AcmeService } from "./acme.js";
|
||||
|
||||
const logger = {
|
||||
@@ -173,4 +174,28 @@ describe("AcmeService challenge", () => {
|
||||
|
||||
assert.deepEqual(parseCalls, ["www.example.com", "certd-key.cname.sub.example.com"]);
|
||||
});
|
||||
|
||||
it("enables proxy mapping when acme directory request fails", async () => {
|
||||
const originalRequest = utils.http.request;
|
||||
utils.http.request = async () => {
|
||||
throw new Error("timeout");
|
||||
};
|
||||
|
||||
try {
|
||||
const service = new AcmeService({
|
||||
userId: 1,
|
||||
userContext: {} as any,
|
||||
logger: logger as any,
|
||||
sslProvider: "google",
|
||||
domainParser: {} as any,
|
||||
});
|
||||
|
||||
const urlMapping = await service.resolveUrlMapping("https://dv.acme-v02.api.pki.goog/directory");
|
||||
|
||||
assert.equal(urlMapping.enabled, true);
|
||||
assert.equal(urlMapping.mappings["dv.acme-v02.api.pki.goog"], "gg.px.certd.handfree.work");
|
||||
} finally {
|
||||
utils.http.request = originalRequest;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,15 +21,30 @@ export type HttpVerifyPlan = {
|
||||
export type DomainVerifyPlan = {
|
||||
domain: string;
|
||||
mainDomain: string;
|
||||
type: "cname" | "dns" | "http";
|
||||
type: "cname" | "dns" | "http" | "dns-persist";
|
||||
dnsProvider?: IDnsProvider;
|
||||
cnameVerifyPlan?: CnameVerifyPlan;
|
||||
httpVerifyPlan?: HttpVerifyPlan;
|
||||
dnsPersistVerifyPlan?: DnsPersistVerifyPlan;
|
||||
};
|
||||
export type DomainsVerifyPlan = {
|
||||
[key: string]: DomainVerifyPlan;
|
||||
};
|
||||
|
||||
export type AcmeAccountInfo = {
|
||||
accountKey: string;
|
||||
accountUri: string;
|
||||
caType: SSLProvider | string;
|
||||
email: string;
|
||||
directoryUrl: string;
|
||||
};
|
||||
|
||||
export type DnsPersistVerifyPlan = {
|
||||
hostRecord: string;
|
||||
recordValue: string;
|
||||
accountUri: string;
|
||||
};
|
||||
|
||||
export type Providers = {
|
||||
dnsProvider?: IDnsProvider;
|
||||
domainsVerifyPlan?: DomainsVerifyPlan;
|
||||
@@ -38,7 +53,7 @@ export type Providers = {
|
||||
export type CertInfo = {
|
||||
crt: string; //fullchain证书
|
||||
key: string; //私钥
|
||||
csr: string; //csr
|
||||
csr?: string; //csr
|
||||
oc?: string; //仅证书,非fullchain证书
|
||||
ic?: string; //中间证书
|
||||
pfx?: string;
|
||||
@@ -153,8 +168,7 @@ export class AcmeService {
|
||||
await this.userContext.setObj(this.buildAccountKey(email), conf);
|
||||
}
|
||||
|
||||
async getAcmeClient(email: string): Promise<acme.Client> {
|
||||
const directoryUrl = acme.getDirectoryUrl({ sslProvider: this.sslProvider, pkType: this.options.privateKeyType });
|
||||
buildUrlMapping(directoryUrl: string): UrlMapping {
|
||||
let targetUrl = directoryUrl.replace("https://", "");
|
||||
targetUrl = targetUrl.substring(0, targetUrl.indexOf("/"));
|
||||
|
||||
@@ -174,10 +188,29 @@ export class AcmeService {
|
||||
if (this.options.reverseProxy && targetUrl) {
|
||||
mappings[targetUrl] = this.options.reverseProxy;
|
||||
}
|
||||
const urlMapping: UrlMapping = {
|
||||
return {
|
||||
enabled: false,
|
||||
mappings,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveUrlMapping(directoryUrl: string) {
|
||||
const urlMapping = this.buildUrlMapping(directoryUrl);
|
||||
if (this.options.useMappingProxy) {
|
||||
urlMapping.enabled = true;
|
||||
return urlMapping;
|
||||
}
|
||||
const isOk = await this.testDirectory(directoryUrl);
|
||||
if (!isOk) {
|
||||
this.logger.info("测试访问失败,自动使用代理");
|
||||
urlMapping.enabled = true;
|
||||
}
|
||||
return urlMapping;
|
||||
}
|
||||
|
||||
async getAcmeClient(email: string): Promise<acme.Client> {
|
||||
const directoryUrl = acme.getDirectoryUrl({ sslProvider: this.sslProvider, pkType: this.options.privateKeyType });
|
||||
const urlMapping = await this.resolveUrlMapping(directoryUrl);
|
||||
const conf = await this.getAccountConfig(email, urlMapping);
|
||||
if (conf.key == null) {
|
||||
conf.key = await this.createNewKey();
|
||||
@@ -185,16 +218,6 @@ export class AcmeService {
|
||||
this.logger.info(`创建新的Accountkey:${email}`);
|
||||
}
|
||||
|
||||
if (this.options.useMappingProxy) {
|
||||
urlMapping.enabled = true;
|
||||
} else {
|
||||
//测试directory是否可以访问
|
||||
const isOk = await this.testDirectory(directoryUrl);
|
||||
if (!isOk) {
|
||||
this.logger.info("测试访问失败,自动使用代理");
|
||||
urlMapping.enabled = true;
|
||||
}
|
||||
}
|
||||
const client = new acme.Client({
|
||||
sslProvider: this.sslProvider,
|
||||
directoryUrl: directoryUrl,
|
||||
@@ -238,6 +261,26 @@ export class AcmeService {
|
||||
return client;
|
||||
}
|
||||
|
||||
async getAcmeClientByAccount(account: AcmeAccountInfo): Promise<acme.Client> {
|
||||
if (!account?.accountKey || !account?.accountUri) {
|
||||
throw new Error("ACME账号信息无效,请重新生成ACME账号");
|
||||
}
|
||||
const directoryUrl = account.directoryUrl || acme.getDirectoryUrl({ sslProvider: account.caType, pkType: this.options.privateKeyType });
|
||||
const urlMapping = await this.resolveUrlMapping(directoryUrl);
|
||||
return new acme.Client({
|
||||
sslProvider: account.caType,
|
||||
directoryUrl,
|
||||
accountKey: account.accountKey,
|
||||
accountUrl: account.accountUri,
|
||||
backoffAttempts: this.options.maxCheckRetryCount || 20,
|
||||
backoffMin: 5000,
|
||||
backoffMax: 30 * 1000,
|
||||
urlMapping,
|
||||
signal: this.options.signal,
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
|
||||
async createNewKey() {
|
||||
const key = await acme.crypto.createPrivateKey(2048);
|
||||
return key.toString();
|
||||
@@ -300,6 +343,18 @@ export class AcmeService {
|
||||
};
|
||||
};
|
||||
|
||||
const doDnsPersistVerify = async (challenge: any, plan: DnsPersistVerifyPlan) => {
|
||||
if (challenge == null) {
|
||||
throw new Error("该域名不支持dns-persist-01方式校验,请确认当前CA是否已开放该能力");
|
||||
}
|
||||
this.logger.info("DNS持久验证");
|
||||
challenge.expectedRecordValue = plan.recordValue;
|
||||
return {
|
||||
challenge,
|
||||
keyAuthorization: "",
|
||||
};
|
||||
};
|
||||
|
||||
let dnsProvider = providers.dnsProvider;
|
||||
let fullRecord = `_acme-challenge.${fullDomain}`;
|
||||
|
||||
@@ -343,6 +398,9 @@ export class AcmeService {
|
||||
} else {
|
||||
throw new Error("未找到域名【" + fullDomain + "】的http校验配置");
|
||||
}
|
||||
} else if (domainVerifyPlan.type === "dns-persist") {
|
||||
checkIpChallenge("dns-persist");
|
||||
return await doDnsPersistVerify(getChallenge("dns-persist-01"), domainVerifyPlan.dnsPersistVerifyPlan);
|
||||
} else {
|
||||
throw new Error("不支持的校验类型", domainVerifyPlan.type);
|
||||
}
|
||||
@@ -394,6 +452,8 @@ export class AcmeService {
|
||||
this.logger.error("删除解析记录出错:", e);
|
||||
throw e;
|
||||
}
|
||||
} else if (challenge.type === "dns-persist-01") {
|
||||
this.logger.info(`DNS持久验证无需清理:${fullDomain}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,9 +467,10 @@ export class AcmeService {
|
||||
privateKeyType?: string;
|
||||
profile?: string;
|
||||
preferredChain?: string;
|
||||
acmeAccount?: AcmeAccountInfo;
|
||||
}): Promise<CertInfo> {
|
||||
const { email, csrInfo, dnsProvider, domainsVerifyPlan, profile, preferredChain } = options;
|
||||
const client: acme.Client = await this.getAcmeClient(email);
|
||||
const { email, csrInfo, dnsProvider, domainsVerifyPlan, profile, preferredChain, acmeAccount } = options;
|
||||
const client: acme.Client = acmeAccount ? await this.getAcmeClientByAccount(acmeAccount) : await this.getAcmeClient(email);
|
||||
|
||||
let domains = options.domains;
|
||||
const encodingDomains = [];
|
||||
@@ -463,12 +524,13 @@ export class AcmeService {
|
||||
domainsVerifyPlan,
|
||||
};
|
||||
/* 自动申请证书 */
|
||||
const challengePriority = domainsVerifyPlan && Object.values(domainsVerifyPlan).some((item: any) => item?.type === "dns-persist") ? ["dns-persist-01"] : ["dns-01", "http-01"];
|
||||
const crt = await client.auto({
|
||||
csr,
|
||||
email: email,
|
||||
termsOfServiceAgreed: true,
|
||||
skipChallengeVerification: this.skipLocalVerify,
|
||||
challengePriority: ["dns-01", "http-01"],
|
||||
challengePriority,
|
||||
challengeCreateFn: async (
|
||||
authz: acme.Authorization,
|
||||
keyAuthorizationGetter: (challenge: Challenge) => Promise<string>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from "assert";
|
||||
import { CertApplyPlugin } from "./apply.js";
|
||||
|
||||
describe("CertApplyPlugin dns-persist verify plan", () => {
|
||||
it("keeps dns-persist entries when building mixed domain verify plans", async () => {
|
||||
const plugin: any = new CertApplyPlugin();
|
||||
plugin.acme = {
|
||||
options: {
|
||||
domainParser: {
|
||||
async parse(domain: string) {
|
||||
return domain;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const plan = await plugin.createDomainsVerifyPlan(
|
||||
["*.handfree.work"],
|
||||
{
|
||||
"handfree.work": {
|
||||
domain: "handfree.work",
|
||||
type: "dns-persist",
|
||||
dnsPersistVerifyPlan: {
|
||||
"handfree.work": {
|
||||
domain: "handfree.work",
|
||||
status: "valid",
|
||||
hostRecord: "_validation-persist",
|
||||
recordValue: "letsencrypt.org; accounturi=https://acme.example/acct/1; policy=wildcard",
|
||||
accountUri: "https://acme.example/acct/1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
accountKey: "private-key",
|
||||
accountUri: "https://acme.example/acct/1",
|
||||
caType: "letsencrypt_staging",
|
||||
email: "user@example.com",
|
||||
directoryUrl: "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(plan["handfree.work"].type, "dns-persist");
|
||||
assert.equal(plan["handfree.work"].dnsPersistVerifyPlan?.hostRecord, "_validation-persist");
|
||||
assert.equal(plan["handfree.work"].dnsPersistVerifyPlan?.recordValue, "letsencrypt.org; accounturi=https://acme.example/acct/1; policy=wildcard");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CancelError, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
|
||||
import { utils } from "@certd/basic";
|
||||
|
||||
import { AcmeService, DomainsVerifyPlan, DomainVerifyPlan, PrivateKeyType, SSLProvider } from "./acme.js";
|
||||
import { AcmeAccountInfo, AcmeService, DomainsVerifyPlan, DomainVerifyPlan, PrivateKeyType, SSLProvider } from "./acme.js";
|
||||
import { createDnsProvider, DnsProviderContext, DnsVerifier, DomainVerifiers, HttpVerifier, IDnsProvider, IDomainVerifierGetter, ISubDomainsGetter } from "@certd/plugin-lib";
|
||||
import { CertReader } from "@certd/plugin-lib";
|
||||
import { CertApplyBasePlugin } from "./base.js";
|
||||
@@ -22,14 +22,22 @@ export type HttpRecordInput = {
|
||||
httpUploaderAccess: number;
|
||||
httpUploadRootDir: string;
|
||||
};
|
||||
export type DnsPersistRecordInput = {
|
||||
domain: string;
|
||||
status?: string;
|
||||
hostRecord?: string;
|
||||
recordValue?: string;
|
||||
accountUri?: string;
|
||||
};
|
||||
export type DomainVerifyPlanInput = {
|
||||
domain: string;
|
||||
type: "cname" | "dns" | "http";
|
||||
type: "cname" | "dns" | "http" | "dns-persist";
|
||||
dnsProviderType?: string;
|
||||
dnsProviderAccessType?: string;
|
||||
dnsProviderAccessId?: number;
|
||||
cnameVerifyPlan?: Record<string, CnameRecordInput>;
|
||||
httpVerifyPlan?: Record<string, HttpRecordInput>;
|
||||
dnsPersistVerifyPlan?: Record<string, DnsPersistRecordInput>;
|
||||
};
|
||||
export type DomainsVerifyPlanInput = {
|
||||
[key: string]: DomainVerifyPlanInput;
|
||||
@@ -99,6 +107,19 @@ const preferredChainMergeScript = (() => {
|
||||
},
|
||||
})
|
||||
export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
constructor() {
|
||||
super();
|
||||
this.version = 1;
|
||||
}
|
||||
|
||||
@TaskInput({
|
||||
title: "版本",
|
||||
value: 2,
|
||||
isSys: true,
|
||||
show: false,
|
||||
})
|
||||
version?: number;
|
||||
|
||||
@TaskInput({
|
||||
title: "域名验证方式",
|
||||
value: "dns",
|
||||
@@ -107,6 +128,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
vModel: "value",
|
||||
options: [
|
||||
{ value: "dns", label: "DNS直接验证" },
|
||||
{ value: "dns-persist", label: "DNS持久验证" },
|
||||
{ value: "cname", label: "CNAME代理验证" },
|
||||
{ value: "http", label: "HTTP文件验证(IP证书只能选它)" },
|
||||
{ value: "dnses", label: "多DNS提供商" },
|
||||
@@ -119,12 +141,11 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
3. <b>HTTP文件验证</b>:不支持泛域名,需要配置网站文件上传(IP证书必须选它)
|
||||
4. <b>多DNS提供商</b>:每个域名可以选择独立的DNS提供商
|
||||
5. <b>自动匹配</b>:此处无需选择校验方式,需要在[域名管理](#/certd/cert/domain)中提前配置好校验方式
|
||||
6. <b>DNS持久验证</b>:需要先配置ACME账号和_validation-persist持久TXT记录,续期时不再增删DNS记录;当前仅 Let's Encrypt 测试环境可以申请
|
||||
`,
|
||||
})
|
||||
challengeType!: string;
|
||||
|
||||
|
||||
|
||||
@TaskInput({
|
||||
title: "DNS解析服务商",
|
||||
component: {
|
||||
@@ -145,7 +166,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
}
|
||||
`,
|
||||
required: true,
|
||||
helper: "您的域名注册商,或者域名的dns服务器属于哪个平台\n如果这里没有,请选择CNAME代理验证校验方式",
|
||||
helper: "您的域名注册商,或者域名的dns服务器属于哪个平台\n如果这里没有,请选择CNAME代理验证",
|
||||
})
|
||||
dnsProviderType!: string;
|
||||
|
||||
@@ -190,18 +211,30 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
}),
|
||||
defaultType: ctx.compute(({form})=>{
|
||||
return form.challengeType || 'cname'
|
||||
}),
|
||||
caType: ctx.compute(({form})=>{
|
||||
return form.sslProvider
|
||||
}),
|
||||
acmeAccountAccessId: ctx.compute(({form})=>{
|
||||
return form.acmeAccountAccessId
|
||||
}),
|
||||
commonAcmeAccountAccessId: ctx.compute(({form})=>{
|
||||
const key = form.sslProvider + 'CommonAcmeAccountAccessId';
|
||||
return form[key]
|
||||
})
|
||||
},
|
||||
show: ctx.compute(({form})=>{
|
||||
return form.challengeType === 'cname' || form.challengeType === 'http' || form.challengeType === 'dnses'
|
||||
return form.challengeType === 'cname' || form.challengeType === 'http' || form.challengeType === 'dnses' || form.challengeType === 'dns-persist'
|
||||
}),
|
||||
helper: ctx.compute(({form})=>{
|
||||
if(form.challengeType === 'cname' ){
|
||||
return '请按照上面的提示,给要申请证书的域名添加CNAME记录,添加后,点击验证,验证成功后不要删除记录,申请和续期证书会一直用它'
|
||||
}else if (form.challengeType === 'http'){
|
||||
return '请按照上面的提示,给每个域名设置文件上传配置,证书申请过程中会上传校验文件到网站根目录的.well-known/acme-challenge/目录下'
|
||||
}else if (form.challengeType === 'http'){
|
||||
}else if (form.challengeType === 'dnses'){
|
||||
return '给每个域名单独配置dns提供商'
|
||||
}else if (form.challengeType === 'dns-persist'){
|
||||
return '请先创建并校验_validation-persist TXT持久记录,校验成功后才能提交流水线;当前仅 Let\\'s Encrypt 测试环境可以申请'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -209,7 +242,6 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
})
|
||||
domainsVerifyPlan!: DomainsVerifyPlanInput;
|
||||
|
||||
|
||||
@TaskInput({
|
||||
title: "证书颁发机构",
|
||||
value: "letsencrypt",
|
||||
@@ -237,6 +269,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
})
|
||||
googleCommonEabAccessId!: number;
|
||||
|
||||
@TaskInput({
|
||||
title: "Google公共ACME账号授权",
|
||||
isSys: true,
|
||||
show: false,
|
||||
})
|
||||
googleCommonAcmeAccountAccessId!: number;
|
||||
|
||||
@TaskInput({
|
||||
title: "ZeroSSL公共EAB授权",
|
||||
isSys: true,
|
||||
@@ -244,6 +283,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
})
|
||||
zerosslCommonEabAccessId!: number;
|
||||
|
||||
@TaskInput({
|
||||
title: "ZeroSSL公共ACME账号授权",
|
||||
isSys: true,
|
||||
show: false,
|
||||
})
|
||||
zerosslCommonAcmeAccountAccessId!: number;
|
||||
|
||||
@TaskInput({
|
||||
title: "SSL.com公共EAB授权",
|
||||
isSys: true,
|
||||
@@ -251,6 +297,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
})
|
||||
sslcomCommonEabAccessId!: number;
|
||||
|
||||
@TaskInput({
|
||||
title: "SSL.com公共ACME账号授权",
|
||||
isSys: true,
|
||||
show: false,
|
||||
})
|
||||
sslcomCommonAcmeAccountAccessId!: number;
|
||||
|
||||
@TaskInput({
|
||||
title: "litessl公共EAB授权",
|
||||
isSys: true,
|
||||
@@ -258,6 +311,13 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
})
|
||||
litesslCommonEabAccessId!: number;
|
||||
|
||||
@TaskInput({
|
||||
title: "litessl公共ACME账号授权",
|
||||
isSys: true,
|
||||
show: false,
|
||||
})
|
||||
litesslCommonAcmeAccountAccessId!: number;
|
||||
|
||||
@TaskInput({
|
||||
title: "EAB授权",
|
||||
component: {
|
||||
@@ -275,7 +335,16 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
mergeScript: `
|
||||
return {
|
||||
show: ctx.compute(({form})=>{
|
||||
console.log("show",form)
|
||||
if (form.version === 2) {
|
||||
return false
|
||||
}
|
||||
if(form.acmeAccountAccessId){
|
||||
return false
|
||||
}
|
||||
const commonAcmeKey = form.sslProvider + 'CommonAcmeAccountAccessId';
|
||||
if (form[commonAcmeKey]) {
|
||||
return false
|
||||
}
|
||||
return (form.sslProvider === 'zerossl' && !form.zerosslCommonEabAccessId)
|
||||
|| (form.sslProvider === 'google' && !form.googleCommonEabAccessId)
|
||||
|| (form.sslProvider === 'sslcom' && !form.sslcomCommonEabAccessId)
|
||||
@@ -286,6 +355,31 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
})
|
||||
eabAccessId!: number;
|
||||
|
||||
@TaskInput({
|
||||
title: "ACME账号授权",
|
||||
component: {
|
||||
name: "access-selector",
|
||||
type: "acmeAccount",
|
||||
},
|
||||
required: false,
|
||||
helper: "请选择颁发机构对应的ACME账号",
|
||||
mergeScript: `
|
||||
return {
|
||||
show: ctx.compute(({form})=>{
|
||||
const commonKey = form.sslProvider + 'CommonAcmeAccountAccessId';
|
||||
if (form[commonKey]) {
|
||||
return false
|
||||
}
|
||||
return !!form.sslProvider
|
||||
}),
|
||||
component:{
|
||||
subtype: ctx.compute(({form})=> form.sslProvider)
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
acmeAccountAccessId!: number;
|
||||
|
||||
@TaskInput({
|
||||
title: "服务账号授权",
|
||||
component: {
|
||||
@@ -298,6 +392,15 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
mergeScript: `
|
||||
return {
|
||||
show: ctx.compute(({form})=>{
|
||||
if (form.version === 2) {
|
||||
return false
|
||||
}
|
||||
if(form.acmeAccountAccessId){
|
||||
return false
|
||||
}
|
||||
if(form.googleCommonAcmeAccountAccessId){
|
||||
return false
|
||||
}
|
||||
return form.sslProvider === 'google' && !form.googleCommonEabAccessId
|
||||
})
|
||||
}
|
||||
@@ -432,7 +535,8 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
async onInit() {
|
||||
let eab: EabAccess = null;
|
||||
|
||||
if (this.sslProvider && !this.sslProvider.startsWith("letsencrypt")) {
|
||||
const isNewVersion = this.version === 2;
|
||||
if (!isNewVersion && this.sslProvider && !this.sslProvider.startsWith("letsencrypt")) {
|
||||
if (this.sslProvider === "google" && this.googleAccessId) {
|
||||
this.logger.info("当前正在使用 google服务账号授权获取EAB");
|
||||
const googleAccess = await this.getAccess(this.googleAccessId);
|
||||
@@ -499,8 +603,23 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
|
||||
let dnsProvider: IDnsProvider = null;
|
||||
let domainsVerifyPlan: DomainsVerifyPlan = null;
|
||||
if (this.challengeType === "cname" || this.challengeType === "http" || this.challengeType === "dnses") {
|
||||
domainsVerifyPlan = await this.createDomainsVerifyPlan(domains, this.domainsVerifyPlan);
|
||||
let acmeAccount: AcmeAccountInfo = null;
|
||||
if (this.acmeAccountAccessId) {
|
||||
const access: any = await this.getAccess(this.acmeAccountAccessId);
|
||||
acmeAccount = this.parseAcmeAccount(access.account);
|
||||
} else {
|
||||
acmeAccount = await this.getCommonAcmeAccount();
|
||||
}
|
||||
if (this.version === 2 && !this.sslProvider.startsWith("letsencrypt") && !acmeAccount) {
|
||||
throw new Error("请选择颁发机构对应的ACME账号");
|
||||
}
|
||||
if (this.challengeType === "dns-persist") {
|
||||
if (!acmeAccount) {
|
||||
throw new Error("DNS持久验证需要先选择ACME账号授权");
|
||||
}
|
||||
domainsVerifyPlan = await this.createDnsPersistDomainsVerifyPlan(domains, acmeAccount);
|
||||
} else if (this.challengeType === "cname" || this.challengeType === "http" || this.challengeType === "dnses") {
|
||||
domainsVerifyPlan = await this.createDomainsVerifyPlan(domains, this.domainsVerifyPlan, acmeAccount);
|
||||
} else if (this.challengeType === "auto") {
|
||||
domainsVerifyPlan = await this.createDomainsVerifyPlanByAuto(domains);
|
||||
} else {
|
||||
@@ -519,6 +638,7 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
privateKeyType: this.privateKeyType,
|
||||
profile: this.certProfile,
|
||||
preferredChain: this.preferredChain,
|
||||
acmeAccount,
|
||||
});
|
||||
|
||||
const certInfo = this.formatCerts(cert);
|
||||
@@ -552,7 +672,80 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
});
|
||||
}
|
||||
|
||||
async createDomainsVerifyPlan(domains: string[], verifyPlanSetting: DomainsVerifyPlanInput): Promise<DomainsVerifyPlan> {
|
||||
parseAcmeAccount(account: string | AcmeAccountInfo): AcmeAccountInfo {
|
||||
if (!account) {
|
||||
throw new Error("ACME账号授权缺少账号信息,请重新生成ACME账号");
|
||||
}
|
||||
const parsed = typeof account === "string" ? JSON.parse(account) : account;
|
||||
if (!parsed.accountKey || !parsed.accountUri) {
|
||||
throw new Error("ACME账号授权无效,请重新生成ACME账号");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async getCommonAcmeAccount(): Promise<AcmeAccountInfo | null> {
|
||||
if (!this.sslProvider || this.sslProvider === "letsencrypt" || this.sslProvider === "letsencrypt_staging") {
|
||||
return null;
|
||||
}
|
||||
const commonAccessId = this[`${this.sslProvider}CommonAcmeAccountAccessId`];
|
||||
if (!commonAccessId) {
|
||||
return null;
|
||||
}
|
||||
const accessService: any = this.ctx.accessService;
|
||||
if (!accessService?.getCommonById) {
|
||||
return null;
|
||||
}
|
||||
const access = await accessService.getCommonById(commonAccessId);
|
||||
if (!access?.account) {
|
||||
return null;
|
||||
}
|
||||
this.logger.info(`使用系统公共${this.sslProvider} ACME账号`);
|
||||
return this.parseAcmeAccount(access.account);
|
||||
}
|
||||
|
||||
private async createDnsPersistDomainsVerifyPlan(domains: string[], acmeAccount: AcmeAccountInfo): Promise<DomainsVerifyPlan> {
|
||||
const plan: DomainsVerifyPlan = {};
|
||||
const domainParser = this.acme.options.domainParser;
|
||||
for (const fullDomain of domains) {
|
||||
const domain = fullDomain.replaceAll("*.", "");
|
||||
const mainDomain = await domainParser.parse(domain);
|
||||
const persistRecord = this.domainsVerifyPlan?.[mainDomain]?.dnsPersistVerifyPlan?.[domain];
|
||||
plan[domain] = this.createDnsPersistDomainVerifyPlan(domain, mainDomain, acmeAccount, persistRecord);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
private createDnsPersistDomainVerifyPlan(domain: string, mainDomain: string, acmeAccount: AcmeAccountInfo, persistRecord?: DnsPersistRecordInput): DomainVerifyPlan {
|
||||
if (!persistRecord) {
|
||||
throw new Error(`DNS持久验证记录${domain}不存在,请先创建并校验`);
|
||||
}
|
||||
if (persistRecord.status !== "valid") {
|
||||
throw new Error(`DNS持久验证记录${domain}还未校验成功`);
|
||||
}
|
||||
return {
|
||||
type: "dns-persist",
|
||||
mainDomain,
|
||||
domain,
|
||||
dnsPersistVerifyPlan: {
|
||||
hostRecord: persistRecord.hostRecord || `_validation-persist.${domain}`,
|
||||
recordValue: persistRecord.recordValue || this.buildDnsPersistRecordValue(acmeAccount.accountUri, true),
|
||||
accountUri: persistRecord.accountUri || acmeAccount.accountUri,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
buildDnsPersistRecordValue(accountUri: string, wildcard = false, persistUntil?: number) {
|
||||
const parts = [`letsencrypt.org`, `accounturi=${accountUri}`];
|
||||
if (wildcard !== false) {
|
||||
parts.push("policy=wildcard");
|
||||
}
|
||||
if (persistUntil) {
|
||||
parts.push(`persistUntil=${persistUntil}`);
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
async createDomainsVerifyPlan(domains: string[], verifyPlanSetting: DomainsVerifyPlanInput, acmeAccount?: AcmeAccountInfo): Promise<DomainsVerifyPlan> {
|
||||
const plan: DomainsVerifyPlan = {};
|
||||
|
||||
const domainParser = this.acme.options.domainParser;
|
||||
@@ -569,6 +762,11 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
plan[domain] = await this.createCnameDomainVerifyPlan(domain, mainDomain);
|
||||
} else if (planSetting.type === "http") {
|
||||
plan[domain] = await this.createHttpDomainVerifyPlan(planSetting.httpVerifyPlan[domain], domain, mainDomain);
|
||||
} else if (planSetting.type === "dns-persist") {
|
||||
if (!acmeAccount) {
|
||||
throw new Error("DNS持久验证需要先选择ACME账号授权");
|
||||
}
|
||||
plan[domain] = this.createDnsPersistDomainVerifyPlan(domain, mainDomain, acmeAccount, planSetting.dnsPersistVerifyPlan?.[domain]);
|
||||
}
|
||||
}
|
||||
return plan;
|
||||
@@ -677,9 +875,9 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
|
||||
}
|
||||
|
||||
async onGetReverseProxyList() {
|
||||
const sysSettingsService:any = await this.ctx.serviceGetter.get("sysSettingsService");
|
||||
const sysSettingsService: any = await this.ctx.serviceGetter.get("sysSettingsService");
|
||||
const sysSettings = await sysSettingsService.getPrivateSettings();
|
||||
return sysSettings.reverseProxyList || []
|
||||
return sysSettings.reverseProxyList || [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ cert.jks:jks格式证书文件,java服务器使用
|
||||
return pem;
|
||||
}
|
||||
|
||||
formatCerts(cert: { crt: string; key: string; csr: string }) {
|
||||
formatCerts(cert: { crt: string; key: string; csr?: string }) {
|
||||
const newCert: CertInfo = {
|
||||
crt: this.formatCert(cert.crt),
|
||||
key: this.formatCert(cert.key),
|
||||
|
||||
@@ -2,8 +2,8 @@ import { AddonInput, BaseAddon, IsAddon } from "@certd/lib-server";
|
||||
import { BuildLoginUrlReq, BuildLogoutUrlReq, IOauthProvider, OnCallbackReq } from "../api.js";
|
||||
|
||||
const CLOGIN_TYPES = [
|
||||
{ label: "QQ", value: "qq", icon: "cib:tencent-qq:#007AFF" },
|
||||
{ label: "微信", value: "wx", icon: "simple-icons:wechat:#34C759" },
|
||||
{ label: "QQ", value: "qq", icon: "svg:icon-qq" },
|
||||
{ label: "微信", value: "wx", icon: "svg:icon-wechat" },
|
||||
{ label: "支付宝", value: "alipay", icon: "simple-icons:alipay:#0099ff" },
|
||||
{ label: "微博", value: "sina", icon: "uiw:weibo:#FF3B30" },
|
||||
{ label: "百度", value: "baidu", icon: "simple-icons:baidu:#007AFF" },
|
||||
|
||||
Reference in New Issue
Block a user