diff --git a/README.md b/README.md index 0852d0d..169244f 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ The tracker excludes form inputs, cookies, localStorage contents, passwords, URL ## GeoIP -When `TRUST_PROXY=true`, BufferDash uses trusted Cloudflare or Vercel location headers when present. Set `IPINFO_TOKEN` for server-side enrichment when appropriate. `IPINFO_TIER=lite` provides country and ASN data; `core` also provides city and region. +When `TRUST_PROXY=true`, BufferDash uses trusted Cloudflare or Vercel location headers when present. Cloudflare's **Add visitor location headers** managed transform supplies city and region without a separate lookup provider. Set `IPINFO_TOKEN` for server-side enrichment or fallback when appropriate. `IPINFO_TIER=lite` provides country and ASN data; `core` also provides city and region. GeoIP sends visitor IPs to the configured provider. Leave the token empty if that does not fit your privacy policy. diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 0eeda96..827f9bc 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -12,6 +12,8 @@ import { TrafficToggle } from "@/components/TrafficToggle"; import { StatusBadge } from "@/components/StatusBadge"; import { InfoCallout } from "@/components/InfoCallout"; import { env } from "@/lib/env"; +import { isCloudflareIp } from "@/lib/cloudflare"; +import { IpAddress } from "@/components/IpAddress"; export default async function DashboardPage({ searchParams }: { searchParams: Promise }) { const params = await searchParams; @@ -61,8 +63,8 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr {data.cities.length ? :

Cities

