diff --git a/server-tools/jd-app-hub/jd-app-hub.service b/server-tools/jd-app-hub/jd-app-hub.service index e40ecc5..1e27f7e 100644 --- a/server-tools/jd-app-hub/jd-app-hub.service +++ b/server-tools/jd-app-hub/jd-app-hub.service @@ -14,6 +14,8 @@ Environment=HUB_PORT=8088 Environment=GUANGHU_NODE_ID=JD-FD-PRIMARY Environment=CODE_CHANNEL_HOST=127.0.0.1 Environment=CODE_CHANNEL_PORT=3340 +Environment=CODE_RATE_LIMIT_MAX=600 +Environment=CODE_BROWSE_RATE_LIMIT_MAX=120 ExecStart=/usr/bin/node __RELEASE_ROOT__/server-tools/jd-app-hub/server.js Restart=always RestartSec=3 diff --git a/server-tools/jd-app-hub/server.js b/server-tools/jd-app-hub/server.js index 61542d2..e7e26da 100644 --- a/server-tools/jd-app-hub/server.js +++ b/server-tools/jd-app-hub/server.js @@ -2,6 +2,7 @@ const fs = require("node:fs"); const http = require("node:http"); +const net = require("node:net"); const path = require("node:path"); const os = require("node:os"); @@ -14,6 +15,42 @@ function json(response, status, value) { response.end(JSON.stringify(value)); } +function clientAddress(request) { + const real = String(request.headers["x-real-ip"] || "").trim(); + if (net.isIP(real)) return real; + const forwarded = String(request.headers["x-forwarded-for"] || "") + .split(",") + .map(value => value.trim()) + .filter(Boolean); + const last = forwarded.at(-1) || ""; + if (net.isIP(last)) return last; + return String(request.socket.remoteAddress || "unknown").slice(0, 64); +} + +function createFixedWindowLimiter({ max, windowMs = 60000, now = Date.now }) { + const entries = new Map(); + return key => { + const timestamp = now(); + let entry = entries.get(key); + if (!entry || timestamp >= entry.resetAt) { + entry = { count: 0, resetAt: timestamp + windowMs }; + entries.set(key, entry); + } + entry.count += 1; + if (entries.size > 4096) { + for (const [storedKey, stored] of entries) { + if (timestamp >= stored.resetAt) entries.delete(storedKey); + } + if (entries.size > 4096) entries.delete(entries.keys().next().value); + } + return { + allowed: entry.count <= max, + remaining: Math.max(0, max - entry.count), + retryAfter: Math.max(1, Math.ceil((entry.resetAt - timestamp) / 1000)) + }; + }; +} + function proxyCodeChannel(request, response, options = {}) { const internalPath = request.url.replace(/^\/code(?=\/|$)/, "") || "/"; const upstream = http.request({ @@ -84,13 +121,34 @@ function readCandidateStatus(response, options = {}) { } function createApp(options = {}) { + const codeLimiter = createFixedWindowLimiter({ + max: Number(options.codeRateLimitMax || process.env.CODE_RATE_LIMIT_MAX || 600), + windowMs: Number(options.codeRateLimitWindowMs || 60000), + now: options.now || Date.now + }); + const browseLimiter = createFixedWindowLimiter({ + max: Number(options.codeBrowseRateLimitMax || process.env.CODE_BROWSE_RATE_LIMIT_MAX || 120), + windowMs: Number(options.codeRateLimitWindowMs || 60000), + now: options.now || Date.now + }); return http.createServer((request, response) => { const url = new URL(request.url, "http://localhost"); if (url.pathname === "/code") { response.writeHead(308, { Location: "/code/", "Cache-Control": "no-store" }); return response.end(); } - if (url.pathname.startsWith("/code/")) return proxyCodeChannel(request, response, options); + if (url.pathname.startsWith("/code/")) { + const address = clientAddress(request); + const general = codeLimiter(address); + const expensive = /\/(?:archive|src\/commit|raw\/commit|blame)(?:\/|$)/.test(url.pathname) + ? browseLimiter(address) + : { allowed: true, remaining: general.remaining, retryAfter: general.retryAfter }; + if (!general.allowed || !expensive.allowed) { + response.setHeader("Retry-After", String(Math.max(general.retryAfter, expensive.retryAfter))); + return json(response, 429, { ok: false, error: "code_channel_rate_limited" }); + } + return proxyCodeChannel(request, response, options); + } if (request.method !== "GET" && request.method !== "HEAD") return json(response, 405, { ok: false, error: "method_not_allowed" }); if (url.pathname === "/api/code-channel-status") return readCandidateStatus(response, options); if (url.pathname === "/api/status") { diff --git a/server-tools/jd-app-hub/test/server.test.js b/server-tools/jd-app-hub/test/server.test.js index 26d05e5..8991169 100644 --- a/server-tools/jd-app-hub/test/server.test.js +++ b/server-tools/jd-app-hub/test/server.test.js @@ -92,6 +92,53 @@ test("JD hub preserves nested code-channel paths and queries without duplicating assert.deepEqual(await root.json(), { path: "/" }); }); +test("JD hub rate limits code-channel traffic by verified proxy client address", async (t) => { + const upstream = http.createServer((_request, response) => { + response.writeHead(200); + response.end("ok"); + }); + upstream.listen(0, "127.0.0.1"); + t.after(() => upstream.close()); + await new Promise((resolve) => upstream.once("listening", resolve)); + + const server = createApp({ + codeChannelPort: upstream.address().port, + codeRateLimitMax: 2 + }).listen(0, "127.0.0.1"); + t.after(() => server.close()); + await new Promise((resolve) => server.once("listening", resolve)); + const endpoint = `http://127.0.0.1:${server.address().port}/code/api/v1/version`; + const headers = { "x-real-ip": "203.0.113.10" }; + assert.equal((await fetch(endpoint, { headers })).status, 200); + assert.equal((await fetch(endpoint, { headers })).status, 200); + const limited = await fetch(endpoint, { headers }); + assert.equal(limited.status, 429); + assert.deepEqual(await limited.json(), { ok: false, error: "code_channel_rate_limited" }); + assert.equal((await fetch(endpoint, { headers: { "x-real-ip": "203.0.113.11" } })).status, 200); +}); + +test("JD hub gives historical source browsing a lower independent ceiling", async (t) => { + const upstream = http.createServer((_request, response) => { + response.writeHead(200); + response.end("ok"); + }); + upstream.listen(0, "127.0.0.1"); + t.after(() => upstream.close()); + await new Promise((resolve) => upstream.once("listening", resolve)); + + const server = createApp({ + codeChannelPort: upstream.address().port, + codeRateLimitMax: 10, + codeBrowseRateLimitMax: 1 + }).listen(0, "127.0.0.1"); + t.after(() => server.close()); + await new Promise((resolve) => server.once("listening", resolve)); + const endpoint = `http://127.0.0.1:${server.address().port}/code/bingshuo/repo/src/commit/deadbeef/file`; + const headers = { "x-forwarded-for": "198.51.100.2, 203.0.113.20" }; + assert.equal((await fetch(endpoint, { headers })).status, 200); + assert.equal((await fetch(endpoint, { headers })).status, 429); +}); + test("JD hub exposes only a categorized candidate failure", async (t) => { const upstream = http.createServer((_request, response) => { response.writeHead(200, { "content-type": "application/json" });