feat: rebuild xiteng.site homelab platform

This commit is contained in:
2026-08-12 09:48:25 +08:00
parent 557b0eca33
commit 5b84988789
128 changed files with 14979 additions and 292 deletions
+6
View File
@@ -0,0 +1,6 @@
dynamic/*
!dynamic/.gitkeep
state/*
!state/.gitkeep
secrets/*
!secrets/.gitkeep
+66
View File
@@ -0,0 +1,66 @@
# Label-managed edge cache
Services opt paths into the shared edge cache with Docker labels. The controller discovers the
labels, writes higher-priority Traefik routers, and maintains a single Cloudflare Cache Rule that
makes the declared host/path pairs eligible for edge caching.
```yaml
labels:
- "xiteng.site.cache.frontend.enabled=true"
- "xiteng.site.cache.frontend.routers=example,example-http"
- "xiteng.site.cache.frontend.paths=/static/,/assets/"
- "xiteng.site.cache.frontend.edge-ttl=604800"
- "xiteng.site.cache.frontend.browser-ttl=0"
- "xiteng.site.cache.frontend.stale-while-revalidate=86400"
```
The policy id (`frontend`) only needs to be unique inside one Compose service. `routers` refers to
Traefik Docker router names declared on the same service. When omitted, all explicit HTTP routers on
the service are used. Each router must declare its entrypoints and service so it can be referenced
from the file provider. Existing router middlewares are inherited, so request headers and origin-side
authentication are not silently removed.
| Field | Default | Behavior |
|---|---:|---|
| `enabled` | required | Only the exact value `true` enables a policy. |
| `routers` | auto | Comma-separated Traefik router names on the same container. |
| `paths` | required | Comma-separated directory prefixes. Each must start and end with `/`; `/`, traversal and encoded paths are rejected. |
| `hosts` | from `Host()` | Optional exact hostnames for complex router rules; hosts must be inside `xiteng.site`. |
| `edge-ttl` | `604800` | Cloudflare TTL in seconds, bounded to 60 seconds365 days. |
| `browser-ttl` | `0` | When non-zero, also overrides the browser `Cache-Control` TTL. |
| `stale-while-revalidate` | `86400` | Stale revalidation window in seconds. |
The generated router only matches `GET` and `HEAD`, has priority 1000 above its base router, and adds
`Cloudflare-CDN-Cache-Control`. Cloudflare's managed rule makes extensionless files eligible for cache,
respects the TTL header for successful responses, and gives 3xx5xx responses a zero edge TTL.
Declaring a path static is a security decision: every response below the prefix must be public and
independent of users, cookies, authorization headers, and request-specific data. Never label HTML,
API, callback, admin, download-token, or user-content paths. A Cloudflare cache hit is served before
Traefik/ForwardAuth, even though cache misses still inherit the base router's middlewares.
Removing a policy does not revoke copies already held by browsers or edge locations; choose bounded
TTLs and purge Cloudflare explicitly when previously public content must be withdrawn immediately.
## Cloudflare token
Place an untracked API token at `secrets/cloudflare_cache_api_token`. It needs access to the
`xiteng.site` zone and the permissions required to edit Cache Rules. The existing DNS-01 token is
intentionally not reused. The controller remains healthy without this file and reports
`waiting-for-token`; in that state Traefik headers and default-extension caching work, but arbitrary
extensionless URLs are not guaranteed to be cached by Cloudflare.
Create the token in Cloudflare, then install it without exposing it in shell history or chat:
```sh
./edge-cache/configure-token
```
Inspect the generated rule without contacting Cloudflare:
```sh
docker compose -f edge-cache/compose.yml exec controller \
node /app/cloudflare.mjs plan
```
The live controller automatically creates or updates only the rule whose description is
`Xiteng label-managed static paths`; it preserves all unrelated Cache Rules.
+19
View File
@@ -0,0 +1,19 @@
import {readFileSync} from "node:fs";
import {cloudflareRuleFromSpec, syncCloudflareRule} from "./controller.mjs";
const command = process.argv[2] || "plan";
const specPath = process.env.CLOUDFLARE_SPEC_PATH || "/state/cloudflare-cache-rules.json";
const tokenPath = process.env.CLOUDFLARE_API_TOKEN_FILE || "/run/secrets/cloudflare_cache_api_token";
const zoneId = process.env.CLOUDFLARE_ZONE_ID || "";
const spec = JSON.parse(readFileSync(specPath, "utf8"));
if (command === "plan") {
console.log(JSON.stringify(cloudflareRuleFromSpec(spec), null, 2));
} else if (["check", "apply"].includes(command)) {
const token = readFileSync(tokenPath, "utf8").trim();
const result = await syncCloudflareRule(spec, {zoneId, token, dryRun: command === "check"});
console.log(JSON.stringify(result, null, 2));
} else {
console.error("Usage: node cloudflare.mjs [plan|check|apply]");
process.exit(2);
}
+52
View File
@@ -0,0 +1,52 @@
services:
controller:
image: node:24-alpine
container_name: xiteng-edge-cache
restart: unless-stopped
command: ["node", "/app/controller.mjs"]
environment:
PORT: 8094
DOCKER_SOCKET: /var/run/docker.sock
OUTPUT_DIRECTORY: /dynamic
STATE_DIRECTORY: /state
DISCOVERY_INTERVAL_MS: 5000
CLOUDFLARE_SYNC_INTERVAL_MS: 60000
CLOUDFLARE_ZONE_NAME: xiteng.site
CLOUDFLARE_ZONE_ID: c1cce0c846ccc0d2138b9b79c5e91e9f
CLOUDFLARE_API_TOKEN_FILE: /run/secrets/cloudflare_cache_api_token
volumes:
- ./controller.mjs:/app/controller.mjs:ro
- ./cloudflare.mjs:/app/cloudflare.mjs:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./dynamic:/dynamic
- ./state:/state
- ./secrets:/run/secrets:ro
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8094/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
labels:
- "traefik.enable=false"
- "xiteng.site.component.edge-cache.enabled=true"
- "xiteng.site.component.edge-cache.name=Edge Cache Controller"
- "xiteng.site.component.edge-cache.description=将服务声明的静态路径编译为 Traefik 路由与 Cloudflare Cache Rule。"
- "xiteng.site.component.edge-cache.section=infrastructure"
- "xiteng.site.component.edge-cache.category=边缘与网络"
- "xiteng.site.component.edge-cache.access=internal"
- "xiteng.site.component.edge-cache.access-label=仅容器网络"
- "xiteng.site.component.edge-cache.icon=EC"
- "xiteng.site.component.edge-cache.accent=blue"
- "xiteng.site.component.edge-cache.order=115"
networks:
- homelab_net
networks:
homelab_net:
external: true
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
set -eu
EDGE_CACHE_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
EDGE_CACHE_TOKEN_FILE="$EDGE_CACHE_DIR/secrets/cloudflare_cache_api_token"
EDGE_CACHE_TOKEN_TMP="$EDGE_CACHE_TOKEN_FILE.tmp.$$"
if [ ! -t 0 ]; then
echo "Refusing to read a Cloudflare token from a non-interactive stdin." >&2
exit 1
fi
printf 'Cloudflare Cache Rules API token: ' > /dev/tty
stty -echo < /dev/tty
IFS= read -r EDGE_CACHE_TOKEN < /dev/tty || true
stty echo < /dev/tty
printf '\n' > /dev/tty
if [ "${#EDGE_CACHE_TOKEN}" -lt 20 ]; then
echo "Token is missing or unexpectedly short." >&2
exit 1
fi
umask 077
printf '%s\n' "$EDGE_CACHE_TOKEN" > "$EDGE_CACHE_TOKEN_TMP"
mv "$EDGE_CACHE_TOKEN_TMP" "$EDGE_CACHE_TOKEN_FILE"
unset EDGE_CACHE_TOKEN
echo "Token installed with mode 600. The running controller will sync the managed Cache Rule automatically."
+509
View File
@@ -0,0 +1,509 @@
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);
});
}
+115
View File
@@ -0,0 +1,115 @@
import test from "node:test";
import assert from "node:assert/strict";
import {buildConfiguration, cloudflareRuleFromSpec, definitionsFromLabels, syncCloudflareRule} from "./controller.mjs";
const labels = {
"com.docker.compose.project": "authentik",
"com.docker.compose.service": "authentik",
"traefik.enable": "true",
"traefik.http.routers.authentik.rule": "Host(`auth.xiteng.site`)",
"traefik.http.routers.authentik.entrypoints": "websecure",
"traefik.http.routers.authentik.service": "authentik",
"traefik.http.routers.authentik.middlewares": "force-https-headers",
"traefik.http.routers.authentik.tls": "true",
"traefik.http.routers.authentik.tls.certresolver": "cfresolver",
"traefik.http.routers.authentik-http.rule": "Host(`auth.xiteng.site`)",
"traefik.http.routers.authentik-http.entrypoints": "web",
"traefik.http.routers.authentik-http.service": "authentik",
"xiteng.site.cache.frontend.enabled": "true",
"xiteng.site.cache.frontend.routers": "authentik,authentik-http",
"xiteng.site.cache.frontend.paths": "/static/dist/,/assets/",
"xiteng.site.cache.frontend.edge-ttl": "604800",
"xiteng.site.cache.frontend.browser-ttl": "3600"
};
function reverseObjectKeys(value) {
if (Array.isArray(value)) {
return value.map(reverseObjectKeys);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).reverse().map(([key, nested]) => [key, reverseObjectKeys(nested)])
);
}
return value;
}
test("parses cache definitions without exposing unrelated labels", () => {
const definitions = definitionsFromLabels(labels);
assert.equal(definitions.size, 1);
assert.equal(definitions.get("frontend").paths, "/static/dist/,/assets/");
assert.equal(definitions.get("frontend").routers, "authentik,authentik-http");
});
test("builds higher-priority GET/HEAD routers and preserves base middleware", () => {
const built = buildConfiguration([{Id: "one", Labels: labels}], {zoneName: "xiteng.site"});
assert.deepEqual(built.errors, []);
assert.equal(built.policyCount, 1);
assert.equal(built.routerCount, 2);
const generated = Object.values(built.traefik.http.routers);
assert.ok(generated.every((router) => router.rule.includes("Method(`GET`)")));
assert.ok(generated.every((router) => router.rule.includes("PathPrefix(`/static/dist/`)")));
assert.ok(generated.some((router) => router.middlewares.includes("force-https-headers@docker")));
assert.ok(generated.every((router) => router.middlewares[0].startsWith("xiteng-static-")));
assert.ok(generated.some((router) => router.tls?.certResolver === "cfresolver"));
const middleware = Object.values(built.traefik.http.middlewares)[0];
assert.equal(middleware.headers.customResponseHeaders["Cache-Control"], "public, max-age=3600, stale-while-revalidate=86400");
assert.match(middleware.headers.customResponseHeaders["Cloudflare-CDN-Cache-Control"], /s-maxage=604800/);
});
test("creates one Cloudflare expression from exact hosts and safe path prefixes", () => {
const built = buildConfiguration([{Id: "one", Labels: labels}], {zoneName: "xiteng.site"});
assert.match(built.cloudflare.expression, /http\.host eq "auth\.xiteng\.site"/);
assert.match(built.cloudflare.expression, /starts_with\(http\.request\.uri\.path, "\/assets\/"\)/);
const rule = cloudflareRuleFromSpec(built.cloudflare);
assert.equal(rule.action_parameters.cache, true);
assert.equal(rule.action_parameters.edge_ttl.status_code_ttl[1].value, 0);
assert.equal(rule.enabled, true);
});
test("does not update a semantically identical Cloudflare rule when API reorders fields", async () => {
const built = buildConfiguration([{Id: "one", Labels: labels}], {zoneName: "xiteng.site"});
const current = {id: "managed-rule", ...reverseObjectKeys(cloudflareRuleFromSpec(built.cloudflare))};
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({
success: true,
result: {id: "cache-ruleset", rules: [current]}
}), {status: 200, headers: {"Content-Type": "application/json"}});
try {
const result = await syncCloudflareRule(built.cloudflare, {zoneId: "zone", token: "token", dryRun: true});
assert.deepEqual(result, {changed: false, action: "unchanged", ruleId: "managed-rule"});
} finally {
globalThis.fetch = originalFetch;
}
});
test("does not override browser Cache-Control unless browser-ttl is explicit", () => {
const edgeOnly = {...labels};
delete edgeOnly["xiteng.site.cache.frontend.browser-ttl"];
const built = buildConfiguration([{Id: "edge-only", Labels: edgeOnly}], {zoneName: "xiteng.site"});
const headers = Object.values(built.traefik.http.middlewares)[0].headers.customResponseHeaders;
assert.equal(headers["Cache-Control"], undefined);
assert.match(headers["Cloudflare-CDN-Cache-Control"], /s-maxage=604800/);
});
test("rejects root and traversal-like cache paths", () => {
for (const paths of ["/", "/static/../admin/", "/static//nested/"]) {
const broken = {...labels, "xiteng.site.cache.frontend.paths": paths};
const built = buildConfiguration([{Id: paths, Labels: broken}], {zoneName: "xiteng.site"});
assert.equal(built.policyCount, 0);
assert.equal(built.errors.length, 1);
assert.equal(Object.keys(built.traefik.http.routers).length, 0);
assert.equal(Object.keys(built.traefik.http.middlewares).length, 0);
}
});
test("does not let labels cache a hostname outside the managed zone", () => {
const broken = {
...labels,
"traefik.http.routers.authentik.rule": "Host(`example.com`)",
"traefik.http.routers.authentik-http.rule": "Host(`example.com`)"
};
const built = buildConfiguration([{Id: "external", Labels: broken}], {zoneName: "xiteng.site"});
assert.equal(built.policyCount, 0);
assert.match(built.errors[0], /outside xiteng\.site/);
});
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@