From 7dddf4ca6042731ec1249a40489f4c3e87460c3e Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 5 Aug 2026 20:55:32 -0500 Subject: [PATCH] Feature set 134. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rate-limit the caller, not the load balancer. Connection.RemoteIpAddress behind Railway is one of ~20 internal 100.64.x addresses, so every anonymous caller shared about twenty buckets and none of them was anybody. Recovered from production logs by hashing candidate ranges against the stored IpHash values: the ten busiest "clients" were 100.64.0.3 through .9. ClientIpResolver reads CF-Connecting-IP, but only on requests carrying a secret the Worker injects — Cloudflare appends to a caller-supplied X-Forwarded-For, and Railway answers on its own hostname where the header can just be typed in, so a spoofable key would be worse than a coarse one. Rate-limit rejections are now recorded. UseRateLimiter short-circuits ahead of the logging middleware, so the logs held zero 429s. Stats: date ranges, and metrics worth the trip. The page was fixed at 24 hours. It now takes any range, switches from hourly to daily buckets past three days, and reports the window it actually measured. Adds keyed-vs-anonymous split, top failing paths, per-endpoint p95, and busiest clients. Also drops the language describing this as a wiki-scraping project. The seeder is gone; attribution stays, because the wiki's text is CC BY-SA. Co-Authored-By: Claude Opus 5 --- .github/workflows/checks.yml | 4 +- .github/workflows/images.yml | 23 +- README.md | 25 +- cloudflare/RATE-LIMITING.md | 93 +++++ cloudflare/maintenance-worker/worker.js | 35 +- cloudflare/maintenance-worker/wrangler.toml | 10 + scripts/MoogleAPI.Scraper/ImageStore.cs | 6 +- scripts/MoogleAPI.Scraper/Program.cs | 16 +- .../Scrapers/ImageReverter.cs | 4 +- .../Scrapers/ImageScraper.cs | 2 +- .../Features/Stats/GetStats/Endpoint.cs | 141 +++++++- .../Features/Stats/GetStats/Models.cs | 51 ++- .../Middleware/RequestLogWriter.cs | 54 +++ .../Middleware/RequestLoggingMiddleware.cs | 62 +--- .../RateLimiting/ApiRateLimiterPolicy.cs | 42 ++- .../RateLimiting/ClientIpResolver.cs | 92 +++++ src/MoogleAPI.Web/Program.cs | 7 + src/MoogleAPI.Web/Stats/index.html | 332 ++++++++++++++++-- .../MoogleAPI.Tests/ClientIpResolverTests.cs | 102 ++++++ 19 files changed, 971 insertions(+), 130 deletions(-) create mode 100644 cloudflare/RATE-LIMITING.md create mode 100644 src/MoogleAPI.Web/Infrastructure/Middleware/RequestLogWriter.cs create mode 100644 src/MoogleAPI.Web/Infrastructure/RateLimiting/ClientIpResolver.cs create mode 100644 tests/MoogleAPI.Tests/ClientIpResolverTests.cs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index c99cf69..bb3c5d5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,7 +1,7 @@ name: Checks -# The gate on every PR to main. Before this the only CI here was the weekly scraper, -# so a PR could only be verified by reading it. +# The gate on every PR to main. Before this there was no CI that ran on a PR at all, +# so a change could only be verified by reading it. # # Format, build, test. Deliberately NOT path-filtered: this is the required status # check for main, and a required check that never runs leaves a docs-only PR pending diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 42ac916..c7b4bf5 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -1,17 +1,16 @@ name: Artwork -# This was "Scrape Final Fantasy Data", on a Sunday-night cron. Both the name and the schedule -# are gone with the wiki stages: the catalogue is curated by hand now, rows are added through the -# dashboard, and an unattended weekly job that rewrites data nobody asked it to touch is exactly -# what was retired. +# Artwork only, and manual only. The catalogue itself is curated by hand through the dashboard — +# no job here writes data on a timer, because an unattended job that rewrites rows nobody asked it +# to touch is exactly what was retired. # -# What is left runs on request only. This is also the only place the R2 and Gemini credentials -# exist, which is why the file survives at all — `generate` spends real money per image, so it -# wants a deliberate press of a button and a ceiling, not a timer. +# This is also the only place the R2 and Gemini credentials exist, which is why the file survives +# at all — `generate` spends real money per image, so it wants a deliberate press of a button and +# a ceiling, not a schedule. # -# Worth knowing about `images`: a row imported from the wiki through the dashboard still points -# at the wiki's own CDN. That stage copies the art into our bucket and repoints the row, so it is -# the natural thing to run after a batch of imports. It skips anything already hosted by us, +# Worth knowing about `images`: a row added through the dashboard can still point at artwork hosted +# somewhere else. That stage copies the art into our bucket and repoints the row, so it is the +# natural thing to run after a batch of additions. It skips anything already hosted by us, # including hand-uploaded art, so it costs nothing when there is nothing to do. on: workflow_dispatch: @@ -59,8 +58,8 @@ jobs: env: CONNECTION_STRING: ${{ secrets.CONNECTION_STRING }} - # Every stage here writes to the bucket, so unlike the old scrape these are required - # rather than optional — the tool exits with an error when they are missing. + # Every stage here writes to the bucket, so these are required rather than optional — + # the tool exits with an error when they are missing. # The account id is the same value under both names — the secret predates the R2 # naming the tool reads it by. R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/README.md b/README.md index 61d9ca5..3136ad2 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ A free, open REST API for Final Fantasy data — characters, monsters, and games - **HybridCache** — stampede-proof L1/L2 caching out of the box - **Rate limiting** — 60 req/min anonymous, 600 req/min with an API key - **Interactive docs** at `/scalar/v1` (far nicer than Swagger UI) -- **Auto-updating** — a GitHub Action scrapes the Final Fantasy Wiki every Sunday +- **Hand-curated** — every row reviewed and edited through the dashboard, not bulk-imported --- @@ -103,7 +103,7 @@ Full interactive docs at [`/scalar/v1`](https://moogleapi.com/scalar/v1). | Caching | `HybridCache` — L1 in-process + optional L2 Redis | | Docs | [Scalar](https://scalar.com) — replaces Swagger UI | | Rate Limiting | `PartitionedRateLimiter` (native .NET 10) | -| Data pipeline | GitHub Actions scraper → Final Fantasy Wiki | +| Artwork pipeline | GitHub Actions → Gemini → Cloudflare R2 | ### Project Structure @@ -138,17 +138,26 @@ MoogleApi.sln │ ├── wwwroot/ ← Landing page + /games hub + four games │ └── Program.cs ├── scripts/ -│ └── MoogleAPI.Scraper/ ← Console app, runs in GitHub Actions +│ └── MoogleAPI.Scraper/ ← Artwork tool, runs in GitHub Actions └── tests/ └── MoogleAPI.Tests/ ``` --- -## 🤖 Data Pipeline +## 🤖 Data & Artwork -A GitHub Action runs every Sunday at 2 AM UTC and scrapes the [Final Fantasy Wiki](https://finalfantasy.fandom.com) via the MediaWiki API. It upserts characters and monsters per game — no duplicates, no full reloads. +The catalogue is curated by hand. Rows are added and edited through the private dashboard, one at +a time, with a person deciding what belongs — there is no unattended job that rewrites the data on +a timer, and no bulk import behind the current contents. -Stages can be run individually with `--only=`: `games`, `characters`, `playable`, `monsters`, `cards`, `images`, `audit`, `generate`, `promote`. The `playable` stage reads each game's character navbox to mark which characters the player actually controls — the only source scoped to a single game, since the wiki has no playable-character category and the prose test answers for the whole compilation. +What still runs on request is the artwork tool, dispatched manually from the **Artwork** workflow. +Its stages are `images` (copy artwork into our own bucket and repoint the row), `audit` (classify +what each image actually is), `generate` (replace it with an illustration in one house style) and +`unpromote` (withdraw generated art and restore the original). `generate` costs money per image, so +it takes an explicit ceiling with `--max` and never runs as part of an unnamed "all stages" pass. + +Artwork is served from Cloudflare R2 at `images.moogleapi.com`. Keys derive from the row id, which +is what makes a move between domains a database pass rather than a re-upload. --- @@ -169,7 +178,9 @@ allowlist with `ApiKeys__Keys__0`, `ApiKeys__Keys__1`, … With none set, everyt ## 📜 Disclaimer -MoogleAPI is a fan project and is not affiliated with or endorsed by Square Enix. All Final Fantasy names, characters, and related marks are trademarks of Square Enix Co., Ltd. Data is sourced from the community-maintained [Final Fantasy Wiki](https://finalfantasy.fandom.com). +MoogleAPI is a fan project and is not affiliated with or endorsed by Square Enix. All Final Fantasy names, characters, and related marks are trademarks of Square Enix Co., Ltd. + +The catalogue was originally seeded from the community-maintained [Final Fantasy Wiki](https://finalfantasy.fandom.com) and is maintained by hand from there on. That attribution stays while any of it remains: the wiki's text is CC BY-SA, which requires credit regardless of how much editing has happened since. --- diff --git a/cloudflare/RATE-LIMITING.md b/cloudflare/RATE-LIMITING.md new file mode 100644 index 0000000..1c09d3f --- /dev/null +++ b/cloudflare/RATE-LIMITING.md @@ -0,0 +1,93 @@ +# Rate limiting + +Two layers, doing different jobs. The edge one is the one that protects costs; the app one is the +one that knows who is keyed. + +## Why the app limiter alone was not enough + +Railway terminates connections at its own load balancer, so `Connection.RemoteIpAddress` inside the +app is one of a small pool of internal `100.64.0.0/10` addresses — never the caller. Measured +against three months of production request logs on 2026-08-05, the ten busiest "clients" were +`100.64.0.3` through `100.64.0.9`, and the distinct-address count sat at 21–24 a day whether the day +served 56 requests or 1,730. Real visitors scale with traffic; a fixed pool of load balancers does +not. + +The consequences were that no caller could be isolated (an abuser's requests scattered across ~20 +buckets), legitimate callers shared buckets with abusers, and the effective anonymous ceiling was +roughly 60/min × proxy nodes × replicas — which is where the ~290/min measured in production came +from. + +## What is fixed in the app + +`ClientIpResolver` now resolves the caller from `CF-Connecting-IP`, but **only** on requests that +carry the shared secret the Worker injects. That condition is the whole design: Cloudflare appends +to a caller-supplied `X-Forwarded-For`, so its first entry is attacker-controlled, and Railway also +answers on its own `*.up.railway.app` hostname where `CF-Connecting-IP` can simply be typed in. A +spoofable partition key would be worse than a coarse one — it would let one caller mint unlimited +fresh windows. Without the secret, the resolver falls back to the peer address: imprecise, never in +the attacker's favour. + +Rejections are also recorded now. `UseRateLimiter` short-circuits ahead of the logging middleware, +so three months of logs contain zero 429s — which was never evidence the limiter wasn't firing. + +### Setup — both sides, or it silently degrades + +```bash +# 1. Generate a secret +openssl rand -hex 32 + +# 2. Cloudflare Worker +cd cloudflare/maintenance-worker +wrangler secret put EDGE_SECRET # paste it + +# 3. Railway → Variables (same value) +Edge__Secret = +``` + +If the two disagree the app stops trusting forwarded addresses and quietly goes back to limiting on +the load balancer. Nothing breaks and nothing warns, so treat rotation as a two-sided change. + +**Verifying it took**, once both are deployed: hit the site through Cloudflare a few times, then +check that `/stats` shows a "Busiest Clients" list that grows with real traffic rather than sitting +at ~20 fixed hashes. Those hashes are the tell. + +## What still needs doing at the edge — not applicable from this repo + +The app limiter is per-process, and Railway runs several replicas, so its ceiling is still +multiplied by replica count. More importantly, a request it rejects has already cost Railway compute +and possibly a Neon query. Blocking at Cloudflare is what actually protects spend. + +In the dashboard: **Security → WAF → Rate limiting rules → Create rule** + +| Field | Value | +|---|---| +| Rule name | `api-anonymous` | +| If incoming requests match | `(http.request.uri.path contains "/api/")` | +| Characteristics | IP | +| Period | 1 minute | +| Requests | 120 | +| Action | Block (or Managed Challenge) | +| Duration | 1 minute | + +Notes on the numbers. 120/min is deliberately looser than the app's 60 — the edge rule is the +backstop against abuse, and the app is what draws the anonymous/keyed distinction. Set the edge +below the app limit and the app's tiers stop meaning anything. + +To exempt keyed callers, add to the rule expression: + +``` +(http.request.uri.path contains "/api/" and not any(http.request.headers["x-api-key"][*] in {"key-one" "key-two"})) +``` + +That places live credentials in a dashboard rule, which is a real trade — it is why this is written +as optional rather than recommended. The alternative is to leave keyed callers subject to the edge +rule too and raise its ceiling. + +Free plans allow a limited number of rate-limiting rules; check the allowance on the current plan +before designing around several. + +## Costs worth knowing + +Images serve from R2, which has no egress fees, so image bandwidth is not the exposure. Railway +compute and Neon are — which makes the list and search endpoints the ones worth protecting. Both +are already fronted by `HybridCache`. diff --git a/cloudflare/maintenance-worker/worker.js b/cloudflare/maintenance-worker/worker.js index 377c4ea..5fe068a 100644 --- a/cloudflare/maintenance-worker/worker.js +++ b/cloudflare/maintenance-worker/worker.js @@ -22,8 +22,10 @@ const FAILURE_STATUSES = new Set([502, 503, 504, 521, 522, 523, 524, 525, 526]); const RETRY_DELAY_MS = 1500; export default { - async fetch(request) { - let response = await tryOrigin(request); + async fetch(request, env) { + const proxied = withEdgeSecret(request, env); + + let response = await tryOrigin(proxied); // Only replay methods that are safe to run twice. A retried POST could double // a daily-guess submission, which is worse than showing the maintenance page. @@ -32,7 +34,7 @@ export default { if (response === null || FAILURE_STATUSES.has(response.status)) { if (isReplayable) { await sleep(RETRY_DELAY_MS); - const second = await tryOrigin(request); + const second = await tryOrigin(proxied); if (second !== null && !FAILURE_STATUSES.has(second.status)) return second; response = second; } @@ -43,6 +45,33 @@ export default { }, }; +/** + * Stamps the request with the shared secret that tells the origin this call really came + * through Cloudflare, so it can believe CF-Connecting-IP and rate-limit the actual caller. + * + * Without this the origin has no usable identity for a request: Railway terminates at its + * own load balancer, so the peer address the app sees is one of about twenty internal + * 100.64.x addresses shared by everybody. X-Forwarded-For can't fill the gap either — + * Cloudflare appends to whatever the caller sends, so its first entry is caller-controlled. + * + * The header is always set or deleted, never passed through: Railway answers on its own + * *.up.railway.app hostname too, and a caller who could forge this on that path would be + * choosing their own rate-limit bucket. Overwriting here means anything inbound is discarded. + */ +function withEdgeSecret(request, env) { + const proxied = new Request(request); + + if (env && env.EDGE_SECRET) { + proxied.headers.set('X-Moogle-Edge', env.EDGE_SECRET); + } else { + // No secret configured: strip it rather than forward a caller's own value. The origin + // then falls back to the peer address, which is imprecise but not attacker-chosen. + proxied.headers.delete('X-Moogle-Edge'); + } + + return proxied; +} + async function tryOrigin(request) { try { return await fetch(request); diff --git a/cloudflare/maintenance-worker/wrangler.toml b/cloudflare/maintenance-worker/wrangler.toml index 1da21bc..634acc7 100644 --- a/cloudflare/maintenance-worker/wrangler.toml +++ b/cloudflare/maintenance-worker/wrangler.toml @@ -17,3 +17,13 @@ zone_name = "moogleapi.com" # No observability, KV, or bindings on purpose — this Worker is pure pass-through # and adding state would put something else in the critical path of every request. +# +# One secret, deliberately not a var and so not written here: +# +# wrangler secret put EDGE_SECRET +# +# It is stamped onto every proxied request as X-Moogle-Edge, and it is what lets the origin +# believe CF-Connecting-IP — see withEdgeSecret in worker.js. The same value goes to Railway +# as Edge__Secret. If the two ever disagree the app simply stops trusting forwarded addresses +# and rate-limits on the load balancer again: degraded, not broken, and silent, so treat +# rotating it as a two-sided change. diff --git a/scripts/MoogleAPI.Scraper/ImageStore.cs b/scripts/MoogleAPI.Scraper/ImageStore.cs index 7329e2b..bc5a3cd 100644 --- a/scripts/MoogleAPI.Scraper/ImageStore.cs +++ b/scripts/MoogleAPI.Scraper/ImageStore.cs @@ -50,7 +50,7 @@ public record ImageStoreOptions( /// Copies artwork into Cloudflare R2, re-encoded on the way in. /// /// -/// Storing the wiki's originals verbatim would be roughly 4.6 GB across the library and would +/// Storing the source originals verbatim would be roughly 4.6 GB across the library and would /// serve megabyte PNGs to phones. Resizing to a sane bound and re-encoding as WebP lands the /// whole set near 0.5 GB while still being sharper than the 400px thumbnails half the rows /// currently point at. @@ -120,7 +120,7 @@ public async Task EnsureBucketAsync(CancellationToken ct) { try { - // No Referer: the wiki's CDN rejects any request that carries one. + // No Referer: the source CDN rejects any request that carries one. using var response = await http.GetAsync(OriginalOf(sourceUrl), ct); if (!response.IsSuccessStatusCode) { @@ -150,7 +150,7 @@ await _s3.PutObjectAsync(new PutObjectRequest return key; } // A missing image is never fatal to a run — including when the source is not a URL at - // all. Not every row's provenance comes from the wiki now: the dashboard records a + // all. Not every row's provenance is a remote URL: the dashboard records a // hand-upload by writing what it did into ImageSourceUrl, and a forced re-copy hands // that straight to HttpClient, which rejects a non-absolute address before any request // leaves the process. Uncaught, one such row would take the whole image stage down with diff --git a/scripts/MoogleAPI.Scraper/Program.cs b/scripts/MoogleAPI.Scraper/Program.cs index 7ed5e73..f098ace 100644 --- a/scripts/MoogleAPI.Scraper/Program.cs +++ b/scripts/MoogleAPI.Scraper/Program.cs @@ -6,16 +6,14 @@ using MoogleAPI.Scraper.Scrapers; using MoogleAPI.Web.Infrastructure.Data; -// This was a wiki scraper. It is now an artwork tool, and it keeps the name only because -// renaming a project is noisier than the clarity would be worth. +// The artwork tool. It keeps the project name only because renaming one is noisier than the +// clarity would be worth. // -// The stages that built the catalogue out of Final Fantasy Wiki — games, characters, playable, -// monsters, cards, repair — are gone. They finished. What they produced has since been corrected -// and curated by hand, and every one of them matched rows by wiki page name rather than by id, -// so a re-run could only undo that work: deleted rows returned, renamed rows arrived a second -// time, and hand-scored popularity was overwritten on sight. New rows are added through the -// dashboard now, one at a time, with the wiki available as something to import from rather than -// as the authority. +// The stages that first populated the catalogue are gone. They were a seeder: they ran, they +// finished, and what they produced has since been corrected and curated by hand. They also +// matched rows by page name rather than by id, so a re-run could only undo that work — deleted +// rows returned, renamed rows arrived a second time, and hand-scored popularity was overwritten +// on sight. Rows are added through the dashboard now, one at a time, by a person. // // What remains is the part that has not finished, because it is about pixels rather than facts: // copying art into R2, classifying what each picture actually is, and paying Gemini to replace diff --git a/scripts/MoogleAPI.Scraper/Scrapers/ImageReverter.cs b/scripts/MoogleAPI.Scraper/Scrapers/ImageReverter.cs index f6a2620..a31ad73 100644 --- a/scripts/MoogleAPI.Scraper/Scrapers/ImageReverter.cs +++ b/scripts/MoogleAPI.Scraper/Scrapers/ImageReverter.cs @@ -81,8 +81,8 @@ public async Task RevertAsync(CancellationToken ct = default) /// Where a row's artwork should point once the generated version is withdrawn. /// /// - /// The copy the image stage made of the wiki's original, which promotion never touched — it - /// only re-pointed the column, so the file is still at its own address. The scraped wiki URL + /// The copy the image stage made of the original, which promotion never touched — it only + /// re-pointed the column, so the file is still at its own address. The recorded source URL /// is the fallback, and it is a poor one: that CDN blocks any request carrying a Referer, /// which is the whole reason the images were copied here. Better than a row with no picture. /// Null means neither exists, and the caller must leave the row alone. diff --git a/scripts/MoogleAPI.Scraper/Scrapers/ImageScraper.cs b/scripts/MoogleAPI.Scraper/Scrapers/ImageScraper.cs index af90dcd..013370c 100644 --- a/scripts/MoogleAPI.Scraper/Scrapers/ImageScraper.cs +++ b/scripts/MoogleAPI.Scraper/Scrapers/ImageScraper.cs @@ -8,7 +8,7 @@ namespace MoogleAPI.Scraper.Scrapers; /// Copies every piece of artwork the API points at into our own bucket. /// /// -/// Until this runs, the API serves URLs on the wiki's CDN — which hotlink-blocks any request +/// Until this runs, the API serves URLs on somebody else's CDN — which hotlink-blocks any request /// carrying a Referer, meaning every consumer has to remember to suppress it, and any change /// on their side breaks every image at once. Once copied, the URLs are ours and stable. /// diff --git a/src/MoogleAPI.Web/Features/Stats/GetStats/Endpoint.cs b/src/MoogleAPI.Web/Features/Stats/GetStats/Endpoint.cs index a1552b1..0d034c5 100644 --- a/src/MoogleAPI.Web/Features/Stats/GetStats/Endpoint.cs +++ b/src/MoogleAPI.Web/Features/Stats/GetStats/Endpoint.cs @@ -4,8 +4,19 @@ namespace MoogleAPI.Web.Features.Stats.GetStats; -public class Endpoint(AppDbContext db) : EndpointWithoutRequest +public class Endpoint(AppDbContext db) : Endpoint { + /// + /// Past this many rows the range is trimmed to its most recent slice rather than loaded whole. + /// The aggregation runs in memory — at three months and ~7,800 rows that is the right trade for + /// a one-reader dashboard, but "all time" grows without bound, and this keeps a future year of + /// traffic from turning one page load into an out-of-memory restart. + /// + private const int MaxRows = 250_000; + + /// Hourly buckets stop being readable somewhere past a few days. + private static readonly TimeSpan HourlyLimit = TimeSpan.FromDays(3); + public override void Configure() { // Global RoutePrefix "api" is prepended, so the final URL is /api/stats @@ -15,17 +26,30 @@ public override void Configure() Description(b => b.ExcludeFromDescription()); } - public override async Task HandleAsync(CancellationToken ct) + public override async Task HandleAsync(StatsRequest req, CancellationToken ct) { var now = DateTime.UtcNow; - var todayUtc = now.Date; - var last24h = now.AddHours(-24); + + // Bounds are clamped rather than rejected: this is a personal dashboard driven by two date + // boxes, and a 400 for from-after-to would be pedantry rather than safety. + var to = req.To is null ? now : AsUtc(req.To.Value); + var from = req.From is null ? to.AddHours(-24) : AsUtc(req.From.Value); + + // Order first, then clamp. Clamping before the swap lets a range that is entirely in the + // future come back out of it — the pair would be reordered around the clamped end and the + // page would report measuring up to a date months away. + if (from > to) + (from, to) = (to, from); + if (to > now) + to = now; + if (from > to) + from = to; var totalRequests = await db.RequestLogs.LongCountAsync(ct); - // Load last 24h into memory — small enough for a personal dashboard var logs = await db.RequestLogs - .Where(r => r.Timestamp >= last24h) + .Where(r => r.Timestamp >= from && r.Timestamp <= to) + .OrderByDescending(r => r.Timestamp) .Select(r => new { r.Timestamp, @@ -35,23 +59,38 @@ public override async Task HandleAsync(CancellationToken ct) r.SearchTerm, r.ResourceType, r.IpHash, + r.IsPremium, }) + .Take(MaxRows + 1) .ToListAsync(ct); - var today = logs.Where(r => r.Timestamp >= todayUtc).ToList(); + var truncated = logs.Count > MaxRows; + if (truncated) + { + logs = logs.Take(MaxRows).ToList(); + // Report the window actually covered, not the one that was asked for. + from = logs[^1].Timestamp; + } + + var useHours = to - from <= HourlyLimit; + var range = new RangeInfo(from, to, useHours ? "hour" : "day", truncated); var summary = new SummaryStats( TotalRequests: totalRequests, - RequestsToday: today.Count, - ErrorsToday: today.Count(r => r.StatusCode >= 400), - RateLimitedToday: today.Count(r => r.StatusCode == 429), - UniqueIpsToday: today.Where(r => r.IpHash != null).Select(r => r.IpHash).Distinct().Count() + RequestsInRange: logs.Count, + ErrorsInRange: logs.Count(r => r.StatusCode >= 400), + // Only populated for rows written from 2026-08-05 on: before that, rejections + // short-circuited ahead of the logging middleware and were never recorded at all. + RateLimitedInRange: logs.Count(r => r.StatusCode == 429), + UniqueClientsInRange: logs.Where(r => r.IpHash != null).Select(r => r.IpHash).Distinct().Count() ); var requestsOverTime = logs - .GroupBy(r => new DateTime(r.Timestamp.Year, r.Timestamp.Month, r.Timestamp.Day, r.Timestamp.Hour, 0, 0, DateTimeKind.Utc)) - .Select(g => new HourlyCount(g.Key, g.Count())) - .OrderBy(h => h.Hour) + .GroupBy(r => useHours + ? new DateTime(r.Timestamp.Year, r.Timestamp.Month, r.Timestamp.Day, r.Timestamp.Hour, 0, 0, DateTimeKind.Utc) + : new DateTime(r.Timestamp.Year, r.Timestamp.Month, r.Timestamp.Day, 0, 0, 0, DateTimeKind.Utc)) + .Select(g => new BucketCount(g.Key, g.Count())) + .OrderBy(b => b.Bucket) .ToList(); var statusCodes = logs @@ -77,12 +116,76 @@ public override async Task HandleAsync(CancellationToken ct) var durations = logs.Select(r => r.DurationMs).OrderBy(d => d).ToList(); var latency = durations.Count > 0 - ? new LatencyStats( - Math.Round(durations.Average(), 1), - durations[durations.Count / 2], - durations[(int)(durations.Count * 0.95)]) + ? new LatencyStats(Math.Round(durations.Average(), 1), Percentile(durations, 0.50), Percentile(durations, 0.95)) : new LatencyStats(0, 0, 0); - await Send.OkAsync(new DashboardStats(summary, requestsOverTime, statusCodes, topEndpoints, topSearchTerms, latency), ct); + var traffic = new TrafficSplit( + PremiumRequests: logs.Count(r => r.IsPremium), + AnonymousRequests: logs.Count(r => !r.IsPremium)); + + var topErrorPaths = logs + .Where(r => r.StatusCode >= 400) + .GroupBy(r => new { r.Path, r.StatusCode }) + .Select(g => new ErrorPathCount(g.Key.Path, g.Key.StatusCode, g.Count())) + .OrderByDescending(e => e.Count) + .Take(10) + .ToList(); + + // A p95 over two requests is not a p95. Endpoints below the floor are left out rather than + // shown with a number that would read as authoritative. + const int minSamplesForPercentile = 5; + var slowestEndpoints = logs + .GroupBy(r => r.Path) + .Where(g => g.Count() >= minSamplesForPercentile) + .Select(g => new EndpointLatency( + g.Key, + Percentile([.. g.Select(r => r.DurationMs).OrderBy(d => d)], 0.95), + g.Count())) + .OrderByDescending(e => e.P95Ms) + .Take(10) + .ToList(); + + // Hashed addresses, and only honest for rows written after the client-IP fix shipped on + // 2026-08-05. Everything before it hashed Railway's internal load balancer, so historic + // "clients" are a pool of about twenty addresses belonging to the host, not to callers. + var topClients = logs + .Where(r => r.IpHash != null) + .GroupBy(r => r.IpHash!) + .Select(g => new ClientCount(g.Key, g.Count(), g.Any(r => r.IsPremium))) + .OrderByDescending(c => c.Count) + .Take(10) + .ToList(); + + await Send.OkAsync(new DashboardStats( + range, summary, requestsOverTime, statusCodes, topEndpoints, topSearchTerms, latency, + traffic, topErrorPaths, slowestEndpoints, topClients), ct); + } + + /// + /// Forces a bound to UTC before it reaches the query. + /// + /// + /// Model binding hands back Kind=Local for an ISO string ending in Z, and Npgsql + /// refuses to write anything but UTC to a timestamp with time zone column — so every + /// range the page could ask for answered 500 while the default 24 hours, built from + /// , worked fine. A value with no zone at all is read as UTC + /// rather than converted, because the dashboard states its ranges in UTC throughout. + /// + private static DateTime AsUtc(DateTime value) => value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc), + }; + + /// + /// Nearest-rank percentile over an already-sorted list. Extracted because the per-endpoint p95 + /// needs the same calculation the overall one does, and two copies of a percentile drift. + /// + private static int Percentile(List sorted, double percentile) + { + if (sorted.Count == 0) return 0; + var index = (int)Math.Ceiling(percentile * sorted.Count) - 1; + return sorted[Math.Clamp(index, 0, sorted.Count - 1)]; } } diff --git a/src/MoogleAPI.Web/Features/Stats/GetStats/Models.cs b/src/MoogleAPI.Web/Features/Stats/GetStats/Models.cs index 4a6ff0b..5b4f6e9 100644 --- a/src/MoogleAPI.Web/Features/Stats/GetStats/Models.cs +++ b/src/MoogleAPI.Web/Features/Stats/GetStats/Models.cs @@ -1,24 +1,61 @@ namespace MoogleAPI.Web.Features.Stats.GetStats; +/// +/// Query for the dashboard. Both bounds are optional and UTC; the default is the last 24 hours, +/// which is what the page asked for before it could ask for anything else. +/// +public class StatsRequest +{ + public DateTime? From { get; set; } + public DateTime? To { get; set; } +} + public record DashboardStats( + RangeInfo Range, SummaryStats Summary, - List RequestsOverTime, + List RequestsOverTime, List StatusCodes, List TopEndpoints, List TopSearchTerms, - LatencyStats Latency + LatencyStats Latency, + // Added 2026-08-05, all of them answering questions the 24-hour view couldn't. + TrafficSplit Traffic, + List TopErrorPaths, + List SlowestEndpoints, + List TopClients ); +/// +/// What was actually measured. Echoed back because the page lets you pick a range, and a chart +/// whose axis silently switched from hours to days is a chart that lies about its own shape. +/// +public record RangeInfo(DateTime From, DateTime To, string Granularity, bool Truncated); + public record SummaryStats( long TotalRequests, - long RequestsToday, - long ErrorsToday, - long RateLimitedToday, - int UniqueIpsToday + long RequestsInRange, + long ErrorsInRange, + long RateLimitedInRange, + int UniqueClientsInRange ); -public record HourlyCount(DateTime Hour, int Count); +public record BucketCount(DateTime Bucket, int Count); public record StatusCount(int StatusCode, int Count); public record EndpointCount(string Path, int Count); public record SearchCount(string Term, int Count, string Resource); public record LatencyStats(double AvgMs, int P50Ms, int P95Ms); + +/// Keyed vs anonymous traffic — the thing to watch if premium keys are ever sold. +public record TrafficSplit(int PremiumRequests, int AnonymousRequests); + +/// Which paths are failing, and how. +public record ErrorPathCount(string Path, int StatusCode, int Count); + +/// +/// Per-endpoint p95: the list form of the single latency figure, so one slow endpoint can't hide +/// inside a healthy average. +/// +public record EndpointLatency(string Path, int P95Ms, int Count); + +/// Busiest callers by hashed address. See the endpoint for the caveat on older rows. +public record ClientCount(string IpHash, int Count, bool IsPremium); diff --git a/src/MoogleAPI.Web/Infrastructure/Middleware/RequestLogWriter.cs b/src/MoogleAPI.Web/Infrastructure/Middleware/RequestLogWriter.cs new file mode 100644 index 0000000..b08123b --- /dev/null +++ b/src/MoogleAPI.Web/Infrastructure/Middleware/RequestLogWriter.cs @@ -0,0 +1,54 @@ +using MoogleAPI.Web.Infrastructure.Data; +using MoogleAPI.Web.Infrastructure.Models; +using System.Security.Cryptography; +using System.Text; + +namespace MoogleAPI.Web.Infrastructure.Middleware; + +/// +/// Writes request-log rows, in the background, from the two places requests end. +/// +/// +/// This exists because rate-limit rejections never reached . +/// UseRateLimiter sits ahead of it in the pipeline and short-circuits, so a 429 was answered +/// without anything being recorded — which is why three months of logs contain zero of them, and +/// why that zero was never evidence the limiter wasn't firing. The limiter's OnRejected +/// callback now writes through here instead, and reordering the pipeline was rejected as the fix: +/// moving UseRateLimiter after the static-file middleware would quietly exempt every script +/// and stylesheet from rate limiting. +/// +public class RequestLogWriter(IServiceScopeFactory scopeFactory) +{ + /// + /// Queues a row and returns immediately. Nothing here is allowed to slow down or fail a + /// response — a dropped analytics row is worth less than the request it would have described. + /// + public void Write(RequestLog entry) => _ = WriteAsync(entry); + + private async Task WriteAsync(RequestLog entry) + { + try + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.RequestLogs.Add(entry); + await db.SaveChangesAsync(); + } + catch + { + // Logging must never surface errors to the caller. + } + } + + /// + /// Hashes the caller's address rather than storing it. Truncated to 16 hex characters, which + /// is what the existing rows use — changing the width would split every client's history in + /// two at the deploy. + /// + public static string HashIp(string? ip) + { + if (ip is null) return "unknown"; + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(ip)); + return Convert.ToHexString(bytes)[..16]; + } +} diff --git a/src/MoogleAPI.Web/Infrastructure/Middleware/RequestLoggingMiddleware.cs b/src/MoogleAPI.Web/Infrastructure/Middleware/RequestLoggingMiddleware.cs index 562c8f1..cf361fb 100644 --- a/src/MoogleAPI.Web/Infrastructure/Middleware/RequestLoggingMiddleware.cs +++ b/src/MoogleAPI.Web/Infrastructure/Middleware/RequestLoggingMiddleware.cs @@ -1,6 +1,3 @@ -using System.Security.Cryptography; -using System.Text; -using MoogleAPI.Web.Infrastructure.Data; using MoogleAPI.Web.Infrastructure.Models; using MoogleAPI.Web.Infrastructure.RateLimiting; @@ -8,8 +5,9 @@ namespace MoogleAPI.Web.Infrastructure.Middleware; public class RequestLoggingMiddleware( RequestDelegate next, - IServiceScopeFactory scopeFactory, - ApiKeyValidator apiKeys) + RequestLogWriter writer, + ApiKeyValidator apiKeys, + ClientIpResolver clientIps) { public async Task InvokeAsync(HttpContext context) { @@ -40,38 +38,23 @@ public async Task InvokeAsync(HttpContext context) // the response is done and the context is no longer ours to read. var isPremium = apiKeys.ResolveKey(context.Request) is not null; - // Fire-and-forget: never slow down the response for logging - _ = WriteLogAsync(context, path, durationMs, isPremium); - } - - private async Task WriteLogAsync(HttpContext context, string path, int durationMs, bool isPremium) - { - try + writer.Write(new RequestLog { - using var scope = scopeFactory.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - - db.RequestLogs.Add(new RequestLog - { - Timestamp = DateTime.UtcNow, - Path = path, - Method = context.Request.Method, - StatusCode = context.Response.StatusCode, - DurationMs = durationMs, - ResourceType = ExtractResourceType(path), - SearchTerm = context.Request.Query["query"].FirstOrDefault(), - // Recognized key, not merely a present header — otherwise the premium share in - // /api/stats counts anyone who sent the header, valid or not. - IsPremium = isPremium, - IpHash = HashIp(context.Connection.RemoteIpAddress?.ToString()), - }); - - await db.SaveChangesAsync(); - } - catch - { - // Logging must never surface errors to the caller - } + Timestamp = DateTime.UtcNow, + Path = path, + Method = context.Request.Method, + StatusCode = context.Response.StatusCode, + DurationMs = durationMs, + ResourceType = ExtractResourceType(path), + SearchTerm = context.Request.Query["query"].FirstOrDefault(), + // Recognized key, not merely a present header — otherwise the premium share in + // /api/stats counts anyone who sent the header, valid or not. + IsPremium = isPremium, + // The caller, not the load balancer — see ClientIpResolver for why the peer address + // was neither. Rows written before that fix hash Railway's internal pool instead, so + // per-client figures are only meaningful from the deploy onward. + IpHash = RequestLogWriter.HashIp(clientIps.Resolve(context.Request)), + }); } private static string? ExtractResourceType(string path) @@ -80,11 +63,4 @@ private async Task WriteLogAsync(HttpContext context, string path, int durationM var parts = path.TrimStart('/').Split('/'); return parts.Length >= 2 ? parts[1] : null; } - - private static string HashIp(string? ip) - { - if (ip is null) return "unknown"; - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(ip)); - return Convert.ToHexString(bytes)[..16]; - } } diff --git a/src/MoogleAPI.Web/Infrastructure/RateLimiting/ApiRateLimiterPolicy.cs b/src/MoogleAPI.Web/Infrastructure/RateLimiting/ApiRateLimiterPolicy.cs index 56e42dc..6f1e632 100644 --- a/src/MoogleAPI.Web/Infrastructure/RateLimiting/ApiRateLimiterPolicy.cs +++ b/src/MoogleAPI.Web/Infrastructure/RateLimiting/ApiRateLimiterPolicy.cs @@ -1,3 +1,5 @@ +using MoogleAPI.Web.Infrastructure.Middleware; +using MoogleAPI.Web.Infrastructure.Models; using System.Threading.RateLimiting; namespace MoogleAPI.Web.Infrastructure.RateLimiting; @@ -13,6 +15,37 @@ public static IServiceCollection AddApiRateLimiting(this IServiceCollection serv { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + // Rejections short-circuit the pipeline, so the logging middleware downstream never + // sees them. Without this the one status code the limiter exists to produce was the + // one status code the analytics could not show. + options.OnRejected = (context, _) => + { + var path = context.HttpContext.Request.Path.Value ?? ""; + if (!path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) + return ValueTask.CompletedTask; + + var services = context.HttpContext.RequestServices; + var clientIps = services.GetRequiredService(); + var apiKeys = services.GetRequiredService(); + + services.GetRequiredService().Write(new RequestLog + { + Timestamp = DateTime.UtcNow, + Path = path, + Method = context.HttpContext.Request.Method, + StatusCode = StatusCodes.Status429TooManyRequests, + // The request was refused before any work happened; recording a measured + // duration here would drag the latency percentiles toward zero. + DurationMs = 0, + ResourceType = path.TrimStart('/').Split('/') is { Length: >= 2 } parts ? parts[1] : null, + SearchTerm = context.HttpContext.Request.Query["query"].FirstOrDefault(), + IsPremium = apiKeys.ResolveKey(context.HttpContext.Request) is not null, + IpHash = RequestLogWriter.HashIp(clientIps.Resolve(context.HttpContext.Request)), + }); + + return ValueTask.CompletedTask; + }; + // One global limiter rather than named policies. There were two named policies here // that no endpoint ever attached with RequireRateLimiting, so they enforced nothing // while appearing to — and the premium one carried the same unchecked-key flaw as @@ -26,8 +59,15 @@ public static IServiceCollection AddApiRateLimiting(this IServiceCollection serv var validator = context.RequestServices.GetRequiredService(); var apiKey = validator.ResolveKey(context.Request); + // Not Connection.RemoteIpAddress: behind Railway's load balancer that is one of + // about twenty internal 100.64.x addresses shared by every caller, which made the + // anonymous limit a pool nobody owned rather than a limit anybody hit. See + // ClientIpResolver — and note it only trusts a forwarded address that arrives with + // the edge secret, because a spoofable partition key is worse than a coarse one. + var clientIps = context.RequestServices.GetRequiredService(); + var partitionKey = apiKey is null - ? $"ip:{context.Connection.RemoteIpAddress}" + ? $"ip:{clientIps.Resolve(context.Request)}" : $"key:{apiKey}"; var permitLimit = apiKey is null ? AnonymousPermitLimit : PremiumPermitLimit; diff --git a/src/MoogleAPI.Web/Infrastructure/RateLimiting/ClientIpResolver.cs b/src/MoogleAPI.Web/Infrastructure/RateLimiting/ClientIpResolver.cs new file mode 100644 index 0000000..6947308 --- /dev/null +++ b/src/MoogleAPI.Web/Infrastructure/RateLimiting/ClientIpResolver.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text; + +namespace MoogleAPI.Web.Infrastructure.RateLimiting; + +/// +/// Works out who is actually calling, which HttpContext.Connection.RemoteIpAddress does not. +/// +/// +/// Railway terminates the connection at its own load balancer, so the peer address Kestrel sees is +/// one of a small pool of internal 100.64.0.0/10 addresses — measured against three months of +/// production logs on 2026-08-05, the ten busiest "clients" were 100.64.0.3 through +/// 100.64.0.9, and the distinct-address count per day sat at 21–24 whether the day served 56 +/// requests or 1,730. Every caller was therefore sharing about twenty rate-limit buckets with every +/// other caller, and the request log's IpHash was recording infrastructure. +/// +/// The fix cannot simply be X-Forwarded-For. Cloudflare appends to whatever value the +/// caller sends, so its leftmost entry is attacker-controlled, and partitioning a rate limiter on a +/// value the attacker chooses is worse than partitioning on the load balancer: it would let one +/// client mint unlimited fresh windows. CF-Connecting-IP is written by Cloudflare and cannot +/// be forged through it — but Railway also answers on its own *.up.railway.app hostname, where +/// nothing stops a caller setting that header by hand. +/// +/// +/// So the header is trusted only when the request carries a secret that the edge Worker injects and +/// that a direct-to-origin caller cannot know. Without the secret configured, or on a request that +/// doesn't carry it, this falls back to the peer address — the old behaviour, which is imprecise but +/// never wrong in the attacker's favour. +/// +/// +public class ClientIpResolver(IOptions options) +{ + /// Injected by cloudflare/maintenance-worker/worker.js on every proxied request. + public const string SecretHeaderName = "X-Moogle-Edge"; + + /// Set by Cloudflare to the true client address; forgeable only if the edge is bypassed. + public const string ClientIpHeaderName = "CF-Connecting-IP"; + + private readonly byte[]? _secret = string.IsNullOrWhiteSpace(options.Value.Secret) + ? null + : Encoding.UTF8.GetBytes(options.Value.Secret); + + /// + /// The caller's address, or "unknown" when there isn't one. Never null, because it keys + /// a rate-limit partition: a null would collapse every such request into one bucket. + /// + public string Resolve(HttpRequest request) + { + if (CameFromOurEdge(request)) + { + // Trimmed like the API key header is: IPAddress.TryParse rejects surrounding + // whitespace outright, and a stray space is not a reason to give up on the address. + var forwarded = request.Headers[ClientIpHeaderName].ToString().Trim(); + + // Parsed rather than taken as text. The value keys a partition and is hashed into the + // request log, and a caller who could put arbitrary strings there could inflate both. + if (System.Net.IPAddress.TryParse(forwarded, out var address)) + return address.ToString(); + } + + return request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + } + + private bool CameFromOurEdge(HttpRequest request) + { + if (_secret is null) + return false; + + var presented = request.Headers[SecretHeaderName].ToString(); + if (string.IsNullOrEmpty(presented)) + return false; + + // Fixed-time: the comparison is attacker-driven and runs on every request, which is the + // shape a timing attack needs. FixedTimeEquals also handles the length mismatch safely. + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(presented), _secret); + } +} + +/// Configuration for the trusted edge. Supply as Edge__Secret. +public class EdgeOptions +{ + public const string SectionName = "Edge"; + + /// + /// Shared with the Cloudflare Worker, which sends it as . + /// Empty is a legitimate state — it means "don't trust forwarded addresses", which is what a + /// local run wants — so this is deliberately not validated at startup. + /// + public string? Secret { get; set; } +} diff --git a/src/MoogleAPI.Web/Program.cs b/src/MoogleAPI.Web/Program.cs index 3bdb105..ca9a607 100644 --- a/src/MoogleAPI.Web/Program.cs +++ b/src/MoogleAPI.Web/Program.cs @@ -57,6 +57,13 @@ // self-service. No startup validation: an empty list legitimately means nobody has premium. builder.Services.Configure(builder.Configuration.GetSection(PremiumKeyOptions.SectionName)); builder.Services.AddSingleton(); + +// Who the caller is, which the peer address only appears to answer. Unset in development, where +// the resolver then falls back to the peer address and nothing forwarded is believed. +builder.Services.Configure(builder.Configuration.GetSection(EdgeOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + builder.Services.AddApiRateLimiting(); // Daily puzzle seeding. Validated at startup rather than on first request: an empty secret diff --git a/src/MoogleAPI.Web/Stats/index.html b/src/MoogleAPI.Web/Stats/index.html index b966465..dac54aa 100644 --- a/src/MoogleAPI.Web/Stats/index.html +++ b/src/MoogleAPI.Web/Stats/index.html @@ -125,6 +125,56 @@ .card-value.red { color: var(--red); } .card-value.blue { color: var(--accent2); } + /* ── Range picker ── */ + .range-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-bottom: 1.5rem; + } + .range-presets { display: flex; gap: 0.35rem; } + .range-btn { + background: var(--surface); + border: 1px solid var(--border); + color: var(--muted); + border-radius: 6px; + padding: 0.4rem 0.8rem; + font-family: 'Raleway', sans-serif; + font-size: 0.78rem; + cursor: pointer; + transition: border-color 0.2s, color 0.2s, background 0.2s; + } + .range-btn:hover { border-color: var(--accent); color: var(--text); } + .range-btn[aria-pressed="true"] { + background: var(--accent); + border-color: var(--accent); + color: #fff; + } + .range-custom { + display: flex; + align-items: center; + gap: 0.4rem; + margin-left: auto; + font-size: 0.75rem; + color: var(--muted); + } + .range-custom input { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-family: 'Raleway', sans-serif; + font-size: 0.75rem; + padding: 0.35rem 0.5rem; + color-scheme: dark; + } + .range-note { + width: 100%; + font-size: 0.72rem; + color: var(--muted); + } + /* ── Chart grid ── */ .chart-row { display: grid; @@ -153,6 +203,44 @@ .chart-wrap { position: relative; } .chart-wrap canvas { max-height: 220px; } + /* ── Tabular panels (errors, slowest, clients) ── */ + .stat-table { width: 100%; border-collapse: collapse; font-size: 0.78rem; } + .stat-table th { + text-align: left; + font-family: 'Josefin Sans', sans-serif; + font-size: 0.65rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + font-weight: 600; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); + } + .stat-table td { + padding: 0.45rem 0; + border-bottom: 1px solid rgba(69,90,100,0.35); + font-family: 'JetBrains Mono', monospace; + font-size: 0.72rem; + } + .stat-table td.num { text-align: right; color: var(--accent2); } + .stat-table tr:last-child td { border-bottom: none; } + .stat-table .empty { color: var(--muted); font-family: 'Raleway', sans-serif; font-style: italic; } + .pill { + font-family: 'Raleway', sans-serif; + font-size: 0.62rem; + padding: 0.1rem 0.4rem; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--muted); + } + .pill.gold { color: var(--gold); border-color: var(--gold); } + .panel-note { + margin-top: 0.9rem; + font-size: 0.68rem; + color: var(--muted); + line-height: 1.5; + } + /* ── Latency panel ── */ .latency-grid { display: grid; @@ -214,6 +302,25 @@
+ +
+
+ + + + +
+
+ + + + + +
+
+
+
@@ -221,19 +328,19 @@
-
Today
-
+
In Range
+
-
Errors Today
+
Errors
-
Rate-Limited Today
+
Rate-Limited
-
Unique IPs Today
+
Unique Clients
@@ -241,7 +348,7 @@
-

