diff --git a/@vates/types/src/xo-app.mts b/@vates/types/src/xo-app.mts index dba7848b8f..4cea17ead6 100644 --- a/@vates/types/src/xo-app.mts +++ b/@vates/types/src/xo-app.mts @@ -204,6 +204,8 @@ export type XoApp = { backupGuard(poolId: XoPool['id']): Promise /* Throw if no authorization */ checkFeatureAuthorization(featureCode: FeatureCode): Promise + /* validate, apply and persist the configuration of a plugin */ + configurePlugin(id: string, configuration: unknown, mergeWithExisting?: boolean): Promise /* connect a server (XCP-ng/XenServer) */ connectXenServer(id: XoServer['id']): Promise // TODO: replace all XoAclBasePrivilege with a more strict type. (discriminate union) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 36fb956141..7d2f4012d2 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -73,6 +73,7 @@ - xo-server patch - xo-server-ipmi-sensors minor - xo-server-netbox patch +- xo-server-openmetrics patch - xo-web patch diff --git a/docs/docs/xo5/advanced.md b/docs/docs/xo5/advanced.md index 256e9c2ec2..1f686a8d46 100644 --- a/docs/docs/xo5/advanced.md +++ b/docs/docs/xo5/advanced.md @@ -403,14 +403,14 @@ The OpenMetrics plugin exposes a `/metrics` endpoint that Prometheus can scrape 2. Find and enable the **OpenMetrics** plugin. 3. Configure the following options: -| Option | Default | Description | -| --------------------- | ---------- | --------------------------------------------------------- | -| **Prometheus secret** | (required) | Bearer token for authentication - you must set this value | +| Option | Default | Description | +| --------------------- | ----------- | -------------------------------------------------------------------------- | +| **Prometheus secret** | (generated) | Bearer token for authentication - generated on first load if left empty | 4. Save and load the plugin. -:::warning -You must set a **Prometheus secret** before loading the plugin. Without a valid secret, the metrics endpoint authentication is ineffective, potentially exposing sensitive infrastructure data (host resources, VM configurations, network details) to unauthorized access. Use a strong, random string (e.g., generated with `openssl rand -hex 32`). +:::tip +If you leave the **Prometheus secret** empty, one is generated the first time the plugin is loaded and saved in the plugin configuration, where you can read it back to configure Prometheus. You can also set your own strong, random string (e.g., generated with `openssl rand -hex 32`). Either way the secret is kept across xo-server restarts. ::: ### Prometheus Configuration diff --git a/packages/xo-server-openmetrics/src/index.mts b/packages/xo-server-openmetrics/src/index.mts index 5689cc529c..f6f61877f9 100644 --- a/packages/xo-server-openmetrics/src/index.mts +++ b/packages/xo-server-openmetrics/src/index.mts @@ -415,6 +415,9 @@ const __dirname = dirname(__filename) const logger = createLogger('xo:xo-server-openmetrics') +/** Id under which xo-server registers this plugin (directory name minus the `xo-server-` prefix) */ +const PLUGIN_ID = 'openmetrics' + /** Default port for the OpenMetrics HTTP server */ const DEFAULT_PORT = 9004 @@ -481,12 +484,36 @@ export const configurationSchema = { type: 'string', title: 'Prometheus secret', description: 'Add this secret to http_config > authorization > credentials, and set type to Bearer', - default: Buffer.from(getRandomValues(new Uint32Array(8))).toString('hex'), }, }, additionalProperties: false, } +/** + * Return the Prometheus bearer token, generating and persisting one on first use. + * + * The secret must survive an xo-server restart. It used to be a random + * `default` in `configurationSchema`: that expression is re-evaluated every + * time the module is loaded, and xo-server never saves the values it fills in + * from schema defaults, so each restart silently invalidated the token + * Prometheus was configured with. + * + * Exported for testability. + */ +export async function ensureSecret( + configuration: PluginConfiguration | undefined, + persist: (configuration: PluginConfiguration) => Promise +): Promise { + const secret = configuration?.secret + if (secret !== undefined && secret !== '') { + return secret + } + + const generated = Buffer.from(getRandomValues(new Uint32Array(8))).toString('hex') + await persist({ secret: generated }) + return generated +} + // ============================================================================ // XOSTOR helpers (module scope) // ============================================================================ @@ -779,11 +806,15 @@ class OpenMetricsPlugin { return } + const secret = await ensureSecret(this.#configuration, configuration => + this.#xo.configurePlugin(PLUGIN_ID, configuration, true) + ) + // Port and bindAddress are fixed for security (server is behind xo-server proxy) const serverConfig: ServerConfiguration = { port: DEFAULT_PORT, bindAddress: DEFAULT_BIND_ADDRESS, - secret: this.#configuration?.secret ?? '', + secret, } logger.info('Starting OpenMetrics server', { diff --git a/packages/xo-server-openmetrics/src/index.test.mts b/packages/xo-server-openmetrics/src/index.test.mts new file mode 100644 index 0000000000..d7b507de3c --- /dev/null +++ b/packages/xo-server-openmetrics/src/index.test.mts @@ -0,0 +1,48 @@ +/** + * Tests for the plugin configuration (Prometheus secret handling) + */ + +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { configurationSchema, ensureSecret } from './index.mjs' + +describe('configurationSchema', () => { + it('does not generate the secret as a schema default', () => { + // a `default` here is re-evaluated on every module load and never persisted + // by xo-server, which used to change the Prometheus secret at each restart + assert.equal((configurationSchema.properties.secret as Record).default, undefined) + }) +}) + +describe('ensureSecret', () => { + it('generates and persists a secret when none is configured', async () => { + const persisted: unknown[] = [] + const secret = await ensureSecret(undefined, async configuration => { + persisted.push(configuration) + }) + + assert.match(secret, /^[0-9a-f]{16}$/) + assert.deepEqual(persisted, [{ secret }]) + }) + + it('generates and persists a secret when the configured one is empty', async () => { + const persisted: unknown[] = [] + const secret = await ensureSecret({ secret: '' }, async configuration => { + persisted.push(configuration) + }) + + assert.match(secret, /^[0-9a-f]{16}$/) + assert.deepEqual(persisted, [{ secret }]) + }) + + it('keeps the configured secret and persists nothing', async () => { + const persisted: unknown[] = [] + const secret = await ensureSecret({ secret: 'cafecafecafecafe' }, async configuration => { + persisted.push(configuration) + }) + + assert.equal(secret, 'cafecafecafecafe') + assert.deepEqual(persisted, []) + }) +})