Skip to content
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
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
node_modules
.next
.git
.DS_Store
.npm-cache
.playwright-cli
.tmp
output
*.tsbuildinfo
.env
.env.local
.env.*
!.env.example
npm-debug.log
Dockerfile
docker-compose.yml
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
# Optional. Set this to the hosted documentation URL, such as https://docs.buffer.lol.
NEXT_PUBLIC_DOCS_URL=

# Optional. Enables the private BufferDash analytics tracker when both values are set.
# Create the site in BufferDash, then use the generated public site key here.
BUFFERDASH_URL=
BUFFERDASH_SITE_ID=

# Optional production hardening for server-backed diagnostics.
# When both Upstash values are set, API rate limits coordinate across serverless replicas.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

# Proxy IP headers are trusted automatically in production. Set false to disable.
# Trust proxy IP headers only when your proxy overwrites client-supplied forwarding headers.
TRUST_PROXY_HEADERS=
TRUSTED_PROXY_PLATFORM=

Expand Down
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Validate buffer.lol

on:
pull_request:
workflow_dispatch:

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
16 changes: 16 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,23 @@ on:
- main

jobs:
validate:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build

deploy:
needs: validate
runs-on: ubuntu-latest

steps:
Expand Down
11 changes: 9 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000

COPY --from=builder /app ./
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs

COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000
CMD ["npm", "start"]
CMD ["node", "server.js"]
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ buffer.lol is a focused toolbox for checking hosts, domains, headers, certificat

| Category | Tools |
| --- | --- |
| Networking | Ping, packet loss, traceroute, DNS lookup, HTTP headers, SSL certificate checker, uptime checker, port checker, CIDR calculator, WHOIS/RDAP lookup, redirect checker, robots.txt/sitemap checker |
| IP | What's my IP, IP geolocation, ASN/ISP lookup, user-agent parser |
| Networking | Browser latency, connection stability, traceroute, DNS lookup, HTTP headers, SSL certificate checker, uptime checker, port checker, CIDR calculator, WHOIS/RDAP lookup, redirect checker, robots.txt/sitemap checker |
| IP | What's my IP, IP network lookup, ASN/ISP lookup, user-agent parser |
| Developer | JSON formatter, Base64 encoder/decoder, hash generator, UUID generator, timestamp converter, URL parser/encoder, JWT decoder, regex tester |

Ping and packet-loss/stability tests run from the visitor's browser to buffer.lol using repeated HTTPS samples. Traceroute uses the included diagnostics worker because route tracing is not available in browsers or typical serverless runtimes.
Browser latency and connection-stability tests run from the visitor's browser to buffer.lol using repeated HTTPS samples. Traceroute uses the included diagnostics worker because route tracing is not available in browsers or typical serverless runtimes.

## Tech Stack

Expand All @@ -49,6 +49,7 @@ Open `http://localhost:3000`.
| `npm run dev` | Start the local Next.js app. |
| `npm run build` | Build the production app. |
| `npm run lint` | Run ESLint for `app` and `components`. |
| `npm test` | Run the security and request-boundary test suite. |
| `npm run typecheck` | Run TypeScript without emitting files. |

## Environment
Expand All @@ -60,18 +61,20 @@ Copy `.env.example` to `.env.local` when configuring production-like behavior:
| Variable | Required | Description |
| --- | --- | --- |
| `NEXT_PUBLIC_DOCS_URL` | No | Docs URL used by the app. Leave empty locally or set to `https://docs.buffer.lol` in production. |
| `BUFFERDASH_URL` | No | Public URL of the private BufferDash instance, such as `https://dash.buffer.lol`. Enables analytics only when paired with `BUFFERDASH_SITE_ID`. |
| `BUFFERDASH_SITE_ID` | No | Public site key generated by BufferDash for `buffer.lol`. |
| `UPSTASH_REDIS_REST_URL` | No | Enables shared Redis-backed API rate limiting when paired with the token. |
| `UPSTASH_REDIS_REST_TOKEN` | No | Upstash REST token for shared rate limiting. |
| `TRUST_PROXY_HEADERS` | No | Proxy IP headers are trusted automatically in production. Set to `false` only if the app is directly exposed without a trusted proxy. |
| `TRUST_PROXY_HEADERS` | No | Set to `true` only when the app is behind a proxy that overwrites client-supplied forwarding headers. |
| `TRUSTED_PROXY_PLATFORM` | No | Alternative proxy preset: `vercel` or `cloudflare`. |
| `ENABLE_WORKER_TOOLS` | No | Enables the worker-backed traceroute tool. |
| `DIAGNOSTICS_WORKER_URL` | No | Base URL for the diagnostics worker. Required when worker tools are enabled. |
| `DIAGNOSTICS_WORKER_TOKEN` | No | Optional bearer token sent to the diagnostics worker. |
| `DIAGNOSTICS_WORKER_TOKEN` | Worker | Required by the diagnostics worker in production and sent as a bearer token by the app. |
| `DIAGNOSTICS_MAX_CONCURRENCY` | No | Per-instance cap for live diagnostics work. Defaults to the app fallback when unset. |

## Diagnostics Worker

The `diagnostics-worker/` service powers the traceroute visualizer. Ping and packet-loss/stability tests run in the browser against buffer.lol.
The `diagnostics-worker/` service powers the traceroute visualizer. Browser latency and connection-stability tests run in the browser against buffer.lol.

