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
11 changes: 4 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# App
NODE_ENV=production
APP_URL=https://dash.example.com
TZ=America/New_York

# Database
# In Docker Compose, keep the host as "postgres".
Expand All @@ -19,20 +20,16 @@ ADMIN_PASSWORD=change_this_password

# Tracking
TRACKING_SECRET=change_this_to_a_long_random_secret
ANONYMIZE_IP=false
ANONYMIZE_IP=true
# Keep true when BufferDash is behind Nginx or Caddy on a VPS.
TRUST_PROXY=true

# Geo/IP Lookup Optional
IPINFO_TOKEN=
MAXMIND_LICENSE_KEY=
ENFORCE_TRACKING_ORIGIN=true

# Security
RATE_LIMIT_TRACKING_PER_MINUTE=120
RATE_LIMIT_ADMIN_PER_MINUTE=60

# Server Monitoring Optional
ENABLE_SERVER_METRICS=true
ENABLE_LOG_INGESTION=false
ENABLE_SERVER_METRICS=false
DATA_RETENTION_DAYS=90
FILTER_BOTS=false
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Validate BufferDash

on:
pull_request:
push:
branches:
- main
workflow_dispatch:

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
27 changes: 19 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# BufferDash

BufferDash is a self-hosted analytics, security, and server monitoring dashboard for websites and VPS deployments. It was built for `buffer.lol`, but the tracker and dashboard support multiple sites from one install.
BufferDash is a self-hosted, first-party web analytics dashboard with traffic-quality signals and optional application-runtime metrics. It was built for `buffer.lol`, but one installation can track multiple sites.

## Features

- Multi-site tracking with public site keys
- Tiny public `/tracker.js` script
- Page views, sessions, unique visitors, referrers, browsers, OS, devices, and live visitors
- Secure IP handling with optional anonymization and hashed IPs
- Basic bot and suspicious path detection
- Bot and unusual-path signals visible to the browser tracker
- Protected admin dashboard with signed HTTP-only sessions and CSRF checks for UI mutations
- Server health metrics for CPU, memory, disk, load average, uptime, and network counters
- Optional metrics visible to the BufferDash process, clearly identified as container/runtime data when applicable
- Docker Compose setup with PostgreSQL

## Quick Start
Expand Down Expand Up @@ -41,7 +41,7 @@ cp .env.example .env
docker compose up -d
```

The Compose stack starts PostgreSQL, waits for it to become healthy, runs Prisma migrations with the `migrate` service, and then starts the app. PostgreSQL is persisted in the `postgres_data` Docker volume and is not published on a host port.
The Compose stack starts PostgreSQL, waits for it to become healthy, runs Prisma migrations with the `migrate` service, and then starts the app. PostgreSQL is persisted in the `postgres_data` Docker volume and is not published on a host port. BufferDash binds only to `127.0.0.1:3000` by default.

Do not expose PostgreSQL publicly. Keep it on Docker's internal network, use a VPS firewall, and put Nginx or Caddy with HTTPS in front of the app.

Expand All @@ -59,13 +59,16 @@ Edit `.env` before starting the stack:

```env
APP_URL=https://dash.example.com
TZ=America/New_York
DATABASE_URL=postgresql://bufferdash:replace_with_a_real_password@postgres:5432/bufferdash
POSTGRES_PASSWORD=replace_with_a_real_password
SESSION_SECRET=replace_with_a_long_random_secret
TRACKING_SECRET=replace_with_a_different_long_random_secret
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD_HASH=replace_with_a_bcrypt_hash
TRUST_PROXY=true
ANONYMIZE_IP=true
ENABLE_SERVER_METRICS=false
```

Generate secrets and the admin password hash:
Expand All @@ -83,6 +86,8 @@ docker compose ps
curl -fsS http://127.0.0.1:3000/health
```

The health endpoint checks both required production configuration and the database. The container will remain unhealthy if placeholder secrets or an invalid admin password hash are still configured.

