Require a token to scrape /metrics

The metrics endpoint fans out a gather to every connected host, so
anyone who could reach the controller could read cluster statistics and
put load on all hosts at will. Scraping now needs a user token holding
the new core.controller.metrics permission. The token is accepted via
Authorization: Bearer as well as X-Access-Token because Prometheus can
only send the former. Concurrent scrapes share one gather so hosts see
at most one metrics request at a time.

The three connection gauges in controller.ts now no-op before the
controller exists, like the other gauges there, so the endpoint can be
tested against the mock controller.

Closes #1009

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Fable 5.1 committed 2026-09-12 22:28:43 +00:00
1 parent 2b981a5734
commit 87aa7b3b4c
6 files changed
+151 -10

No files matched your search

+66 -1
View File
@@ -3,7 +3,7 @@ import events from "node:events";
import FormData from "form-data";
import http from "node:http";
import { wait } from "@clusterio/lib";
import { wait, expositionContentType } from "@clusterio/lib";
import * as routes from "@clusterio/controller/dist/node/src/routes.js";
import * as mock from "../mock.js";
@@ -72,6 +72,71 @@ describe("controller/src/routes", function() {
assert.equal(await responses[1].text(), "test content");
});
});
describe("/metrics", function() {
let endpoint, testToken;
beforeEach(function() {
endpoint = `http://localhost:${port}/metrics`;
testToken = controller.users.signUserToken({ id: "test" });
});
it("should respond with 401 without a valid token", async function() {
let response;
response = await fetch(endpoint);
assert.equal(response.status, 401);
response = await fetch(endpoint, { headers: { "X-Access-Token": "invalid" } });
assert.equal(response.status, 401);
response = await fetch(endpoint, { headers: { "Authorization": "Bearer invalid" } });
assert.equal(response.status, 401);
response = await fetch(endpoint, { headers: { "Authorization": `Basic ${testToken}` } });
assert.equal(response.status, 401);
const user = controller.users.getOrCreateUser("test");
user.tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
user.saveRecord();
response = await fetch(endpoint, { headers: { "Authorization": `Bearer ${testToken}` } });
assert.equal(response.status, 401);
});
it("should respond with 403 when user has insufficient permission", async function() {
const user = controller.users.getOrCreateUser("player");
let response = await fetch(endpoint, {
headers: { "Authorization": `Bearer ${controller.users.signUserToken(user)}` },
});
assert.equal(response.status, 403);
});
it("should respond with metrics to an authorized user", async function() {
for (const headers of [
{ "X-Access-Token": testToken },
{ "Authorization": `Bearer ${testToken}` },
{ "Authorization": `bearer ${testToken}` },
]) {
let response = await fetch(endpoint, { headers });
assert.equal(response.status, 200);
const contentType = response.headers.get("content-type");
for (const part of expositionContentType.split("; ")) {
assert(contentType.includes(part), `expected ${part} in content type ${contentType}`);
}
assert(/^# HELP /m.test(await response.text()), "expected exposition text");
}
});
it("should share one gather between concurrent requests", async function() {
let calls = 0;
controller.hooks.metrics.attach("test", async () => {
calls += 1;
await wait(100);
});
const headers = { "X-Access-Token": testToken };
let responses = await Promise.all([
fetch(endpoint, { headers }),
fetch(endpoint, { headers }),
fetch(endpoint, { headers }),
]);
for (const response of responses) {
assert.equal(response.status, 200);
}
assert.equal(calls, 1);
let response = await fetch(endpoint, { headers });
assert.equal(response.status, 200);
assert.equal(calls, 2);
});
});
describe("stripStaticPrefix()", function() {
it("should remove a leading static/", function() {
assert.equal(routes.stripStaticPrefix("static/main.abc.js"), "main.abc.js");