diff --git a/.dockerignore b/.dockerignore index d439b2db..825d5803 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,4 @@ LICENSE README.md .editorconfig +.vscode diff --git a/.gitignore b/.gitignore index 8d435974..9caa313c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist-ssr /data !/data/.gitkeep +.vscode \ No newline at end of file diff --git a/README.md b/README.md index 301fb35b..811be050 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ It is a self-hosted monitoring tool like "Uptime Robot". * Monitoring uptime for HTTP(s) / TCP / Ping. * Fancy, Reactive, Fast UI/UX. -* Notifications via Webhook, Telegram, Discord and email (SMTP). +* Notifications via Webhook, Telegram, Discord, Gotify, Slack, Pushover, Email (SMTP) and more by Apprise. * 20 seconds interval. # How to Use @@ -80,7 +80,7 @@ PS: For every new release, it takes some time to build the docker image, please ```bash git fetch --all -git checkout 1.0.6 --force +git checkout 1.0.7 --force npm install npm run build pm2 restart uptime-kuma diff --git a/db/kuma.db b/db/kuma.db index 07c93cf8..6e02ccc0 100644 Binary files a/db/kuma.db and b/db/kuma.db differ diff --git a/db/patch2.sql b/db/patch2.sql new file mode 100644 index 00000000..012d0150 --- /dev/null +++ b/db/patch2.sql @@ -0,0 +1,9 @@ +BEGIN TRANSACTION; + +CREATE TABLE monitor_tls_info ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + monitor_id INTEGER NOT NULL, + info_json TEXT +); + +COMMIT; diff --git a/db/patch3.sql b/db/patch3.sql new file mode 100644 index 00000000..e615632f --- /dev/null +++ b/db/patch3.sql @@ -0,0 +1,37 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +-- Add maxretries column to monitor +PRAGMA foreign_keys=off; + +BEGIN TRANSACTION; + +create table monitor_dg_tmp +( + id INTEGER not null + primary key autoincrement, + name VARCHAR(150), + active BOOLEAN default 1 not null, + user_id INTEGER + references user + on update cascade on delete set null, + interval INTEGER default 20 not null, + url TEXT, + type VARCHAR(20), + weight INTEGER default 2000, + hostname VARCHAR(255), + port INTEGER, + created_date DATETIME, + keyword VARCHAR(255), + maxretries INTEGER NOT NULL DEFAULT 0 +); + +insert into monitor_dg_tmp(id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword) select id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword from monitor; + +drop table monitor; + +alter table monitor_dg_tmp rename to monitor; + +create index user_id on monitor (user_id); + +COMMIT; + +PRAGMA foreign_keys=on; diff --git a/package-lock.json b/package-lock.json index bddd68e6..0a2a3a46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "uptime-kuma", - "version": "1.0.6", + "version": "1.0.7", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -29,6 +29,40 @@ "to-fast-properties": "^2.0.0" } }, + "@fortawesome/fontawesome-common-types": { + "version": "0.2.35", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.35.tgz", + "integrity": "sha512-IHUfxSEDS9dDGqYwIW7wTN6tn/O8E0n5PcAHz9cAaBoZw6UpG20IG/YM3NNLaGPwPqgjBAFjIURzqoQs3rrtuw==" + }, + "@fortawesome/fontawesome-svg-core": { + "version": "1.2.35", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.35.tgz", + "integrity": "sha512-uLEXifXIL7hnh2sNZQrIJWNol7cTVIzwI+4qcBIq9QWaZqUblm0IDrtSqbNg+3SQf8SMGHkiSigD++rHmCHjBg==", + "requires": { + "@fortawesome/fontawesome-common-types": "^0.2.35" + } + }, + "@fortawesome/free-regular-svg-icons": { + "version": "5.15.3", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.3.tgz", + "integrity": "sha512-q4/p8Xehy9qiVTdDWHL4Z+o5PCLRChePGZRTXkl+/Z7erDVL8VcZUuqzJjs6gUz6czss4VIPBRdCz6wP37/zMQ==", + "requires": { + "@fortawesome/fontawesome-common-types": "^0.2.35" + } + }, + "@fortawesome/free-solid-svg-icons": { + "version": "5.15.3", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.3.tgz", + "integrity": "sha512-XPeeu1IlGYqz4VWGRAT5ukNMd4VHUEEJ7ysZ7pSSgaEtNvSo+FLurybGJVmiqkQdK50OkSja2bfZXOeyMGRD8Q==", + "requires": { + "@fortawesome/fontawesome-common-types": "^0.2.35" + } + }, + "@fortawesome/vue-fontawesome": { + "version": "3.0.0-4", + "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.0-4.tgz", + "integrity": "sha512-dQVhhMRcUPCb0aqk5ohm0KGk5OJ7wFZ9aYapLzJB3Z+xs7LhkRWLTb87reelUAG5PFDjutDAXuloT9hi6cz72A==" + }, "@popperjs/core": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz", diff --git a/package.json b/package.json index 94c73d2f..f0af413e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uptime-kuma", - "version": "1.0.6", + "version": "1.0.7", "license": "MIT", "repository": { "type": "git", @@ -12,14 +12,18 @@ "update": "", "build": "vite build", "vite-preview-dist": "vite preview --host", - "build-docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:1.0.6 --target release . --push", + "build-docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma -t louislam/uptime-kuma:1 -t louislam/uptime-kuma:1.0.7 --target release . --push", "build-docker-nightly": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t louislam/uptime-kuma:nightly --target nightly . --push", "build-docker-nightly-amd64": "docker buildx build --platform linux/amd64 -t louislam/uptime-kuma:nightly-amd64 --target nightly . --push", - "setup": "git checkout 1.0.6 && npm install && npm run build", + "setup": "git checkout 1.0.7 && npm install && npm run build", "version-global-replace": "node extra/version-global-replace.js", "mark-as-nightly": "node extra/mark-as-nightly.js" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^1.2.35", + "@fortawesome/free-regular-svg-icons": "^5.15.3", + "@fortawesome/free-solid-svg-icons": "^5.15.3", + "@fortawesome/vue-fontawesome": "^3.0.0-4", "@popperjs/core": "^2.9.2", "args-parser": "^1.3.0", "axios": "^0.21.1", diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 3bb82e4c..0e9c109f 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/server/database.js b/server/database.js index 49659e61..2c9b2f65 100644 --- a/server/database.js +++ b/server/database.js @@ -8,7 +8,7 @@ class Database { static templatePath = "./db/kuma.db" static path = './data/kuma.db'; - static latestVersion = 1; + static latestVersion = 3; static noReject = true; static async patch() { diff --git a/server/model/monitor.js b/server/model/monitor.js index c366869b..552149e5 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -1,15 +1,23 @@ const Prometheus = require('prom-client'); +const https = require('https'); const dayjs = require("dayjs"); const utc = require('dayjs/plugin/utc') var timezone = require('dayjs/plugin/timezone') dayjs.extend(utc) dayjs.extend(timezone) const axios = require("axios"); -const {tcping, ping} = require("../util-server"); +const {debug, UP, DOWN, PENDING} = require("../util"); +const {tcping, ping, checkCertificate} = require("../util-server"); const {R} = require("redbean-node"); const {BeanModel} = require("redbean-node/dist/bean-model"); const {Notification} = require("../notification") +// Use Custom agent to disable session reuse +// https://github.com/nodejs/node/issues/3940 +const customAgent = new https.Agent({ + maxCachedSessions: 0 +}); + const commonLabels = [ 'monitor_name', 'monitor_type', @@ -18,24 +26,24 @@ const commonLabels = [ 'monitor_port', ] - const monitor_response_time = new Prometheus.Gauge({ name: 'monitor_response_time', help: 'Monitor Response Time (ms)', labelNames: commonLabels }); + const monitor_status = new Prometheus.Gauge({ name: 'monitor_status', help: 'Monitor Status (1 = UP, 0= DOWN)', labelNames: commonLabels }); + /** * status: * 0 = DOWN * 1 = UP */ class Monitor extends BeanModel { - async toJSON() { let notificationIDList = {}; @@ -54,6 +62,7 @@ class Monitor extends BeanModel { url: this.url, hostname: this.hostname, port: this.port, + maxretries: this.maxretries, weight: this.weight, active: this.active, type: this.type, @@ -65,6 +74,7 @@ class Monitor extends BeanModel { start(io) { let previousBeat = null; + let retries = 0; const monitorLabelValues = { monitor_name: this.name, @@ -74,21 +84,23 @@ class Monitor extends BeanModel { monitor_port: this.port } - const beat = async () => { + if (! previousBeat) { previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [ this.id ]) } + const isFirstBeat = !previousBeat; + let bean = R.dispense("heartbeat") bean.monitor_id = this.id; bean.time = R.isoDateTime(dayjs.utc()); - bean.status = 0; + bean.status = DOWN; // Duration - if (previousBeat) { + if (! isFirstBeat) { bean.duration = dayjs(bean.time).diff(dayjs(previousBeat.time), 'second'); } else { bean.duration = 0; @@ -98,13 +110,27 @@ class Monitor extends BeanModel { if (this.type === "http" || this.type === "keyword") { let startTime = dayjs().valueOf(); let res = await axios.get(this.url, { - headers: { 'User-Agent':'Uptime-Kuma' } - }) + headers: { "User-Agent": "Uptime-Kuma" }, + httpsAgent: customAgent, + }); bean.msg = `${res.status} - ${res.statusText}` bean.ping = dayjs().valueOf() - startTime; + // Check certificate if https is used + + let certInfoStartTime = dayjs().valueOf(); + if (this.getUrl()?.protocol === "https:") { + try { + await this.updateTlsInfo(checkCertificate(res)); + } catch (e) { + console.error(e.message) + } + } + + debug("Cert Info Query Time: " + (dayjs().valueOf() - certInfoStartTime) + "ms") + if (this.type === "http") { - bean.status = 1; + bean.status = UP; } else { let data = res.data; @@ -116,7 +142,7 @@ class Monitor extends BeanModel { if (data.includes(this.keyword)) { bean.msg += ", keyword is found" - bean.status = 1; + bean.status = UP; } else { throw new Error(bean.msg + ", but keyword is not found") } @@ -127,30 +153,52 @@ class Monitor extends BeanModel { } else if (this.type === "port") { bean.ping = await tcping(this.hostname, this.port); bean.msg = "" - bean.status = 1; + bean.status = UP; } else if (this.type === "ping") { bean.ping = await ping(this.hostname); bean.msg = "" - bean.status = 1; + bean.status = UP; } + retries = 0; + } catch (error) { + if ((this.maxretries > 0) && (retries < this.maxretries)) { + retries++; + bean.status = PENDING; + } bean.msg = error.message; } - // Mark as important if status changed - if (! previousBeat || previousBeat.status !== bean.status) { + // * ? -> ANY STATUS = important [isFirstBeat] + // UP -> PENDING = not important + // * UP -> DOWN = important + // UP -> UP = not important + // PENDING -> PENDING = not important + // * PENDING -> DOWN = important + // PENDING -> UP = not important + // DOWN -> PENDING = this case not exists + // DOWN -> DOWN = not important + // * DOWN -> UP = important + let isImportant = isFirstBeat || + (previousBeat.status === UP && bean.status === DOWN) || + (previousBeat.status === DOWN && bean.status === UP) || + (previousBeat.status === PENDING && bean.status === DOWN); + + // Mark as important if status changed, ignore pending pings, + // Don't notify if disrupted changes to up + if (isImportant) { bean.important = true; - // Do not send if first beat is UP - if (previousBeat || bean.status !== 1) { + // Send only if the first beat is DOWN + if (!isFirstBeat || bean.status === DOWN) { let notificationList = await R.getAll(`SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id `, [ this.id ]) let text; - if (bean.status === 1) { + if (bean.status === UP) { text = "✅ Up" } else { text = "🔴 Down" @@ -171,11 +219,12 @@ class Monitor extends BeanModel { bean.important = false; } - monitor_status.set(monitorLabelValues, bean.status) - if (bean.status === 1) { + if (bean.status === UP) { console.info(`Monitor #${this.id} '${this.name}': Successful Response: ${bean.ping} ms | Interval: ${this.interval} seconds | Type: ${this.type}`) + } else if (bean.status === PENDING) { + console.warn(`Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Type: ${this.type}`) } else { console.warn(`Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Type: ${this.type}`) } @@ -198,10 +247,35 @@ class Monitor extends BeanModel { clearInterval(this.heartbeatInterval) } + // Helper Method: + // returns URL object for further usage + // returns null if url is invalid + getUrl() { + try { + return new URL(this.url); + } catch (_) { + return null; + } + } + + // Store TLS info to database + async updateTlsInfo(checkCertificateResult) { + let tls_info_bean = await R.findOne("monitor_tls_info", "monitor_id = ?", [ + this.id + ]); + if (tls_info_bean == null) { + tls_info_bean = R.dispense("monitor_tls_info"); + tls_info_bean.monitor_id = this.id; + } + tls_info_bean.info_json = JSON.stringify(checkCertificateResult); + await R.store(tls_info_bean); + } + static async sendStats(io, monitorID, userID) { Monitor.sendAvgPing(24, io, monitorID, userID); Monitor.sendUptime(24, io, monitorID, userID); Monitor.sendUptime(24 * 30, io, monitorID, userID); + Monitor.sendCertInfo(io, monitorID, userID); } /** @@ -222,6 +296,15 @@ class Monitor extends BeanModel { io.to(userID).emit("avgPing", monitorID, avgPing); } + static async sendCertInfo(io, monitorID, userID) { + let tls_info = await R.findOne("monitor_tls_info", "monitor_id = ?", [ + monitorID + ]); + if (tls_info != null) { + io.to(userID).emit("certInfo", monitorID, tls_info.info_json); + } + } + /** * Uptime with calculation * Calculation based on: @@ -270,7 +353,7 @@ class Monitor extends BeanModel { } total += value; - if (row.status === 0) { + if (row.status === 0 || row.status === 2) { downtime += value; } } diff --git a/server/notification.js b/server/notification.js index 3c0e9f2a..9da8a0dc 100644 --- a/server/notification.js +++ b/server/notification.js @@ -204,7 +204,7 @@ class Notification { } let data = { - "message": "Uptime Kuma Alert\n\nMessage:" +msg + '\nTime (UTC):' +time, + "message": "Uptime Kuma Alert\n\nMessage:"+msg+ '\nTime (UTC):' +heartbeatJSON["time"], "user":notification.pushoveruserkey, "token": notification.pushoverapptoken, "sound": notification.pushoversounds, diff --git a/server/server.js b/server/server.js index 816d55f6..624e69be 100644 --- a/server/server.js +++ b/server/server.js @@ -235,6 +235,7 @@ let needSetup = false; bean.url = monitor.url bean.interval = monitor.interval bean.hostname = monitor.hostname; + bean.maxretries = monitor.maxretries; bean.port = monitor.port; bean.keyword = monitor.keyword; @@ -544,12 +545,12 @@ async function afterLogin(socket, user) { let monitorList = await sendMonitorList(socket) for (let monitorID in monitorList) { - await sendHeartbeatList(socket, monitorID); - await sendImportantHeartbeatList(socket, monitorID); - await Monitor.sendStats(io, monitorID, user.id) + sendHeartbeatList(socket, monitorID); + sendImportantHeartbeatList(socket, monitorID); + Monitor.sendStats(io, monitorID, user.id) } - await sendNotificationList(socket) + sendNotificationList(socket) } async function getMonitorJSONList(userID) { diff --git a/server/util-server.js b/server/util-server.js index b387f4c7..f03823d3 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -70,3 +70,52 @@ exports.getSettings = async function (type) { return result; } + + +// ssl-checker by @dyaa +// param: res - response object from axios +// return an object containing the certificate information + +const getDaysBetween = (validFrom, validTo) => + Math.round(Math.abs(+validFrom - +validTo) / 8.64e7); + +const getDaysRemaining = (validFrom, validTo) => { + const daysRemaining = getDaysBetween(validFrom, validTo); + if (new Date(validTo).getTime() < new Date().getTime()) { + return -daysRemaining; + } + return daysRemaining; +}; + +exports.checkCertificate = function (res) { + const { + valid_from, + valid_to, + subjectaltname, + issuer, + fingerprint, + } = res.request.res.socket.getPeerCertificate(false); + + if (!valid_from || !valid_to || !subjectaltname) { + throw { message: 'No TLS certificate in response' }; + } + + const valid = res.request.res.socket.authorized || false; + + const validTo = new Date(valid_to); + + const validFor = subjectaltname + .replace(/DNS:|IP Address:/g, "") + .split(", "); + + const daysRemaining = getDaysRemaining(new Date(), validTo); + + return { + valid, + validFor, + validTo, + daysRemaining, + issuer, + fingerprint, + }; +} \ No newline at end of file diff --git a/server/util.js b/server/util.js index 0a8877b8..081561bf 100644 --- a/server/util.js +++ b/server/util.js @@ -1,6 +1,10 @@ // Common JS cannot be used in frontend sadly // sleep, ucfirst is duplicated in ../src/util-frontend.js +exports.DOWN = 0; +exports.UP = 1; +exports.PENDING = 2; + exports.sleep = function (ms) { return new Promise(resolve => setTimeout(resolve, ms)); } @@ -14,3 +18,9 @@ exports.ucfirst = function (str) { return firstLetter.toUpperCase() + str.substr(1); } +exports.debug = (msg) => { + if (process.env.NODE_ENV === "development") { + console.log(msg) + } +} + diff --git a/src/assets/vars.scss b/src/assets/vars.scss index 31b0262d..ebec378a 100644 --- a/src/assets/vars.scss +++ b/src/assets/vars.scss @@ -1,7 +1,8 @@ $primary: #5CDD8B; $danger: #DC3545; +$warning: #f8a306; $link-color: #111; $border-radius: 50rem; $highlight: #7ce8a4; -$highlight-white: #e7faec; +$highlight-white: #e7faec; \ No newline at end of file diff --git a/src/components/Datetime.vue b/src/components/Datetime.vue index e84c877b..3e551659 100644 --- a/src/components/Datetime.vue +++ b/src/components/Datetime.vue @@ -14,12 +14,23 @@ dayjs.extend(relativeTime) export default { props: { value: String, + dateOnly: { + type: Boolean, + default: false, + }, }, computed: { displayText() { - let format = "YYYY-MM-DD HH:mm:ss"; - return dayjs.utc(this.value).tz(this.$root.timezone).format(format) + if (this.value !== undefined && this.value !== "") { + let format = "YYYY-MM-DD HH:mm:ss"; + if (this.dateOnly) { + format = "YYYY-MM-DD"; + } + return dayjs.utc(this.value).tz(this.$root.timezone).format(format); + } else { + return ""; + } }, } } diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index 48ffd292..03cdceca 100644 --- a/src/components/HeartbeatBar.vue +++ b/src/components/HeartbeatBar.vue @@ -3,7 +3,7 @@
- - + + - +
+
+ + +
Maximum retries before the service is marked as down and a notification is sent
+
+
@@ -61,7 +67,7 @@

Notifications

Not available, please setup.

-
+
@@ -56,7 +56,7 @@

Notifications

Not available, please setup.

-

Please assign the notification to monitor(s) to get it works.

+

Please assign a notification to monitor(s) to get it to work.