Files
homelab/edge-cache/controller.mjs
T

510 lines
19 KiB
JavaScript

import http from "node:http";
import {createHash} from "node:crypto";
import {mkdirSync, readFileSync, renameSync, writeFileSync} from "node:fs";
const labelPrefix = "xiteng.site.cache.";
const allowedFields = new Set([
"enabled",
"routers",
"hosts",
"paths",
"edge-ttl",
"browser-ttl",
"stale-while-revalidate"
]);
const managedRuleDescription = "Xiteng label-managed static paths";
function boundedInteger(value, fallback, minimum, maximum) {
const parsed = Number.parseInt(value || "", 10);
return Number.isFinite(parsed) ? Math.max(minimum, Math.min(maximum, parsed)) : fallback;
}
function csv(value) {
return [...new Set(String(value || "").split(",").map((item) => item.trim()).filter(Boolean))];
}
function stableName(...parts) {
const source = parts.join("-").toLowerCase();
const readable = source.replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 48);
const digest = createHash("sha256").update(source).digest("hex").slice(0, 10);
return `${readable || "cache"}-${digest}`;
}
function normalizedLabels(labels) {
return Object.fromEntries(Object.entries(labels || {}).map(([key, value]) => [key.toLowerCase(), String(value)]));
}
function normalizePathPrefix(value) {
const path = String(value || "").trim();
if (!path.startsWith("/") || !path.endsWith("/") || path === "/") {
throw new Error(`static path must start and end with / and cannot be root: ${path || "(empty)"}`);
}
if (path.includes("..") || path.includes("//") || !/^\/[A-Za-z0-9._~/-]+\/$/.test(path)) {
throw new Error(`static path contains unsafe characters: ${path}`);
}
return path;
}
function normalizeHost(value, zoneName = "") {
const host = String(value || "").trim().toLowerCase().replace(/\.$/, "");
if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(host)) {
throw new Error(`invalid hostname: ${value}`);
}
if (zoneName && host !== zoneName && !host.endsWith(`.${zoneName}`)) {
throw new Error(`hostname is outside ${zoneName}: ${host}`);
}
return host;
}
function extractHosts(rule, zoneName) {
const hosts = [];
for (const match of String(rule || "").matchAll(/\bHost\(([^)]*)\)/g)) {
for (const quoted of match[1].matchAll(/`([^`]+)`/g)) {
hosts.push(normalizeHost(quoted[1], zoneName));
}
}
return [...new Set(hosts)];
}
function qualifyReference(value, provider) {
const reference = String(value || "").trim();
return !reference || reference.includes("@") ? reference : `${reference}@${provider}`;
}
export function definitionsFromLabels(rawLabels) {
const labels = normalizedLabels(rawLabels);
const definitions = new Map();
for (const [key, value] of Object.entries(labels)) {
if (!key.startsWith(labelPrefix)) {
continue;
}
const remainder = key.slice(labelPrefix.length);
const separator = remainder.indexOf(".");
const id = remainder.slice(0, separator);
const field = remainder.slice(separator + 1);
if (separator <= 0 || !/^[a-z0-9][a-z0-9-]*$/.test(id) || !allowedFields.has(field)) {
continue;
}
if (!definitions.has(id)) {
definitions.set(id, {});
}
definitions.get(id)[field] = value.trim();
}
return definitions;
}
function routerNames(labels) {
const names = new Set();
for (const key of Object.keys(labels)) {
const match = key.match(/^traefik\.http\.routers\.([a-z0-9-]+)\.rule$/);
if (match) {
names.add(match[1]);
}
}
return [...names].sort();
}
function serviceNames(labels) {
const names = new Set();
for (const key of Object.keys(labels)) {
const match = key.match(/^traefik\.http\.services\.([a-z0-9-]+)\.loadbalancer\.server\.port$/);
if (match) {
names.add(match[1]);
}
}
return [...names];
}
function cloudflareExpression(policies) {
const clauses = [];
for (const policy of policies) {
const hosts = policy.hosts.map((host) => `http.host eq ${JSON.stringify(host)}`);
const paths = policy.paths.map((path) => `starts_with(http.request.uri.path, ${JSON.stringify(path)})`);
clauses.push(`((${hosts.join(" or ")}) and (${paths.join(" or ")}))`);
}
return clauses.length ? `(${clauses.join(" or ")})` : "(http.host eq \"cache-disabled.invalid\")";
}
export function buildConfiguration(containers, {zoneName = "xiteng.site"} = {}) {
const routers = {};
const middlewares = {};
const cloudflarePolicies = [];
const errors = [];
const seenPolicies = new Set();
for (const container of containers || []) {
const labels = normalizedLabels(container.Labels);
if (labels["traefik.enable"] !== "true") {
continue;
}
const project = labels["com.docker.compose.project"] || "docker";
const service = labels["com.docker.compose.service"] || String(container.Names?.[0] || container.Id || "service").replace(/^\//, "");
const definitions = definitionsFromLabels(labels);
for (const [policyId, definition] of definitions) {
if (definition.enabled !== "true") {
continue;
}
const policyKey = `${project}/${service}/${policyId}`;
if (seenPolicies.has(policyKey)) {
continue;
}
seenPolicies.add(policyKey);
try {
const paths = csv(definition.paths).map(normalizePathPrefix).sort();
if (!paths.length) {
throw new Error("at least one static path is required");
}
const selectedRouters = (csv(definition.routers).length ? csv(definition.routers) : routerNames(labels)).sort();
if (!selectedRouters.length) {
throw new Error("no Traefik routers found; set .routers explicitly");
}
const edgeTtl = boundedInteger(definition["edge-ttl"], 604800, 60, 31536000);
const browserTtl = boundedInteger(definition["browser-ttl"], 0, 0, 31536000);
const staleWhileRevalidate = boundedInteger(definition["stale-while-revalidate"], 86400, 0, 604800);
const middlewareName = `xiteng-static-${stableName(policyKey)}`;
const responseHeaders = {
"Cloudflare-CDN-Cache-Control": `public, s-maxage=${edgeTtl}${staleWhileRevalidate ? `, stale-while-revalidate=${staleWhileRevalidate}` : ""}`,
"X-Xiteng-Cache-Policy": policyKey
};
if (browserTtl > 0) {
responseHeaders["Cache-Control"] = `public, max-age=${browserTtl}${staleWhileRevalidate ? `, stale-while-revalidate=${staleWhileRevalidate}` : ""}`;
}
const policyRouters = {};
const discoveredHosts = new Set(csv(definition.hosts).map((host) => normalizeHost(host, zoneName)));
for (const baseRouter of selectedRouters) {
if (!/^[a-z0-9][a-z0-9-]*$/.test(baseRouter)) {
throw new Error(`invalid router name: ${baseRouter}`);
}
const routerPrefix = `traefik.http.routers.${baseRouter}.`;
const baseRule = labels[`${routerPrefix}rule`];
if (!baseRule) {
throw new Error(`router ${baseRouter} has no rule on this service`);
}
for (const host of extractHosts(baseRule, zoneName)) {
discoveredHosts.add(host);
}
const entryPoints = csv(labels[`${routerPrefix}entrypoints`]);
if (!entryPoints.length) {
throw new Error(`router ${baseRouter} must declare entrypoints`);
}
let targetService = labels[`${routerPrefix}service`];
if (!targetService) {
const candidates = serviceNames(labels);
if (candidates.length !== 1) {
throw new Error(`router ${baseRouter} must declare its Traefik service`);
}
[targetService] = candidates;
}
const inheritedMiddlewares = csv(labels[`${routerPrefix}middlewares`]).map((item) => qualifyReference(item, "docker"));
const generatedRouter = {
rule: `(${baseRule}) && (Method(\`GET\`) || Method(\`HEAD\`)) && (${paths.map((path) => `PathPrefix(\`${path}\`)`).join(" || ")})`,
entryPoints,
service: qualifyReference(targetService, "docker"),
// Traefik runs response middleware in reverse order. Put the cache
// middleware first so it is the final writer of cache headers.
middlewares: [middlewareName, ...inheritedMiddlewares],
priority: boundedInteger(labels[`${routerPrefix}priority`], 0, 0, 1000000) + 1000
};
if (labels[`${routerPrefix}tls`] && labels[`${routerPrefix}tls`] !== "false") {
generatedRouter.tls = {};
if (labels[`${routerPrefix}tls.certresolver`]) {
generatedRouter.tls.certResolver = labels[`${routerPrefix}tls.certresolver`];
}
if (labels[`${routerPrefix}tls.options`]) {
generatedRouter.tls.options = qualifyReference(labels[`${routerPrefix}tls.options`], "docker");
}
}
policyRouters[`xiteng-static-${stableName(policyKey, baseRouter)}`] = generatedRouter;
}
if (!discoveredHosts.size) {
throw new Error("no exact Host() value found; set .hosts explicitly");
}
middlewares[middlewareName] = {headers: {customResponseHeaders: responseHeaders}};
Object.assign(routers, policyRouters);
cloudflarePolicies.push({
id: policyKey,
hosts: [...discoveredHosts].sort(),
paths
});
} catch (error) {
errors.push(`${policyKey}: ${error.message}`);
}
}
}
cloudflarePolicies.sort((left, right) => left.id.localeCompare(right.id));
return {
traefik: {http: {routers, middlewares}},
cloudflare: {
version: 1,
zone: zoneName,
description: managedRuleDescription,
expression: cloudflareExpression(cloudflarePolicies),
policies: cloudflarePolicies
},
errors,
policyCount: cloudflarePolicies.length,
routerCount: Object.keys(routers).length
};
}
export function cloudflareRuleFromSpec(spec) {
return {
action: "set_cache_settings",
action_parameters: {
cache: true,
edge_ttl: {
mode: "respect_origin",
status_code_ttl: [
{status_code_range: {to: 199}, value: 0},
{status_code_range: {from: 300, to: 499}, value: 0},
{status_code_range: {from: 500}, value: -1}
]
},
browser_ttl: {mode: "respect_origin"},
cache_key: {cache_deception_armor: true},
serve_stale: {disable_stale_while_updating: false}
},
expression: spec.expression,
description: managedRuleDescription,
enabled: Array.isArray(spec.policies) && spec.policies.length > 0
};
}
async function cloudflareRequest(path, {token, method = "GET", body} = {}) {
const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(15000)
});
const payload = await response.json();
if (!response.ok || !payload.success) {
const detail = (payload.errors || []).map((error) => `${error.code}: ${error.message}`).join("; ");
const error = new Error(`Cloudflare API ${response.status}${detail ? ` (${detail})` : ""}`);
error.status = response.status;
throw error;
}
return payload.result;
}
function canonicalizeJson(value) {
if (Array.isArray(value)) {
return value.map(canonicalizeJson);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, canonicalizeJson(value[key])])
);
}
return value;
}
function comparableRule(rule) {
return JSON.stringify(canonicalizeJson({
action: rule.action,
action_parameters: rule.action_parameters,
expression: rule.expression,
description: rule.description,
enabled: rule.enabled !== false
}));
}
export async function syncCloudflareRule(spec, {zoneId, token, dryRun = false} = {}) {
if (!zoneId || !token) {
throw new Error("Cloudflare zone ID and API token are required");
}
const desired = cloudflareRuleFromSpec(spec);
let ruleset;
try {
ruleset = await cloudflareRequest(`/zones/${zoneId}/rulesets/phases/http_request_cache_settings/entrypoint`, {token});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
const current = ruleset?.rules?.find((rule) => rule.description === managedRuleDescription);
if (current && comparableRule(current) === comparableRule(desired)) {
return {changed: false, action: "unchanged", ruleId: current.id};
}
if (dryRun) {
return {changed: true, action: current ? "update" : "create", desired};
}
if (!ruleset) {
const created = await cloudflareRequest(`/zones/${zoneId}/rulesets`, {
token,
method: "POST",
body: {
name: "Xiteng label-managed cache rules",
description: managedRuleDescription,
kind: "zone",
phase: "http_request_cache_settings",
rules: [desired]
}
});
return {changed: true, action: "create-ruleset", ruleId: created.rules?.[0]?.id};
}
if (!current) {
const created = await cloudflareRequest(`/zones/${zoneId}/rulesets/${ruleset.id}/rules`, {
token,
method: "POST",
body: desired
});
return {changed: true, action: "create", ruleId: created.id};
}
const updated = await cloudflareRequest(`/zones/${zoneId}/rulesets/${ruleset.id}/rules/${current.id}`, {
token,
method: "PATCH",
body: desired
});
return {changed: true, action: "update", ruleId: updated.id};
}
function writeIfChanged(path, value) {
let current = "";
try {
current = readFileSync(path, "utf8");
} catch {
// The first discovery creates the file.
}
if (current === value) {
return false;
}
const temporary = `${path}.tmp`;
writeFileSync(temporary, value, {mode: 0o644});
renameSync(temporary, path);
return true;
}
function dockerGet(socketPath, pathname) {
return new Promise((resolve, reject) => {
const request = http.request({socketPath, path: pathname, method: "GET", headers: {Accept: "application/json"}}, (response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("end", () => {
if ((response.statusCode || 500) >= 400) {
reject(new Error(`Docker API ${response.statusCode}`));
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch (error) {
reject(error);
}
});
});
request.setTimeout(5000, () => request.destroy(new Error("Docker API timeout")));
request.on("error", reject);
request.end();
});
}
function readToken(path) {
try {
return readFileSync(path, "utf8").trim();
} catch {
return "";
}
}
export async function runController() {
const port = boundedInteger(process.env.PORT, 8094, 1, 65535);
const socketPath = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
const outputDirectory = process.env.OUTPUT_DIRECTORY || "/dynamic";
const stateDirectory = process.env.STATE_DIRECTORY || "/state";
const intervalMs = boundedInteger(process.env.DISCOVERY_INTERVAL_MS, 5000, 2000, 60000);
const cloudflareIntervalMs = boundedInteger(process.env.CLOUDFLARE_SYNC_INTERVAL_MS, 60000, 10000, 3600000);
const zoneName = process.env.CLOUDFLARE_ZONE_NAME || "xiteng.site";
const zoneId = process.env.CLOUDFLARE_ZONE_ID || "";
const tokenFile = process.env.CLOUDFLARE_API_TOKEN_FILE || "/run/secrets/cloudflare_cache_api_token";
const traefikPath = `${outputDirectory}/xiteng-static-cache.yml`;
const cloudflarePath = `${stateDirectory}/cloudflare-cache-rules.json`;
const state = {
ready: false,
lastDiscoveryAt: null,
policies: 0,
routers: 0,
errors: [],
cloudflare: {status: "waiting-for-token", lastSyncAt: null, error: null}
};
let discoveryRunning = false;
let lastCloudflareAttempt = 0;
let lastCloudflareSpec = "";
mkdirSync(outputDirectory, {recursive: true});
mkdirSync(stateDirectory, {recursive: true});
async function discover() {
if (discoveryRunning) {
return;
}
discoveryRunning = true;
try {
const containers = await dockerGet(socketPath, "/containers/json?all=0");
const built = buildConfiguration(containers, {zoneName});
const traefikValue = `${JSON.stringify(built.traefik, null, 2)}\n`;
const cloudflareValue = `${JSON.stringify(built.cloudflare, null, 2)}\n`;
const changed = writeIfChanged(traefikPath, traefikValue);
writeIfChanged(cloudflarePath, cloudflareValue);
state.ready = true;
state.lastDiscoveryAt = new Date().toISOString();
state.policies = built.policyCount;
state.routers = built.routerCount;
state.errors = built.errors;
if (changed || built.errors.length) {
console.log(JSON.stringify({event: "cache-config", changed, policies: built.policyCount, routers: built.routerCount, errors: built.errors}));
}
const token = readToken(tokenFile);
const now = Date.now();
if (!token) {
state.cloudflare.status = "waiting-for-token";
state.cloudflare.error = null;
} else if (cloudflareValue !== lastCloudflareSpec || now - lastCloudflareAttempt >= cloudflareIntervalMs) {
lastCloudflareAttempt = now;
try {
const result = await syncCloudflareRule(built.cloudflare, {zoneId, token});
lastCloudflareSpec = cloudflareValue;
state.cloudflare = {status: result.action, lastSyncAt: new Date().toISOString(), error: null};
if (result.changed) {
console.log(JSON.stringify({event: "cloudflare-cache-rule", action: result.action, policies: built.policyCount}));
}
} catch (error) {
state.cloudflare = {status: "error", lastSyncAt: new Date().toISOString(), error: error.message};
console.error(JSON.stringify({event: "cloudflare-cache-rule-error", error: error.message}));
}
}
} catch (error) {
state.errors = [error.message];
console.error(JSON.stringify({event: "cache-controller-error", error: error.message}));
} finally {
discoveryRunning = false;
}
}
const server = http.createServer((request, response) => {
if (request.url === "/healthz") {
const healthy = state.ready && state.errors.length === 0 && state.cloudflare.status !== "error";
response.writeHead(healthy ? 200 : 503, {"Content-Type": "application/json", "Cache-Control": "no-store"});
response.end(JSON.stringify(state));
return;
}
response.writeHead(404, {"Content-Type": "text/plain", "Cache-Control": "no-store"});
response.end("Not found");
});
server.listen(port, "0.0.0.0");
await discover();
setInterval(discover, intervalMs).unref();
}
if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
runController().catch((error) => {
console.error(error);
process.exit(1);
});
}