Skip to content
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,4 @@ Hoot is an all-in-one app for exploring domain names, providing instant insights
- Keep secrets in `.env.local`
- Review `server/trpc.ts` when extending routers
- Validate all external inputs with Zod
- Use `server-only` imports for sensitive modules
- Use `server-only` imports for sensitive modules
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,18 @@

## 🛠️ Tech Stack

- **Next.js 15** with Turbopack
- **Next.js 15** (App Router)
- **React 19**
- **TypeScript**
- **Tailwind CSS v4**
- **tRPC** API endpoints
- **Upstash Redis** for caching
- **Vercel Blob** for favicon & screenshot storage
- **Puppeteer Core + @sparticuz/chromium** for server-side screenshots (fallback to `puppeteer` locally)
- **rdapper** for RDAP registration lookups with WHOIS fallback
- **Puppeteer** for server-side screenshots
- **Mapbox** for embedded IP geolocation maps
- **PostHog** for product analytics
- **Biome** linting and formatting

---

Expand Down Expand Up @@ -56,6 +60,8 @@

---

## 🙋‍♂️ About
## 📜 License

Made with ❤️ by [@jakejarvis](https://github.com/jakejarvis). [Licensed under MIT.](LICENSE) Owl logo by Jordy Matsuoka from Noun Project (CC BY 3.0).
[MIT](LICENSE)

Owl logo by [Jordy Madueño](https://thenounproject.com/creator/jordymadueno/) from [Noun Project](https://thenounproject.com/) (CC BY 3.0).
1 change: 0 additions & 1 deletion components/domain/domain-report-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ vi.mock("@/hooks/use-domain-queries", () => ({
dnsProvider: { name: "Cloudflare", domain: "cloudflare.com" },
hostingProvider: { name: "Vercel", domain: "vercel.com" },
emailProvider: { name: "Google Workspace", domain: "google.com" },
ipAddress: null,
geo: {
city: "",
region: "",
Expand Down
1 change: 0 additions & 1 deletion components/domain/sections/hosting-email-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ describe("HostingEmailSection", () => {
dnsProvider: { name: "Cloudflare", domain: "cloudflare.com" },
hostingProvider: { name: "Vercel", domain: "vercel.com" },
emailProvider: { name: "Google Workspace", domain: "google.com" },
ipAddress: "1.2.3.4",
geo: {
city: "",
region: "",
Expand Down
File renamed without changes.
29 changes: 16 additions & 13 deletions server/services/cloudflare.ts → lib/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export interface CloudflareIpRanges {
ipv6Cidrs: string[];
}

let lastLoadedIpv6Parsed: Array<[ipaddr.IPv6, number]> | undefined;
let lastLoadedIpv4Parsed: Array<[ipaddr.IPv4, number]> | undefined;
let lastLoadedIpv6Parsed: Array<[ipaddr.IPv6, number]> | undefined;

export const getCloudflareIpRanges = unstable_cache(
async (): Promise<CloudflareIpRanges> => {
Expand All @@ -16,41 +16,44 @@ export const getCloudflareIpRanges = unstable_cache(
throw new Error(`Failed to fetch Cloudflare IPs: ${res.status}`);
}
const data = await res.json();

const ranges: CloudflareIpRanges = {
ipv4Cidrs: data.result?.ipv4_cidrs || [],
ipv6Cidrs: data.result?.ipv6_cidrs || [],
};
// Pre-parse IPv6 CIDRs for fast sync/async checks

// Pre-parse IPv4 CIDRs for fast sync/async checks
try {
lastLoadedIpv6Parsed = ranges.ipv6Cidrs
lastLoadedIpv4Parsed = ranges.ipv4Cidrs
.map((cidr) => {
try {
const [net, prefix] = ipaddr.parseCIDR(cidr);
if (net.kind() !== "ipv6") return undefined;
return [net as ipaddr.IPv6, prefix] as [ipaddr.IPv6, number];
if (net.kind() !== "ipv4") return undefined;
return [net as ipaddr.IPv4, prefix] as [ipaddr.IPv4, number];
} catch {
return undefined;
}
})
.filter(Boolean) as Array<[ipaddr.IPv6, number]>;
.filter(Boolean) as Array<[ipaddr.IPv4, number]>;
} catch {
lastLoadedIpv6Parsed = undefined;
lastLoadedIpv4Parsed = undefined;
}
// Pre-parse IPv4 CIDRs for fast sync/async checks

// Pre-parse IPv6 CIDRs for fast sync/async checks
try {
lastLoadedIpv4Parsed = ranges.ipv4Cidrs
lastLoadedIpv6Parsed = ranges.ipv6Cidrs
.map((cidr) => {
try {
const [net, prefix] = ipaddr.parseCIDR(cidr);
if (net.kind() !== "ipv4") return undefined;
return [net as ipaddr.IPv4, prefix] as [ipaddr.IPv4, number];
if (net.kind() !== "ipv6") return undefined;
return [net as ipaddr.IPv6, prefix] as [ipaddr.IPv6, number];
} catch {
return undefined;
}
})
.filter(Boolean) as Array<[ipaddr.IPv4, number]>;
.filter(Boolean) as Array<[ipaddr.IPv6, number]>;
} catch {
lastLoadedIpv4Parsed = undefined;
lastLoadedIpv6Parsed = undefined;
}
return ranges;
},
Expand Down
169 changes: 86 additions & 83 deletions lib/providers/README.md
Original file line number Diff line number Diff line change
@@ -1,78 +1,84 @@
# Provider Detection System

This system provides extensible and robust provider detection using a clean rule-based approach.
This system provides extensible and robust provider detection using a declarative JSON-serializable logic AST with AND/OR/NOT composition over domain primitives.

## Architecture

The detection system is built around simple, focused types:
### Logic AST

### DetectionRule
```ts
type HeaderEquals = { kind: "headerEquals"; name: string; value: string };
type HeaderIncludes = { kind: "headerIncludes"; name: string; substr: string };
type HeaderPresent = { kind: "headerPresent"; name: string };
type MxSuffix = { kind: "mxSuffix"; suffix: string };
type NsSuffix = { kind: "nsSuffix"; suffix: string };
type IssuerEquals = { kind: "issuerEquals"; value: string };
type IssuerIncludes = { kind: "issuerIncludes"; substr: string };
type RegistrarEquals = { kind: "registrarEquals"; value: string };
type RegistrarIncludes = { kind: "registrarIncludes"; substr: string };

type Logic =
| { all: Logic[] }
| { any: Logic[] }
| { not: Logic }
| HeaderEquals
| HeaderIncludes
| HeaderPresent
| MxSuffix
| NsSuffix
| IssuerEquals
| IssuerIncludes
| RegistrarEquals
| RegistrarIncludes;
```

Each rule is specific and focused:
DetectionContext passed to the evaluator:

```typescript
type DetectionRule =
// HTTP header matching
| { type: "header"; name: string; value?: string; present?: boolean }
// DNS record matching
| { type: "dns"; recordType: "MX" | "NS"; value: string };
```ts
interface DetectionContext {
headers: Record<string, string>; // normalized lowercased names
mx: string[]; // lowercased FQDNs (no trailing dot)
ns: string[]; // lowercased FQDNs (no trailing dot)
issuer?: string; // lowercased certificate issuer string
registrar?: string; // lowercased registrar name from WHOIS/RDAP
}
```

### Provider
### Evaluator

Each provider has metadata and an array of detection rules:
See `evalRule` in `lib/providers/detection.ts`.

```typescript
interface Provider {
name: string; // "Vercel"
domain: string; // "vercel.com"
rules: DetectionRule[]; // Array of rules that identify this provider
}
### Provider Catalog

Providers are defined with a single `rule` per provider and a `category`:

```ts
type Provider = {
name: string;
domain: string;
category: "hosting" | "email" | "dns" | "ca" | "registrar";
rule: Logic;
};
```

## Usage

### Basic Detection

```typescript
import {
detectHostingProvider,
detectEmailProvider,
```ts
import {
detectHostingProvider,
detectEmailProvider,
detectDnsProvider,
detectCertificateAuthority,
resolveRegistrarDomain
detectRegistrar,
} from '@/lib/providers/detection';

// Hosting detection
const headers = [
{ name: 'server', value: 'vercel' },
{ name: 'x-vercel-id', value: 'abc123' }
];
const hosting = detectHostingProvider(headers);
console.log(hosting); // { name: "Vercel", domain: "vercel.com" }

// Email detection
const mxRecords = ['mx1.google.com', 'mx2.google.com'];
const email = detectEmailProvider(mxRecords);
console.log(email); // { name: "Google Workspace", domain: "google.com" }

// DNS detection
const nsRecords = ['ns1.cloudflare.com', 'ns2.cloudflare.com'];
const dns = detectDnsProvider(nsRecords);
console.log(dns); // { name: "Cloudflare", domain: "cloudflare.com" }

// Certificate Authority detection (alias matching against issuer string)
const hosting = detectHostingProvider([{ name: 'server', value: 'Vercel' }]);
const email = detectEmailProvider(['aspmx.l.google.com.']);
const dns = detectDnsProvider(['ns1.cloudflare.com']);
const ca = detectCertificateAuthority("Let's Encrypt R3");
console.log(ca); // { name: "Let's Encrypt", domain: "letsencrypt.org" }

// Registrar domain resolution (partial match of registrar names)
const registrarName = 'GoDaddy Inc.';
const registrarDomain = resolveRegistrarDomain(registrarName);
console.log(registrarDomain); // "godaddy.com"
const registrar = detectRegistrar('GoDaddy Inc.');
```

### Adding New Providers

Add to the appropriate section in `catalog.ts`:

```typescript
Expand All @@ -81,56 +87,53 @@ export const HOSTING_PROVIDERS: Provider[] = [
{
name: "Railway",
domain: "railway.app",
rules: [
{ type: "header", name: "x-railway-id", present: true },
{ type: "header", name: "server", value: "railway" }
],
category: "hosting",
rule: {
any: [
{ kind: "headerPresent", name: "x-railway-id" },
{ kind: "headerEquals", name: "server", value: "railway" },
],
},
},
];
```

## Rule Types

### Header Rules
### Header primitives

```typescript
// Check if header exists
{ type: "header", name: "cf-ray", present: true }
```ts
{ kind: "headerPresent", name: "cf-ray" }
{ kind: "headerEquals", name: "server", value: "vercel" }
{ kind: "headerIncludes", name: "server", substr: "cloud" }
```

// Check header value contains substring
{ type: "header", name: "server", value: "vercel" }
### DNS primitives

// Check header exists (shorthand - same as present: true)
{ type: "header", name: "x-vercel-id" }
```ts
{ kind: "mxSuffix", suffix: "aspmx.l.google.com" }
{ kind: "nsSuffix", suffix: "cloudflare.com" }
```

### DNS Rules

```typescript
// Check MX record contains substring
{ type: "dns", recordType: "MX", value: "google.com" }
### Certificate Authority primitives

// Check NS record contains substring
{ type: "dns", recordType: "NS", value: "cloudflare.com" }
```ts
{ kind: "issuerIncludes", substr: "let's encrypt" }
{ kind: "issuerEquals", value: "isrg r3" }
```

### Certificate Authorities

Modeled via a dedicated provider type with alias matching (no DetectionRule):
### Registrar primitives

```ts
interface CertificateAuthorityProvider {
name: string; // "Let's Encrypt"
domain: string; // "letsencrypt.org"
aliases?: string[]; // ["isrg", "r3", "lets encrypt"]
}
{ kind: "registrarIncludes", substr: "godaddy inc" }
{ kind: "registrarEquals", value: "namecheap" }
```

## Rule Evaluation

- **Provider-level**: A provider matches if **ANY** of its rules match (OR logic)
- **Rule-level**: Each rule has specific matching criteria
- **Fallback**: Returns "Unknown" if no provider matches, or first hostname for DNS/email
- **Provider-level**: Evaluate the provider's single `rule` with `evalRule`. Compose complex logic using `{ all | any | not }`.
- **Rule primitives**: Header, DNS, issuer, and registrar primitives match against normalized context values.
- **Fallbacks**: Returns "Unknown" if no provider matches; DNS/Email fall back to the registrable domain of the first record when unknown.

## Benefits

Expand All @@ -140,4 +143,4 @@ interface CertificateAuthorityProvider {
4. **Robust**: Unified evaluation prevents inconsistencies
5. **Fast**: Efficient pre-calculated contexts avoid redundant work

This approach provides a clean, maintainable foundation for provider detection that's easy to understand and extend.
This approach provides a clean, maintainable foundation for provider detection that's easy to understand and extend.
Loading