mirror of
https://github.com/certd/certd.git
synced 2026-05-16 13:17:29 +08:00
perf: 域名注册过期时间获取再次优化
This commit is contained in:
@@ -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;
|
||||||
@@ -36,53 +46,91 @@ 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": [
|
||||||
@@ -107,22 +155,94 @@ export class TldClient {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user