1
mirror of https://github.com/jakejarvis/hoot.git synced 2025-10-18 20:14:25 -04:00
Files
hoot/server/services/certificates.ts

213 lines
6.7 KiB
TypeScript

import tls from "node:tls";
import { eq } from "drizzle-orm";
import { getDomainTld } from "rdapper";
import { captureServer } from "@/lib/analytics/server";
import { toRegistrableDomain } from "@/lib/domain-server";
import { detectCertificateAuthority } from "@/lib/providers/detection";
import type { Certificate } from "@/lib/schemas";
import { db } from "@/server/db/client";
import { certificates as certTable } from "@/server/db/schema";
import { ttlForCertificates } from "@/server/db/ttl";
import { replaceCertificates } from "@/server/repos/certificates";
import { upsertDomain } from "@/server/repos/domains";
import { resolveProviderId } from "@/server/repos/providers";
export async function getCertificates(domain: string): Promise<Certificate[]> {
console.debug("[certificates] start", { domain });
// Fast path: DB
const registrable = toRegistrableDomain(domain);
const d = registrable
? await upsertDomain({
name: registrable,
tld: getDomainTld(registrable) ?? "",
unicodeName: domain,
})
: null;
const existing = d
? await db
.select({
issuer: certTable.issuer,
subject: certTable.subject,
altNames: certTable.altNames,
validFrom: certTable.validFrom,
validTo: certTable.validTo,
expiresAt: certTable.expiresAt,
})
.from(certTable)
.where(eq(certTable.domainId, d.id))
: ([] as Array<{
issuer: string;
subject: string;
altNames: unknown;
validFrom: Date;
validTo: Date;
expiresAt: Date | null;
}>);
if (existing.length > 0) {
const nowMs = Date.now();
const fresh = existing.every(
(c) => (c.expiresAt?.getTime?.() ?? 0) > nowMs,
);
if (fresh) {
const out: Certificate[] = existing.map((c) => ({
issuer: c.issuer,
subject: c.subject,
altNames: (c.altNames as unknown as string[]) ?? [],
validFrom: new Date(c.validFrom).toISOString(),
validTo: new Date(c.validTo).toISOString(),
caProvider: detectCertificateAuthority(c.issuer),
}));
return out;
}
}
// Client gating avoids calling this without A/AAAA; server does not pre-check DNS here.
const startedAt = Date.now();
let outcome: "ok" | "timeout" | "error" = "ok";
try {
const chain = await new Promise<tls.DetailedPeerCertificate[]>(
(resolve, reject) => {
const socket = tls.connect(
{
host: domain,
port: 443,
servername: domain,
rejectUnauthorized: false,
},
() => {
const peer = socket.getPeerCertificate(
true,
) as tls.DetailedPeerCertificate;
const chain: tls.DetailedPeerCertificate[] = [];
let current: tls.DetailedPeerCertificate | null = peer;
while (current) {
chain.push(current);
const next = (
current as unknown as { issuerCertificate?: unknown }
).issuerCertificate as tls.DetailedPeerCertificate | undefined;
current = next && next !== current ? next : null;
}
socket.end();
resolve(chain);
},
);
socket.setTimeout(6000, () => {
outcome = "timeout";
socket.destroy(new Error("TLS timeout"));
});
socket.on("error", (err) => {
outcome = "error";
reject(err);
});
},
);
const out: Certificate[] = chain.map((c) => {
const issuerName = toName(c.issuer);
return {
issuer: issuerName,
subject: toName(c.subject),
altNames: parseAltNames(
(c as Partial<{ subjectaltname: string }>).subjectaltname,
),
validFrom: new Date(c.valid_from).toISOString(),
validTo: new Date(c.valid_to).toISOString(),
caProvider: detectCertificateAuthority(issuerName),
};
});
await captureServer("tls_probe", {
domain: registrable ?? domain,
chain_length: out.length,
duration_ms: Date.now() - startedAt,
outcome,
});
const now = new Date();
const earliestValidTo =
out.length > 0
? new Date(Math.min(...out.map((c) => new Date(c.validTo).getTime())))
: new Date(Date.now() + 3600_000);
if (d) {
const chainWithIds = await Promise.all(
out.map(async (c) => {
const caProviderId = await resolveProviderId({
category: "ca",
domain: c.caProvider.domain,
name: c.caProvider.name,
});
return {
issuer: c.issuer,
subject: c.subject,
altNames: c.altNames as unknown as string[],
validFrom: new Date(c.validFrom),
validTo: new Date(c.validTo),
caProviderId,
};
}),
);
await replaceCertificates({
domainId: d.id,
chain: chainWithIds,
fetchedAt: now,
expiresAt: ttlForCertificates(now, earliestValidTo),
});
}
console.info("[certificates] ok", {
domain: registrable ?? domain,
chain_length: out.length,
duration_ms: Date.now() - startedAt,
});
return out;
} catch (err) {
console.warn("[certificates] error", {
domain: registrable ?? domain,
error: (err as Error)?.message,
});
await captureServer("tls_probe", {
domain: registrable ?? domain,
chain_length: 0,
duration_ms: Date.now() - startedAt,
outcome,
error: String(err),
});
// Do not treat as fatal; return empty and avoid long-lived negative cache
return [];
}
}
export function toName(subject: tls.PeerCertificate["subject"] | undefined) {
if (!subject) return "";
const maybeRecord = subject as unknown as Record<string, unknown>;
const cn =
typeof maybeRecord?.CN === "string"
? (maybeRecord.CN as string)
: undefined;
const o =
typeof maybeRecord?.O === "string" ? (maybeRecord.O as string) : undefined;
return cn ? cn : o ? o : JSON.stringify(subject);
}
export function parseAltNames(subjectAltName: string | undefined): string[] {
if (typeof subjectAltName !== "string" || subjectAltName.length === 0) {
return [];
}
return subjectAltName
.split(",")
.map((segment) => segment.trim())
.map((segment) => {
const idx = segment.indexOf(":");
if (idx === -1) return ["", segment] as const;
const kind = segment.slice(0, idx).trim().toUpperCase();
const value = segment.slice(idx + 1).trim();
return [kind, value] as const;
})
.filter(
([kind, value]) => !!value && (kind === "DNS" || kind === "IP ADDRESS"),
)
.map(([_, value]) => value);
}