- - {overview.pageViews === 0 ? "City data will appear with new page views." : !env.ipinfoToken ? "Configure IPINFO_TOKEN and IPINFO_TIER=core for city analytics." : env.ipinfoTier === "lite" ? "IPinfo Lite supplies country and ASN; Core is needed for city and region." : "Core is configured, but recent events did not include a city."} + + {overview.pageViews === 0 ? "City data will appear with new page views." : !env.ipinfoToken ? "Enable Cloudflare’s Add visitor location headers managed transform, or configure IPinfo Core." : env.ipinfoTier === "lite" ? "Enable Cloudflare’s location headers or use IPinfo Core; IPinfo Lite supplies only country and ASN." : "Core is configured, but recent events did not include a city."}
} @@ -83,7 +85,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr {shortDate(event.createdAt)} {event.site.name} {event.path || event.type} - {maskIp(event.ipAddress)} + {event.browser || "Unknown"} diff --git a/app/(dashboard)/http/page.tsx b/app/(dashboard)/http/page.tsx index 69ae5f6..a644c9c 100644 --- a/app/(dashboard)/http/page.tsx +++ b/app/(dashboard)/http/page.tsx @@ -13,6 +13,8 @@ import { parseDateWindow, parsePage, parsePageSize, parseTraffic, type SearchPar import { compactDuration, numberFormat, shortDate } from "@/lib/format"; import { getHttpPage } from "@/lib/list-data"; import { maskIp } from "@/lib/ip"; +import { isCloudflareIp } from "@/lib/cloudflare"; +import { IpAddress } from "@/components/IpAddress"; const one = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value; @@ -56,7 +58,7 @@ export default async function HttpPage({ searchParams }: { searchParams: Promise

Recent 4xx/5xx samples

Sanitized; no queries, bodies, cookies, or authorization
TimeStatusHostMethodPathDurationVisitorClassificationProxy error - {data.samples.map((sample) => {shortDate(sample.occurredAt)}= 500 ? "error" : "warning"}`}>{sample.status >= 500 ? "Server error" : "Client error"} · {sample.status}{sample.host}{sample.method}{sample.path}{compactDuration(sample.durationMs)}{maskIp(sample.ipAddress)}{sample.proxyError || "—"})} + {data.samples.map((sample) => {shortDate(sample.occurredAt)}= 500 ? "error" : "warning"}`}>{sample.status >= 500 ? "Server error" : "Client error"} · {sample.status}{sample.host}{sample.method}{sample.path}{compactDuration(sample.durationMs)}{sample.proxyError || "—"})} {!data.samples.length && No 4xx/5xx samples match these filters.}
; diff --git a/app/(dashboard)/live/page.tsx b/app/(dashboard)/live/page.tsx index 4f29cd7..837e9bf 100644 --- a/app/(dashboard)/live/page.tsx +++ b/app/(dashboard)/live/page.tsx @@ -6,6 +6,8 @@ import { maskIp } from "@/lib/ip"; import { parseTraffic, type SearchParams } from "@/lib/filters"; import { TrafficToggle } from "@/components/TrafficToggle"; import { StatusBadge } from "@/components/StatusBadge"; +import { isCloudflareIp } from "@/lib/cloudflare"; +import { IpAddress } from "@/components/IpAddress"; export default async function LivePage({ searchParams }: { searchParams: Promise }) { const params = await searchParams; @@ -25,7 +27,7 @@ export default async function LivePage({ searchParams }: { searchParams: Promise {visitors.map((event) => ( {shortDate(event.createdAt)} - {maskIp(event.ipAddress)} + {[event.city, event.country].filter(Boolean).join(", ") || "Unknown"} {event.site.name} diff --git a/app/(dashboard)/logs/page.tsx b/app/(dashboard)/logs/page.tsx index c99c87a..719162a 100644 --- a/app/(dashboard)/logs/page.tsx +++ b/app/(dashboard)/logs/page.tsx @@ -5,6 +5,7 @@ import { Pagination } from "@/components/Pagination"; import { FilterBar } from "@/components/FilterBar"; import { DataTable } from "@/components/DataTable"; import { StatusBadge } from "@/components/StatusBadge"; +import { EventType } from "@/components/EventType"; import { parseDateWindow, parsePage, parsePageSize, parseTraffic, type SearchParams } from "@/lib/filters"; import { getAppLogPage } from "@/lib/list-data"; import { shortDate } from "@/lib/format"; @@ -31,7 +32,7 @@ export default async function LogsPage({ searchParams }: { searchParams: Promise
TimeKindSourceTypeClassificationMessage - {data.rows.map((row) => {shortDate(row.createdAt)}{row.kind}{row.source}{row.type}{row.message})} + {data.rows.map((row) => {shortDate(row.createdAt)}{row.kind}{row.source}{row.message})} {!data.rows.length && No events match these filters.}
; diff --git a/app/(dashboard)/security/page.tsx b/app/(dashboard)/security/page.tsx index e160135..64291c5 100644 --- a/app/(dashboard)/security/page.tsx +++ b/app/(dashboard)/security/page.tsx @@ -7,6 +7,9 @@ import { FilterBar } from "@/components/FilterBar"; import { DataTable } from "@/components/DataTable"; import { InfoCallout } from "@/components/InfoCallout"; import { StatusBadge } from "@/components/StatusBadge"; +import { EventType } from "@/components/EventType"; +import { IpAddress } from "@/components/IpAddress"; +import { isCloudflareIp } from "@/lib/cloudflare"; import { parseDateWindow, parsePage, parsePageSize, parseTraffic, queryString, type SearchParams } from "@/lib/filters"; import { getSecurityPage } from "@/lib/list-data"; import { shortDate } from "@/lib/format"; @@ -34,6 +37,7 @@ export default async function SecurityPage({ searchParams }: { searchParams: Pro + {data.types.includes("not_found") && A visitor or automated scanner requested a URL that does not exist. The requested path is shown in the Message column. This event type is unrelated to IP or location lookup failures.} {filters.ipHash && Showing the complete privacy-preserving hash {filters.ipHash}. Clear}

Repeat flagged visitors

@@ -44,7 +48,7 @@ export default async function SecurityPage({ searchParams }: { searchParams: Pro

Security events

Stable newest-first ordering
TimeTypeIPSourceClassificationMessage - {data.events.map((event) => {shortDate(event.createdAt)}{event.type}{maskIp(event.ipAddress)}{event.source}{event.message})} + {data.events.map((event) => {shortDate(event.createdAt)}{event.source}{event.message})} {!data.events.length && No security events match these filters.}
diff --git a/app/(dashboard)/sites/[siteId]/page.tsx b/app/(dashboard)/sites/[siteId]/page.tsx index 5052ca8..a339eec 100644 --- a/app/(dashboard)/sites/[siteId]/page.tsx +++ b/app/(dashboard)/sites/[siteId]/page.tsx @@ -8,6 +8,8 @@ import { TopList } from "@/components/TopList"; import { getDashboardData, getRecentEvents, getSite } from "@/lib/data"; import { compactDuration, numberFormat, shortDate } from "@/lib/format"; import { maskIp } from "@/lib/ip"; +import { isCloudflareIp } from "@/lib/cloudflare"; +import { IpAddress } from "@/components/IpAddress"; import { trackingSnippet } from "@/lib/snippet"; import { parseRange, rangeLabel } from "@/lib/range"; import { parseTraffic, type SearchParams } from "@/lib/filters"; @@ -59,7 +61,7 @@ export default async function SiteDetailPage({ params, searchParams }: { params: - {data.cities.length ? :

Cities

City data applies prospectively to new events.
} + {data.cities.length ? :

Cities

Enable Cloudflare's Add visitor location headers managed transform or use IPinfo Core. City data applies prospectively to new events.
}

Devices

@@ -76,7 +78,7 @@ export default async function SiteDetailPage({ params, searchParams }: { params: {shortDate(event.createdAt)} {event.path || event.type} - {maskIp(event.ipAddress)} + {[event.city, event.country].filter(Boolean).join(", ") || "Unknown"} {event.referrerDomain || "Direct"} diff --git a/app/globals.css b/app/globals.css index fea16f2..e631f4a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -349,6 +349,22 @@ td { color: #ded9ea; } +.ip-address-toggle { + margin: -0.35rem -0.45rem; + border: 0; + border-radius: 6px; + padding: 0.35rem 0.45rem; + color: #ddd6fe; + background: rgba(167, 139, 250, 0.1); + cursor: pointer; + font: inherit; + text-decoration: underline dotted; + text-underline-offset: 0.2em; +} + +.ip-address-toggle:hover { background: rgba(167, 139, 250, 0.18); } +.event-type { color: #fed7aa; } + .form-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; diff --git a/components/EventType.tsx b/components/EventType.tsx new file mode 100644 index 0000000..e391577 --- /dev/null +++ b/components/EventType.tsx @@ -0,0 +1,12 @@ +const typeDetails: Record = { + not_found: { + label: "Not found · 404", + description: "A visitor or bot requested a URL that does not exist. This is not an IP lookup failure." + } +}; + +export function EventType({ value }: { value: string }) { + const detail = typeDetails[value]; + if (!detail) return {value.replaceAll("_", " ")}; + return {detail.label}; +} diff --git a/components/IpAddress.tsx b/components/IpAddress.tsx new file mode 100644 index 0000000..0a800b7 --- /dev/null +++ b/components/IpAddress.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useState } from "react"; + +export function IpAddress({ address, isCloudflare = false }: { address: string; isCloudflare?: boolean }) { + const [showAddress, setShowAddress] = useState(false); + if (!isCloudflare) return {address}; + + return ; +} diff --git a/lib/cloudflare.ts b/lib/cloudflare.ts new file mode 100644 index 0000000..3ff75af --- /dev/null +++ b/lib/cloudflare.ts @@ -0,0 +1,51 @@ +// Keep these ranges in sync with https://www.cloudflare.com/ips-v4/ and /ips-v6/. +const cloudflareIpv4Ranges = [ + ["173.245.48.0", 20], ["103.21.244.0", 22], ["103.22.200.0", 22], + ["103.31.4.0", 22], ["141.101.64.0", 18], ["108.162.192.0", 18], + ["190.93.240.0", 20], ["188.114.96.0", 20], ["197.234.240.0", 22], + ["198.41.128.0", 17], ["162.158.0.0", 15], ["104.16.0.0", 13], + ["104.24.0.0", 14], ["172.64.0.0", 13], ["131.0.72.0", 22] +] as const; + +const cloudflareIpv6Prefixes = [ + ["2400", "cb00", 32], ["2606", "4700", 32], ["2803", "f800", 32], + ["2405", "b500", 32], ["2405", "8100", 32], ["2a06", "98c0", 29], + ["2c0f", "f248", 32] +] as const; + +function ipv4Number(value: string) { + const parts = value.split("."); + if (parts.length !== 4) return null; + const octets = parts.map(Number); + if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null; + return octets.reduce((result, part) => result * 256 + part, 0) >>> 0; +} + +function isCloudflareIpv4(ip: string) { + const value = ipv4Number(ip); + if (value === null) return false; + return cloudflareIpv4Ranges.some(([network, prefix]) => { + const base = ipv4Number(network)!; + const size = 2 ** (32 - prefix); + return Math.floor(value / size) === Math.floor(base / size); + }); +} + +function isCloudflareIpv6(ip: string) { + // Every current Cloudflare IPv6 allocation is distinguishable by its first + // two hextets. This also recognizes already-stored anonymized /64 addresses. + const [first, second] = ip.toLowerCase().split(":"); + if (!first || !second || !/^[0-9a-f]{1,4}$/.test(first) || !/^[0-9a-f]{1,4}$/.test(second)) return false; + const firstValue = Number.parseInt(first, 16); + const secondValue = Number.parseInt(second, 16); + return cloudflareIpv6Prefixes.some(([rangeFirst, rangeSecond, prefix]) => { + if (firstValue !== Number.parseInt(rangeFirst, 16)) return false; + const mask = prefix === 32 ? 0xffff : 0xffff << (32 - prefix); + return (secondValue & mask) === (Number.parseInt(rangeSecond, 16) & mask); + }); +} + +export function isCloudflareIp(ip?: string | null) { + if (!ip) return false; + return ip.includes(":") ? isCloudflareIpv6(ip) : isCloudflareIpv4(ip); +} diff --git a/lib/geo.ts b/lib/geo.ts index ea038a1..09a712b 100644 --- a/lib/geo.ts +++ b/lib/geo.ts @@ -20,8 +20,8 @@ export function geoFromTrustedHeaders(headers: Headers): GeoData { const country = headerValue(headers, ["cf-ipcountry", "x-vercel-ip-country", "x-geo-country"]); return { country: country && country !== "XX" ? country.slice(0, 120) : null, - region: headerValue(headers, ["x-vercel-ip-country-region", "x-geo-region"])?.slice(0, 120) || null, - city: headerValue(headers, ["x-vercel-ip-city", "x-geo-city"])?.slice(0, 120) || null, + region: headerValue(headers, ["cf-region", "x-vercel-ip-country-region", "x-geo-region"])?.slice(0, 120) || null, + city: headerValue(headers, ["cf-ipcity", "x-vercel-ip-city", "x-geo-city"])?.slice(0, 120) || null, asn: headerValue(headers, ["cf-asn", "x-geo-asn"])?.slice(0, 80) || null, isp: headerValue(headers, ["x-geo-isp"])?.slice(0, 180) || null }; diff --git a/tests/cloudflare.test.ts b/tests/cloudflare.test.ts new file mode 100644 index 0000000..174fcc9 --- /dev/null +++ b/tests/cloudflare.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { isCloudflareIp } from "../lib/cloudflare"; + +describe("Cloudflare network detection", () => { + it("recognizes current Cloudflare IPv4 ranges and anonymized addresses", () => { + expect(isCloudflareIp("104.16.10.20")).toBe(true); + expect(isCloudflareIp("172.71.0.0")).toBe(true); + expect(isCloudflareIp("8.8.8.8")).toBe(false); + expect(isCloudflareIp("172.72.0.1")).toBe(false); + }); + + it("recognizes current Cloudflare IPv6 ranges and anonymized addresses", () => { + expect(isCloudflareIp("2606:4700:3037::6815:abcd")).toBe(true); + expect(isCloudflareIp("2a06:98c7:1234:5678::")).toBe(true); + expect(isCloudflareIp("2001:4860:4860::8888")).toBe(false); + expect(isCloudflareIp("2a06:98c8::1")).toBe(false); + }); +}); diff --git a/tests/geo.test.ts b/tests/geo.test.ts index e561881..fdf2f70 100644 --- a/tests/geo.test.ts +++ b/tests/geo.test.ts @@ -10,6 +10,14 @@ describe("GeoIP helpers", () => { expect(geoFromTrustedHeaders(headers)).toMatchObject({ country: "US", city: "New York", region: "NY" }); }); + it("uses Cloudflare visitor location headers without a provider", async () => { + vi.resetModules(); + vi.stubEnv("TRUST_PROXY", "true"); + const { geoFromTrustedHeaders } = await import("../lib/geo"); + const headers = new Headers({ "cf-ipcountry": "US", "cf-ipcity": "Philadelphia", "cf-region": "Pennsylvania" }); + expect(geoFromTrustedHeaders(headers)).toMatchObject({ country: "US", city: "Philadelphia", region: "Pennsylvania" }); + }); + it("does not send private addresses to a provider", async () => { const { isPublicIp } = await import("../lib/geo"); expect(isPublicIp("192.168.1.2")).toBe(false);