Requests / Hour — Last 24h

+

Requests

@@ -249,25 +356,66 @@

Requests / Hour — Last 24h

-

Top Endpoints — Last 24h

+

Top Endpoints

-

Status Codes — Last 24h

+

Status Codes

+ +
+
+

Top Failing Paths

+ + + +
PathStatusCount
+
+
+

Slowest Endpoints (p95)

+ + + +
Pathp95Reqs
+

Endpoints with fewer than five requests in range are omitted — a p95 over two samples isn't one.

+
+
+ + +
+
+

Keyed vs Anonymous

+
+ +
+
+
+

Busiest Clients

+ + + +
ClientTierRequests
+

+ Hashed addresses, real only from 2026-08-05 onward. Before that the app recorded the peer + address, which behind Railway's load balancer is a pool of about twenty internal addresses — + so earlier rows count infrastructure, not callers. +

+
+
+
-

Top Search Terms — Last 24h

+

Top Search Terms

-

Latency — Last 24h

+

Latency

Avg
@@ -322,21 +470,34 @@

Latency — Last 24h

destroyCharts(); // Cards - document.getElementById('c-total').textContent = fmt(data.summary.totalRequests); - document.getElementById('c-today').textContent = fmt(data.summary.requestsToday); - document.getElementById('c-errors').textContent = fmt(data.summary.errorsToday); - document.getElementById('c-ratelimited').textContent = fmt(data.summary.rateLimitedToday); - document.getElementById('c-ips').textContent = fmt(data.summary.uniqueIpsToday); + document.getElementById('c-total').textContent = fmt(data.summary.totalRequests); + document.getElementById('c-range').textContent = fmt(data.summary.requestsInRange); + document.getElementById('c-errors').textContent = fmt(data.summary.errorsInRange); + document.getElementById('c-ratelimited').textContent = fmt(data.summary.rateLimitedInRange); + document.getElementById('c-ips').textContent = fmt(data.summary.uniqueClientsInRange); + document.getElementById('c-range-label').textContent = rangeLabel(); + + // What was actually measured, which is not always what was asked for: the server trims very + // large ranges to their most recent slice and says so. + const r = data.range; + const span = `${new Date(r.from).toLocaleString()} → ${new Date(r.to).toLocaleString()}`; + document.getElementById('range-note').textContent = + r.truncated ? `${span} · trimmed to the most recent rows` : span; + document.getElementById('h-timeline').textContent = + r.granularity === 'hour' ? 'Requests / Hour' : 'Requests / Day'; // Latency document.getElementById('l-avg').innerHTML = `${data.latency.avgMs}ms`; document.getElementById('l-p50').innerHTML = `${data.latency.p50Ms}ms`; document.getElementById('l-p95').innerHTML = `${data.latency.p95Ms}ms`; - // Timeline - const tlLabels = data.requestsOverTime.map(h => { - const d = new Date(h.hour); - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + // Timeline. Labels follow the granularity the server picked — a daily series labelled with + // clock times reads as a series of midnights. + const tlLabels = data.requestsOverTime.map(b => { + const d = new Date(b.bucket); + return data.range.granularity === 'hour' + ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : d.toLocaleDateString([], { month: 'short', day: 'numeric' }); }); charts.timeline = new Chart(document.getElementById('chart-timeline'), { type: 'line', @@ -344,7 +505,7 @@

