feat: 支持dns-persist-01持久化验证方式申请证书,优化Acme账号的存储方式

This commit is contained in:
xiaojunnuo
2026-05-24 05:42:51 +08:00
parent 8edb6f8727
commit 67b05e2d75
51 changed files with 3352 additions and 110 deletions
@@ -44,14 +44,28 @@ describe("AutoFix", () => {
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 () => {
@@ -66,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,6 +4,8 @@ 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;
@@ -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,6 +58,14 @@ 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) {
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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);
}
}