diff --git a/server-tools/hololake-code-channel/jd-candidate/app.ini b/server-tools/hololake-code-channel/jd-candidate/app.ini index aa394e7..a224a89 100644 --- a/server-tools/hololake-code-channel/jd-candidate/app.ini +++ b/server-tools/hololake-code-channel/jd-candidate/app.ini @@ -9,6 +9,7 @@ PATH = /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/hlcc.db [repository] ROOT = /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories +DISABLE_DOWNLOAD_SOURCE_ARCHIVES = true [server] DOMAIN = guanghulab.com @@ -37,6 +38,10 @@ ENABLED = false [mirror] ENABLED = false +[log] +MODE = console +LEVEL = Warn + [other] SHOW_FOOTER_BRANDING = false SHOW_FOOTER_POWERED_BY = false diff --git a/server-tools/hololake-code-channel/jd-candidate/hlcc-bootstrap.py b/server-tools/hololake-code-channel/jd-candidate/hlcc-bootstrap.py index e411be0..35bcf9b 100644 --- a/server-tools/hololake-code-channel/jd-candidate/hlcc-bootstrap.py +++ b/server-tools/hololake-code-channel/jd-candidate/hlcc-bootstrap.py @@ -492,9 +492,11 @@ def bootstrap() -> None: custom_target = STATE_ROOT / "data" / "custom" if custom_source.is_dir(): shutil.copytree(custom_source, custom_target, dirs_exist_ok=True) + for installed in custom_target.rglob("*"): + if installed.is_symlink(): + raise RuntimeError("custom template symlink forbidden") + installed.chmod(0o700 if installed.is_dir() else 0o600) set_status(stage="launching") - log_path = STATE_ROOT / "logs" / "hlcc.log" - log_handle = log_path.open("ab", buffering=0) process = subprocess.Popen( [ str(binary), @@ -504,7 +506,7 @@ def bootstrap() -> None: "--config", str(config_target), ], - stdout=log_handle, + stdout=None, stderr=subprocess.STDOUT, ) wait_for_candidate(process) diff --git a/server-tools/hololake-code-channel/jd-candidate/native.test.js b/server-tools/hololake-code-channel/jd-candidate/native.test.js index 7fc03c4..336722e 100644 --- a/server-tools/hololake-code-channel/jd-candidate/native.test.js +++ b/server-tools/hololake-code-channel/jd-candidate/native.test.js @@ -65,10 +65,16 @@ assert.match( ); assert.match(ini, /APP_NAME = 光湖代码频道/); assert.match(ini, /ROOT_URL = https:\/\/guanghulab\.com\/code\//); +assert.match(ini, /\[repository\][\s\S]*DISABLE_DOWNLOAD_SOURCE_ARCHIVES = true/); assert.match(ini, /REQUIRE_SIGNIN_VIEW = false/); +assert.match(ini, /\[log\][\s\S]*MODE = console[\s\S]*LEVEL = Warn/); assert.match(ini, /SHOW_FOOTER_BRANDING = false/); assert.match(ini, /SHOW_FOOTER_VERSION = false/); assert.match(ini, /\[cron\.update_checker\][\s\S]*ENABLED = false/); +assert.match(bootstrap, /stdout=None/); +assert.doesNotMatch(bootstrap, /hlcc\.log|log_path\.open/); +assert.match(bootstrap, /custom template symlink forbidden/); +assert.match(bootstrap, /installed\.chmod\(0o700 if installed\.is_dir\(\) else 0o600\)/); assert.match(unit, /^User=guanghu$/m); assert.match(unit, /^ProtectSystem=strict$/m); assert.match(unit, /^ReadOnlyPaths=__RELEASE_ROOT__$/m); 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..2953820 100644 --- a/server-tools/jd-app-hub/server.js +++ b/server-tools/jd-app-hub/server.js @@ -2,18 +2,55 @@ 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"); const ROOT = __dirname; const TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8" }; -const APP_VERSION = "2.0.1"; +const APP_VERSION = "2.1.0"; function json(response, status, value) { response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); 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..e1e2233 100644 --- a/server-tools/jd-app-hub/test/server.test.js +++ b/server-tools/jd-app-hub/test/server.test.js @@ -17,6 +17,7 @@ test("JD hub exposes a minimal non-secret status response", async (t) => { const body = await response.json(); assert.equal(body.ok, true); assert.equal(body.node_id, "JD-FD-PRIMARY"); + assert.equal(body.version, "2.1.0"); assert.equal("ip" in body, false); assert.equal("token" in body, false); }); @@ -92,6 +93,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" });