Latency — Last 24h

labels: tlLabels, datasets: [{ label: 'Requests', - data: data.requestsOverTime.map(h => h.count), + data: data.requestsOverTime.map(b => b.count), borderColor: BLUE_B, backgroundColor: 'rgba(66,133,244,0.12)', tension: 0.35, @@ -440,14 +601,143 @@

Latency — Last 24h

}, }); + // Keyed vs anonymous + charts.traffic = new Chart(document.getElementById('chart-traffic'), { + type: 'doughnut', + data: { + labels: ['Keyed', 'Anonymous'], + datasets: [{ + data: [data.traffic.premiumRequests, data.traffic.anonymousRequests], + backgroundColor: [GOLD, BLUE], + borderColor: '#37474f', + borderWidth: 2, + }], + }, + options: { + responsive: true, + cutout: '65%', + plugins: { legend: { position: 'bottom', labels: { padding: 12, boxWidth: 12 } } }, + }, + }); + + // Tables + fillTable('t-errors', data.topErrorPaths, 'No failed requests in range.', e => [ + cell(e.path), cell(String(e.statusCode)), cell(fmt(e.count), 'num'), + ]); + + fillTable('t-slowest', data.slowestEndpoints, 'Not enough requests in range to rank.', e => [ + cell(e.path), cell(`${fmt(e.p95Ms)}ms`, 'num'), cell(fmt(e.count), 'num'), + ]); + + fillTable('t-clients', data.topClients, 'No traffic in range.', c => [ + cell(c.ipHash), + cellHtml(c.isPremium ? 'keyed' : 'anon'), + cell(fmt(c.count), 'num'), + ]); + document.getElementById('last-updated').textContent = 'Updated ' + new Date().toLocaleTimeString(); } + // Built as DOM rather than innerHTML: paths and hashes come from request data, and one + // crafted path would otherwise be markup on a page that is logged into. + function cell(text, cls) { + const td = document.createElement('td'); + td.textContent = text; + if (cls) td.className = cls; + return td; + } + + function cellHtml(html) { + const td = document.createElement('td'); + td.innerHTML = html; // literals only — never request data + return td; + } + + function fillTable(id, rows, emptyText, toCells) { + const body = document.getElementById(id); + body.replaceChildren(); + + if (!rows || rows.length === 0) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = 3; + td.className = 'empty'; + td.textContent = emptyText; + tr.append(td); + body.append(tr); + return; + } + + for (const row of rows) { + const tr = document.createElement('tr'); + tr.append(...toCells(row)); + body.append(tr); + } + } + + // ── Range selection ──────────────────────────────────────────────────────── + // Relative presets stay relative: the page refreshes every two minutes, and "Last 24h" that + // silently froze at the hour it was clicked would drift out of date while being watched. + const ALL_TIME_FROM = '2000-01-01T00:00:00Z'; + let range = { hours: 24, from: null, to: null }; + + function rangeLabel() { + if (range.from || range.to) return 'In Range'; + if (range.hours === 'all') return 'All Time'; + if (range.hours === 24) return 'Last 24h'; + return `Last ${range.hours / 24}d`; + } + + function rangeQuery() { + if (range.from || range.to) { + const params = new URLSearchParams(); + if (range.from) params.set('from', range.from); + if (range.to) params.set('to', range.to); + return `?${params}`; + } + if (range.hours === 'all') return `?from=${encodeURIComponent(ALL_TIME_FROM)}`; + const from = new Date(Date.now() - range.hours * 3600 * 1000).toISOString(); + return `?from=${encodeURIComponent(from)}`; + } + + function selectPreset(hours) { + range = { hours: hours === 'all' ? 'all' : Number(hours), from: null, to: null }; + document.getElementById('range-from').value = ''; + document.getElementById('range-to').value = ''; + for (const btn of document.querySelectorAll('#range-presets .range-btn')) { + btn.setAttribute('aria-pressed', String(btn.dataset.hours === String(hours))); + } + loadStats(); + } + + document.getElementById('range-presets').addEventListener('click', e => { + const btn = e.target.closest('.range-btn'); + if (btn) selectPreset(btn.dataset.hours); + }); + + document.getElementById('range-apply').addEventListener('click', () => { + const from = document.getElementById('range-from').value; + const to = document.getElementById('range-to').value; + if (!from && !to) return; + + // The date inputs give a bare day. "To" covers the whole of that day rather than stopping at + // its first instant, which would otherwise return nothing for a same-day from/to. + range = { + hours: null, + from: from ? `${from}T00:00:00Z` : null, + to: to ? `${to}T23:59:59Z` : null, + }; + for (const btn of document.querySelectorAll('#range-presets .range-btn')) { + btn.setAttribute('aria-pressed', 'false'); + } + loadStats(); + }); + async function loadStats() { const banner = document.getElementById('error-banner'); try { - const res = await fetch('/api/stats'); + const res = await fetch(`/api/stats${rangeQuery()}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); banner.style.display = 'none'; diff --git a/tests/MoogleAPI.Tests/ClientIpResolverTests.cs b/tests/MoogleAPI.Tests/ClientIpResolverTests.cs new file mode 100644 index 0000000..323dcf8 --- /dev/null +++ b/tests/MoogleAPI.Tests/ClientIpResolverTests.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using MoogleAPI.Web.Infrastructure.RateLimiting; +using System.Net; + +namespace MoogleAPI.Tests; + +/// +/// The resolver decides which rate-limit bucket a caller lands in, so the interesting cases are +/// all the ones where a caller would like to choose that for themselves. +/// +public class ClientIpResolverTests +{ + private const string Secret = "edge-secret-value"; + private const string PeerAddress = "100.64.0.5"; // Railway's load balancer, as seen in production + private const string CallerAddress = "203.0.113.9"; + + private static ClientIpResolver Resolver(string? secret = Secret) => + new(Options.Create(new EdgeOptions { Secret = secret })); + + private static HttpRequest Request(string? edgeSecret, string? forwardedFor, string? peer = PeerAddress) + { + var context = new DefaultHttpContext(); + + if (peer is not null) + context.Connection.RemoteIpAddress = IPAddress.Parse(peer); + + if (edgeSecret is not null) + context.Request.Headers[ClientIpResolver.SecretHeaderName] = edgeSecret; + + if (forwardedFor is not null) + context.Request.Headers[ClientIpResolver.ClientIpHeaderName] = forwardedFor; + + return context.Request; + } + + [Fact] + public void TrustsTheForwardedAddressWhenTheEdgeSecretMatches() + { + var resolved = Resolver().Resolve(Request(Secret, CallerAddress)); + + Assert.Equal(CallerAddress, resolved); + } + + [Fact] + public void IgnoresTheForwardedAddressWhenTheSecretIsWrong() + { + var resolved = Resolver().Resolve(Request("not-the-secret", CallerAddress)); + + Assert.Equal(PeerAddress, resolved); + } + + [Fact] + public void IgnoresTheForwardedAddressWhenNoSecretIsPresented() + { + // The bypass that matters: Railway answers on its own hostname too, so a caller who could + // set CF-Connecting-IP by hand there would be picking their own rate-limit partition. + var resolved = Resolver().Resolve(Request(edgeSecret: null, forwardedFor: CallerAddress)); + + Assert.Equal(PeerAddress, resolved); + } + + [Fact] + public void IgnoresForwardedAddressesEntirelyWhenNoSecretIsConfigured() + { + // Local runs and any deploy that hasn't been given the secret: believe nothing forwarded. + var resolved = Resolver(secret: null).Resolve(Request(Secret, CallerAddress)); + + Assert.Equal(PeerAddress, resolved); + } + + [Theory] + [InlineData("not-an-address")] + [InlineData("")] + [InlineData("203.0.113.9, 198.51.100.4")] // a header holding a list, not one address + public void FallsBackWhenTheForwardedValueIsNotAnAddress(string forwarded) + { + // Unparsed, this would become a partition key of its own, so a caller sending junk could + // mint a fresh window per request. + var resolved = Resolver().Resolve(Request(Secret, forwarded)); + + Assert.Equal(PeerAddress, resolved); + } + + [Fact] + public void ReturnsUnknownRatherThanNullWhenThereIsNoAddressAtAll() + { + // Null would collapse every such request into one bucket keyed on "ip:". + var resolved = Resolver().Resolve(Request(edgeSecret: null, forwardedFor: null, peer: null)); + + Assert.Equal("unknown", resolved); + } + + [Fact] + public void NormalisesTheForwardedAddress() + { + // Parsed and re-rendered, so a padded value and a clean one cannot become two buckets. + var resolved = Resolver().Resolve(Request(Secret, " 203.0.113.9 ")); + + Assert.Equal(CallerAddress, resolved); + } +}