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);
+ }
+}