fix: 支持google persist校验方式

This commit is contained in:
xiaojunnuo
2026-09-30 18:19:23 +08:00
parent 2d0b85b305
commit 97d2b50074
11 changed files with 99 additions and 28 deletions
+2 -1
View File
@@ -27,7 +27,8 @@
"https-proxy-agent": "^7.0.5",
"lodash-es": "^4.17.21",
"node-forge": "^1.3.1",
"punycode.js": "^2.3.1"
"punycode.js": "^2.3.1",
"psl": "^1.15.0"
},
"devDependencies": {
"@types/node": "^20.14.10",
@@ -0,0 +1,21 @@
// @ts-ignore
import psl from "psl";
export function getDnsPersistIssuer(directoryUrl?: string) {
if (!directoryUrl) {
return "letsencrypt.org";
}
let directory: URL;
try {
directory = new URL(directoryUrl);
} catch {
throw new Error(`ACME Directory URL无效,无法生成DNS持久验证Issuer:${directoryUrl}`);
}
const issuer = psl.get(directory.hostname);
if (!issuer) {
throw new Error(`无法从ACME Directory URL获取可注册主域名:${directory.hostname}`);
}
return issuer;
}
+1
View File
@@ -79,3 +79,4 @@ export * from './verify.js'
export * from './error.js'
export * from './util.js'
export * from './dns-persist.js'
@@ -1,7 +1,16 @@
import assert from "assert";
import { getDnsPersistIssuer } from "@certd/acme-client";
import { buildDnsPersistRecordValue, DnsPersistRecordService } from "./dns-persist-record-service.js";
describe("DnsPersistRecordService", () => {
it("gets issuer from the ACME directory URL", () => {
assert.equal(getDnsPersistIssuer("https://dv.acme-v02.api.pki.goog/directory"), "pki.goog");
assert.equal(getDnsPersistIssuer("https://acme-v02.api.letsencrypt.org/directory"), "letsencrypt.org");
assert.equal(getDnsPersistIssuer("https://acme.example.co.uk/directory"), "example.co.uk");
assert.equal(getDnsPersistIssuer(), "letsencrypt.org");
assert.throws(() => getDnsPersistIssuer("not a url"), /Directory URL无效/);
assert.throws(() => getDnsPersistIssuer("https://localhost/directory"), /可注册主域名/);
});
it("builds dns-persist-01 record value", () => {
const value = buildDnsPersistRecordValue({
accountUri: "https://example.com/acct/1",
@@ -12,6 +21,36 @@ describe("DnsPersistRecordService", () => {
assert.equal(value, "letsencrypt.org; accounturi=https://example.com/acct/1; policy=wildcard; persistUntil=1893456000");
});
it("uses the registrable domain from the ACME directory URL", async () => {
const service = new DnsPersistRecordService();
(service as any).accessService = {
async getAccessById() {
return {
account: JSON.stringify({
accountKey: "private-key",
accountUri: "https://pki.goog/acct/12345678",
caType: "custom",
directoryUrl: "https://dv.acme-v02.api.pki.goog/directory",
}),
};
},
};
const buildRequest = {
domain: "example.com",
caType: "custom",
acmeAccountAccessId: 12,
userId: 1,
};
const acmeAccount = await service.getAcmeAccount(buildRequest);
assert.equal(acmeAccount.account.issuer, "pki.goog");
const record = await service.buildRecordByAcmeAccount(buildRequest);
assert.equal(record.recordValue, "pki.goog; accounturi=https://pki.goog/acct/12345678; policy=wildcard");
});
it("builds validation host from wildcard domain", async () => {
const service = new DnsPersistRecordService();
@@ -2,14 +2,13 @@ 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 { createChallengeFn, getDnsPersistIssuer } 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) {
@@ -93,6 +92,9 @@ export class DnsPersistRecordService extends BaseService<DnsPersistRecordEntity>
if (!parsed.accountKey || !parsed.accountUri) {
throw new Error("ACME账号授权无效,请重新生成ACME账号");
}
if (!parsed.issuer) {
parsed.issuer = getDnsPersistIssuer(parsed.directoryUrl);
}
return parsed;
}
@@ -123,7 +125,7 @@ export class DnsPersistRecordService extends BaseService<DnsPersistRecordEntity>
};
}
async buildRecord(req: { domain: string; accountUri: string; wildcard?: boolean; persistUntil?: number; userId?: number; projectId?: number }) {
async buildRecord(req: { domain: string; accountUri: string; issuer?: 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 {
@@ -141,6 +143,7 @@ export class DnsPersistRecordService extends BaseService<DnsPersistRecordEntity>
const record = await this.buildRecord({
domain: req.domain,
accountUri: account.accountUri,
issuer: account.issuer,
wildcard: true,
persistUntil: req.persistUntil,
userId: req.userId,
@@ -23,6 +23,7 @@ describe("AcmeAccountAccess", () => {
assert.equal(account.accountKey, "private-key");
assert.equal(account.accountUri, "https://example.com/acct/1");
assert.equal(account.issuer, "example.com");
});
it("generates account payload through acme service", async () => {
@@ -9,6 +9,7 @@ export type AcmeAccountInfo = {
caType: string;
email: string;
directoryUrl: string;
issuer?: string;
eab?: {
kid?: string;
hmacKey?: string;
@@ -20,10 +21,11 @@ function parseAccount(account?: string | AcmeAccountInfo): AcmeAccountInfo | nul
if (!account) {
return null;
}
if (typeof account !== "string") {
return account;
const parsed = typeof account === "string" ? JSON.parse(account) : account;
if (!parsed.issuer) {
parsed.issuer = acme.getDnsPersistIssuer(parsed.directoryUrl);
}
return JSON.parse(account);
return parsed;
}
@IsAccess({
@@ -297,6 +299,7 @@ export class AcmeAccountAccess extends BaseAccess {
caType: this.caType,
email: this.email,
directoryUrl,
issuer: acme.getDnsPersistIssuer(directoryUrl),
};
if (externalAccountBinding) {
account.eab = {
@@ -38,6 +38,7 @@ export type AcmeAccountInfo = {
caType: SSLProvider | string;
email: string;
directoryUrl: string;
issuer?: string;
};
export type DnsPersistVerifyPlan = {
@@ -4,6 +4,14 @@ import { NonRetryableException } from "@certd/lib-server";
import { CertApplyPlugin } from "./apply.js";
describe("CertApplyPlugin dns-persist verify plan", () => {
it("builds dns-persist fallback values from the ACME account issuer", () => {
const plugin: any = new CertApplyPlugin();
const recordValue = plugin.buildDnsPersistRecordValue("https://pki.goog/acct/12345678", true, undefined, "pki.goog");
assert.equal(recordValue, "pki.goog; accounturi=https://pki.goog/acct/12345678; policy=wildcard");
});
it("keeps dns-persist entries when building mixed domain verify plans", async () => {
const plugin: any = new CertApplyPlugin();
plugin.acme = {
@@ -1,4 +1,5 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { getDnsPersistIssuer } from "@certd/acme-client";
import { utils } from "@certd/basic";
import { CustomAcmeProvider, NonRetryableException } from "@certd/lib-server";
@@ -664,10 +665,10 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
async doCertApply() {
await this.getAcmeClient();
// 自定义ACME不支持DNS持久验证(_validation-persist 为 Let's Encrypt 特有机制)
if (this.acmeProvider && this.acmeProvider.builtIn !== true && this.challengeType === "dns-persist") {
throw new Error("自定义ACME不支持DNS持久验证,请改用其他域名验证方式");
}
// 自定义ACME暂不支持DNS持久验证
// if (this.acmeProvider && this.acmeProvider.builtIn !== true && this.challengeType === "dns-persist") {
// throw new Error("自定义ACME不支持DNS持久验证,请改用其他域名验证方式");
// }
let email = this.email;
if (this.eab && this.eab.email) {
email = this.eab.email;
@@ -786,6 +787,9 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
if (!parsed.accountKey || !parsed.accountUri) {
throw new Error("ACME账号无效,请重新生成ACME账号");
}
if (!parsed.issuer) {
parsed.issuer = getDnsPersistIssuer(parsed.directoryUrl);
}
return parsed;
}
@@ -834,14 +838,14 @@ export class CertApplyPlugin extends CertApplyBasePlugin {
domain,
dnsPersistVerifyPlan: {
hostRecord: persistRecord.hostRecord || `_validation-persist.${domain}`,
recordValue: persistRecord.recordValue || this.buildDnsPersistRecordValue(acmeAccount.accountUri, true),
recordValue: persistRecord.recordValue || this.buildDnsPersistRecordValue(acmeAccount.accountUri, true, undefined, acmeAccount.issuer),
accountUri: persistRecord.accountUri || acmeAccount.accountUri,
},
};
}
buildDnsPersistRecordValue(accountUri: string, wildcard = false, persistUntil?: number) {
const parts = [`letsencrypt.org`, `accounturi=${accountUri}`];
buildDnsPersistRecordValue(accountUri: string, wildcard = false, persistUntil?: number, issuer = "letsencrypt.org") {
const parts = [issuer, `accounturi=${accountUri}`];
if (wildcard !== false) {
parts.push("policy=wildcard");
}
@@ -139,7 +139,7 @@ describe("WangsuAccess 授权方式", () => {
it("未选择 ApiKey 方式时,ApiKey 专属接口给出明确指引", () => {
const { access } = createAccess({ authType: "aksk" });
assert.throws(() => access.encryptPrivateKey("MOCK-KEY"), /当前授权未选择「ApiKey(CDN Pro)」鉴权方式/);
assert.throws(() => access.encryptPrivateKey("MOCK-KEY"), /当前授权未选择「ApiKey」鉴权方式/);
});
it("requireApiKeyAuth 在 apikey 方式下放行,在 aksk 方式下报错", () => {
@@ -147,7 +147,7 @@ describe("WangsuAccess 授权方式", () => {
assert.doesNotThrow(() => access.requireApiKeyAuth());
const akskAccess = createAccess({ authType: "aksk" }).access;
assert.throws(() => akskAccess.requireApiKeyAuth(), /当前授权未选择「ApiKey(CDN Pro)」鉴权方式/);
assert.throws(() => akskAccess.requireApiKeyAuth(), /当前授权未选择「ApiKey」鉴权方式/);
});
it("doRequest 传入 dateStr 时用该 Date 鉴权,保证与私钥加密用的是同一个 Date", async () => {
@@ -196,17 +196,6 @@ describe("WangsuAccess 授权方式", () => {
assert.deepEqual(list, certificates);
});
it("getCertDnsNames 兼容 dnsNames / dns_names / domains 三种域名写法", () => {
assert.deepEqual(WangsuAccess.getCertDnsNames({ dnsNames: ["a.example.com"] }), ["a.example.com"]);
assert.deepEqual(WangsuAccess.getCertDnsNames({ dns_names: ["b.example.com"] }), ["b.example.com"]);
assert.deepEqual(WangsuAccess.getCertDnsNames({ domains: ["c.example.com"] }), ["c.example.com"]);
// 单个字符串也统一成数组
assert.deepEqual(WangsuAccess.getCertDnsNames({ dnsNames: "d.example.com" }), ["d.example.com"]);
// 没有域名信息时返回空数组,交给调用方提示,不要抛异常
assert.deepEqual(WangsuAccess.getCertDnsNames({ id: "cert-1" }), []);
assert.deepEqual(WangsuAccess.getCertDnsNames(null), []);
});
it("apikey 方式下 PATCH 证书把请求体透传", async () => {
const { access, http } = createAccess({ authType: "apikey" });
http.push(cdnProResponse({ status: 201, headers: { location: "https://x/cdn/certificates/cert-1/versions/2" }, data: "" }));
@@ -293,6 +282,6 @@ describe("WangsuAccess 授权方式", () => {
it("encryptPrivateKey 在未选择 ApiKey 方式时直接报错", () => {
const { access } = createAccess({ authType: "aksk" });
assert.throws(() => access.encryptPrivateKey("MOCK-KEY"), /当前授权未选择「ApiKey(CDN Pro)」鉴权方式/);
assert.throws(() => access.encryptPrivateKey("MOCK-KEY"), /当前授权未选择「ApiKey」鉴权方式/);
});
});