From 91a1b9755066bf280e194dabf7c3a9f936e2643f Mon Sep 17 00:00:00 2001 From: xiaojunnuo Date: Tue, 5 May 2026 21:56:08 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E5=9F=9F=E5=90=8D=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E6=97=B6=E9=97=B4=E8=8E=B7=E5=8F=96=E5=86=8D?= =?UTF-8?q?=E6=AC=A1=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/plugins/lib/dicts.ts | 48 ++-- .../modules/cert/service/tld-client.test.ts | 83 +++++++ .../src/modules/cert/service/tld-client.ts | 216 ++++++++++++++---- 3 files changed, 284 insertions(+), 63 deletions(-) create mode 100644 packages/ui/certd-server/src/modules/cert/service/tld-client.test.ts diff --git a/packages/ui/certd-client/src/components/plugins/lib/dicts.ts b/packages/ui/certd-client/src/components/plugins/lib/dicts.ts index 91f553a2e..b46664f60 100644 --- a/packages/ui/certd-client/src/components/plugins/lib/dicts.ts +++ b/packages/ui/certd-client/src/components/plugins/lib/dicts.ts @@ -1,24 +1,18 @@ import { dict } from "@fast-crud/fast-crud"; import { $t } from "/@/locales"; -export const Dicts = { - sslProviderDict: dict({ - data: [ - { value: "letsencrypt", label: "Let's Encrypt" }, - { value: "zerossl", label: "ZeroSSL" }, - ], - }), - challengeTypeDict: dict({ +function createChallengeTypeDict() { + return dict({ data: [ { value: "dns", label: $t("certd.verifyPlan.dnsChallenge"), color: "green" }, { value: "cname", label: $t("certd.verifyPlan.cnameProxyChallenge"), color: "blue" }, { value: "http", label: $t("certd.verifyPlan.httpChallenge"), color: "yellow" }, ], - }), - dnsProviderTypeDict: dict({ - url: "pi/dnsProvider/dnsProviderTypeDict", - }), - uploaderTypeDict: dict({ + }); +} + +function createUploaderTypeDict() { + return dict({ data: [ { label: "SFTP", value: "sftp" }, { label: "SCP", value: "scp" }, @@ -29,11 +23,35 @@ export const Dicts = { { label: "S3/Minio", value: "s3" }, { label: $t("certd.verifyPlan.uploader.sshDeprecated"), value: "ssh", disabled: true }, ], - }), - domainFromTypeDict: dict({ + }); +} + +function createDomainFromTypeDict() { + return dict({ data: [ { value: "manual", label: $t("certd.verifyPlan.domainFrom.manual") }, { value: "auto", label: $t("certd.verifyPlan.domainFrom.auto") }, ], + }); +} + +export const Dicts = { + sslProviderDict: dict({ + data: [ + { value: "letsencrypt", label: "Let's Encrypt" }, + { value: "zerossl", label: "ZeroSSL" }, + ], }), + get challengeTypeDict() { + return createChallengeTypeDict(); + }, + dnsProviderTypeDict: dict({ + url: "pi/dnsProvider/dnsProviderTypeDict", + }), + get uploaderTypeDict() { + return createUploaderTypeDict(); + }, + get domainFromTypeDict() { + return createDomainFromTypeDict(); + }, }; diff --git a/packages/ui/certd-server/src/modules/cert/service/tld-client.test.ts b/packages/ui/certd-server/src/modules/cert/service/tld-client.test.ts new file mode 100644 index 000000000..be60bcf30 --- /dev/null +++ b/packages/ui/certd-server/src/modules/cert/service/tld-client.test.ts @@ -0,0 +1,83 @@ +/// +/// + +import assert from "node:assert/strict"; + +import { http } from "@certd/basic"; + +import { TldClient } from "./tld-client.js"; + +describe("TldClient", () => { + it("falls back to rdap.ss after RDAP and whoiser fail", async () => { + const client = new TldClient() as any; + const calls: string[] = []; + + client.init = async () => {}; + client.getDomainExpirationByRdap = async () => { + calls.push("rdap"); + throw new Error("rdap failed"); + }; + client.getDomainExpirationByWhoiser = async () => { + calls.push("whoiser"); + throw new Error("whoiser failed"); + }; + client.getDomainExpirationByRdapSs = async () => { + calls.push("rdap.ss"); + return { expirationDate: 1795104000000 }; + }; + + const result = await client.getDomainExpirationDate("google.com.hk"); + + assert.deepEqual(calls, ["rdap", "whoiser", "rdap.ss"]); + assert.equal(result.expirationDate, 1795104000000); + }); + + it("queries rdap.ss and parses HK whois date fields", async () => { + const originalRequest = http.request; + let requestedConfig: any; + const originalRequestTimes = (TldClient as any).rdapSsRequestTimes; + + try { + (TldClient as any).rdapSsRequestTimes = []; + http.request = async (config: any) => { + requestedConfig = config; + return { + success: true, + data: { + whoisData: { + "Domain Name Commencement Date": "14-07-2001", + "Expiry Date": "20-11-2026", + }, + }, + }; + }; + + const result = await (new TldClient() as any).getDomainExpirationByRdapSs("google.com.hk"); + + assert.equal(requestedConfig.url, "https://rdap.ss/api/query?q=google.com.hk"); + assert.equal(requestedConfig.method, "GET"); + assert.equal(result.registrationDate, 995040000000); + assert.equal(result.expirationDate, 1795104000000); + } finally { + http.request = originalRequest; + (TldClient as any).rdapSsRequestTimes = originalRequestTimes; + } + }); + + it("throws when rdap.ss rate-limit wait is over 3 minutes", async () => { + const now = Date.now(); + const originalRequestTimes = (TldClient as any).rdapSsRequestTimes; + + try { + (TldClient as any).rdapSsRequestTimes = Array.from({ length: 1200 }, () => now); + const client = new TldClient() as any; + client.sleep = async () => { + throw new Error("should not sleep when wait time is over limit"); + }; + + await assert.rejects(() => client.waitRdapSsRateLimit(), /rdap\.ss查询达到速率限制,等待时间超过3分钟/); + } finally { + (TldClient as any).rdapSsRequestTimes = originalRequestTimes; + } + }); +}); diff --git a/packages/ui/certd-server/src/modules/cert/service/tld-client.ts b/packages/ui/certd-server/src/modules/cert/service/tld-client.ts index 3e73c728b..6681ad533 100644 --- a/packages/ui/certd-server/src/modules/cert/service/tld-client.ts +++ b/packages/ui/certd-server/src/modules/cert/service/tld-client.ts @@ -1,7 +1,9 @@ - -import { http, logger } from '@certd/basic'; +import { http, logger } from "@certd/basic"; import { parseDomainByPsl } from "@certd/plugin-lib"; -import dayjs from 'dayjs'; +import dayjs from "dayjs"; +import customParseFormat from "dayjs/plugin/customParseFormat.js"; + +dayjs.extend(customParseFormat); export interface DomainInfo { expirationDate?: number; @@ -9,11 +11,19 @@ export interface DomainInfo { } export class TldClient { - private rdapMap: Record = {} + private static readonly RDAP_SS_API = "https://rdap.ss/api/query"; + private static readonly RDAP_SS_RATE_LIMITS = [ + { windowMs: 60 * 1000, max: 30 }, + { windowMs: 60 * 60 * 1000, max: 1200 }, + { windowMs: 24 * 60 * 60 * 1000, max: 12000 }, + ]; + private static readonly RDAP_SS_MAX_WAIT_MS = 3 * 60 * 1000; + private static rdapSsRequestTimes: number[] = []; + + private rdapMap: Record = {}; private isInitialized = false; - constructor() { - } + constructor() {} async init() { if (this.isInitialized) { @@ -22,12 +32,12 @@ export class TldClient { const dnsJson = await http.request({ url: "https://data.iana.org/rdap/dns.json", method: "GET", - }) + }); for (const item of dnsJson.services) { - const suffixes = item[0] - const urls = item[1] + const suffixes = item[0]; + const urls = item[1]; for (const suffix of suffixes) { - this.rdapMap[suffix] = urls[0] + this.rdapMap[suffix] = urls[0]; } } this.isInitialized = true; @@ -35,54 +45,92 @@ export class TldClient { async getDomainExpirationDate(domain: string): Promise { await this.init(); - - const parsed = parseDomainByPsl(domain) - const mainDomain = parsed.domain || '' + + const parsed = parseDomainByPsl(domain); + const mainDomain = parsed.domain || ""; if (mainDomain !== domain) { - const message= `【${domain}】为子域名,无法获取过期时间` - logger.warn(message) - throw new Error(message) + const message = `【${domain}】为子域名,无法获取过期时间`; + logger.warn(message); + throw new Error(message); } - + try { - return await this.getDomainExpirationByRdap(domain, parsed.tld || '') + return await this.getDomainExpirationByRdap(domain, parsed.tld || ""); } catch (error) { - logger.error(error.message) - return await this.getDomainExpirationByWhoiser(domain) + logger.error(this.getErrorMessage(error)); + } + + try { + return await this.getDomainExpirationByWhoiser(domain); + } catch (error) { + logger.error(this.getErrorMessage(error)); + return await this.getDomainExpirationByRdapSs(domain); } } private async getDomainExpirationByRdap(domain: string, suffix: string): Promise { - const rdapUrl = this.rdapMap[suffix] + const rdapUrl = this.rdapMap[suffix]; if (!rdapUrl) { - throw new Error(`【${domain}】未找到${suffix}的rdap地址`) + throw new Error(`【${domain}】未找到${suffix}的rdap地址`); } - + const rdap = await http.request({ url: `${rdapUrl}domain/${domain}`, method: "GET", - }) + }); - let res: DomainInfo = {} - const events = rdap.events || [] + let res: DomainInfo = {}; + const events = rdap.events || []; for (const item of events) { - if (item.eventAction === 'expiration') { - res.expirationDate = dayjs(item.eventDate).valueOf() - } else if (item.eventAction === 'registration') { - res.registrationDate = dayjs(item.eventDate).valueOf() + if (item.eventAction === "expiration") { + res.expirationDate = dayjs(item.eventDate).valueOf(); + } else if (item.eventAction === "registration") { + res.registrationDate = dayjs(item.eventDate).valueOf(); } } - return res + return res; + } + + private async getDomainExpirationByRdapSs(domain: string): Promise { + await this.waitRdapSsRateLimit(); + + const result = await http.request({ + url: `${TldClient.RDAP_SS_API}?q=${encodeURIComponent(domain)}`, + method: "GET", + logRes: false, + }); + + if (!result?.success || !result?.data) { + throw new Error(`【${domain}】rdap.ss查询失败`); + } + + const data = result.data?.whoisData || result.data?.rawData || {}; + const res: DomainInfo = {}; + const expirationDate = this.parseFirstDate(data, ["Expiry Date", "Expiration Date", "Registry Expiry Date", "expires"]); + const registrationDate = this.parseFirstDate(data, ["Domain Name Commencement Date", "Created Date", "Creation Date", "Registration Date", "Registered On"]); + + if (expirationDate) { + res.expirationDate = expirationDate; + } + if (registrationDate) { + res.registrationDate = registrationDate; + } + + if (!res.expirationDate) { + throw new Error(`【${domain}】rdap.ss查询未找到过期时间`); + } + + return res; } private async getDomainExpirationByWhoiser(domain: string): Promise { - const whoiser = await import("whoiser") + const whoiser = await import("whoiser"); const result = await whoiser.whoisDomain(domain, { follow: 2, - timeout: 5000 - }) - - let res: DomainInfo = {} + timeout: 5000, + }); + + let res: DomainInfo = {}; /** * { "Domain Status": [ @@ -105,24 +153,96 @@ export class TldClient { DNSSEC: "unsigned", } */ - + for (const server in result) { - const data = result[server] as any - if (data['Expiry Date']) { - res.expirationDate = dayjs(data['Expiry Date']).valueOf() + const data = result[server] as any; + if (data["Expiry Date"]) { + res.expirationDate = dayjs(data["Expiry Date"]).valueOf(); } - if (data['Created Date']) { - res.registrationDate = dayjs(data['Created Date']).valueOf() + if (data["Created Date"]) { + res.registrationDate = dayjs(data["Created Date"]).valueOf(); } if (res.expirationDate && res.registrationDate) { - break + break; } } - + if (!res.expirationDate) { - throw new Error(`【${domain}】whois查询未找到过期时间`) + throw new Error(`【${domain}】whois查询未找到过期时间`); } - - return res + + return res; } -} \ No newline at end of file + + private async waitRdapSsRateLimit() { + while (true) { + const now = Date.now(); + const maxWindowMs = Math.max(...TldClient.RDAP_SS_RATE_LIMITS.map(item => item.windowMs)); + TldClient.rdapSsRequestTimes = TldClient.rdapSsRequestTimes.filter(time => now - time < maxWindowMs); + + const waitMs = TldClient.RDAP_SS_RATE_LIMITS.reduce((maxWaitMs, limit) => { + const times = TldClient.rdapSsRequestTimes.filter(time => now - time < limit.windowMs); + if (times.length < limit.max) { + return maxWaitMs; + } + const earliestTime = Math.min(...times); + return Math.max(maxWaitMs, earliestTime + limit.windowMs - now); + }, 0); + + if (waitMs <= 0) { + TldClient.rdapSsRequestTimes.push(now); + return; + } + + if (waitMs > TldClient.RDAP_SS_MAX_WAIT_MS) { + throw new Error(`rdap.ss查询达到速率限制,等待时间超过3分钟`); + } + + logger.warn(`rdap.ss查询达到速率限制,将在${waitMs}ms后重试`); + await this.sleep(waitMs); + } + } + + private parseFirstDate(data: Record, keys: string[]) { + for (const key of keys) { + const value = data[key]; + const values = Array.isArray(value) ? value : [value]; + for (const item of values) { + const timestamp = this.parseDateValue(item); + if (timestamp) { + return timestamp; + } + } + } + } + + private parseDateValue(value: any) { + if (!value || typeof value !== "string") { + return; + } + + const formats = ["DD-MM-YYYY", "YYYY-MM-DD", "YYYY-MM-DD HH:mm:ss", "YYYY/MM/DD", "YYYY/MM/DD HH:mm:ss", "MMM D YYYY", "MMM D, YYYY"]; + for (const format of formats) { + const parsed = dayjs(value, format, true); + if (parsed.isValid()) { + return parsed.valueOf(); + } + } + + const parsed = dayjs(value); + if (parsed.isValid()) { + return parsed.valueOf(); + } + } + + private sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + private getErrorMessage(error: unknown) { + if (error instanceof Error) { + return error.message; + } + return String(error); + } +}