It exposes `POST /api/traceroute`, validates public targets, runs Linux `traceroute` with strict timeouts, and returns JSON to the main app. The worker also keeps server-side ping endpoints available for future/internal use, but the public ping and packet-loss pages do not use them.

Expand All @@ -85,6 +88,21 @@ DIAGNOSTICS_WORKER_TOKEN=use-a-long-random-secret

The worker container needs `NET_RAW` for low-level network diagnostics. See `diagnostics-worker/README.md` for the full compose snippet.

## BufferDash Analytics

buffer.lol can send page views, outbound clicks, sessions, and custom product events to a private BufferDash instance.

1. Deploy BufferDash at a private dashboard URL, such as `https://dash.buffer.lol`.
2. In BufferDash, open `/sites`, create a site for `buffer.lol`, and copy the generated public site key.
3. Set these values for the buffer.lol app:

```env
BUFFERDASH_URL=https://dash.buffer.lol
BUFFERDASH_SITE_ID=buffer-lol-example
```

When both values are present, the root layout loads `/bufferdash.js`, which injects `https://dash.buffer.lol/tracker.js` on every page. Leave either value empty to disable analytics.

## API

Server-backed tools use a shared endpoint:
Expand Down Expand Up @@ -143,6 +161,7 @@ Before publishing, run:
```bash
npm run lint
npm run typecheck
npm test
npm run build
```

Expand Down
1 change: 0 additions & 1 deletion app/api/_lib/client-ip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export function trustProxyHeaders() {

const platform = process.env.TRUSTED_PROXY_PLATFORM?.toLowerCase();
return (
process.env.NODE_ENV === "production" ||
platform === "vercel" ||
platform === "cloudflare" ||
process.env.VERCEL === "1" ||
Expand Down
3 changes: 3 additions & 0 deletions app/api/_lib/ip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ const IPV4_BLOCKED_RANGES: Array<[number, number]> = [
const IPV6_BLOCKED_RANGES: Array<[bigint, number]> = [
[BigInt(0), 128],
[BigInt(1), 128],
[ipv6ToBigInt("::"), 96],
[ipv6ToBigInt("::ffff:0:0:0"), 96],
[ipv6ToBigInt("64:ff9b::"), 96],
[ipv6ToBigInt("64:ff9b:1::"), 48],
[ipv6ToBigInt("100::"), 64],
[ipv6ToBigInt("2001::"), 23],
[ipv6ToBigInt("2001:2::"), 48],
Expand Down
10 changes: 10 additions & 0 deletions app/api/_lib/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type RateLimitResult = {
};

const buckets = new Map<string, Bucket>();
const MAX_MEMORY_BUCKETS = 10_000;
let lastCleanup = Date.now();

export async function checkRateLimit(request: NextRequest, options: RateLimitOptions): Promise<RateLimitResult> {
Expand All @@ -44,6 +45,7 @@ function checkMemoryRateLimit(key: string, options: RateLimitOptions): RateLimit
if (!bucket || bucket.resetAt <= now) {
bucket = { count: 0, resetAt: now + options.windowMs };
buckets.set(key, bucket);
trimBuckets();
}

const allowed = bucket.count < options.limit;
Expand All @@ -52,6 +54,14 @@ function checkMemoryRateLimit(key: string, options: RateLimitOptions): RateLimit
return toRateLimitResult(allowed, options.limit, Math.max(0, options.limit - bucket.count), bucket.resetAt);
}

function trimBuckets() {
while (buckets.size > MAX_MEMORY_BUCKETS) {
const oldestKey = buckets.keys().next().value;
if (typeof oldestKey !== "string") break;
buckets.delete(oldestKey);
}
}

async function checkUpstashRateLimit(key: string, options: RateLimitOptions): Promise<RateLimitResult> {
const windowId = Math.floor(Date.now() / options.windowMs);
const redisKey = `rate:${key}:${windowId}`;
Expand Down
12 changes: 12 additions & 0 deletions app/api/_lib/request-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ type CacheEntry<T> = {

const cache = new Map<string, CacheEntry<unknown>>();
const inflight = new Map<string, Promise<unknown>>();
const MAX_CACHE_ENTRIES = 500;
let activeRequests = 0;
let lastCleanup = Date.now();

Expand All @@ -13,17 +14,28 @@ export async function withCache<T>(key: string, ttlMs: number, factory: () => Pr
const cached = cache.get(key) as CacheEntry<T> | undefined;

if (cached && cached.expiresAt > now) {
cache.delete(key);
cache.set(key, cached);
return cached.value;
}

return dedupe(key, async () => {
const value = await factory();
cache.set(key, { value, expiresAt: Date.now() + ttlMs });
cleanupCache(Date.now());
trimCache();
return value;
});
}

function trimCache() {
while (cache.size > MAX_CACHE_ENTRIES) {
const oldestKey = cache.keys().next().value;
if (typeof oldestKey !== "string") break;
cache.delete(oldestKey);
}
}

export async function dedupe<T>(key: string, factory: () => Promise<T>): Promise<T> {
const existing = inflight.get(key) as Promise<T> | undefined;
if (existing) return existing;
Expand Down
Loading
Loading