Merge pull request 'fix: stop JD code-channel storage growth' (#1) from fix/jd-hlcc-storage-guard-20260806 into main
Reviewed-on: #1
This commit is contained in:
commit
cc7c991d68
6 changed files with 126 additions and 5 deletions
|
|
@ -9,6 +9,7 @@ PATH = /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/hlcc.db
|
||||||
|
|
||||||
[repository]
|
[repository]
|
||||||
ROOT = /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories
|
ROOT = /var/lib/guanghu/personas/guanghu/hlcc-v16.0.1/data/repositories
|
||||||
|
DISABLE_DOWNLOAD_SOURCE_ARCHIVES = true
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
DOMAIN = guanghulab.com
|
DOMAIN = guanghulab.com
|
||||||
|
|
@ -37,6 +38,10 @@ ENABLED = false
|
||||||
[mirror]
|
[mirror]
|
||||||
ENABLED = false
|
ENABLED = false
|
||||||
|
|
||||||
|
[log]
|
||||||
|
MODE = console
|
||||||
|
LEVEL = Warn
|
||||||
|
|
||||||
[other]
|
[other]
|
||||||
SHOW_FOOTER_BRANDING = false
|
SHOW_FOOTER_BRANDING = false
|
||||||
SHOW_FOOTER_POWERED_BY = false
|
SHOW_FOOTER_POWERED_BY = false
|
||||||
|
|
|
||||||
|
|
@ -492,9 +492,11 @@ def bootstrap() -> None:
|
||||||
custom_target = STATE_ROOT / "data" / "custom"
|
custom_target = STATE_ROOT / "data" / "custom"
|
||||||
if custom_source.is_dir():
|
if custom_source.is_dir():
|
||||||
shutil.copytree(custom_source, custom_target, dirs_exist_ok=True)
|
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")
|
set_status(stage="launching")
|
||||||
log_path = STATE_ROOT / "logs" / "hlcc.log"
|
|
||||||
log_handle = log_path.open("ab", buffering=0)
|
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
[
|
[
|
||||||
str(binary),
|
str(binary),
|
||||||
|
|
@ -504,7 +506,7 @@ def bootstrap() -> None:
|
||||||
"--config",
|
"--config",
|
||||||
str(config_target),
|
str(config_target),
|
||||||
],
|
],
|
||||||
stdout=log_handle,
|
stdout=None,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
)
|
)
|
||||||
wait_for_candidate(process)
|
wait_for_candidate(process)
|
||||||
|
|
|
||||||
|
|
@ -65,10 +65,16 @@ assert.match(
|
||||||
);
|
);
|
||||||
assert.match(ini, /APP_NAME = 光湖代码频道/);
|
assert.match(ini, /APP_NAME = 光湖代码频道/);
|
||||||
assert.match(ini, /ROOT_URL = https:\/\/guanghulab\.com\/code\//);
|
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, /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_BRANDING = false/);
|
||||||
assert.match(ini, /SHOW_FOOTER_VERSION = false/);
|
assert.match(ini, /SHOW_FOOTER_VERSION = false/);
|
||||||
assert.match(ini, /\[cron\.update_checker\][\s\S]*ENABLED = 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, /^User=guanghu$/m);
|
||||||
assert.match(unit, /^ProtectSystem=strict$/m);
|
assert.match(unit, /^ProtectSystem=strict$/m);
|
||||||
assert.match(unit, /^ReadOnlyPaths=__RELEASE_ROOT__$/m);
|
assert.match(unit, /^ReadOnlyPaths=__RELEASE_ROOT__$/m);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ Environment=HUB_PORT=8088
|
||||||
Environment=GUANGHU_NODE_ID=JD-FD-PRIMARY
|
Environment=GUANGHU_NODE_ID=JD-FD-PRIMARY
|
||||||
Environment=CODE_CHANNEL_HOST=127.0.0.1
|
Environment=CODE_CHANNEL_HOST=127.0.0.1
|
||||||
Environment=CODE_CHANNEL_PORT=3340
|
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
|
ExecStart=/usr/bin/node __RELEASE_ROOT__/server-tools/jd-app-hub/server.js
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,55 @@
|
||||||
|
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const http = require("node:http");
|
const http = require("node:http");
|
||||||
|
const net = require("node:net");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
|
|
||||||
const ROOT = __dirname;
|
const ROOT = __dirname;
|
||||||
const TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8" };
|
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) {
|
function json(response, status, value) {
|
||||||
response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
|
response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
|
||||||
response.end(JSON.stringify(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 = {}) {
|
function proxyCodeChannel(request, response, options = {}) {
|
||||||
const internalPath = request.url.replace(/^\/code(?=\/|$)/, "") || "/";
|
const internalPath = request.url.replace(/^\/code(?=\/|$)/, "") || "/";
|
||||||
const upstream = http.request({
|
const upstream = http.request({
|
||||||
|
|
@ -84,13 +121,34 @@ function readCandidateStatus(response, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createApp(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) => {
|
return http.createServer((request, response) => {
|
||||||
const url = new URL(request.url, "http://localhost");
|
const url = new URL(request.url, "http://localhost");
|
||||||
if (url.pathname === "/code") {
|
if (url.pathname === "/code") {
|
||||||
response.writeHead(308, { Location: "/code/", "Cache-Control": "no-store" });
|
response.writeHead(308, { Location: "/code/", "Cache-Control": "no-store" });
|
||||||
return response.end();
|
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 (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/code-channel-status") return readCandidateStatus(response, options);
|
||||||
if (url.pathname === "/api/status") {
|
if (url.pathname === "/api/status") {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ test("JD hub exposes a minimal non-secret status response", async (t) => {
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
assert.equal(body.ok, true);
|
assert.equal(body.ok, true);
|
||||||
assert.equal(body.node_id, "JD-FD-PRIMARY");
|
assert.equal(body.node_id, "JD-FD-PRIMARY");
|
||||||
|
assert.equal(body.version, "2.1.0");
|
||||||
assert.equal("ip" in body, false);
|
assert.equal("ip" in body, false);
|
||||||
assert.equal("token" 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: "/" });
|
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) => {
|
test("JD hub exposes only a categorized candidate failure", async (t) => {
|
||||||
const upstream = http.createServer((_request, response) => {
|
const upstream = http.createServer((_request, response) => {
|
||||||
response.writeHead(200, { "content-type": "application/json" });
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue