182 lines
7.6 KiB
JavaScript
182 lines
7.6 KiB
JavaScript
"use strict";
|
|
|
|
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.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({
|
|
host: options.codeChannelHost || process.env.CODE_CHANNEL_HOST || "127.0.0.1",
|
|
port: Number(options.codeChannelPort || process.env.CODE_CHANNEL_PORT || 3340),
|
|
method: request.method,
|
|
path: internalPath,
|
|
headers: {
|
|
...request.headers,
|
|
host: request.headers.host || "guanghulab.com",
|
|
"x-forwarded-host": request.headers["x-forwarded-host"] || request.headers.host || "guanghulab.com",
|
|
"x-forwarded-proto": request.headers["x-forwarded-proto"] || "https"
|
|
},
|
|
timeout: 300000
|
|
}, upstreamResponse => {
|
|
response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
|
|
upstreamResponse.pipe(response);
|
|
});
|
|
upstream.on("timeout", () => upstream.destroy(new Error("upstream_timeout")));
|
|
upstream.on("error", () => {
|
|
if (!response.headersSent) return json(response, 502, { ok: false, error: "code_channel_unavailable" });
|
|
response.destroy();
|
|
});
|
|
request.pipe(upstream);
|
|
}
|
|
|
|
function candidateFailureCategory(message) {
|
|
const value = String(message || "").toLowerCase();
|
|
if (/urlopen|timed out|http error|temporary failure|name or service/.test(value)) return "relay_download_failed";
|
|
if (/no such file.*gpg|gpg.*no such file/.test(value)) return "signature_verifier_unavailable";
|
|
if (/fingerprint|signature|gpg/.test(value)) return "signature_verification_failed";
|
|
if (/candidate exited|candidate stopped/.test(value)) return "candidate_process_exited";
|
|
if (/readiness timeout/.test(value)) return "candidate_readiness_timeout";
|
|
return value ? "bootstrap_failed" : "";
|
|
}
|
|
|
|
function readCandidateStatus(response, options = {}) {
|
|
const request = http.get({
|
|
host: options.codeChannelHealthHost || "127.0.0.1",
|
|
port: Number(options.codeChannelHealthPort || 3341),
|
|
path: "/health",
|
|
timeout: 3000
|
|
}, upstreamResponse => {
|
|
let raw = "";
|
|
upstreamResponse.setEncoding("utf8");
|
|
upstreamResponse.on("data", chunk => {
|
|
raw += chunk;
|
|
if (raw.length > 8192) upstreamResponse.destroy(new Error("status_too_large"));
|
|
});
|
|
upstreamResponse.on("end", () => {
|
|
try {
|
|
const source = JSON.parse(raw);
|
|
return json(response, 200, {
|
|
ok: source.ok === true,
|
|
version: String(source.version || ""),
|
|
code: String(source.code || ""),
|
|
ready: source.ready === true,
|
|
stage: String(source.stage || "unknown"),
|
|
failure_category: source.ok === true ? "" : candidateFailureCategory(source.error)
|
|
});
|
|
} catch {
|
|
return json(response, 502, { ok: false, ready: false, error: "invalid_candidate_status" });
|
|
}
|
|
});
|
|
});
|
|
request.on("timeout", () => request.destroy(new Error("status_timeout")));
|
|
request.on("error", () => json(response, 502, { ok: false, ready: false, error: "candidate_status_unavailable" }));
|
|
}
|
|
|
|
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/")) {
|
|
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") {
|
|
return json(response, 200, {
|
|
ok: true,
|
|
node_id: process.env.GUANGHU_NODE_ID || "JD-FD-PRIMARY",
|
|
role: "fifth-domain-primary",
|
|
service: "jd-app-hub",
|
|
version: APP_VERSION,
|
|
code_channel_proxy: "loopback-only",
|
|
uptime_seconds: Math.floor(process.uptime()),
|
|
load_1m: Number(os.loadavg()[0].toFixed(2)),
|
|
memory_used_percent: Number((((os.totalmem() - os.freemem()) / os.totalmem()) * 100).toFixed(1)),
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
}
|
|
const relative = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, "");
|
|
if (!/^(index\.html|styles\.css|app\.js)$/.test(relative)) return json(response, 404, { ok: false, error: "not_found" });
|
|
const file = path.join(ROOT, relative);
|
|
response.writeHead(200, { "Content-Type": TYPES[path.extname(file)] || "application/octet-stream", "Cache-Control": relative === "index.html" ? "no-cache" : "public, max-age=3600" });
|
|
fs.createReadStream(file).pipe(response);
|
|
});
|
|
}
|
|
|
|
if (require.main === module) {
|
|
const host = process.env.HUB_HOST || "127.0.0.1";
|
|
const port = Number(process.env.HUB_PORT || 8088);
|
|
createApp().listen(port, host, () => console.log(`JD app hub listening on ${host}:${port}`));
|
|
}
|
|
|
|
module.exports = { createApp };
|