Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 5 additions & 3 deletions app/(dashboard)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchParams> }) {
const params = await searchParams;
Expand Down Expand Up @@ -61,8 +63,8 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
<TopList title="Devices" rows={data.devices} />
<TopList title="Countries" rows={data.countries} />
{data.cities.length ? <TopList title="Cities" rows={data.cities} /> : <section className="panel"><div className="panel-header"><h2>Cities</h2></div>
<InfoCallout title={overview.pageViews === 0 ? "No traffic yet" : !env.ipinfoToken ? "City provider not configured" : env.ipinfoTier === "lite" ? "IPinfo Core required" : "No city data returned"}>
{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."}
<InfoCallout title={overview.pageViews === 0 ? "No traffic yet" : !env.ipinfoToken ? "City data not received" : env.ipinfoTier === "lite" ? "City data not received" : "No city data returned"}>
{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."}
</InfoCallout></section>}
<TopList title="Tools used" rows={data.topTools} />
</section>
Expand All @@ -83,7 +85,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
<td>{shortDate(event.createdAt)}</td>
<td>{event.site.name}</td>
<td>{event.path || event.type}</td>
<td>{maskIp(event.ipAddress)}</td>
<td><IpAddress address={maskIp(event.ipAddress)} isCloudflare={isCloudflareIp(event.ipAddress)} /></td>
<td><StatusBadge isBot={event.isBot} botName={event.botName} asn={event.asn} isp={event.isp} /></td>
<td>{event.browser || "Unknown"}</td>
</tr>
Expand Down
4 changes: 3 additions & 1 deletion app/(dashboard)/http/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -56,7 +58,7 @@ export default async function HttpPage({ searchParams }: { searchParams: Promise
<section className="dashboard-grid"><TopList title="Top failing paths" rows={data.paths} /><TopList title="Status codes" rows={data.statuses} /></section>
<section className="panel span-full"><div className="panel-header"><h2>Recent 4xx/5xx samples</h2><span>Sanitized; no queries, bodies, cookies, or authorization</span></div>
<DataTable label="HTTP error samples"><thead><tr><th>Time</th><th>Status</th><th>Host</th><th>Method</th><th>Path</th><th>Duration</th><th>Visitor</th><th>Classification</th><th>Proxy error</th></tr></thead><tbody>
{data.samples.map((sample) => <tr key={sample.id}><td>{shortDate(sample.occurredAt)}</td><td><span className={`status-badge ${sample.status >= 500 ? "error" : "warning"}`}>{sample.status >= 500 ? "Server error" : "Client error"} · {sample.status}</span></td><td>{sample.host}</td><td>{sample.method}</td><td className="wrap-cell" title={sample.path}>{sample.path}</td><td>{compactDuration(sample.durationMs)}</td><td>{maskIp(sample.ipAddress)}</td><td><StatusBadge isBot={sample.isBot} botName={sample.botName} /></td><td className="wrap-cell">{sample.proxyError || "—"}</td></tr>)}
{data.samples.map((sample) => <tr key={sample.id}><td>{shortDate(sample.occurredAt)}</td><td><span className={`status-badge ${sample.status >= 500 ? "error" : "warning"}`}>{sample.status >= 500 ? "Server error" : "Client error"} · {sample.status}</span></td><td>{sample.host}</td><td>{sample.method}</td><td className="wrap-cell" title={sample.path}>{sample.path}</td><td>{compactDuration(sample.durationMs)}</td><td><IpAddress address={maskIp(sample.ipAddress)} isCloudflare={isCloudflareIp(sample.ipAddress)} /></td><td><StatusBadge isBot={sample.isBot} botName={sample.botName} /></td><td className="wrap-cell">{sample.proxyError || "—"}</td></tr>)}
{!data.samples.length && <tr><td colSpan={9}>No 4xx/5xx samples match these filters.</td></tr>}
</tbody></DataTable><Pagination path="/http" params={params} page={page} pageSize={pageSize} total={data.sampleCount} /></section>
</>;
Expand Down
4 changes: 3 additions & 1 deletion app/(dashboard)/live/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchParams> }) {
const params = await searchParams;
Expand All @@ -25,7 +27,7 @@ export default async function LivePage({ searchParams }: { searchParams: Promise
{visitors.map((event) => (
<tr key={event.id}>
<td>{shortDate(event.createdAt)}</td>
<td>{maskIp(event.ipAddress)}</td>
<td><IpAddress address={maskIp(event.ipAddress)} isCloudflare={isCloudflareIp(event.ipAddress)} /></td>
<td><StatusBadge isBot={event.isBot} botName={event.botName} asn={event.asn} isp={event.isp} /></td>
<td>{[event.city, event.country].filter(Boolean).join(", ") || "Unknown"}</td>
<td>{event.site.name}</td>
Expand Down
3 changes: 2 additions & 1 deletion app/(dashboard)/logs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,7 +32,7 @@ export default async function LogsPage({ searchParams }: { searchParams: Promise
<button className="primary-button" type="submit">Apply filters</button>
</form></FilterBar>
<section className="panel span-full"><DataTable label="Event stream"><thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Type</th><th>Classification</th><th>Message</th></tr></thead><tbody>
{data.rows.map((row) => <tr key={`${row.kind}:${row.id}`}><td>{shortDate(row.createdAt)}</td><td>{row.kind}</td><td>{row.source}</td><td>{row.type}</td><td><StatusBadge isBot={row.isBot} /></td><td className="wrap-cell" title={row.message}>{row.message}</td></tr>)}
{data.rows.map((row) => <tr key={`${row.kind}:${row.id}`}><td>{shortDate(row.createdAt)}</td><td>{row.kind}</td><td>{row.source}</td><td><EventType value={row.type} /></td><td><StatusBadge isBot={row.isBot} /></td><td className="wrap-cell" title={row.message}>{row.message}</td></tr>)}
{!data.rows.length && <tr><td colSpan={6}>No events match these filters.</td></tr>}
</tbody></DataTable><Pagination path="/logs" params={params} page={page} pageSize={pageSize} total={data.total} /></section>
</>;
Expand Down
6 changes: 5 additions & 1 deletion app/(dashboard)/security/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -34,6 +37,7 @@ export default async function SecurityPage({ searchParams }: { searchParams: Pro
<label><span>Rows</span><select name="pageSize" defaultValue={pageSize}>{[25,50,100].map((value) => <option key={value}>{value}</option>)}</select></label>
<button className="primary-button" type="submit">Apply filters</button>
</form></FilterBar>
{data.types.includes("not_found") && <InfoCallout title="What “Not found · 404” means">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.</InfoCallout>}
{filters.ipHash && <InfoCallout title="Filtered fingerprint">Showing the complete privacy-preserving hash <code>{filters.ipHash}</code>. <Link href={`/security?${queryString(params, { ipHash: undefined, page: 1 })}`}>Clear</Link></InfoCallout>}
<section className="dashboard-grid">
<section className="panel"><div className="panel-header"><h2>Repeat flagged visitors</h2></div>
Expand All @@ -44,7 +48,7 @@ export default async function SecurityPage({ searchParams }: { searchParams: Pro
</section>
<section className="panel span-full"><div className="panel-header"><h2>Security events</h2><span>Stable newest-first ordering</span></div>
<DataTable label="Security events"><thead><tr><th>Time</th><th>Type</th><th>IP</th><th>Source</th><th>Classification</th><th>Message</th></tr></thead><tbody>
{data.events.map((event) => <tr key={event.id}><td>{shortDate(event.createdAt)}</td><td>{event.type}</td><td>{maskIp(event.ipAddress)}</td><td>{event.source}</td><td><StatusBadge isBot={event.isBot} botName={event.botName} /></td><td className="wrap-cell" title={event.message}>{event.message}</td></tr>)}
{data.events.map((event) => <tr key={event.id}><td>{shortDate(event.createdAt)}</td><td><EventType value={event.type} /></td><td><IpAddress address={maskIp(event.ipAddress)} isCloudflare={isCloudflareIp(event.ipAddress)} /></td><td>{event.source}</td><td><StatusBadge isBot={event.isBot} botName={event.botName} /></td><td className="wrap-cell" title={event.message}>{event.message}</td></tr>)}
{!data.events.length && <tr><td colSpan={6}>No security events match these filters.</td></tr>}
</tbody></DataTable><Pagination path="/security" params={params} page={page} pageSize={pageSize} total={data.total} />
</section>
Expand Down
6 changes: 4 additions & 2 deletions app/(dashboard)/sites/[siteId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -59,7 +61,7 @@ export default async function SiteDetailPage({ params, searchParams }: { params:
<TopList title="Browsers" rows={data.browsers} />
<TopList title="Operating systems" rows={data.operatingSystems} />
<TopList title="Countries" rows={data.countries} />
{data.cities.length ? <TopList title="Cities" rows={data.cities} /> : <section className="panel"><div className="panel-header"><h2>Cities</h2></div><InfoCallout title={!site._count.events ? "No traffic yet" : !env.ipinfoToken ? "City provider not configured" : env.ipinfoTier === "lite" ? "IPinfo Core required" : "No city data returned"}>City data applies prospectively to new events.</InfoCallout></section>}
{data.cities.length ? <TopList title="Cities" rows={data.cities} /> : <section className="panel"><div className="panel-header"><h2>Cities</h2></div><InfoCallout title={!site._count.events ? "No traffic yet" : !env.ipinfoToken || env.ipinfoTier === "lite" ? "City data not received" : "No city data returned"}>Enable Cloudflare&apos;s Add visitor location headers managed transform or use IPinfo Core. City data applies prospectively to new events.</InfoCallout></section>}
<TopList title="Tools used" rows={data.topTools} />
<section className="panel">
<div className="panel-header"><h2>Devices</h2></div>
Expand All @@ -76,7 +78,7 @@ export default async function SiteDetailPage({ params, searchParams }: { params:
<tr key={event.id}>
<td>{shortDate(event.createdAt)}</td>
<td>{event.path || event.type}</td>
<td>{maskIp(event.ipAddress)}</td>
<td><IpAddress address={maskIp(event.ipAddress)} isCloudflare={isCloudflareIp(event.ipAddress)} /></td>
<td><StatusBadge isBot={event.isBot} botName={event.botName} asn={event.asn} isp={event.isp} /></td>
<td>{[event.city, event.country].filter(Boolean).join(", ") || "Unknown"}</td>
<td>{event.referrerDomain || "Direct"}</td>
Expand Down
16 changes: 16 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions components/EventType.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const typeDetails: Record<string, { label: string; description: string }> = {
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 <span>{value.replaceAll("_", " ")}</span>;
return <span className="event-type" title={detail.description}>{detail.label}</span>;
}
16 changes: 16 additions & 0 deletions components/IpAddress.tsx
Original file line number Diff line number Diff line change
@@ -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 <span>{address}</span>;

return <button
type="button"
className="ip-address-toggle"
aria-label={showAddress ? `Cloudflare address ${address}; show provider` : `Cloudflare address; show IP`}
title={showAddress ? "Show provider" : "Show masked IP address"}
onClick={() => setShowAddress((current) => !current)}
>{showAddress ? address : "Cloudflare"}</button>;
}
51 changes: 51 additions & 0 deletions lib/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 2 additions & 2 deletions lib/geo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down
18 changes: 18 additions & 0 deletions tests/cloudflare.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading