perf: 域名注册过期时间获取再次优化

This commit is contained in:
xiaojunnuo
2026-05-05 21:56:08 +08:00
parent 9951ab678f
commit 91a1b97550
3 changed files with 284 additions and 63 deletions
@@ -1,24 +1,18 @@
import { dict } from "@fast-crud/fast-crud"; import { dict } from "@fast-crud/fast-crud";
import { $t } from "/@/locales"; import { $t } from "/@/locales";
export const Dicts = { function createChallengeTypeDict() {
sslProviderDict: dict({ return dict({
data: [
{ value: "letsencrypt", label: "Let's Encrypt" },
{ value: "zerossl", label: "ZeroSSL" },
],
}),
challengeTypeDict: dict({
data: [ data: [
{ value: "dns", label: $t("certd.verifyPlan.dnsChallenge"), color: "green" }, { value: "dns", label: $t("certd.verifyPlan.dnsChallenge"), color: "green" },
{ value: "cname", label: $t("certd.verifyPlan.cnameProxyChallenge"), color: "blue" }, { value: "cname", label: $t("certd.verifyPlan.cnameProxyChallenge"), color: "blue" },
{ value: "http", label: $t("certd.verifyPlan.httpChallenge"), color: "yellow" }, { value: "http", label: $t("certd.verifyPlan.httpChallenge"), color: "yellow" },
], ],
}), });
dnsProviderTypeDict: dict({ }
url: "pi/dnsProvider/dnsProviderTypeDict",
}), function createUploaderTypeDict() {
uploaderTypeDict: dict({ return dict({
data: [ data: [
{ label: "SFTP", value: "sftp" }, { label: "SFTP", value: "sftp" },
{ label: "SCP", value: "scp" }, { label: "SCP", value: "scp" },
@@ -29,11 +23,35 @@ export const Dicts = {
{ label: "S3/Minio", value: "s3" }, { label: "S3/Minio", value: "s3" },
{ label: $t("certd.verifyPlan.uploader.sshDeprecated"), value: "ssh", disabled: true }, { label: $t("certd.verifyPlan.uploader.sshDeprecated"), value: "ssh", disabled: true },
], ],
}), });
domainFromTypeDict: dict({ }
function createDomainFromTypeDict() {
return dict({
data: [ data: [
{ value: "manual", label: $t("certd.verifyPlan.domainFrom.manual") }, { value: "manual", label: $t("certd.verifyPlan.domainFrom.manual") },
{ value: "auto", label: $t("certd.verifyPlan.domainFrom.auto") }, { 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();
},
}; };
@@ -0,0 +1,83 @@
/// <reference types="mocha" />
/// <reference types="node" />
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;
}
});
});
@@ -1,7 +1,9 @@
import { http, logger } from "@certd/basic";
import { http, logger } from '@certd/basic';
import { parseDomainByPsl } from "@certd/plugin-lib"; 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 { export interface DomainInfo {
expirationDate?: number; expirationDate?: number;
@@ -9,11 +11,19 @@ export interface DomainInfo {
} }
export class TldClient { export class TldClient {
private rdapMap: Record<string, string> = {} 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<string, string> = {};
private isInitialized = false; private isInitialized = false;
constructor() { constructor() {}
}
async init() { async init() {
if (this.isInitialized) { if (this.isInitialized) {
@@ -22,12 +32,12 @@ export class TldClient {
const dnsJson = await http.request({ const dnsJson = await http.request({
url: "https://data.iana.org/rdap/dns.json", url: "https://data.iana.org/rdap/dns.json",
method: "GET", method: "GET",
}) });
for (const item of dnsJson.services) { for (const item of dnsJson.services) {
const suffixes = item[0] const suffixes = item[0];
const urls = item[1] const urls = item[1];
for (const suffix of suffixes) { for (const suffix of suffixes) {
this.rdapMap[suffix] = urls[0] this.rdapMap[suffix] = urls[0];
} }
} }
this.isInitialized = true; this.isInitialized = true;
@@ -35,54 +45,92 @@ export class TldClient {
async getDomainExpirationDate(domain: string): Promise<DomainInfo> { async getDomainExpirationDate(domain: string): Promise<DomainInfo> {
await this.init(); await this.init();
const parsed = parseDomainByPsl(domain) const parsed = parseDomainByPsl(domain);
const mainDomain = parsed.domain || '' const mainDomain = parsed.domain || "";
if (mainDomain !== domain) { if (mainDomain !== domain) {
const message= `${domain}】为子域名,无法获取过期时间` const message = `${domain}】为子域名,无法获取过期时间`;
logger.warn(message) logger.warn(message);
throw new Error(message) throw new Error(message);
} }
try { try {
return await this.getDomainExpirationByRdap(domain, parsed.tld || '') return await this.getDomainExpirationByRdap(domain, parsed.tld || "");
} catch (error) { } catch (error) {
logger.error(error.message) logger.error(this.getErrorMessage(error));
return await this.getDomainExpirationByWhoiser(domain) }
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<DomainInfo> { private async getDomainExpirationByRdap(domain: string, suffix: string): Promise<DomainInfo> {
const rdapUrl = this.rdapMap[suffix] const rdapUrl = this.rdapMap[suffix];
if (!rdapUrl) { if (!rdapUrl) {
throw new Error(`${domain}】未找到${suffix}的rdap地址`) throw new Error(`${domain}】未找到${suffix}的rdap地址`);
} }
const rdap = await http.request({ const rdap = await http.request({
url: `${rdapUrl}domain/${domain}`, url: `${rdapUrl}domain/${domain}`,
method: "GET", method: "GET",
}) });
let res: DomainInfo = {} let res: DomainInfo = {};
const events = rdap.events || [] const events = rdap.events || [];
for (const item of events) { for (const item of events) {
if (item.eventAction === 'expiration') { if (item.eventAction === "expiration") {
res.expirationDate = dayjs(item.eventDate).valueOf() res.expirationDate = dayjs(item.eventDate).valueOf();
} else if (item.eventAction === 'registration') { } else if (item.eventAction === "registration") {
res.registrationDate = dayjs(item.eventDate).valueOf() res.registrationDate = dayjs(item.eventDate).valueOf();
} }
} }
return res return res;
}
private async getDomainExpirationByRdapSs(domain: string): Promise<DomainInfo> {
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<DomainInfo> { private async getDomainExpirationByWhoiser(domain: string): Promise<DomainInfo> {
const whoiser = await import("whoiser") const whoiser = await import("whoiser");
const result = await whoiser.whoisDomain(domain, { const result = await whoiser.whoisDomain(domain, {
follow: 2, follow: 2,
timeout: 5000 timeout: 5000,
}) });
let res: DomainInfo = {} let res: DomainInfo = {};
/** /**
* { * {
"Domain Status": [ "Domain Status": [
@@ -105,24 +153,96 @@ export class TldClient {
DNSSEC: "unsigned", DNSSEC: "unsigned",
} }
*/ */
for (const server in result) { for (const server in result) {
const data = result[server] as any const data = result[server] as any;
if (data['Expiry Date']) { if (data["Expiry Date"]) {
res.expirationDate = dayjs(data['Expiry Date']).valueOf() res.expirationDate = dayjs(data["Expiry Date"]).valueOf();
} }
if (data['Created Date']) { if (data["Created Date"]) {
res.registrationDate = dayjs(data['Created Date']).valueOf() res.registrationDate = dayjs(data["Created Date"]).valueOf();
} }
if (res.expirationDate && res.registrationDate) { if (res.expirationDate && res.registrationDate) {
break break;
} }
} }
if (!res.expirationDate) { if (!res.expirationDate) {
throw new Error(`${domain}】whois查询未找到过期时间`) throw new Error(`${domain}】whois查询未找到过期时间`);
} }
return res return res;
} }
}
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<string, any>, 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);
}
}