@growth-labs/seo 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +22 -0
- package/dist/utils/validation.js.map +1 -1
- package/package.json +9 -5
- package/src/_internal/state.ts +26 -0
- package/src/bindings.ts +146 -0
- package/src/cron/prune-aeo-r2.ts +140 -0
- package/src/durable-objects/aeo-revalidation-coord.ts +246 -0
- package/src/index.ts +380 -0
- package/src/middleware/seo.ts +350 -0
- package/src/options.ts +456 -0
- package/src/routes/aeo-twin.ts +130 -0
- package/src/routes/apple-news.ts +36 -0
- package/src/routes/llms-full.ts +36 -0
- package/src/routes/llms.ts +15 -0
- package/src/routes/podcast-narration.ts +45 -0
- package/src/routes/podcast.ts +27 -0
- package/src/routes/revalidate.ts +298 -0
- package/src/routes/robots.ts +21 -0
- package/src/routes/rss.ts +29 -0
- package/src/routes/sitemap-articles.ts +25 -0
- package/src/routes/sitemap-index.ts +89 -0
- package/src/routes/sitemap-markdown.ts +39 -0
- package/src/routes/sitemap-pages.ts +24 -0
- package/src/routes/sitemap-products.ts +24 -0
- package/src/routes/sitemap-videos.ts +24 -0
- package/src/runtime.ts +17 -0
- package/src/site-url-core.ts +71 -0
- package/src/site-url.ts +21 -0
- package/src/types.ts +166 -0
- package/src/utils/aeo-summary.ts +176 -0
- package/src/utils/aeo-twin-emitter.ts +173 -0
- package/src/utils/aeo.ts +223 -0
- package/src/utils/apple-news-anf.ts +163 -0
- package/src/utils/apple-news-rss.ts +136 -0
- package/src/utils/content-filter.ts +87 -0
- package/src/utils/crawler-class.ts +155 -0
- package/src/utils/define-content-provider.ts +65 -0
- package/src/utils/effective-auth.ts +44 -0
- package/src/utils/fcrdns.ts +269 -0
- package/src/utils/fresh-layer.ts +175 -0
- package/src/utils/hreflang.ts +26 -0
- package/src/utils/index.ts +91 -0
- package/src/utils/json-ld/article.ts +120 -0
- package/src/utils/json-ld/audio.ts +32 -0
- package/src/utils/json-ld/breadcrumb.ts +28 -0
- package/src/utils/json-ld/faq.ts +18 -0
- package/src/utils/json-ld/howto.ts +23 -0
- package/src/utils/json-ld/index.ts +12 -0
- package/src/utils/json-ld/item-list.ts +26 -0
- package/src/utils/json-ld/organization.ts +42 -0
- package/src/utils/json-ld/person.ts +25 -0
- package/src/utils/json-ld/product.ts +155 -0
- package/src/utils/json-ld/video.ts +20 -0
- package/src/utils/json-ld/website.ts +27 -0
- package/src/utils/llms-full.ts +90 -0
- package/src/utils/llms.ts +45 -0
- package/src/utils/meta.ts +184 -0
- package/src/utils/podcast.ts +112 -0
- package/src/utils/robots.ts +47 -0
- package/src/utils/rss.ts +64 -0
- package/src/utils/seo-head.ts +81 -0
- package/src/utils/sitemap-markdown.ts +80 -0
- package/src/utils/sitemap.ts +169 -0
- package/src/utils/staleness.ts +61 -0
- package/src/utils/validation.ts +308 -0
- package/src/virtual.d.ts +8 -0
- package/src/vite-plugin.ts +66 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { CrawlerClass } from '../types.js'
|
|
2
|
+
import type { FcrdnsVerifier } from './fcrdns.js'
|
|
3
|
+
|
|
4
|
+
// ─── User-Agent patterns ───
|
|
5
|
+
|
|
6
|
+
// Known LLM-training crawlers. Blocked at robots.txt AND 403'd in middleware on
|
|
7
|
+
// access: 'members' items. Matched case-insensitively against the UA string.
|
|
8
|
+
const LLM_TRAINING_UAS = [
|
|
9
|
+
'GPTBot',
|
|
10
|
+
'ClaudeBot',
|
|
11
|
+
'CCBot',
|
|
12
|
+
'Google-Extended',
|
|
13
|
+
'PerplexityBot',
|
|
14
|
+
'Applebot-Extended',
|
|
15
|
+
'Bytespider',
|
|
16
|
+
'FacebookBot',
|
|
17
|
+
'OAI-SearchBot',
|
|
18
|
+
'anthropic-ai',
|
|
19
|
+
'cohere-ai',
|
|
20
|
+
'AI2Bot',
|
|
21
|
+
'Diffbot',
|
|
22
|
+
'ImagesiftBot',
|
|
23
|
+
'Omgilibot',
|
|
24
|
+
'Omgili',
|
|
25
|
+
'Timpibot',
|
|
26
|
+
] as const
|
|
27
|
+
|
|
28
|
+
// User-directed LLM agents — fetch on behalf of a logged-in user. Treated as anonymous:
|
|
29
|
+
// these forward response to a third-party model that may retain them, so never serve
|
|
30
|
+
// gated content even if member cookies are present.
|
|
31
|
+
const USER_DIRECTED_UAS = [
|
|
32
|
+
'ChatGPT-User',
|
|
33
|
+
'Claude-User',
|
|
34
|
+
'PerplexityBot-User',
|
|
35
|
+
'Google-NotebookLM',
|
|
36
|
+
] as const
|
|
37
|
+
|
|
38
|
+
// FCrDNS suffixes for verified search crawlers. Case-insensitive, dot-boundary match.
|
|
39
|
+
// Deliberately excludes googleusercontent.com (Google Cloud VMs, Apps Script, proxy
|
|
40
|
+
// infrastructure that would let any GCP user impersonate Googlebot).
|
|
41
|
+
export const VERIFIED_SEARCH_SUFFIXES = [
|
|
42
|
+
'googlebot.com',
|
|
43
|
+
'google.com',
|
|
44
|
+
'search.msn.com',
|
|
45
|
+
'applebot.apple.com',
|
|
46
|
+
'duckduckbot.com',
|
|
47
|
+
] as const
|
|
48
|
+
|
|
49
|
+
// ─── Classification ───
|
|
50
|
+
|
|
51
|
+
export interface ClassifyRequestInput {
|
|
52
|
+
request: Request
|
|
53
|
+
// FCrDNS verifier. Injected so tests can mock DNS without hitting the network.
|
|
54
|
+
// In production, wired to the DoH-backed implementation in fcrdns.ts.
|
|
55
|
+
fcrdnsVerify?: FcrdnsVerifier
|
|
56
|
+
// If true, skips the Cloudflare Bot Management fast path. Used in tests.
|
|
57
|
+
skipBotManagementFastPath?: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Classify a request into one of four crawler classes.
|
|
62
|
+
*
|
|
63
|
+
* Order of checks:
|
|
64
|
+
* 1. Cloudflare Bot Management fast path (zero subrequests) when available.
|
|
65
|
+
* 2. LLM-training UA match.
|
|
66
|
+
* 3. User-directed LLM agent UA match.
|
|
67
|
+
* 4. FCrDNS against verified-search-crawler suffixes (DoH subrequests if uncached).
|
|
68
|
+
* 5. Fallthrough → anonymous.
|
|
69
|
+
*
|
|
70
|
+
* The BM fast path and UA checks always run; FCrDNS only runs when no UA matches
|
|
71
|
+
* (spoofed UAs that claim to be Googlebot without FCrDNS confirmation fail here).
|
|
72
|
+
*
|
|
73
|
+
* @returns the crawler class for cache-key segmentation, body-variant selection,
|
|
74
|
+
* and JSON-LD redaction decisions downstream.
|
|
75
|
+
*/
|
|
76
|
+
export async function classifyRequest(input: ClassifyRequestInput): Promise<CrawlerClass> {
|
|
77
|
+
const { request, fcrdnsVerify, skipBotManagementFastPath = false } = input
|
|
78
|
+
|
|
79
|
+
// 1. Cloudflare Bot Management fast path — zero subrequests, Cloudflare-stood-behind.
|
|
80
|
+
// Only present on Enterprise zones with BM enabled. Absent on Free/Pro/Business.
|
|
81
|
+
if (!skipBotManagementFastPath) {
|
|
82
|
+
const cf = (
|
|
83
|
+
request as {
|
|
84
|
+
cf?: { botManagement?: { verifiedBot?: boolean }; verifiedBotCategory?: string }
|
|
85
|
+
}
|
|
86
|
+
).cf
|
|
87
|
+
if (cf?.botManagement?.verifiedBot && cf.verifiedBotCategory === 'Search Engine Crawler') {
|
|
88
|
+
return 'verifiedSearchCrawler'
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const ua = request.headers.get('user-agent') ?? ''
|
|
93
|
+
|
|
94
|
+
// 2. User-directed LLM agent — checked BEFORE training-bot UAs, because many
|
|
95
|
+
// user-directed UAs contain training-bot substrings (e.g. `PerplexityBot-User`
|
|
96
|
+
// contains `PerplexityBot`; `ChatGPT-User` contains `ChatGPT`). The more
|
|
97
|
+
// specific rule must match first.
|
|
98
|
+
if (matchesAnyToken(ua, USER_DIRECTED_UAS)) {
|
|
99
|
+
return 'userDirectedLlmAgent'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 3. LLM-training crawler — matches a training-bot UA substring.
|
|
103
|
+
if (matchesAnyToken(ua, LLM_TRAINING_UAS)) {
|
|
104
|
+
return 'llmTrainingCrawler'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 4. FCrDNS for verified search crawlers. Only attempted if a verifier is wired
|
|
108
|
+
// (the classifier is pure when FCrDNS isn't available — production middleware
|
|
109
|
+
// always wires it; pure-function tests can skip it).
|
|
110
|
+
if (fcrdnsVerify) {
|
|
111
|
+
const clientIp = getClientIp(request)
|
|
112
|
+
if (clientIp) {
|
|
113
|
+
const verified = await fcrdnsVerify({
|
|
114
|
+
clientIp,
|
|
115
|
+
trustedSuffixes: VERIFIED_SEARCH_SUFFIXES,
|
|
116
|
+
})
|
|
117
|
+
if (verified) return 'verifiedSearchCrawler'
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 5. Fallthrough.
|
|
122
|
+
return 'anonymous'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Internals ───
|
|
126
|
+
|
|
127
|
+
function matchesAnyToken(ua: string, tokens: readonly string[]): boolean {
|
|
128
|
+
if (!ua) return false
|
|
129
|
+
const lowered = ua.toLowerCase()
|
|
130
|
+
return tokens.some((token) => lowered.includes(token.toLowerCase()))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Extract the effective client IP from a request. Prefers CF-Connecting-IP
|
|
135
|
+
* (Cloudflare-verified); falls back to X-Forwarded-For's first entry.
|
|
136
|
+
*/
|
|
137
|
+
function getClientIp(request: Request): string | null {
|
|
138
|
+
const cf = request.headers.get('cf-connecting-ip')
|
|
139
|
+
if (cf) return cf
|
|
140
|
+
const xff = request.headers.get('x-forwarded-for')
|
|
141
|
+
if (xff) {
|
|
142
|
+
const first = xff.split(',')[0]?.trim()
|
|
143
|
+
if (first) return first
|
|
144
|
+
}
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Exports for testing ───
|
|
149
|
+
|
|
150
|
+
export const _internals = {
|
|
151
|
+
LLM_TRAINING_UAS,
|
|
152
|
+
USER_DIRECTED_UAS,
|
|
153
|
+
matchesAnyToken,
|
|
154
|
+
getClientIp,
|
|
155
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { APIContext } from 'astro'
|
|
2
|
+
import type { ContentItem, ContentProviderParams, ContentType } from '../types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Narrows `ContentItem` per `ContentType` so a consumer-authored provider
|
|
6
|
+
* body gets compile-time feedback when a required field for that type is
|
|
7
|
+
* missing from the returned objects.
|
|
8
|
+
*
|
|
9
|
+
* The base `ContentItem` is intentionally permissive because most fields are
|
|
10
|
+
* optional at the JSON-LD layer. These narrowed variants add the fields that
|
|
11
|
+
* the feed and sitemap generators actually need for each content type —
|
|
12
|
+
* `video` for videos, `product` for products — so an author can't silently
|
|
13
|
+
* ship an item with `type: 'products'` but no `product` payload.
|
|
14
|
+
*/
|
|
15
|
+
export interface ContentItemByType {
|
|
16
|
+
articles: ContentItem
|
|
17
|
+
pages: ContentItem
|
|
18
|
+
videos: ContentItem & { video: NonNullable<ContentItem['video']> }
|
|
19
|
+
products: ContentItem & { product: NonNullable<ContentItem['product']> }
|
|
20
|
+
authors: ContentItem
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type TypedContentProvider = <T extends ContentType>(
|
|
24
|
+
params: ContentProviderParams & { type: T },
|
|
25
|
+
context: APIContext,
|
|
26
|
+
) => Promise<ContentItemByType[T][]>
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Identity helper that preserves the narrowest `type` → return-shape binding
|
|
30
|
+
* for a consumer-authored content provider. Use inside your
|
|
31
|
+
* `contentProviderModule` default export so TypeScript catches mismatches
|
|
32
|
+
* between the `type` the caller requested and the shape you returned.
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* // src/lib/content-provider.mjs
|
|
36
|
+
* import { defineContentProvider } from '@growth-labs/seo/utils'
|
|
37
|
+
*
|
|
38
|
+
* export default defineContentProvider(async ({ type }, ctx) => {
|
|
39
|
+
* if (type === 'products') {
|
|
40
|
+
* return [{
|
|
41
|
+
* url: 'https://example.com/widget',
|
|
42
|
+
* title: 'Widget',
|
|
43
|
+
* // Required for `products` — compile error if omitted.
|
|
44
|
+
* product: {
|
|
45
|
+
* name: 'Widget', description: 'Blue', price: 10, currency: 'USD',
|
|
46
|
+
* availability: 'InStock', images: ['https://example.com/widget.jpg'],
|
|
47
|
+
* },
|
|
48
|
+
* }]
|
|
49
|
+
* }
|
|
50
|
+
* return []
|
|
51
|
+
* })
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export function defineContentProvider(provider: TypedContentProvider): TypedContentProvider {
|
|
55
|
+
return provider
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Module-style variant of {@link defineContentProvider}. Semantically identical
|
|
60
|
+
* but communicates intent at the call site: "this is the default export of my
|
|
61
|
+
* content-provider module file." Use whichever reads better.
|
|
62
|
+
*/
|
|
63
|
+
export function defineContentProviderModule(provider: TypedContentProvider): TypedContentProvider {
|
|
64
|
+
return provider
|
|
65
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CrawlerClass, EffectiveAuthSegment } from '../types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Raw auth segment from the consumer's auth layer. Consumers produce this (typically
|
|
5
|
+
* 'anon' for unauthenticated, 'member' for a logged-in subscriber) and feed it into
|
|
6
|
+
* the package to derive the effective segment.
|
|
7
|
+
*/
|
|
8
|
+
export type RawAuthSegment = 'anon' | 'member'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compute the effective auth segment given a crawler class and a raw consumer auth segment.
|
|
12
|
+
*
|
|
13
|
+
* This function encodes the policy from spec "Effective auth segment" (lines
|
|
14
|
+
* "crawler class overrides member cookies"):
|
|
15
|
+
*
|
|
16
|
+
* - Verified search crawler → 'search-full' (regardless of cookies). Gets the
|
|
17
|
+
* sanctioned paywall-marked full body under Flexible Sampling.
|
|
18
|
+
* - LLM training crawler → caller should 403 before calling this; if it does reach
|
|
19
|
+
* here, we surface 'anon' defensively.
|
|
20
|
+
* - User-directed LLM agent → 'anon', ALWAYS. Even if member cookies are present.
|
|
21
|
+
* Load-bearing: ChatGPT-User / Claude-User forward responses to a third-party
|
|
22
|
+
* model that may retain them; we cannot verify the cookies belong to a paid
|
|
23
|
+
* subscriber, and leaking gated content to a third-party LLM defeats gating.
|
|
24
|
+
* - Anonymous → raw as-is.
|
|
25
|
+
*
|
|
26
|
+
* Consumers use `effectiveAuthSegment` (NOT raw `authSegment`) in cache keys so
|
|
27
|
+
* that cache segmentation reflects the crawler-class override.
|
|
28
|
+
*/
|
|
29
|
+
export function computeEffectiveAuthSegment(
|
|
30
|
+
crawlerClass: CrawlerClass,
|
|
31
|
+
rawAuthSegment: RawAuthSegment,
|
|
32
|
+
): EffectiveAuthSegment {
|
|
33
|
+
switch (crawlerClass) {
|
|
34
|
+
case 'verifiedSearchCrawler':
|
|
35
|
+
return 'search-full'
|
|
36
|
+
case 'llmTrainingCrawler':
|
|
37
|
+
// Caller should reject with 403 before computing this; defensive fallback.
|
|
38
|
+
return 'anon'
|
|
39
|
+
case 'userDirectedLlmAgent':
|
|
40
|
+
return 'anon' // Override: never 'member', even with cookies.
|
|
41
|
+
case 'anonymous':
|
|
42
|
+
return rawAuthSegment
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// Forward-confirmed reverse DNS verification. Used to classify incoming requests
|
|
2
|
+
// as verified search crawlers when Cloudflare Bot Management isn't available.
|
|
3
|
+
//
|
|
4
|
+
// Algorithm (spec "FCrDNS algorithm"):
|
|
5
|
+
// 1. PTR lookup on the client IP → hostname
|
|
6
|
+
// 2. Hostname must end with a trusted suffix, on a dot boundary
|
|
7
|
+
// 3. A/AAAA lookup on that hostname → IPs
|
|
8
|
+
// 4. Client IP must be in the result set
|
|
9
|
+
//
|
|
10
|
+
// Caching (spec "FCrDNS cache semantics"):
|
|
11
|
+
// - Positive: keyed by (clientIp, matchedHostname); TTL = min(10min, rDNS TTL, fDNS TTL)
|
|
12
|
+
// - Negative: 60s fixed TTL
|
|
13
|
+
// - Process-local only; do NOT persist across deploys
|
|
14
|
+
|
|
15
|
+
// ─── Resolver interface ───
|
|
16
|
+
|
|
17
|
+
export interface DnsAnswer {
|
|
18
|
+
name: string
|
|
19
|
+
type: number
|
|
20
|
+
TTL: number // seconds
|
|
21
|
+
data: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DnsResolver {
|
|
25
|
+
/**
|
|
26
|
+
* Perform a DoH-JSON-style DNS query.
|
|
27
|
+
*
|
|
28
|
+
* Returns the Answer array or null on failure. `type` is an RFC 1035
|
|
29
|
+
* numeric record type (12 = PTR, 1 = A, 28 = AAAA).
|
|
30
|
+
*
|
|
31
|
+
* Production implementation: {@link createDohResolver}. Tests inject a mock.
|
|
32
|
+
*/
|
|
33
|
+
query(name: string, type: 'PTR' | 'A' | 'AAAA'): Promise<DnsAnswer[] | null>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Public API ───
|
|
37
|
+
|
|
38
|
+
export interface FcrdnsVerifyInput {
|
|
39
|
+
clientIp: string
|
|
40
|
+
trustedSuffixes: readonly string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type FcrdnsVerifier = (input: FcrdnsVerifyInput) => Promise<boolean>
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create an FCrDNS verifier. Returns a function that, given an IP and suffix list,
|
|
47
|
+
* returns true iff the IP's forward-confirmed reverse DNS lands on a trusted suffix.
|
|
48
|
+
*
|
|
49
|
+
* The returned verifier carries its own cache; create one per Worker isolate.
|
|
50
|
+
*
|
|
51
|
+
* @param resolver DNS resolver. Default in production: DoH against 1.1.1.1.
|
|
52
|
+
* Tests inject a mock.
|
|
53
|
+
* @param now time source (for testable TTL expiry). Default: Date.now.
|
|
54
|
+
*/
|
|
55
|
+
export function createFcrdnsVerifier(
|
|
56
|
+
resolver: DnsResolver,
|
|
57
|
+
now: () => number = Date.now,
|
|
58
|
+
): FcrdnsVerifier {
|
|
59
|
+
const cache = new FcrdnsCache(now)
|
|
60
|
+
|
|
61
|
+
return async ({ clientIp, trustedSuffixes }: FcrdnsVerifyInput): Promise<boolean> => {
|
|
62
|
+
// Cache check (positive or negative).
|
|
63
|
+
const cached = cache.get(clientIp)
|
|
64
|
+
if (cached !== undefined) return cached
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// 1. Reverse DNS lookup.
|
|
68
|
+
const ptrName = reverseIpToArpa(clientIp)
|
|
69
|
+
if (!ptrName) {
|
|
70
|
+
cache.setNegative(clientIp)
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
const ptrAnswers = await resolver.query(ptrName, 'PTR')
|
|
74
|
+
if (!ptrAnswers || ptrAnswers.length === 0) {
|
|
75
|
+
cache.setNegative(clientIp)
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2. Suffix match on any PTR answer (dot-boundary, case-insensitive).
|
|
80
|
+
let matchedHostname: string | null = null
|
|
81
|
+
let ptrTtl = Number.POSITIVE_INFINITY
|
|
82
|
+
for (const answer of ptrAnswers) {
|
|
83
|
+
const hostname = stripTrailingDot(answer.data).toLowerCase()
|
|
84
|
+
if (matchesAnyTrustedSuffix(hostname, trustedSuffixes)) {
|
|
85
|
+
matchedHostname = hostname
|
|
86
|
+
ptrTtl = Math.min(ptrTtl, answer.TTL)
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!matchedHostname) {
|
|
91
|
+
cache.setNegative(clientIp)
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. Forward DNS lookup. Try A first; if IPv6, also AAAA.
|
|
96
|
+
const isIpv6 = clientIp.includes(':')
|
|
97
|
+
const fwdType = isIpv6 ? 'AAAA' : 'A'
|
|
98
|
+
const fwdAnswers = await resolver.query(matchedHostname, fwdType)
|
|
99
|
+
if (!fwdAnswers || fwdAnswers.length === 0) {
|
|
100
|
+
cache.setNegative(clientIp)
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 4. Client IP must be in the result set.
|
|
105
|
+
const normalizedClient = normalizeIp(clientIp)
|
|
106
|
+
const matched = fwdAnswers.some((a) => normalizeIp(a.data) === normalizedClient)
|
|
107
|
+
if (!matched) {
|
|
108
|
+
cache.setNegative(clientIp)
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Positive: cache with TTL = min(10min, rDNS TTL, fDNS TTL).
|
|
113
|
+
const fwdTtl = fwdAnswers.reduce((min, a) => Math.min(min, a.TTL), Number.POSITIVE_INFINITY)
|
|
114
|
+
const ttlSeconds = Math.min(600, ptrTtl, fwdTtl)
|
|
115
|
+
cache.setPositive(clientIp, matchedHostname, ttlSeconds * 1000)
|
|
116
|
+
return true
|
|
117
|
+
} catch {
|
|
118
|
+
// Any error → fail closed (anonymous), short negative cache to avoid repeat hits.
|
|
119
|
+
cache.setNegative(clientIp)
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── DoH resolver (production default) ───
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a DoH-JSON resolver that queries Cloudflare's 1.1.1.1. One subrequest per
|
|
129
|
+
* DNS lookup. Not a singleton — resolver has no persistent state of its own.
|
|
130
|
+
*
|
|
131
|
+
* Docs: https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/
|
|
132
|
+
*/
|
|
133
|
+
export function createDohResolver(
|
|
134
|
+
baseUrl = 'https://1.1.1.1/dns-query',
|
|
135
|
+
fetchImpl: typeof fetch = fetch,
|
|
136
|
+
): DnsResolver {
|
|
137
|
+
return {
|
|
138
|
+
async query(name: string, type: 'PTR' | 'A' | 'AAAA'): Promise<DnsAnswer[] | null> {
|
|
139
|
+
const url = `${baseUrl}?name=${encodeURIComponent(name)}&type=${type}`
|
|
140
|
+
const response = await fetchImpl(url, {
|
|
141
|
+
headers: { accept: 'application/dns-json' },
|
|
142
|
+
})
|
|
143
|
+
if (!response.ok) return null
|
|
144
|
+
const body = (await response.json()) as { Status?: number; Answer?: DnsAnswer[] }
|
|
145
|
+
// Status 0 = NOERROR. Anything else means no usable answer.
|
|
146
|
+
if (body.Status !== 0 || !body.Answer) return null
|
|
147
|
+
return body.Answer
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Internals ───
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Convert an IP address to its in-addr.arpa / ip6.arpa PTR query name.
|
|
156
|
+
* Returns null for malformed input.
|
|
157
|
+
*/
|
|
158
|
+
function reverseIpToArpa(ip: string): string | null {
|
|
159
|
+
if (ip.includes(':')) {
|
|
160
|
+
// IPv6 — expand and nibble-reverse into ip6.arpa.
|
|
161
|
+
const expanded = expandIpv6(ip)
|
|
162
|
+
if (!expanded) return null
|
|
163
|
+
const nibbles = expanded.replace(/:/g, '').split('').reverse().join('.')
|
|
164
|
+
return `${nibbles}.ip6.arpa`
|
|
165
|
+
}
|
|
166
|
+
// IPv4 — reverse octets into in-addr.arpa.
|
|
167
|
+
const octets = ip.split('.')
|
|
168
|
+
if (octets.length !== 4 || octets.some((o) => !/^\d+$/.test(o))) return null
|
|
169
|
+
return `${octets.reverse().join('.')}.in-addr.arpa`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Expand an IPv6 address to its full 32-hex-character form (colon-separated groups
|
|
174
|
+
* of 4). Handles "::" compression. Returns null for malformed input.
|
|
175
|
+
*/
|
|
176
|
+
function expandIpv6(ip: string): string | null {
|
|
177
|
+
const doubleColon = ip.indexOf('::')
|
|
178
|
+
let parts: string[]
|
|
179
|
+
if (doubleColon === -1) {
|
|
180
|
+
parts = ip.split(':')
|
|
181
|
+
} else {
|
|
182
|
+
const head = ip.slice(0, doubleColon).split(':').filter(Boolean)
|
|
183
|
+
const tail = ip
|
|
184
|
+
.slice(doubleColon + 2)
|
|
185
|
+
.split(':')
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
const missing = 8 - head.length - tail.length
|
|
188
|
+
if (missing < 0) return null
|
|
189
|
+
parts = [...head, ...Array(missing).fill('0000'), ...tail]
|
|
190
|
+
}
|
|
191
|
+
if (parts.length !== 8) return null
|
|
192
|
+
return parts.map((p) => p.padStart(4, '0')).join(':')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function matchesAnyTrustedSuffix(hostname: string, suffixes: readonly string[]): boolean {
|
|
196
|
+
const lowered = hostname.toLowerCase()
|
|
197
|
+
return suffixes.some((suffix) => {
|
|
198
|
+
const lsuffix = suffix.toLowerCase()
|
|
199
|
+
return lowered === lsuffix || lowered.endsWith(`.${lsuffix}`)
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function stripTrailingDot(s: string): string {
|
|
204
|
+
return s.endsWith('.') ? s.slice(0, -1) : s
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Normalize an IP for comparison. IPv4 addresses pass through; IPv6 are canonicalized
|
|
209
|
+
* by expanding "::" and stripping leading zeros in each group.
|
|
210
|
+
*/
|
|
211
|
+
function normalizeIp(ip: string): string {
|
|
212
|
+
if (!ip.includes(':')) return ip // IPv4
|
|
213
|
+
const expanded = expandIpv6(ip)
|
|
214
|
+
if (!expanded) return ip
|
|
215
|
+
// Strip leading zeros in each group for canonical comparison.
|
|
216
|
+
return expanded
|
|
217
|
+
.split(':')
|
|
218
|
+
.map((g) => g.replace(/^0+/, '') || '0')
|
|
219
|
+
.join(':')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── Cache ───
|
|
223
|
+
|
|
224
|
+
interface CacheEntry {
|
|
225
|
+
verified: boolean
|
|
226
|
+
expiresAt: number
|
|
227
|
+
hostname?: string
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
class FcrdnsCache {
|
|
231
|
+
private readonly entries = new Map<string, CacheEntry>()
|
|
232
|
+
|
|
233
|
+
constructor(private readonly now: () => number) {}
|
|
234
|
+
|
|
235
|
+
get(clientIp: string): boolean | undefined {
|
|
236
|
+
const entry = this.entries.get(clientIp)
|
|
237
|
+
if (!entry) return undefined
|
|
238
|
+
if (entry.expiresAt <= this.now()) {
|
|
239
|
+
this.entries.delete(clientIp)
|
|
240
|
+
return undefined
|
|
241
|
+
}
|
|
242
|
+
return entry.verified
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
setPositive(clientIp: string, hostname: string, ttlMs: number): void {
|
|
246
|
+
this.entries.set(clientIp, {
|
|
247
|
+
verified: true,
|
|
248
|
+
hostname,
|
|
249
|
+
expiresAt: this.now() + Math.max(1000, ttlMs),
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
setNegative(clientIp: string): void {
|
|
254
|
+
this.entries.set(clientIp, {
|
|
255
|
+
verified: false,
|
|
256
|
+
expiresAt: this.now() + 60_000,
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─── Exports for testing ───
|
|
262
|
+
|
|
263
|
+
export const _internals = {
|
|
264
|
+
reverseIpToArpa,
|
|
265
|
+
expandIpv6,
|
|
266
|
+
matchesAnyTrustedSuffix,
|
|
267
|
+
normalizeIp,
|
|
268
|
+
FcrdnsCache,
|
|
269
|
+
}
|