diff --git a/package-lock.json b/package-lock.json index a1c94ee78..8c0506ba1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -98,6 +98,7 @@ "@playwright/test": "~1.39.0", "@popperjs/core": "~2.10.2", "@testcontainers/hivemq": "^10.13.1", + "@testcontainers/postgresql": "^11.9.0", "@testcontainers/rabbitmq": "^10.13.2", "@types/bootstrap": "~5.1.9", "@types/node": "^20.8.6", @@ -5680,6 +5681,63 @@ "testcontainers": "^10.28.0" } }, + "node_modules/@testcontainers/postgresql": { + "version": "11.9.0", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.9.0.tgz", + "integrity": "sha512-beLyLdLygFllktviM132Xd6tQ4i5FnuyZP+4BQEjUb5sJYHYnIrV/ZBzRRflIlF8gugt1GXgudkmr/HxM9vtKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "testcontainers": "^11.9.0" + } + }, + "node_modules/@testcontainers/postgresql/node_modules/docker-compose": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-1.3.0.tgz", + "integrity": "sha512-7Gevk/5eGD50+eMD+XDnFnOrruFkL0kSd7jEG4cjmqweDSUhB7i0g8is/nBdVpl+Bx338SqIB2GLKm32M+Vs6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@testcontainers/postgresql/node_modules/testcontainers": { + "version": "11.9.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-11.9.0.tgz", + "integrity": "sha512-SQ6OqQUig7HcGVF72i+ZVIMvxPSpEz8cgC/B63ekqMzgf98DnveoBbOmqux/Wa5wQAQCt4mEPNMa/Jz7vMg9fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@types/dockerode": "^3.3.47", + "archiver": "^7.0.1", + "async-lock": "^1.4.1", + "byline": "^5.0.0", + "debug": "^4.4.3", + "docker-compose": "^1.3.0", + "dockerode": "^4.0.9", + "get-port": "^7.1.0", + "proper-lockfile": "^4.1.2", + "properties-reader": "^2.3.0", + "ssh-remote-port-forward": "^1.0.4", + "tar-fs": "^3.1.1", + "tmp": "^0.2.5", + "undici": "^7.16.0" + } + }, + "node_modules/@testcontainers/postgresql/node_modules/undici": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", + "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/@testcontainers/rabbitmq": { "version": "10.28.0", "resolved": "https://registry.npmjs.org/@testcontainers/rabbitmq/-/rabbitmq-10.28.0.tgz", @@ -5789,9 +5847,9 @@ } }, "node_modules/@types/dockerode": { - "version": "3.3.44", - "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.44.tgz", - "integrity": "sha512-fUpIHlsbYpxAJb285xx3vp7q5wf5mjqSn3cYwl/MhiM+DB99OdO5sOCPlO0PjO+TyOtphPs7tMVLU/RtOo/JjA==", + "version": "3.3.47", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.47.tgz", + "integrity": "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 343de9352..6088015b3 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "nanoid": "~3.3.4", "net-snmp": "^3.11.2", "node-cloudflared-tunnel": "~1.0.9", + "node-radius-utils": "~1.2.0", "nodemailer": "~6.9.13", "nostr-tools": "^2.10.4", "notp": "~2.0.3", @@ -135,7 +136,6 @@ "protobufjs": "~7.2.4", "qs": "~6.10.4", "radius": "~1.1.4", - "node-radius-utils": "~1.2.0", "redbean-node": "~0.3.0", "redis": "~5.9.0", "semver": "~7.5.4", @@ -159,6 +159,7 @@ "@playwright/test": "~1.39.0", "@popperjs/core": "~2.10.2", "@testcontainers/hivemq": "^10.13.1", + "@testcontainers/postgresql": "^11.9.0", "@testcontainers/rabbitmq": "^10.13.2", "@types/bootstrap": "~5.1.9", "@types/node": "^20.8.6", diff --git a/server/model/monitor.js b/server/model/monitor.js index b2ea982c9..eff8add94 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -8,7 +8,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MI PING_COUNT_MIN, PING_COUNT_MAX, PING_COUNT_DEFAULT, PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT } = require("../../src/util"); -const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, +const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, mysqlQuery, setSetting, httpNtlm, radius, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal, checkCertificateHostname } = require("../util-server"); const { R } = require("redbean-node"); @@ -783,14 +783,6 @@ class Monitor extends BeanModel { await mssqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1"); - bean.msg = ""; - bean.status = UP; - bean.ping = dayjs().valueOf() - startTime; - } else if (this.type === "postgres") { - let startTime = dayjs().valueOf(); - - await postgresQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1"); - bean.msg = ""; bean.status = UP; bean.ping = dayjs().valueOf() - startTime; diff --git a/server/monitor-types/postgres.js b/server/monitor-types/postgres.js new file mode 100644 index 000000000..6d6940191 --- /dev/null +++ b/server/monitor-types/postgres.js @@ -0,0 +1,83 @@ +const { MonitorType } = require("./monitor-type"); +const { log, UP } = require("../../src/util"); +const dayjs = require("dayjs"); +const postgresConParse = require("pg-connection-string").parse; +const { Client } = require("pg"); + +class PostgresMonitorType extends MonitorType { + name = "postgres"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + let startTime = dayjs().valueOf(); + + let query = monitor.databaseQuery; + // No query provided by user, use SELECT 1 + if (!query || (typeof query === "string" && query.trim() === "")) { + query = "SELECT 1"; + } + await this.postgresQuery(monitor.databaseConnectionString, query); + + heartbeat.msg = ""; + heartbeat.status = UP; + heartbeat.ping = dayjs().valueOf() - startTime; + } + + /** + * Run a query on Postgres + * @param {string} connectionString The database connection string + * @param {string} query The query to validate the database with + * @returns {Promise<(string[] | object[] | object)>} Response from + * server + */ + async postgresQuery(connectionString, query) { + return new Promise((resolve, reject) => { + const config = postgresConParse(connectionString); + + // Fix #3868, which true/false is not parsed to boolean + if (typeof config.ssl === "string") { + config.ssl = config.ssl === "true"; + } + + if (config.password === "") { + // See https://github.com/brianc/node-postgres/issues/1927 + reject(new Error("Password is undefined.")); + return; + } + const client = new Client(config); + + client.on("error", (error) => { + log.debug("postgres", "Error caught in the error event handler."); + reject(error); + }); + + client.connect((err) => { + if (err) { + reject(err); + client.end(); + } else { + // Connected here + try { + client.query(query, (err, res) => { + if (err) { + reject(err); + } else { + resolve(res); + } + client.end(); + }); + } catch (e) { + reject(e); + client.end(); + } + } + }); + }); + } +} + +module.exports = { + PostgresMonitorType, +}; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index d91f6be81..982af6cf8 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -113,6 +113,7 @@ class UptimeKumaServer { UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing(); UptimeKumaServer.monitorTypeList["websocket-upgrade"] = new WebSocketMonitorType(); UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType(); + UptimeKumaServer.monitorTypeList["postgres"] = new PostgresMonitorType(); UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType(); UptimeKumaServer.monitorTypeList["smtp"] = new SMTPMonitorType(); UptimeKumaServer.monitorTypeList["group"] = new GroupMonitorType(); @@ -558,6 +559,7 @@ const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor const { TailscalePing } = require("./monitor-types/tailscale-ping"); const { WebSocketMonitorType } = require("./monitor-types/websocket-upgrade"); const { DnsMonitorType } = require("./monitor-types/dns"); +const { PostgresMonitorType } = require("./monitor-types/postgres"); const { MqttMonitorType } = require("./monitor-types/mqtt"); const { SMTPMonitorType } = require("./monitor-types/smtp"); const { GroupMonitorType } = require("./monitor-types/group"); diff --git a/server/util-server.js b/server/util-server.js index 80110399c..d28f05a97 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -11,8 +11,6 @@ const iconv = require("iconv-lite"); const chardet = require("chardet"); const chroma = require("chroma-js"); const mssql = require("mssql"); -const { Client } = require("pg"); -const postgresConParse = require("pg-connection-string").parse; const mysql = require("mysql2"); const { NtlmClient } = require("./modules/axios-ntlm/lib/ntlmClient.js"); const { Settings } = require("./settings"); @@ -349,64 +347,6 @@ exports.mssqlQuery = async function (connectionString, query) { } }; -/** - * Run a query on Postgres - * @param {string} connectionString The database connection string - * @param {string} query The query to validate the database with - * @returns {Promise<(string[] | object[] | object)>} Response from - * server - */ -exports.postgresQuery = function (connectionString, query) { - return new Promise((resolve, reject) => { - const config = postgresConParse(connectionString); - - // Fix #3868, which true/false is not parsed to boolean - if (typeof config.ssl === "string") { - config.ssl = config.ssl === "true"; - } - - if (config.password === "") { - // See https://github.com/brianc/node-postgres/issues/1927 - reject(new Error("Password is undefined.")); - return; - } - const client = new Client(config); - - client.on("error", (error) => { - log.debug("postgres", "Error caught in the error event handler."); - reject(error); - }); - - client.connect((err) => { - if (err) { - reject(err); - client.end(); - } else { - // Connected here - try { - // No query provided by user, use SELECT 1 - if (!query || (typeof query === "string" && query.trim() === "")) { - query = "SELECT 1"; - } - - client.query(query, (err, res) => { - if (err) { - reject(err); - } else { - resolve(res); - } - client.end(); - }); - } catch (e) { - reject(e); - client.end(); - } - } - }); - - }); -}; - /** * Run a query on MySQL/MariaDB * @param {string} connectionString The database connection string diff --git a/test/backend-test/test-postgres.js b/test/backend-test/test-postgres.js new file mode 100644 index 000000000..71d26d3a1 --- /dev/null +++ b/test/backend-test/test-postgres.js @@ -0,0 +1,60 @@ +const { describe, test } = require("node:test"); +const assert = require("node:assert"); +const { PostgreSqlContainer } = require("@testcontainers/postgresql"); +const { PostgresMonitorType } = require("../../server/monitor-types/postgres"); +const { UP, PENDING } = require("../../src/util"); + +describe( + "Postgres Single Node", + { + skip: + !!process.env.CI && + (process.platform !== "linux" || process.arch !== "x64"), + }, + () => { + test("Postgres is running", async () => { + // The default timeout of 30 seconds might not be enough for the container to start + const postgresContainer = await new PostgreSqlContainer( + "postgres:latest" + ) + .withStartupTimeout(60000) + .start(); + const postgresMonitor = new PostgresMonitorType(); + const monitor = { + databaseConnectionString: postgresContainer.getConnectionUri(), + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + try { + await postgresMonitor.check(monitor, heartbeat, {}); + assert.strictEqual(heartbeat.status, UP); + } finally { + postgresContainer.stop(); + } + }); + + test("Postgres is not running", async () => { + const postgresMonitor = new PostgresMonitorType(); + const monitor = { + databaseConnectionString: "http://localhost:15432", + }; + + const heartbeat = { + msg: "", + status: PENDING, + }; + + // regex match any string + const regex = /.+/; + + await assert.rejects( + postgresMonitor.check(monitor, heartbeat, {}), + regex + ); + }); + } +);