diff --git a/.dockerignore b/.dockerignore index e7ad658b..0bc56885 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,6 +18,7 @@ README.md .vscode .eslint* .stylelint* +/.devcontainer /.github yarn.lock app.json @@ -35,6 +36,7 @@ tsconfig.json /extra/healthcheck extra/exe-builder + ### .gitignore content (commented rules are duplicated) #node_modules diff --git a/.github/workflows/auto-test.yml b/.github/workflows/auto-test.yml index e1e43ccf..161c5bc5 100644 --- a/.github/workflows/auto-test.yml +++ b/.github/workflows/auto-test.yml @@ -22,7 +22,7 @@ jobs: strategy: matrix: os: [macos-latest, ubuntu-latest, windows-latest, ARM64] - node: [ 14, 18 ] + node: [ 14, 20 ] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: @@ -50,7 +50,7 @@ jobs: strategy: matrix: os: [ ARMv7 ] - node: [ 14.21.3, 18.16.1 ] + node: [ 14.21.3, 20.5.0 ] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: diff --git a/.stylelintrc b/.stylelintrc index 00ddcaae..0bcdb7c2 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -10,6 +10,7 @@ "color-function-notation": "legacy", "shorthand-property-no-redundant-values": null, "color-hex-length": null, - "declaration-block-no-redundant-longhand-properties": null + "declaration-block-no-redundant-longhand-properties": null, + "at-rule-no-unknown": null } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a933a450..c91a483b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,19 +34,19 @@ Yes or no, it depends on what you will try to do. Since I don't want to waste yo Here are some references: -✅ Usually Accept: +### ✅ Usually accepted: - Bug fix - Security fix - Adding notification providers -- Adding new language files (You should go to https://weblate.kuma.pet for existing languages) +- Adding new language files (see [these instructions](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md)) - Adding new language keys: `$t("...")` -⚠️ Discussion First +### ⚠️ Discussion required: - Large pull requests - New features -❌ Won't Merge -- A dedicated pr for translating existing languages (You can now translate on https://weblate.kuma.pet) +### ❌ Won't be merged: +- A dedicated pr for translating existing languages (see [these instructions](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md)) - Do not pass the auto test - Any breaking changes - Duplicated pull requests @@ -106,11 +106,11 @@ I personally do not like something that requires so many configurations before y ## Tools -- Node.js >= 14 -- NPM >= 8.5 -- Git -- IDE that supports ESLint and EditorConfig (I am using IntelliJ IDEA) -- A SQLite GUI tool (SQLite Expert Personal is suggested) +- [`Node.js`](https://nodejs.org/) >= 14 +- [`npm`](https://www.npmjs.com/) >= 8.5 +- [`git`](https://git-scm.com/) +- IDE that supports [`ESLint`](https://eslint.org/) and EditorConfig (I am using [`IntelliJ IDEA`](https://www.jetbrains.com/idea/)) +- A SQLite GUI tool (f.ex. [`SQLite Expert Personal`](https://www.sqliteexpert.com/download.html) or [`DBeaver Community`](https://dbeaver.io/download/)) ## Install Dependencies for Development @@ -218,7 +218,17 @@ If for maybe security reasons, a library must be updated. Then you must need to ## Translations -Please read: https://github.com/louislam/uptime-kuma/tree/master/src/languages +Please add **all** the strings which are translatable to `src/lang/en.json` (If translation keys are ommited, they can not be translated). + +**Don't include any other languages in your inital Pull-Request** (even if this is your mother tounge), to avoid merge-conflicts between weblate and `master`. +The translations can then (after merging a PR into `master`) be translated by awesome people donating their language-skills. + +If you want to help by translating Uptime Kuma into your language, please visit the [instructions on how to translate using weblate](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md). + +## Spelling & Grammar + +Feel free to correct the grammar in the documentation or code. +My mother language is not english and my grammar is not that great. ## Wiki diff --git a/README.md b/README.md index 0e41652d..151e9a6e 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ +
+ +
+ # Uptime Kuma +Uptime Kuma is an easy-to-use self-hosted monitoring tool. + [![GitHub Sponsors](https://img.shields.io/github/sponsors/louislam?label=GitHub%20Sponsors)](https://github.com/sponsors/louislam) Translation status -
- -
- -Uptime Kuma is an easy-to-use self-hosted monitoring tool. - ## 🥔 Live Demo @@ -184,7 +184,10 @@ If you want to report a bug or request a new feature, feel free to open a [new i ### Translations If you want to translate Uptime Kuma into your language, please visit [Weblate Readme](https://github.com/louislam/uptime-kuma/blob/master/src/lang/README.md). -Feel free to correct my grammar in this README, source code, or wiki, as my mother language is not English and my grammar is not that great. +## Spelling & Grammar + +Feel free to correct the grammar in the documentation or code. +My mother language is not english and my grammar is not that great. ### Create Pull Requests If you want to modify Uptime Kuma, please read this guide and follow the rules here: https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md diff --git a/babel.config.js b/babel.config.js index 6bb8a01a..d4c89547 100644 --- a/babel.config.js +++ b/babel.config.js @@ -4,8 +4,4 @@ if (process.env.TEST_FRONTEND) { config.presets = [ "@babel/preset-env" ]; } -if (process.env.TEST_BACKEND) { - config.plugins = [ "babel-plugin-rewire" ]; -} - module.exports = config; diff --git a/db/patch-add-certificate-expiry-status-page.sql b/db/patch-add-certificate-expiry-status-page.sql new file mode 100644 index 00000000..63a20105 --- /dev/null +++ b/db/patch-add-certificate-expiry-status-page.sql @@ -0,0 +1,7 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +ALTER TABLE status_page + ADD show_certificate_expiry BOOLEAN default 0 NOT NULL; + +COMMIT; diff --git a/db/patch-add-gamedig-given-port.sql b/db/patch-add-gamedig-given-port.sql new file mode 100644 index 00000000..897a9c72 --- /dev/null +++ b/db/patch-add-gamedig-given-port.sql @@ -0,0 +1,7 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +ALTER TABLE monitor + ADD gamedig_given_port_only BOOLEAN default 1 not null; + +COMMIT; diff --git a/db/patch-add-timeout-monitor.sql b/db/patch-add-timeout-monitor.sql new file mode 100644 index 00000000..32d49d1e --- /dev/null +++ b/db/patch-add-timeout-monitor.sql @@ -0,0 +1,6 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +ALTER TABLE monitor + ADD timeout DOUBLE default 0 not null; +COMMIT; \ No newline at end of file diff --git a/db/patch-monitor-oauth-cc.sql b/db/patch-monitor-oauth-cc.sql new file mode 100644 index 00000000..f33e9529 --- /dev/null +++ b/db/patch-monitor-oauth-cc.sql @@ -0,0 +1,19 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +ALTER TABLE monitor + ADD oauth_client_id TEXT default null; + +ALTER TABLE monitor + ADD oauth_client_secret TEXT default null; + +ALTER TABLE monitor + ADD oauth_token_url TEXT default null; + +ALTER TABLE monitor + ADD oauth_scopes TEXT default null; + +ALTER TABLE monitor + ADD oauth_auth_method TEXT default null; + +COMMIT; diff --git a/package.json b/package.json index d2c87b15..400f0782 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uptime-kuma", - "version": "1.22.1", + "version": "1.23.0-beta.1", "license": "MIT", "repository": { "type": "git", @@ -99,6 +99,7 @@ "http-proxy-agent": "~5.0.0", "https-proxy-agent": "~5.0.1", "iconv-lite": "~0.6.3", + "isomorphic-ws": "^5.0.0", "jsesc": "~3.0.2", "jsonata": "^2.0.3", "jsonwebtoken": "~9.0.0", @@ -115,7 +116,9 @@ "node-cloudflared-tunnel": "~1.0.9", "node-radius-client": "~1.0.0", "nodemailer": "~6.6.5", + "nostr-tools": "^1.13.1", "notp": "~2.0.3", + "openid-client": "^5.4.2", "password-hash": "~1.2.2", "pg": "~8.8.0", "pg-connection-string": "~2.5.0", @@ -132,7 +135,8 @@ "socks-proxy-agent": "6.1.1", "tar": "~6.1.11", "tcp-ping": "~0.1.1", - "thirty-two": "~1.0.2" + "thirty-two": "~1.0.2", + "ws": "^8.13.0" }, "devDependencies": { "@actions/github": "~5.0.1", @@ -149,7 +153,6 @@ "@vue/compiler-sfc": "~3.3.4", "@vuepic/vue-datepicker": "~3.4.8", "aedes": "^0.46.3", - "babel-plugin-rewire": "~1.2.0", "bootstrap": "5.1.3", "chart.js": "~4.2.1", "chartjs-adapter-dayjs-4": "~1.0.4", diff --git a/server/database.js b/server/database.js index a770e29a..0af8a312 100644 --- a/server/database.js +++ b/server/database.js @@ -28,6 +28,8 @@ class Database { static sqlitePath; + static dockerTLSDir; + /** * @type {boolean} */ @@ -79,6 +81,10 @@ class Database { "patch-add-invert-keyword.sql": true, "patch-added-json-query.sql": true, "patch-added-kafka-producer.sql": true, + "patch-add-certificate-expiry-status-page.sql": true, + "patch-monitor-oauth-cc.sql": true, + "patch-add-timeout-monitor.sql": true, + "patch-add-gamedig-given-port.sql": true, }; /** @@ -101,23 +107,28 @@ class Database { // Data Directory (must be end with "/") Database.dataDir = process.env.DATA_DIR || args["data-dir"] || "./data/"; - Database.sqlitePath = Database.dataDir + "kuma.db"; + Database.sqlitePath = path.join(Database.dataDir, "kuma.db"); if (! fs.existsSync(Database.dataDir)) { fs.mkdirSync(Database.dataDir, { recursive: true }); } - Database.uploadDir = Database.dataDir + "upload/"; + Database.uploadDir = path.join(Database.dataDir, "upload/"); if (! fs.existsSync(Database.uploadDir)) { fs.mkdirSync(Database.uploadDir, { recursive: true }); } // Create screenshot dir - Database.screenshotDir = Database.dataDir + "screenshots/"; + Database.screenshotDir = path.join(Database.dataDir, "screenshots/"); if (! fs.existsSync(Database.screenshotDir)) { fs.mkdirSync(Database.screenshotDir, { recursive: true }); } + Database.dockerTLSDir = path.join(Database.dataDir, "docker-tls/"); + if (! fs.existsSync(Database.dockerTLSDir)) { + fs.mkdirSync(Database.dockerTLSDir, { recursive: true }); + } + log.info("db", `Data Dir: ${Database.dataDir}`); } diff --git a/server/docker.js b/server/docker.js index ff231502..1a8c0a5d 100644 --- a/server/docker.js +++ b/server/docker.js @@ -2,8 +2,16 @@ const axios = require("axios"); const { R } = require("redbean-node"); const version = require("../package.json").version; const https = require("https"); +const fs = require("fs"); +const path = require("path"); +const Database = require("./database"); class DockerHost { + + static CertificateFileNameCA = "ca.pem"; + static CertificateFileNameCert = "cert.pem"; + static CertificateFileNameKey = "key.pem"; + /** * Save a docker host * @param {Object} dockerHost Docker host to save @@ -66,10 +74,6 @@ class DockerHost { "Accept": "*/*", "User-Agent": "Uptime-Kuma/" + version }, - httpsAgent: new https.Agent({ - maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) - rejectUnauthorized: false, - }), }; if (dockerHost.dockerType === "socket") { @@ -77,6 +81,7 @@ class DockerHost { } else if (dockerHost.dockerType === "tcp") { options.baseURL = DockerHost.patchDockerURL(dockerHost.dockerDaemon); } + options.httpsAgent = new https.Agent(DockerHost.getHttpsAgentOptions(dockerHost.dockerType, options.baseURL)); let res = await axios.request(options); @@ -111,6 +116,53 @@ class DockerHost { } return url; } + + /** + * Returns HTTPS agent options with client side TLS parameters if certificate files + * for the given host are available under a predefined directory path. + * + * The base path where certificates are looked for can be set with the + * 'DOCKER_TLS_DIR_PATH' environmental variable or defaults to 'data/docker-tls/'. + * + * If a directory in this path exists with a name matching the FQDN of the docker host + * (e.g. the FQDN of 'https://example.com:2376' is 'example.com' so the directory + * 'data/docker-tls/example.com/' would be searched for certificate files), + * then 'ca.pem', 'key.pem' and 'cert.pem' files are included in the agent options. + * File names can also be overridden via 'DOCKER_TLS_FILE_NAME_(CA|KEY|CERT)'. + * + * @param {String} dockerType i.e. "tcp" or "socket" + * @param {String} url The docker host URL rewritten to https:// + * @return {Object} + * */ + static getHttpsAgentOptions(dockerType, url) { + let baseOptions = { + maxCachedSessions: 0, + rejectUnauthorized: true + }; + let certOptions = {}; + + let dirName = (new URL(url)).hostname; + + let caPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameCA); + let certPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameCert); + let keyPath = path.join(Database.dockerTLSDir, dirName, DockerHost.CertificateFileNameKey); + + if (dockerType === "tcp" && fs.existsSync(caPath) && fs.existsSync(certPath) && fs.existsSync(keyPath)) { + let ca = fs.readFileSync(caPath); + let key = fs.readFileSync(keyPath); + let cert = fs.readFileSync(certPath); + certOptions = { + ca, + key, + cert + }; + } + + return { + ...baseOptions, + ...certOptions + }; + } } module.exports = { diff --git a/server/model/group.js b/server/model/group.js index 3f3b3b12..5b712ace 100644 --- a/server/model/group.js +++ b/server/model/group.js @@ -9,12 +9,12 @@ class Group extends BeanModel { * @param {boolean} [showTags=false] Should the JSON include monitor tags * @returns {Object} */ - async toPublicJSON(showTags = false) { + async toPublicJSON(showTags = false, certExpiry = false) { let monitorBeanList = await this.getMonitorList(); let monitorList = []; for (let bean of monitorBeanList) { - monitorList.push(await bean.toPublicJSON(showTags)); + monitorList.push(await bean.toPublicJSON(showTags, certExpiry)); } return { diff --git a/server/model/monitor.js b/server/model/monitor.js index dfdb4316..237eb79e 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -6,7 +6,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, TimeLogger, MAX_INTERVA SQL_DATETIME_FORMAT } = require("../../src/util"); const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery, - redisPingAsync, mongodbPing, kafkaProducerAsync + redisPingAsync, mongodbPing, kafkaProducerAsync, getOidcTokenClientCredentials, } = require("../util-server"); const { R } = require("redbean-node"); const { BeanModel } = require("redbean-node/dist/bean-model"); @@ -38,11 +38,12 @@ class Monitor extends BeanModel { * Only show necessary data to public * @returns {Object} */ - async toPublicJSON(showTags = false) { + async toPublicJSON(showTags = false, certExpiry = false) { let obj = { id: this.id, name: this.name, sendUrl: this.sendUrl, + type: this.type, }; if (this.sendUrl) { @@ -52,6 +53,13 @@ class Monitor extends BeanModel { if (showTags) { obj.tags = await this.getTags(); } + + if (certExpiry && this.type === "http") { + const { certExpiryDaysRemaining, validCert } = await this.getCertExpiry(this.id); + obj.certExpiryDaysRemaining = certExpiryDaysRemaining; + obj.validCert = validCert; + } + return obj; } @@ -95,6 +103,7 @@ class Monitor extends BeanModel { active: await this.isActive(), forceInactive: !await Monitor.isParentActive(this.id), type: this.type, + timeout: this.timeout, interval: this.interval, retryInterval: this.retryInterval, resendInterval: this.resendInterval, @@ -127,6 +136,7 @@ class Monitor extends BeanModel { radiusCalledStationId: this.radiusCalledStationId, radiusCallingStationId: this.radiusCallingStationId, game: this.game, + gamedigGivenPortOnly: this.getGameDigGivenPortOnly(), httpBodyEncoding: this.httpBodyEncoding, jsonPath: this.jsonPath, expectedValue: this.expectedValue, @@ -147,6 +157,11 @@ class Monitor extends BeanModel { grpcMetadata: this.grpcMetadata, basic_auth_user: this.basic_auth_user, basic_auth_pass: this.basic_auth_pass, + oauth_client_id: this.oauth_client_id, + oauth_client_secret: this.oauth_client_secret, + oauth_token_url: this.oauth_token_url, + oauth_scopes: this.oauth_scopes, + oauth_auth_method: this.oauth_auth_method, pushToken: this.pushToken, databaseConnectionString: this.databaseConnectionString, radiusUsername: this.radiusUsername, @@ -185,6 +200,31 @@ class Monitor extends BeanModel { return await R.getAll("SELECT mt.*, tag.name, tag.color FROM monitor_tag mt JOIN tag ON mt.tag_id = tag.id WHERE mt.monitor_id = ? ORDER BY tag.name", [ this.id ]); } + /** + * Gets certificate expiry for this monitor + * @param {number} monitorID ID of monitor to send + * @returns {Promise>} + */ + async getCertExpiry(monitorID) { + let tlsInfoBean = await R.findOne("monitor_tls_info", "monitor_id = ?", [ + monitorID, + ]); + let tlsInfo; + if (tlsInfoBean) { + tlsInfo = JSON.parse(tlsInfoBean?.info_json); + if (tlsInfo?.valid && tlsInfo?.certInfo?.daysRemaining) { + return { + certExpiryDaysRemaining: tlsInfo.certInfo.daysRemaining, + validCert: true + }; + } + } + return { + certExpiryDaysRemaining: "", + validCert: false + }; + } + /** * Encode user and password to Base64 encoding * for HTTP "basic" auth, as per RFC-7617 @@ -242,6 +282,10 @@ class Monitor extends BeanModel { return JSON.parse(this.accepted_statuscodes_json); } + getGameDigGivenPortOnly() { + return Boolean(this.gamedigGivenPortOnly); + } + /** * Start monitor * @param {Server} io Socket server instance @@ -314,7 +358,10 @@ class Monitor extends BeanModel { const lastBeat = await Monitor.getPreviousHeartbeat(child.id); // Only change state if the monitor is in worse conditions then the ones before - if (bean.status === UP && (lastBeat.status === PENDING || lastBeat.status === DOWN)) { + // lastBeat.status could be null + if (!lastBeat) { + bean.status = PENDING; + } else if (bean.status === UP && (lastBeat.status === PENDING || lastBeat.status === DOWN)) { bean.status = lastBeat.status; } else if (bean.status === PENDING && lastBeat.status === DOWN) { bean.status = lastBeat.status; @@ -342,6 +389,24 @@ class Monitor extends BeanModel { }; } + // OIDC: Basic client credential flow. + // Additional grants might be implemented in the future + let oauth2AuthHeader = {}; + if (this.auth_method === "oauth2-cc") { + try { + if (this.oauthAccessToken === undefined || new Date(this.oauthAccessToken.expires_at * 1000) <= new Date()) { + log.debug("monitor", `[${this.name}] The oauth access-token undefined or expired. Requesting a new one`); + this.oauthAccessToken = await getOidcTokenClientCredentials(this.oauth_token_url, this.oauth_client_id, this.oauth_client_secret, this.oauth_scopes, this.oauth_auth_method); + log.debug("monitor", `[${this.name}] Obtained oauth access-token. Expires at ${new Date(this.oauthAccessToken.expires_at * 1000)}`); + } + oauth2AuthHeader = { + "Authorization": this.oauthAccessToken.token_type + " " + this.oauthAccessToken.access_token, + }; + } catch (e) { + throw new Error("The oauth config is invalid. " + e.message); + } + } + const httpsAgentOptions = { maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) rejectUnauthorized: !this.getIgnoreTls(), @@ -370,12 +435,13 @@ class Monitor extends BeanModel { const options = { url: this.url, method: (this.method || "get").toLowerCase(), - timeout: this.interval * 1000 * 0.8, + timeout: this.timeout * 1000, headers: { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "User-Agent": "Uptime-Kuma/" + version, ...(contentType ? { "Content-Type": contentType } : {}), ...(basicAuthHeader), + ...(oauth2AuthHeader), ...(this.headers ? JSON.parse(this.headers) : {}) }, maxRedirects: this.maxredirects, @@ -589,7 +655,7 @@ class Monitor extends BeanModel { } let res = await axios.get(steamApiUrl, { - timeout: this.interval * 1000 * 0.8, + timeout: this.timeout * 1000, headers: { "Accept": "*/*", "User-Agent": "Uptime-Kuma/" + version, @@ -627,7 +693,7 @@ class Monitor extends BeanModel { type: this.game, host: this.hostname, port: this.port, - givenPortOnly: true, + givenPortOnly: this.getGameDigGivenPortOnly(), }); bean.msg = state.name; @@ -661,6 +727,9 @@ class Monitor extends BeanModel { options.socketPath = dockerHost._dockerDaemon; } else if (dockerHost._dockerType === "tcp") { options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon); + options.httpsAgent = CacheableDnsHttpAgent.getHttpsAgent( + DockerHost.getHttpsAgentOptions(dockerHost._dockerType, options.baseURL) + ); } log.debug("monitor", `[${this.name}] Axios Request`); @@ -760,29 +829,19 @@ class Monitor extends BeanModel { port = this.port; } - try { - const resp = await radius( - this.hostname, - this.radiusUsername, - this.radiusPassword, - this.radiusCalledStationId, - this.radiusCallingStationId, - this.radiusSecret, - port, - this.interval * 1000 * 0.8, - ); - if (resp.code) { - bean.msg = resp.code; - } - bean.status = UP; - } catch (error) { - bean.status = DOWN; - if (error.response?.code) { - bean.msg = error.response.code; - } else { - bean.msg = error.message; - } - } + const resp = await radius( + this.hostname, + this.radiusUsername, + this.radiusPassword, + this.radiusCalledStationId, + this.radiusCallingStationId, + this.radiusSecret, + port, + this.interval * 1000 * 0.4, + ); + + bean.msg = resp.code; + bean.status = UP; bean.ping = dayjs().valueOf() - startTime; } else if (this.type === "redis") { let startTime = dayjs().valueOf(); diff --git a/server/model/status_page.js b/server/model/status_page.js index 65b77367..e168acf2 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -90,6 +90,8 @@ class StatusPage extends BeanModel { * @param {StatusPage} statusPage */ static async getStatusPageData(statusPage) { + const config = await statusPage.toPublicJSON(); + // Incident let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [ statusPage.id, @@ -110,13 +112,13 @@ class StatusPage extends BeanModel { ]); for (let groupBean of list) { - let monitorGroup = await groupBean.toPublicJSON(showTags); + let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry); publicGroupList.push(monitorGroup); } // Response return { - config: await statusPage.toPublicJSON(), + config, incident, publicGroupList, maintenanceList, @@ -234,6 +236,7 @@ class StatusPage extends BeanModel { footerText: this.footer_text, showPoweredBy: !!this.show_powered_by, googleAnalyticsId: this.google_analytics_tag_id, + showCertificateExpiry: !!this.show_certificate_expiry, }; } @@ -255,6 +258,7 @@ class StatusPage extends BeanModel { footerText: this.footer_text, showPoweredBy: !!this.show_powered_by, googleAnalyticsId: this.google_analytics_tag_id, + showCertificateExpiry: !!this.show_certificate_expiry, }; } diff --git a/server/notification-providers/flashduty.js b/server/notification-providers/flashduty.js new file mode 100644 index 00000000..0d6f69e5 --- /dev/null +++ b/server/notification-providers/flashduty.js @@ -0,0 +1,98 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); +const { UP, DOWN, getMonitorRelativeURL } = require("../../src/util"); +const { setting } = require("../util-server"); +const successMessage = "Sent Successfully."; + +class FlashDuty extends NotificationProvider { + name = "FlashDuty"; + + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + try { + if (heartbeatJSON == null) { + const title = "Uptime Kuma Alert"; + const monitor = { + type: "ping", + url: msg, + name: "https://flashcat.cloud" + }; + return this.postNotification(notification, title, msg, monitor); + } + + if (heartbeatJSON.status === UP) { + const title = "Uptime Kuma Monitor ✅ Up"; + + return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, "Ok"); + } + + if (heartbeatJSON.status === DOWN) { + const title = "Uptime Kuma Monitor 🔴 Down"; + return this.postNotification(notification, title, heartbeatJSON.msg, monitorJSON, notification.flashdutySeverity); + } + } catch (error) { + this.throwGeneralAxiosError(error); + } + } + /** + * Generate a monitor url from the monitors infomation + * @param {Object} monitorInfo Monitor details + * @returns {string|undefined} + */ + + genMonitorUrl(monitorInfo) { + if (monitorInfo.type === "port" && monitorInfo.port) { + return monitorInfo.hostname + ":" + monitorInfo.port; + } + if (monitorInfo.hostname != null) { + return monitorInfo.hostname; + } + return monitorInfo.url; + } + + /** + * Send the message + * @param {BeanModel} notification Message title + * @param {string} title Message + * @param {string} body Message + * @param {Object} monitorInfo Monitor details + * @param {string} eventStatus Monitor status (Info, Warning, Critical, Ok) + * @returns {string} + */ + async postNotification(notification, title, body, monitorInfo, eventStatus) { + const options = { + method: "POST", + url: "https://api.flashcat.cloud/event/push/alert/standard?integration_key=" + notification.flashdutyIntegrationKey, + headers: { "Content-Type": "application/json" }, + data: { + description: `[${title}] [${monitorInfo.name}] ${body}`, + title, + event_status: eventStatus || "Info", + alert_key: String(monitorInfo.id) || Math.random().toString(36).substring(7), + labels: monitorInfo?.tags?.reduce((acc, item) => ({ ...acc, + [item.name]: item.value + }), { resource: this.genMonitorUrl(monitorInfo) }), + } + }; + + const baseURL = await setting("primaryBaseURL"); + if (baseURL && monitorInfo) { + options.client = "Uptime Kuma"; + options.client_url = baseURL + getMonitorRelativeURL(monitorInfo.id); + } + + let result = await axios.request(options); + if (result.status == null) { + throw new Error("FlashDuty notification failed with invalid response!"); + } + if (result.status < 200 || result.status >= 300) { + throw new Error("FlashDuty notification failed with status code " + result.status); + } + if (result.statusText != null) { + return "FlashDuty notification succeed: " + result.statusText; + } + + return successMessage; + } +} + +module.exports = FlashDuty; diff --git a/server/notification-providers/nostr.js b/server/notification-providers/nostr.js new file mode 100644 index 00000000..2c17840d --- /dev/null +++ b/server/notification-providers/nostr.js @@ -0,0 +1,119 @@ +const { log } = require("../../src/util"); +const NotificationProvider = require("./notification-provider"); +const { + relayInit, + getPublicKey, + getEventHash, + getSignature, + nip04, + nip19 +} = require("nostr-tools"); + +// polyfills for node versions +const semver = require("semver"); +const nodeVersion = process.version; +if (semver.lt(nodeVersion, "16.0.0")) { + log.warn("monitor", "Node <= 16 is unsupported for nostr, sorry :("); +} else if (semver.lt(nodeVersion, "18.0.0")) { + // polyfills for node 16 + global.crypto = require("crypto"); + global.WebSocket = require("isomorphic-ws"); + if (typeof crypto !== "undefined" && !crypto.subtle && crypto.webcrypto) { + crypto.subtle = crypto.webcrypto.subtle; + } +} else if (semver.lt(nodeVersion, "20.0.0")) { + // polyfills for node 18 + global.crypto = require("crypto"); + global.WebSocket = require("isomorphic-ws"); +} else { + // polyfills for node 20 + global.WebSocket = require("isomorphic-ws"); +} + +class Nostr extends NotificationProvider { + name = "nostr"; + + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + // All DMs should have same timestamp + const createdAt = Math.floor(Date.now() / 1000); + + const senderPrivateKey = await this.getPrivateKey(notification.sender); + const senderPublicKey = getPublicKey(senderPrivateKey); + const recipientsPublicKeys = await this.getPublicKeys(notification.recipients); + + // Create NIP-04 encrypted direct message event for each recipient + const events = []; + for (const recipientPublicKey of recipientsPublicKeys) { + const ciphertext = await nip04.encrypt(senderPrivateKey, recipientPublicKey, msg); + let event = { + kind: 4, + pubkey: senderPublicKey, + created_at: createdAt, + tags: [[ "p", recipientPublicKey ]], + content: ciphertext, + }; + event.id = getEventHash(event); + event.sig = getSignature(event, senderPrivateKey); + events.push(event); + } + + // Publish events to each relay + const relays = notification.relays.split("\n"); + let successfulRelays = 0; + + // Connect to each relay + for (const relayUrl of relays) { + const relay = relayInit(relayUrl); + try { + await relay.connect(); + successfulRelays++; + + // Publish events + for (const event of events) { + relay.publish(event); + } + } catch (error) { + continue; + } finally { + relay.close(); + } + } + + // Report success or failure + if (successfulRelays === 0) { + throw Error("Failed to connect to any relays."); + } + return `${successfulRelays}/${relays.length} relays connected.`; + } + + async getPrivateKey(sender) { + try { + const senderDecodeResult = await nip19.decode(sender); + const { data } = senderDecodeResult; + return data; + } catch (error) { + throw new Error(`Failed to get private key: ${error.message}`); + } + } + + async getPublicKeys(recipients) { + const recipientsList = recipients.split("\n"); + const publicKeys = []; + for (const recipient of recipientsList) { + try { + const recipientDecodeResult = await nip19.decode(recipient); + const { type, data } = recipientDecodeResult; + if (type === "npub") { + publicKeys.push(data); + } else { + throw new Error("not an npub"); + } + } catch (error) { + throw new Error(`Error decoding recipient: ${error}`); + } + } + return publicKeys; + } +} + +module.exports = Nostr; diff --git a/server/notification-providers/pushdeer.js b/server/notification-providers/pushdeer.js index bbd83f4b..288137d1 100644 --- a/server/notification-providers/pushdeer.js +++ b/server/notification-providers/pushdeer.js @@ -8,7 +8,9 @@ class PushDeer extends NotificationProvider { async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { let okMsg = "Sent Successfully."; - let pushdeerlink = "https://api2.pushdeer.com/message/push"; + let endpoint = "/message/push"; + let serverUrl = notification.pushdeerServer || "https://api2.pushdeer.com"; + let pushdeerlink = `${serverUrl.trim().replace(/\/*$/, "")}${endpoint}`; let valid = msg != null && monitorJSON != null && heartbeatJSON != null; diff --git a/server/notification.js b/server/notification.js index ea5c8ee0..570c440d 100644 --- a/server/notification.js +++ b/server/notification.js @@ -21,11 +21,13 @@ const LineNotify = require("./notification-providers/linenotify"); const LunaSea = require("./notification-providers/lunasea"); const Matrix = require("./notification-providers/matrix"); const Mattermost = require("./notification-providers/mattermost"); +const Nostr = require("./notification-providers/nostr"); const Ntfy = require("./notification-providers/ntfy"); const Octopush = require("./notification-providers/octopush"); const OneBot = require("./notification-providers/onebot"); const Opsgenie = require("./notification-providers/opsgenie"); const PagerDuty = require("./notification-providers/pagerduty"); +const FlashDuty = require("./notification-providers/flashduty"); const PagerTree = require("./notification-providers/pagertree"); const PromoSMS = require("./notification-providers/promosms"); const Pushbullet = require("./notification-providers/pushbullet"); @@ -84,11 +86,13 @@ class Notification { new LunaSea(), new Matrix(), new Mattermost(), + new Nostr(), new Ntfy(), new Octopush(), new OneBot(), new Opsgenie(), new PagerDuty(), + new FlashDuty(), new PagerTree(), new PromoSMS(), new Pushbullet(), @@ -115,7 +119,6 @@ class Notification { new GoAlert(), new ZohoCliq() ]; - for (let item of list) { if (! item.name) { throw new Error("Notification provider without name"); diff --git a/server/server.js b/server/server.js index 644bc52a..7363955b 100644 --- a/server/server.js +++ b/server/server.js @@ -49,7 +49,7 @@ if (! process.env.NODE_ENV) { } log.info("server", "Node Env: " + process.env.NODE_ENV); -log.info("server", "Inside Container: " + process.env.UPTIME_KUMA_IS_CONTAINER === "1"); +log.info("server", "Inside Container: " + (process.env.UPTIME_KUMA_IS_CONTAINER === "1")); log.info("server", "Importing Node libraries"); const fs = require("fs"); @@ -670,6 +670,10 @@ let needSetup = false; let notificationIDList = monitor.notificationIDList; delete monitor.notificationIDList; + // Ensure status code ranges are strings + if (!monitor.accepted_statuscodes.every((code) => typeof code === "string")) { + throw new Error("Accepted status codes are not all strings"); + } monitor.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes); delete monitor.accepted_statuscodes; @@ -686,7 +690,10 @@ let needSetup = false; await updateMonitorNotification(bean.id, notificationIDList); await server.sendMonitorList(socket); - await startMonitor(socket.userID, bean.id); + + if (monitor.active !== false) { + await startMonitor(socket.userID, bean.id); + } log.info("monitor", `Added Monitor: ${monitor.id} User ID: ${socket.userID}`); @@ -732,6 +739,11 @@ let needSetup = false; removeGroupChildren = true; } + // Ensure status code ranges are strings + if (!monitor.accepted_statuscodes.every((code) => typeof code === "string")) { + throw new Error("Accepted status codes are not all strings"); + } + bean.name = monitor.name; bean.description = monitor.description; bean.parent = monitor.parent; @@ -742,6 +754,12 @@ let needSetup = false; bean.headers = monitor.headers; bean.basic_auth_user = monitor.basic_auth_user; bean.basic_auth_pass = monitor.basic_auth_pass; + bean.timeout = monitor.timeout; + bean.oauth_client_id = monitor.oauth_client_id, + bean.oauth_client_secret = monitor.oauth_client_secret, + bean.oauth_auth_method = this.oauth_auth_method, + bean.oauth_token_url = monitor.oauth_token_url, + bean.oauth_scopes = monitor.oauth_scopes, bean.tlsCa = monitor.tlsCa; bean.tlsCert = monitor.tlsCert; bean.tlsKey = monitor.tlsKey; @@ -800,6 +818,7 @@ let needSetup = false; bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation; bean.kafkaProducerSaslOptions = JSON.stringify(monitor.kafkaProducerSaslOptions); bean.kafkaProducerMessage = monitor.kafkaProducerMessage; + bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly; bean.validate(); @@ -1401,6 +1420,7 @@ let needSetup = false; // Define default values let retryInterval = 0; + let timeout = monitorListData[i].timeout || (monitorListData[i].interval * 0.8); // fallback to old value /* Only replace the default value with the backup file data for the specific version, where it appears the first time @@ -1426,6 +1446,7 @@ let needSetup = false; basic_auth_pass: monitorListData[i].basic_auth_pass, authWorkstation: monitorListData[i].authWorkstation, authDomain: monitorListData[i].authDomain, + timeout, interval: monitorListData[i].interval, retryInterval: retryInterval, resendInterval: monitorListData[i].resendInterval || 0, diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js index 411bda55..eba40dae 100644 --- a/server/socket-handlers/status-page-socket-handler.js +++ b/server/socket-handlers/status-page-socket-handler.js @@ -162,6 +162,7 @@ module.exports.statusPageSocketHandler = (socket) => { statusPage.footer_text = config.footerText; statusPage.custom_css = config.customCSS; statusPage.show_powered_by = config.showPoweredBy; + statusPage.show_certificate_expiry = config.showCertificateExpiry; statusPage.modified_date = R.isoDateTime(); statusPage.google_analytics_tag_id = config.googleAnalyticsId; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 6781488e..7817c9e1 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -11,6 +11,7 @@ const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); const { Settings } = require("./settings"); const dayjs = require("dayjs"); const childProcess = require("child_process"); +const path = require("path"); // DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead. /** @@ -214,7 +215,7 @@ class UptimeKumaServer { * @param {boolean} outputToConsole Should the error also be output to console? */ static errorLog(error, outputToConsole = true) { - const errorLogStream = fs.createWriteStream(Database.dataDir + "/error.log", { + const errorLogStream = fs.createWriteStream(path.join(Database.dataDir, "/error.log"), { flags: "a" }); diff --git a/server/util-server.js b/server/util-server.js index 7ae1d199..778e7c6f 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -21,6 +21,8 @@ const grpc = require("@grpc/grpc-js"); const protojs = require("protobufjs"); const radiusClient = require("node-radius-client"); const redis = require("redis"); +const oidc = require("openid-client"); + const { dictionaries: { rfc2865: { file, attributes }, @@ -55,6 +57,43 @@ exports.initJWTSecret = async () => { return jwtSecretBean; }; +/** + * Decodes a jwt and returns the payload portion without verifying the jqt. + * @param {string} jwt The input jwt as a string + * @returns {Object} Decoded jwt payload object + */ +exports.decodeJwt = (jwt) => { + return JSON.parse(Buffer.from(jwt.split(".")[1], "base64").toString()); +}; + +/** + * Gets a Access Token form a oidc/oauth2 provider + * @param {string} tokenEndpoint The token URI form the auth service provider + * @param {string} clientId The oidc/oauth application client id + * @param {string} clientSecret The oidc/oauth application client secret + * @param {string} scope The scope the for which the token should be issued for + * @param {string} authMethod The method on how to sent the credentials. Default client_secret_basic + * @returns {Promise} TokenSet promise if the token request was successful + */ +exports.getOidcTokenClientCredentials = async (tokenEndpoint, clientId, clientSecret, scope, authMethod = "client_secret_basic") => { + const oauthProvider = new oidc.Issuer({ token_endpoint: tokenEndpoint }); + let client = new oauthProvider.Client({ + client_id: clientId, + client_secret: clientSecret, + token_endpoint_auth_method: authMethod + }); + + // Increase default timeout and clock tolerance + client[oidc.custom.http_options] = () => ({ timeout: 10000 }); + client[oidc.custom.clock_tolerance] = 5; + + let grantParams = { grant_type: "client_credentials" }; + if (scope) { + grantParams.scope = scope; + } + return await client.grant(grantParams); +}; + /** * Send TCP request to specified hostname and port * @param {string} hostname Hostname / address of machine @@ -489,6 +528,7 @@ exports.radius = function ( host: hostname, hostPort: port, timeout: timeout, + retries: 1, dictionaries: [ file ], }); @@ -500,6 +540,12 @@ exports.radius = function ( [ attributes.CALLING_STATION_ID, callingStationId ], [ attributes.CALLED_STATION_ID, calledStationId ], ], + }).catch((error) => { + if (error.response?.code) { + throw Error(error.response.code); + } else { + throw Error(error.message); + } }); }; @@ -677,7 +723,6 @@ exports.checkCertificate = function (res) { * @param {number} status The status code to check * @param {string[]} acceptedCodes An array of accepted status codes * @returns {boolean} True if status code within range, false otherwise - * @throws {Error} Will throw an error if the provided status code is not a valid range string or code string */ exports.checkStatusCode = function (status, acceptedCodes) { if (acceptedCodes == null || acceptedCodes.length === 0) { @@ -685,6 +730,11 @@ exports.checkStatusCode = function (status, acceptedCodes) { } for (const codeRange of acceptedCodes) { + if (typeof codeRange !== "string") { + log.error("monitor", `Accepted status code not a string. ${codeRange} is of type ${typeof codeRange}`); + continue; + } + const codeRangeSplit = codeRange.split("-").map(string => parseInt(string)); if (codeRangeSplit.length === 1) { if (status === codeRangeSplit[0]) { @@ -695,7 +745,8 @@ exports.checkStatusCode = function (status, acceptedCodes) { return true; } } else { - throw new Error("Invalid status code range"); + log.error("monitor", `${codeRange} is not a valid status code range`); + continue; } } @@ -1007,3 +1058,13 @@ module.exports.grpcQuery = async (options) => { }; module.exports.prompt = (query) => new Promise((resolve) => rl.question(query, resolve)); + +// For unit test, export functions +if (process.env.TEST_BACKEND) { + module.exports.__test = { + parseCertificateInfo, + }; + module.exports.__getPrivateFunction = (functionName) => { + return module.exports.__test[functionName]; + }; +} diff --git a/src/assets/app.scss b/src/assets/app.scss index 0eff9a06..0d3f0454 100644 --- a/src/assets/app.scss +++ b/src/assets/app.scss @@ -111,6 +111,10 @@ optgroup { padding-right: 20px; } +.btn-sm { + border-radius: 25px; +} + .btn-primary { color: white; @@ -158,6 +162,26 @@ optgroup { background-color: #161B22; } +.btn-outline-normal { + padding: 4px 10px; + border: 1px solid #ced4da; + border-radius: 25px; + background-color: transparent; + + .dark & { + color: $dark-font-color; + border: 1px solid $dark-font-color2; + } + + &.active { + background-color: $highlight-white; + + .dark & { + background-color: $dark-font-color2; + } + } +} + @media (max-width: 550px) { .table-shadow-box { padding: 10px !important; @@ -436,7 +460,6 @@ optgroup { .monitor-list { &.scrollbar { overflow-y: auto; - height: calc(100% - 107px); } @media (max-width: 770px) { diff --git a/src/components/ActionSelect.vue b/src/components/ActionSelect.vue new file mode 100644 index 00000000..ae09e656 --- /dev/null +++ b/src/components/ActionSelect.vue @@ -0,0 +1,70 @@ + + + diff --git a/src/components/CreateGroupDialog.vue b/src/components/CreateGroupDialog.vue new file mode 100644 index 00000000..ed20610c --- /dev/null +++ b/src/components/CreateGroupDialog.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index 1f19180f..8323f7cf 100644 --- a/src/components/HeartbeatBar.vue +++ b/src/components/HeartbeatBar.vue @@ -5,15 +5,24 @@ v-for="(beat, index) in shortBeatList" :key="index" class="beat" - :class="{ 'empty' : (beat === 0), 'down' : (beat.status === 0), 'pending' : (beat.status === 2), 'maintenance' : (beat.status === 3) }" + :class="{ 'empty': (beat === 0), 'down': (beat.status === 0), 'pending': (beat.status === 2), 'maintenance': (beat.status === 3) }" :style="beatStyle" :title="getBeatTitle(beat)" /> +
+
{{ timeSinceFirstBeat }} ago
+
+
{{ timeSinceLastBeat }}
+
@@ -271,4 +431,12 @@ export default { padding-left: 67px; margin-top: 5px; } + +.selection-controls { + margin-top: 5px; + display: flex; + align-items: center; + gap: 10px; +} + diff --git a/src/components/MonitorListFilterDropdown.vue b/src/components/MonitorListFilterDropdown.vue index 01b9678f..fe8b3ea2 100644 --- a/src/components/MonitorListFilterDropdown.vue +++ b/src/components/MonitorListFilterDropdown.vue @@ -44,6 +44,7 @@ export default { diff --git a/src/components/NotificationDialog.vue b/src/components/NotificationDialog.vue index f977225f..f7fce0d3 100644 --- a/src/components/NotificationDialog.vue +++ b/src/components/NotificationDialog.vue @@ -126,6 +126,7 @@ export default { "lunasea": "LunaSea", "matrix": "Matrix", "mattermost": "Mattermost", + "nostr": "Nostr", "ntfy": "Ntfy", "octopush": "Octopush", "OneBot": "OneBot", @@ -157,6 +158,7 @@ export default { "AliyunSMS": "AliyunSMS (阿里云短信服务)", "DingDing": "DingDing (钉钉自定义机器人)", "Feishu": "Feishu (飞书)", + "FlashDuty": "FlashDuty (快猫星云)", "FreeMobile": "FreeMobile (mobile.free.fr)", "PushDeer": "PushDeer", "promosms": "PromoSMS", diff --git a/src/components/PublicGroupList.vue b/src/components/PublicGroupList.vue index 4bd4a295..ba2230f0 100644 --- a/src/components/PublicGroupList.vue +++ b/src/components/PublicGroupList.vue @@ -61,12 +61,17 @@ /> -
- +
+
+ +
+
+ +
- +
@@ -103,6 +108,10 @@ export default { /** Should tags be shown? */ showTags: { type: Boolean, + }, + /** Should expiry be shown? */ + showCertificateExpiry: { + type: Boolean, } }, data() { @@ -154,6 +163,33 @@ export default { } return monitor.element.sendUrl && monitor.element.url && monitor.element.url !== "https://" && !this.editMode; }, + + /** + * Returns formatted certificate expiry or Bad cert message + * @param {Object} monitor Monitor to show expiry for + * @returns {string} + */ + formattedCertExpiryMessage(monitor) { + if (monitor?.element?.validCert && monitor?.element?.certExpiryDaysRemaining) { + return monitor.element.certExpiryDaysRemaining + " " + this.$tc("day", monitor.element.certExpiryDaysRemaining); + } else if (monitor?.element?.validCert === false) { + return this.$t("noOrBadCertificate"); + } else { + return this.$t("Unknown") + " " + this.$tc("day", 2); + } + }, + + /** + * Returns certificate expiry based on days remaining + * @param {Object} monitor Monitor to show expiry for + * @returns {string} + */ + certExpiryColor(monitor) { + if (monitor?.element?.validCert && monitor.element.certExpiryDaysRemaining > 7) { + return "#059669"; + } + return "#DC2626"; + }, } }; @@ -161,6 +197,15 @@ export default {