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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ IP2LOCATION_API_KEY=""
IPCHECKING_API_KEY=""
MAC_LOOKUP_API_KEY=""
IPCHECKING_API_ENDPOINT=""
RIPESTAT_SOURCE_APP=""
# Security related
SECURITY_BLACKLIST_LOG_FILE_PATH=""
SECURITY_RATE_LIMIT=""
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ common/maxmind-db/*.next
common/maxmind-db/*.mmdb
.learnings/
docs/

# Private AI context (per-machine, not for the public repo)
local-context.md
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Single repo, two halves: a Vue 3 SPA front-end and an Express 5 back-end API, se
| Backend | Express 5 |
| Logger | `pino` + `pino-pretty` (dev) + `pino-http` (request logs) — singleton at `common/logger.js` |
| Auth | Firebase Auth (optional, env-gated) |
| PWA | Serwist |
| PWA install | `manifest.webmanifest` only (installable but online-only — no service worker) |
| Tests | Node built-in test runner (`node --test`) |
| Runtime libs | chart.js · svgmap · @cloudflare/speedtest · maxmind · whoiser · thumbmarkjs · ua-parser-js · detect-gpu · circle-progress.vue · @vueuse/core (used by shadcn-vue primitives) |

Expand Down Expand Up @@ -119,3 +119,7 @@ The backend enforces access control and timeouts through shared middleware rathe
- **Add yourself as a co-author to the commit.** If you are an AI.
- **Commit message style** follows recent `git log` — `Refactor(xxx): …` / `Fix(ui): …` / `Feat(xxx): …` / `Style: …` / `Chore: …` prefix.
- **On every commit, scan AGENTS.md (root + relevant sub-file) for staleness** — if the change adds a convention, renames a shared module, flips a rule, or invalidates an example, update the doc in the same commit. AGENTS.md drifting from reality is the main failure mode of this kind of document.

---

If [local-context.md](./local-context.md) exists in the workspace root, please Read it as well — it lists Knowledge Hub paths relevant to this project (machine-local only; not in git).
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ Download `GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb` from your MaxMind account
| `IPAPIIS_API_KEY` | No | `""` | API Key for IPAPI.is, used to obtain IP geolocation information through IPAPI.is |
| `IP2LOCATION_API_KEY` | No | `""` | API Key for IP2Location.io, used to obtain IP geolocation information through IP2Location.io |
| `CLOUDFLARE_API` | No | `""` | API Key for Cloudflare, used to obtain AS system information through Cloudflare |
| `RIPESTAT_SOURCE_APP` | No | `""` | Source app name for RIPE.net, used to obtain ASN history information through RIPE.net |
| `MAC_LOOKUP_API_KEY` | No | `""` | API Key for MAC Lookup, used to obtain MAC address information |
| `VITE_CURL_IPV4_DOMAIN` | No | `""` | Provides the IPv4 domain for the CURL API to users |
| `VITE_CURL_IPV6_DOMAIN` | No | `""` | Provides the IPv6 domain for the CURL API to users |
Expand Down
1 change: 1 addition & 0 deletions README_FR.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ Téléchargez `GeoLite2-City.mmdb` et `GeoLite2-ASN.mmdb` depuis votre compte Ma
| `IPAPIIS_API_KEY` | Non | `""` | Clé API pour IPAPI.is, utilisée pour obtenir des informations de géolocalisation sur l'adresse IP via IPAPI.is |
| `IP2LOCATION_API_KEY` | Non | `""` | Clé API pour IP2Location.io, utilisée pour obtenir des informations de géolocalisation sur l'adresse IP via IP2Location.io |
| `CLOUDFLARE_API` | Non | `""` | Clé API pour Cloudflare, utilisée pour obtenir des informations sur le système AS via Cloudflare |
| `RIPESTAT_SOURCE_APP` | Non | `""` | Nom de l'application source pour RIPE.net, utilisé pour obtenir des informations sur l'historique ASN via RIPE.net |
| `MAC_LOOKUP_API_KEY` | Non | `""` | Clé API pour MAC Lookup, utilisée pour obtenir des informations sur l'adresse MAC via MAC Lookup |
| `VITE_CURL_IPV4_DOMAIN` | Non | `""` | Fournit aux utilisateurs le domaine IPv4 pour l'API CURL |
| `VITE_CURL_IPV6_DOMAIN` | Non | `""` | Fournit aux utilisateurs le domaine IPv6 pour l'API CURL |
Expand Down
1 change: 1 addition & 0 deletions README_TR.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ MaxMind hesabınızdan `GeoLite2-City.mmdb` ve `GeoLite2-ASN.mmdb` dosyalarını
| `IPAPIIS_API_KEY` | Hayır | `""` | IPAPI.is API anahtarı, IP konum bilgisi almak için |
| `IP2LOCATION_API_KEY` | Hayır | `""` | IP2Location.io API anahtarı, IP konum bilgisi almak için |
| `CLOUDFLARE_API` | Hayır | `""` | Cloudflare API anahtarı, AS sistemi bilgisi almak için |
| `RIPESTAT_SOURCE_APP` | Hayır | `""` | RIPE.net kaynak uygulama adı, RIPE.net üzerinden ASN geçmiş bilgisi almak için |
| `MAC_LOOKUP_API_KEY` | Hayır | `""` | MAC Lookup API anahtarı, MAC adresi bilgisi almak için |
| `VITE_CURL_IPV4_DOMAIN` | Hayır | `""` | Kullanıcılara CURL API için IPv4 domain sağlar |
| `VITE_CURL_IPV6_DOMAIN` | Hayır | `""` | Kullanıcılara CURL API için IPv6 domain sağlar |
Expand Down
1 change: 1 addition & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ MyIP 依赖 MaxMind 提供的免费 **GeoLite2** 数据库(City + ASN)来进
| `IPAPIIS_API_KEY` | 否 | `""` | IPAPI.is 的 API Key,用于通过 IPAPI.is 获取 IP 归属地信息 |
| `IP2LOCATION_API_KEY` | 否 | `""` | IP2Location.io 的 API Key,用于通过 IP2Location.io 获取 IP 归属地信息 |
| `CLOUDFLARE_API` | 否 | `""` | Cloudflare 的 API Key,用于通过 Cloudflare 获取 AS 系统的信息 |
| `RIPESTAT_SOURCE_APP` | 否 | `""` | RIPE.net 的源应用名称,用于通过 RIPE.net 获取 ASN 的历史信息 |
| `MAC_LOOKUP_API_KEY` | 否 | `""` | MAC 查询的 API Key,用于通过 MAC Lookup 获取 MAC 地址的归属信息 |
| `VITE_CURL_IPV4_DOMAIN` | 否 | `""` | 为用户提供 CURL API 的 IPv4 域名 |
| `VITE_CURL_IPV6_DOMAIN` | 否 | `""` | 为用户提供 CURL API 的 IPv6 域名 |
Expand Down
14 changes: 14 additions & 0 deletions api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ common/

- `requireReferer` is mounted globally on `/api/*` in `backend-server.js`. It rejects any request whose `Referer` header isn't on the `ALLOWED_DOMAINS` list (plus `localhost` always). **Handlers must not repeat the referer check.**
- `requireValidIP()` is attached per-route to every handler that takes `?ip=`. It rejects missing or malformed IPs before the handler runs. **Handlers must not repeat the IP check** — inside the handler body, `req.query.ip` is already known to be a well-formed string.
- `requireValidPrefix()` is the same pattern for `?prefix=` (CIDR-shaped param). Used by `asn-history` so the frontend can quantize the user's IP to its BGP DFZ-floor (/24 v4 or /48 v6) before the request lands, maximizing CF edge cache reuse across every IP in the same prefix.
- If you add a new handler that needs a different-shape param guard, add the guard to `common/guards.js` and attach it in `backend-server.js` rather than open-coding the check in the handler.

### Private-API header pass-through (intentional exception)
Expand All @@ -84,6 +85,19 @@ This is deliberate — the upstream expects caller context (Accept-Language, aut

Some handlers keep a `req.method !== 'GET'` (or `PUT`) branch even though Express routes already gate the method. These exist because dedicated smoke tests assert on that branch directly against the handler. If you add or copy a handler, leave the defensive gate in place if a test covers it.

## Edge caching

Default: every `/api/*` response gets `Cache-Control: no-store` from the global middleware in `backend-server.js`. Routes that serve slowly-changing public data opt in to Cloudflare edge caching by attaching the `cacheable(maxAgeSeconds)` middleware (defined in `backend-server.js`):

```js
app.get('/api/cfradar', cacheable(60 * 60), cfHander);
app.get('/api/asn-history', requireValidPrefix(), cacheable(24 * 60 * 60), asnHistoryHandler);
```

Write the TTL as a multiplied expression (`60 * 60` / `24 * 60 * 60` / `30 * 24 * 60 * 60`) rather than a raw second count — the intent is self-evident at a glance, and JS folds the constant at call time so there's no runtime cost.

The middleware hooks `res.json` and only sets `Cache-Control: public, max-age=N` when the response status is < 400 — error responses keep `no-store` so CF never caches a 4xx/5xx page. Handlers themselves stay pure and don't touch `Cache-Control`. **Auth'd or per-user endpoints** (`ipchecking` / `invisibility` / `dns-leak-test/session` / `getuserinfo` / etc.) must not be wrapped with `cacheable` — their caching belongs at the upstream service that owns the auth context.

## Testing

- Smoke tests for every handler live in `tests/api-handlers.test.js`. They cover method gating, param presence / validity (beyond what middleware handles), and the "API key missing" early-return branches.
Expand Down
124 changes: 124 additions & 0 deletions api/asn-history.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// /api/asn-history — RIPEstat routing-history for a CIDR prefix (the
// frontend quantizes the user's IP to /24 v4 or /48 v6 first, so all IPs
// in the same prefix collapse to one CF edge cache entry). Org names per
// ASN come from RIPEstat as-overview, fetched in parallel, best-effort.

import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';

const prefixLength = (prefix) => parseInt((prefix || '').split('/')[1], 10);

// BGP-meaningful prefix floor per family. Shorter prefixes are leaks /
// default routes that happen to cover the IP but don't attribute it.
const MIN_PREFIX = { v4: 8, v6: 19 };

// Below this peer count an announcement is route noise / brief misconfig.
const MIN_PEERS = 30;

// RIPEstat polite-citizen marker; overridable per deployment.
const SOURCE_APP = process.env.RIPESTAT_SOURCE_APP || 'myip';

// Shorter timeout for the org enrichment calls — they're best-effort, we'd
// rather return ASN-only history than make the user wait the full upstream
// timeout for a slow secondary lookup.
const ORG_FETCH_TIMEOUT_MS = 8000;

function summarizeOrigin(entry, minLen) {
const acceptedPrefixes = (entry.prefixes || []).filter(p => prefixLength(p.prefix) >= minLen);
if (acceptedPrefixes.length === 0) return null;

const allTimes = [];
for (const p of acceptedPrefixes) {
for (const t of p.timelines || []) allTimes.push(t);
}
if (allTimes.length === 0) return null;

let firstSeen = allTimes[0].starttime;
let lastSeen = allTimes[0].endtime;
let maxPeers = 0;
for (const t of allTimes) {
if (t.starttime < firstSeen) firstSeen = t.starttime;
if (t.endtime > lastSeen) lastSeen = t.endtime;
if ((t.full_peers_seeing || 0) > maxPeers) maxPeers = t.full_peers_seeing;
}

if (maxPeers < MIN_PEERS) return null;

return {
asn: String(entry.origin),
org: null,
firstSeen,
lastSeen,
peers: Math.round(maxPeers),
prefixes: acceptedPrefixes.map(p => p.prefix),
};
}

// Holder is typically "<HANDLE> - <Company>, <CC>"; strip the leading handle.
function extractOrgFromHolder(holder) {
if (!holder || typeof holder !== 'string') return null;
const dashIdx = holder.indexOf(' - ');
return dashIdx > 0 ? holder.slice(dashIdx + 3).trim() : holder.trim();
}

// Best-effort. Any failure (timeout, non-2xx, parse error) yields null so the
// parent Promise.all never rejects and the row falls back to ASN-only display.
async function fetchAsOrgName(asn) {
try {
const url = `https://stat.ripe.net/data/as-overview/data.json`
+ `?resource=AS${encodeURIComponent(asn)}&sourceapp=${SOURCE_APP}`;
const res = await fetchUpstream(url, { timeoutMs: ORG_FETCH_TIMEOUT_MS });
if (!res.ok) return null;
const payload = await res.json();
return extractOrgFromHolder(payload?.data?.holder);
} catch (error) {
logger.warn({ err: error, asn }, 'as-overview lookup failed');
return null;
}
}

export default async (req, res) => {
// Prefix presence + validity guaranteed by requireValidPrefix middleware.
// Frontend quantizes the user's IP to /24 (v4) or /48 (v6) so every IP in
// the same prefix collapses to one cache entry at CF's edge.
const prefix = req.query.prefix;
const family = prefix.includes(':') ? 'v6' : 'v4';
const minLen = MIN_PREFIX[family];

try {
const url = `https://stat.ripe.net/data/routing-history/data.json`
+ `?resource=${encodeURIComponent(prefix)}&sourceapp=${SOURCE_APP}`;
const apiRes = await fetchUpstream(url);
if (!apiRes.ok) {
logger.warn({ prefix, status: apiRes.status }, 'RIPEstat routing-history non-2xx');
return res.status(502).json({ error: 'Upstream error' });
}
const payload = await apiRes.json();
const origins = payload?.data?.by_origin || [];

const history = origins
.map(entry => summarizeOrigin(entry, minLen))
.filter(Boolean)
.sort((a, b) => (b.lastSeen || '').localeCompare(a.lastSeen || ''));

// Org enrichment is strictly best-effort: anything that goes wrong here
// leaves rows with org=null, but the ASN-keyed timeline still ships.
try {
const uniqueAsns = [...new Set(history.map(row => row.asn))];
const orgPairs = await Promise.all(
uniqueAsns.map(async asn => [asn, await fetchAsOrgName(asn)])
);
const orgByAsn = Object.fromEntries(orgPairs);
for (const row of history) {
row.org = orgByAsn[row.asn] || null;
}
} catch (error) {
logger.warn({ err: error, prefix }, 'as-overview batch failed; returning ASN-only history');
}

res.json({ prefix, history });
} catch (error) {
logger.error({ err: error, prefix }, 'asn-history handler failed');
res.status(500).json({ error: error.message });
}
};
Loading
Loading