mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 23:14:53 +09:00
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:
1 parent
2b981a5734
commit
87aa7b3b4c
6 files changed
+151
-10
No files matched your search
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user