For the VPS firewall, allow only SSH, HTTP, and HTTPS from the public internet. The app should stay behind the reverse proxy on port `3000`, and PostgreSQL should not be reachable from outside Docker.

## Database Backups
Expand Down Expand Up @@ -144,6 +149,7 @@ The tracker does not collect form inputs, cookies, localStorage contents, passwo
See `.env.example` for the full set. The most important production values are:

- `APP_URL`
- `TZ` (the dashboard's reporting timezone)
- `DATABASE_URL`
- `POSTGRES_PASSWORD`
- `SESSION_SECRET`
Expand All @@ -152,21 +158,24 @@ See `.env.example` for the full set. The most important production values are:
- `TRACKING_SECRET`
- `ANONYMIZE_IP`
- `TRUST_PROXY`
- `ENFORCE_TRACKING_ORIGIN`

Settings are environment-driven in v1 so secrets and operational toggles are not exposed through a browser editor.

## Privacy Notes

BufferDash can log IP addresses and user agents. If you deploy it, disclose analytics and IP logging in the privacy policy for every tracked site. Set `ANONYMIZE_IP=true` to store truncated IP addresses while still retaining an HMAC hash for uniqueness and abuse detection.

Data retention cleanup is available from `/settings`. The default retention window is controlled by `DATA_RETENTION_DAYS`.
Data retention cleanup is available from `/settings`. It removes old events, sessions, orphaned visitor identifiers, traffic flags, and runtime metrics. The default retention window is controlled by `DATA_RETENTION_DAYS`.

## Security Notes

- `.env` is ignored by Git.
- Admin sessions are signed, HTTP-only, SameSite cookies.
- UI mutations include CSRF tokens.
- Production rejects placeholder secrets, non-HTTPS `APP_URL` values, and missing bcrypt admin hashes.
- `/api/track` validates payloads with Zod and rate limits by IP.
- Tracking requests are restricted to each site's configured domain by default. This limits accidental or casual key reuse, though browser origin headers are not a substitute for a private credential.
- Public APIs never return analytics data.
- Client-submitted IP, country, city, browser, OS, and device values are not trusted.
- v1 intentionally does not include a browser terminal, arbitrary file browser, or `.env` editor.
Expand All @@ -182,7 +191,8 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Overwrite, rather than append to, client-supplied forwarding headers.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Expand All @@ -191,10 +201,11 @@ server {
## Roadmap

- GeoIP enrichment with IPinfo or MaxMind
- Fail2Ban and SSH log ingestion
- Optional reverse-proxy, Fail2Ban, and SSH log ingestion with a dedicated least-privilege agent
- User roles and TOTP
- Read-only dashboards
- Background metric collection worker
- Scheduled uptime, latency, HTTP status, and TLS-expiry monitoring
- Background runtime metric and retention workers
- Public screenshots and deployment guides

## License
Expand Down
4 changes: 2 additions & 2 deletions app/(dashboard)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ export default async function DashboardPage() {
</div>
<TopBarChart data={data.browsers} />
</section>
<TopList title="Countries" rows={data.countries} />
<TopList title="Tools used · 24h" rows={data.topTools} />
</section>

<section className="panel span-full">
<div className="panel-header">
<h2>Recent events</h2>
<span>{numberFormat(overview.securityEvents)} security events today</span>
<span>{numberFormat(overview.securityEvents)} traffic flags today</span>
</div>
<div className="table-wrap">
<table>
Expand Down
12 changes: 6 additions & 6 deletions app/(dashboard)/security/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@ export default async function SecurityPage() {

return (
<>
<PageHeader eyebrow="Security" title="Suspicious traffic and bot activity" description="Basic scanner, bot, and abuse detection from tracked requests." />
<PageHeader eyebrow="Traffic quality" title="Bots and unusual tracked visits" description="Signals visible to the browser tracker. VPS, SSH, reverse-proxy, and requests that do not execute JavaScript are not included." />
<section className="dashboard-grid">
<TopList title="Suspicious IP hashes" rows={suspiciousIps} />
<section className="panel">
<div className="panel-header"><h2>Detection coverage</h2></div>
<div className="panel-header"><h2>Tracker coverage</h2></div>
<div className="settings-list">
<p>Known bots and crawlers</p>
<p>WordPress and exposed-secret probes</p>
<p>Unusual paths reached by JavaScript-capable clients</p>
<p>Empty or abnormal user agents</p>
<p>Rate-limit enforcement on tracking</p>
<p>Tracking endpoint rate-limit enforcement</p>
</div>
</section>
</section>
<section className="panel span-full">
<div className="panel-header"><h2>Security events</h2></div>
<div className="panel-header"><h2>Traffic flags</h2></div>
<div className="table-wrap">
<table>
<thead><tr><th>Time</th><th>Type</th><th>IP</th><th>Source</th><th>Message</th></tr></thead>
Expand All @@ -37,7 +37,7 @@ export default async function SecurityPage() {
<td>{event.message}</td>
</tr>
))}
{events.length === 0 && <tr><td colSpan={5}>No suspicious events recorded.</td></tr>}
{events.length === 0 && <tr><td colSpan={5}>No tracked traffic flags recorded.</td></tr>}
</tbody>
</table>
</div>
Expand Down
4 changes: 2 additions & 2 deletions app/(dashboard)/server/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ export default async function ServerPage() {

return (
<>
<PageHeader eyebrow="Server" title="VPS health" description="OS-level CPU, memory, disk, load, uptime, and network counters." />
<PageHeader eyebrow="Runtime" title="Application runtime health" description="Opt-in metrics visible to the BufferDash process. In Docker, some values may describe the container rather than the full VPS." />
<section className="metrics-grid">
<MetricCard label="CPU" value={`${Math.round(latest?.cpuPercent || 0)}%`} detail="Current load" tone="orange" />
<MetricCard label="Memory" value={`${memoryPercent}%`} detail={`${Math.round(latest?.memoryUsedMb || 0)} MB used`} />
<MetricCard label="Disk" value={`${diskPercent}%`} detail={`${Math.round(latest?.diskUsedGb || 0)} GB used`} tone="green" />
<MetricCard label="Load avg" value={(latest?.load1 || 0).toFixed(2)} detail={`${Math.round((latest?.uptimeSeconds || 0) / 3600)}h uptime`} />
</section>
<section className="panel span-full">
<div className="panel-header"><h2>Resource history</h2><span>Sampled when dashboard/API is read</span></div>
<div className="panel-header"><h2>Resource history</h2><span>Sampled when this page or its API is read</span></div>
<ServerChart data={history} />
</section>
</>
Expand Down
13 changes: 5 additions & 8 deletions app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { deleteOldDataAction, updateSettingsAction } from "@/app/actions";
import { deleteOldDataAction } from "@/app/actions";
import { PageHeader } from "@/components/PageHeader";
import { ActionForm } from "@/components/StateMessage";
import { getCsrfToken } from "@/lib/auth";
Expand All @@ -16,18 +16,15 @@ export default async function SettingsPage() {
<div className="settings-list">
<p><span>IP anonymization</span><strong>{env.anonymizeIp ? "On" : "Off"}</strong></p>
<p><span>Trust proxy headers</span><strong>{env.trustProxy ? "On" : "Off"}</strong></p>
<p><span>Site origin checks</span><strong>{env.enforceTrackingOrigin ? "On" : "Off"}</strong></p>
<p><span>Bot filtering</span><strong>{env.filterBots ? "On" : "Off"}</strong></p>
<p><span>Retention default</span><strong>{env.dataRetentionDays} days</strong></p>
<p><span>Log ingestion</span><strong>{env.enableLogIngestion ? "On" : "Off"}</strong></p>
<p><span>Runtime metrics</span><strong>{env.enableServerMetrics ? "On" : "Off"}</strong></p>
</div>
</section>
<section className="panel">
<div className="panel-header"><h2>Update settings</h2></div>
<ActionForm action={updateSettingsAction} className="stack-form">
<input type="hidden" name="csrf" value={csrf} />
<p className="muted">Edit `.env` for secrets, IP policy, and server toggles. This keeps production secrets out of the UI.</p>
<button className="secondary-button" type="submit">Why locked?</button>
</ActionForm>
<div className="panel-header"><h2>Configuration</h2></div>
<p className="muted">Edit `.env` and restart BufferDash to change privacy, proxy, retention, and runtime settings. Secrets are intentionally never editable in the browser.</p>
</section>
</section>
<section className="panel span-full">
Expand Down
2 changes: 1 addition & 1 deletion app/(dashboard)/sites/[siteId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default async function SiteDetailPage({ params }: { params: Promise<{ sit
<section className="dashboard-grid">
<TopList title="Top pages" rows={data.topPages} />
<TopList title="Referrers" rows={data.referrers} />
<TopList title="Countries" rows={data.countries} />
<TopList title="Tools used · 24h" rows={data.topTools} />
<section className="panel">
<div className="panel-header"><h2>Devices</h2></div>
<TopBarChart data={data.devices} />
Expand Down
29 changes: 11 additions & 18 deletions app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ export async function loginAction(_state: ActionState, formData: FormData): Prom
redirect("/dashboard");
}

export async function logoutAction() {
export async function logoutAction(formData: FormData) {
await requireAdmin();
await assertCsrf(formData);
const store = await cookies();
store.delete(SESSION_COOKIE);
store.delete(CSRF_COOKIE);
Expand All @@ -82,7 +84,6 @@ export async function createSiteAction(_state: ActionState, formData: FormData):
data: {
...parsed.data,
publicKey,
secretKey: nanoid(32),
ownerId: owner.id
}
});
Expand All @@ -100,16 +101,6 @@ export async function deleteSiteAction(formData: FormData) {
redirect("/sites");
}

export async function updateSettingsAction(_state: ActionState, formData: FormData): Promise<ActionState> {
await requireAdmin();
await assertCsrf(formData);

return {
success:
"Settings are environment controlled in v1. Update .env and restart the app so secrets and privacy flags stay out of the browser."
};
}

export async function deleteOldDataAction(_state: ActionState, formData: FormData): Promise<ActionState> {
await requireAdmin();
await assertCsrf(formData);
Expand All @@ -119,13 +110,15 @@ export async function deleteOldDataAction(_state: ActionState, formData: FormDat
}

const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
await Promise.all([
prisma.event.deleteMany({ where: { createdAt: { lt: cutoff } } }),
prisma.securityEvent.deleteMany({ where: { createdAt: { lt: cutoff } } }),
prisma.serverMetric.deleteMany({ where: { createdAt: { lt: cutoff } } })
]);
await prisma.$transaction(async (tx) => {
await tx.event.deleteMany({ where: { createdAt: { lt: cutoff } } });
await tx.securityEvent.deleteMany({ where: { createdAt: { lt: cutoff } } });
await tx.serverMetric.deleteMany({ where: { createdAt: { lt: cutoff } } });
await tx.session.deleteMany({ where: { endedAt: { lt: cutoff } } });
await tx.visitor.deleteMany({ where: { events: { none: {} }, sessions: { none: {} } } });
});

return { success: `Deleted analytics, security, and metric rows older than ${days} days.` };
return { success: `Deleted analytics, sessions, visitors, security events, and metrics older than ${days} days.` };
}

function normalizeDomain(value: string) {
Expand Down
9 changes: 0 additions & 9 deletions app/api/admin/logout/route.ts

This file was deleted.

8 changes: 0 additions & 8 deletions app/api/admin/me/route.ts

This file was deleted.

10 changes: 0 additions & 10 deletions app/api/admin/security/events/route.ts

This file was deleted.

10 changes: 0 additions & 10 deletions app/api/admin/server/status/route.ts

This file was deleted.

16 changes: 0 additions & 16 deletions app/api/admin/settings/route.ts

This file was deleted.

Loading
Loading