diff --git a/README.md b/README.md index ab42a559..026f3b4d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ VPS is sponsored by Uptime Kuma sponsors on [Open Collective](https://opencollec ## ⭐ Features -* Monitoring uptime for HTTP(s) / TCP / HTTP(s) Keyword / Ping / DNS Record / Push / Steam Game Server. +* Monitoring uptime for HTTP(s) / TCP / HTTP(s) Keyword / Ping / DNS Record / Push / Steam Game Server / Docker Containers. * Fancy, Reactive, Fast UI/UX. * Notifications via Telegram, Discord, Gotify, Slack, Pushover, Email (SMTP), and [90+ notification services, click here for the full list](https://github.com/louislam/uptime-kuma/tree/master/src/components/notifications). * 20 second intervals. diff --git a/db/patch-add-docker-columns.sql b/db/patch-add-docker-columns.sql new file mode 100644 index 00000000..4cea448d --- /dev/null +++ b/db/patch-add-docker-columns.sql @@ -0,0 +1,18 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +CREATE TABLE docker_host ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + user_id INT NOT NULL, + docker_daemon VARCHAR(255), + docker_type VARCHAR(255), + name VARCHAR(255) +); + +ALTER TABLE monitor + ADD docker_host INTEGER REFERENCES docker_host(id); + +ALTER TABLE monitor + ADD docker_container VARCHAR(255); + +COMMIT; diff --git a/db/patch-add-radius-monitor.sql b/db/patch-add-radius-monitor.sql new file mode 100644 index 00000000..1fd5b44f --- /dev/null +++ b/db/patch-add-radius-monitor.sql @@ -0,0 +1,18 @@ +BEGIN TRANSACTION; + +ALTER TABLE monitor + ADD radius_username VARCHAR(255); + +ALTER TABLE monitor + ADD radius_password VARCHAR(255); + +ALTER TABLE monitor + ADD radius_calling_station_id VARCHAR(50); + +ALTER TABLE monitor + ADD radius_called_station_id VARCHAR(50); + +ALTER TABLE monitor + ADD radius_secret VARCHAR(255); + +COMMIT diff --git a/db/patch-monitor-add-resend-interval.sql b/db/patch-monitor-add-resend-interval.sql new file mode 100644 index 00000000..8e28bf69 --- /dev/null +++ b/db/patch-monitor-add-resend-interval.sql @@ -0,0 +1,10 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +BEGIN TRANSACTION; + +ALTER TABLE monitor + ADD resend_interval INTEGER default 0 not null; + +ALTER TABLE heartbeat + ADD down_count INTEGER default 0 not null; + +COMMIT; diff --git a/docker/alpine-base.dockerfile b/docker/alpine-base.dockerfile index cde65bb6..1d74de05 100644 --- a/docker/alpine-base.dockerfile +++ b/docker/alpine-base.dockerfile @@ -4,5 +4,5 @@ WORKDIR /app # Install apprise, iputils for non-root ping, setpriv RUN apk add --no-cache iputils setpriv dumb-init python3 py3-cryptography py3-pip py3-six py3-yaml py3-click py3-markdown py3-requests py3-requests-oauthlib && \ - pip3 --no-cache-dir install apprise==0.9.9 && \ + pip3 --no-cache-dir install apprise==1.0.0 && \ rm -rf /root/.cache diff --git a/docker/debian-base.dockerfile b/docker/debian-base.dockerfile index f90968a8..20bef3dd 100644 --- a/docker/debian-base.dockerfile +++ b/docker/debian-base.dockerfile @@ -11,7 +11,7 @@ WORKDIR /app RUN apt update && \ apt --yes --no-install-recommends install python3 python3-pip python3-cryptography python3-six python3-yaml python3-click python3-markdown python3-requests python3-requests-oauthlib \ sqlite3 iputils-ping util-linux dumb-init && \ - pip3 --no-cache-dir install apprise==0.9.9 && \ + pip3 --no-cache-dir install apprise==1.0.0 && \ rm -rf /var/lib/apt/lists/* && \ apt --yes autoremove diff --git a/package-lock.json b/package-lock.json index 778e6bc3..0cf62fa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "mqtt": "^4.2.8", "mssql": "^8.1.0", "node-cloudflared-tunnel": "~1.0.9", + "node-radius-client": "^1.0.0", "nodemailer": "~6.6.5", "notp": "~2.0.3", "password-hash": "~1.2.2", @@ -8215,6 +8216,12 @@ "readable-stream": "^3.6.0" } }, + "node_modules/hoek": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-6.1.3.tgz", + "integrity": "sha512-YXXAAhmF9zpQbC7LEcREFtXfGq5K1fmd+4PHkBq8NUqmzW3G+Dq10bI/i0KucLRwss3YYFQ0fSfoxBZYiGUqtQ==", + "deprecated": "This module has moved and is now available at @hapi/hoek. Please update your dependencies as this version is no longer maintained an may contain bugs and security issues." + }, "node_modules/homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -8915,6 +8922,17 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "devOptional": true }, + "node_modules/isemail": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/isemail/-/isemail-3.2.0.tgz", + "integrity": "sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==", + "dependencies": { + "punycode": "2.x.x" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -12151,6 +12169,32 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "dev": true }, + "node_modules/node-radius-client": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-radius-client/-/node-radius-client-1.0.0.tgz", + "integrity": "sha512-FkR9cMV5hNoX+kKDUTzuagvEixlLiaEJQ1/ywOdhahsihKrGDhVZmnCvmrCStA589MT3yuC/J2eKc6z68IGdBw==", + "dependencies": { + "joi": "^14.3.1", + "node-radius-utils": "^1.2.0", + "radius": "^1.1.4" + } + }, + "node_modules/node-radius-client/node_modules/joi": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-14.3.1.tgz", + "integrity": "sha512-LQDdM+pkOrpAn4Lp+neNIFV3axv1Vna3j38bisbQhETPMANYRbFJFUyOZcOClYvM/hppMhGWuKSFEK9vjrB+bQ==", + "deprecated": "This module has moved and is now available at @hapi/joi. Please update your dependencies as this version is no longer maintained an may contain bugs and security issues.", + "dependencies": { + "hoek": "6.x.x", + "isemail": "3.x.x", + "topo": "3.x.x" + } + }, + "node_modules/node-radius-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/node-radius-utils/-/node-radius-utils-1.2.0.tgz", + "integrity": "sha512-i3Sf6khnenl0aXumo0whAlfPWTaBqHxEnVBBxpu3dZ7q69NkPPv71rvPjlDZ5wkeKCTNNUTECljerS5kcYQxRw==" + }, "node_modules/node-releases": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.5.tgz", @@ -13429,6 +13473,14 @@ "node": ">=8" } }, + "node_modules/radius": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/radius/-/radius-1.1.4.tgz", + "integrity": "sha512-UWuzdF6xf3NpsXFZZmUEkxtEalDXj8hdmMXgbGzn7vOk6zXNsiIY2I6SJ1euHt7PTQuMoz2qDEJB+AfJDJgQYw==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -15261,6 +15313,15 @@ "node": ">=0.6" } }, + "node_modules/topo": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/topo/-/topo-3.0.3.tgz", + "integrity": "sha512-IgpPtvD4kjrJ7CRA3ov2FhWQADwv+Tdqbsf1ZnPUSAtCJ9e1Z44MmoSGDXGk4IppoZA7jd/QRkNddlLJWlUZsQ==", + "deprecated": "This module has moved and is now available at @hapi/topo. Please update your dependencies as this version is no longer maintained an may contain bugs and security issues.", + "dependencies": { + "hoek": "6.x.x" + } + }, "node_modules/toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", @@ -22641,6 +22702,11 @@ "readable-stream": "^3.6.0" } }, + "hoek": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-6.1.3.tgz", + "integrity": "sha512-YXXAAhmF9zpQbC7LEcREFtXfGq5K1fmd+4PHkBq8NUqmzW3G+Dq10bI/i0KucLRwss3YYFQ0fSfoxBZYiGUqtQ==" + }, "homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -23123,6 +23189,14 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "devOptional": true }, + "isemail": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/isemail/-/isemail-3.2.0.tgz", + "integrity": "sha512-zKqkK+O+dGqevc93KNsbZ/TqTUFd46MwWjYOoMrjIMZ51eU7DtQG3Wmd9SQQT7i7RVnuTPEiYEWHU3MSbxC1Tg==", + "requires": { + "punycode": "2.x.x" + } + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -25618,6 +25692,33 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "dev": true }, + "node-radius-client": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-radius-client/-/node-radius-client-1.0.0.tgz", + "integrity": "sha512-FkR9cMV5hNoX+kKDUTzuagvEixlLiaEJQ1/ywOdhahsihKrGDhVZmnCvmrCStA589MT3yuC/J2eKc6z68IGdBw==", + "requires": { + "joi": "^14.3.1", + "node-radius-utils": "^1.2.0", + "radius": "^1.1.4" + }, + "dependencies": { + "joi": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-14.3.1.tgz", + "integrity": "sha512-LQDdM+pkOrpAn4Lp+neNIFV3axv1Vna3j38bisbQhETPMANYRbFJFUyOZcOClYvM/hppMhGWuKSFEK9vjrB+bQ==", + "requires": { + "hoek": "6.x.x", + "isemail": "3.x.x", + "topo": "3.x.x" + } + } + } + }, + "node-radius-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/node-radius-utils/-/node-radius-utils-1.2.0.tgz", + "integrity": "sha512-i3Sf6khnenl0aXumo0whAlfPWTaBqHxEnVBBxpu3dZ7q69NkPPv71rvPjlDZ5wkeKCTNNUTECljerS5kcYQxRw==" + }, "node-releases": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.5.tgz", @@ -26532,6 +26633,11 @@ "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", "dev": true }, + "radius": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/radius/-/radius-1.1.4.tgz", + "integrity": "sha512-UWuzdF6xf3NpsXFZZmUEkxtEalDXj8hdmMXgbGzn7vOk6zXNsiIY2I6SJ1euHt7PTQuMoz2qDEJB+AfJDJgQYw==" + }, "range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -27967,6 +28073,14 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, + "topo": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/topo/-/topo-3.0.3.tgz", + "integrity": "sha512-IgpPtvD4kjrJ7CRA3ov2FhWQADwv+Tdqbsf1ZnPUSAtCJ9e1Z44MmoSGDXGk4IppoZA7jd/QRkNddlLJWlUZsQ==", + "requires": { + "hoek": "6.x.x" + } + }, "toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", diff --git a/package.json b/package.json index ea6c5a79..981ca191 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "uptime-kuma", - "version": "1.17.1", + "version": "1.18.0-beta.0", "license": "MIT", "repository": { "type": "git", @@ -91,6 +91,7 @@ "mqtt": "^4.2.8", "mssql": "^8.1.0", "node-cloudflared-tunnel": "~1.0.9", + "node-radius-client": "^1.0.0", "nodemailer": "~6.6.5", "notp": "~2.0.3", "password-hash": "~1.2.2", diff --git a/server/client.js b/server/client.js index 279acd3a..a0c52e1e 100644 --- a/server/client.js +++ b/server/client.js @@ -125,10 +125,35 @@ async function sendInfo(socket) { }); } +/** + * Send list of docker hosts to client + * @param {Socket} socket Socket.io socket instance + * @returns {Promise} + */ +async function sendDockerHostList(socket) { + const timeLogger = new TimeLogger(); + + let result = []; + let list = await R.find("docker_host", " user_id = ? ", [ + socket.userID, + ]); + + for (let bean of list) { + result.push(bean.toJSON()); + } + + io.to(socket.userID).emit("dockerHostList", result); + + timeLogger.print("Send Docker Host List"); + + return list; +} + module.exports = { sendNotificationList, sendImportantHeartbeatList, sendHeartbeatList, sendProxyList, sendInfo, + sendDockerHostList }; diff --git a/server/database.js b/server/database.js index 00fd48d9..b1a23a47 100644 --- a/server/database.js +++ b/server/database.js @@ -53,6 +53,7 @@ class Database { "patch-2fa-invalidate-used-token.sql": true, "patch-notification_sent_history.sql": true, "patch-monitor-basic-auth.sql": true, + "patch-add-docker-columns.sql": true, "patch-status-page.sql": true, "patch-proxy.sql": true, "patch-monitor-expiry-notification.sql": true, @@ -61,6 +62,8 @@ class Database { "patch-add-clickable-status-page-link.sql": true, "patch-add-sqlserver-monitor.sql": true, "patch-add-other-auth.sql": { parents: [ "patch-monitor-basic-auth.sql" ] }, + "patch-add-radius-monitor.sql": true, + "patch-monitor-add-resend-interval.sql": true, }; /** @@ -147,6 +150,9 @@ class Database { await R.exec("PRAGMA cache_size = -12000"); await R.exec("PRAGMA auto_vacuum = FULL"); + // Avoid error "SQLITE_BUSY: database is locked" by allowing SQLITE to wait up to 5 seconds to do a write + await R.exec("PRAGMA busy_timeout = 5000"); + // This ensures that an operating system crash or power failure will not corrupt the database. // FULL synchronous is very safe, but it is also slower. // Read more: https://sqlite.org/pragma.html#pragma_synchronous diff --git a/server/docker.js b/server/docker.js new file mode 100644 index 00000000..177fa6cb --- /dev/null +++ b/server/docker.js @@ -0,0 +1,106 @@ +const axios = require("axios"); +const { R } = require("redbean-node"); +const version = require("../package.json").version; +const https = require("https"); + +class DockerHost { + /** + * Save a docker host + * @param {Object} dockerHost Docker host to save + * @param {?number} dockerHostID ID of the docker host to update + * @param {number} userID ID of the user who adds the docker host + * @returns {Promise} + */ + static async save(dockerHost, dockerHostID, userID) { + let bean; + + if (dockerHostID) { + bean = await R.findOne("docker_host", " id = ? AND user_id = ? ", [ dockerHostID, userID ]); + + if (!bean) { + throw new Error("docker host not found"); + } + + } else { + bean = R.dispense("docker_host"); + } + + bean.user_id = userID; + bean.docker_daemon = dockerHost.dockerDaemon; + bean.docker_type = dockerHost.dockerType; + bean.name = dockerHost.name; + + await R.store(bean); + + return bean; + } + + /** + * Delete a Docker host + * @param {number} dockerHostID ID of the Docker host to delete + * @param {number} userID ID of the user who created the Docker host + * @returns {Promise} + */ + static async delete(dockerHostID, userID) { + let bean = await R.findOne("docker_host", " id = ? AND user_id = ? ", [ dockerHostID, userID ]); + + if (!bean) { + throw new Error("docker host not found"); + } + + // Delete removed proxy from monitors if exists + await R.exec("UPDATE monitor SET docker_host = null WHERE docker_host = ?", [ dockerHostID ]); + + await R.trash(bean); + } + + /** + * Fetches the amount of containers on the Docker host + * @param {Object} dockerHost Docker host to check for + * @returns {number} Total amount of containers on the host + */ + static async testDockerHost(dockerHost) { + const options = { + url: "/containers/json?all=true", + headers: { + "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") { + options.socketPath = dockerHost.dockerDaemon; + } else if (dockerHost.dockerType === "tcp") { + options.baseURL = dockerHost.dockerDaemon; + } + + let res = await axios.request(options); + + if (Array.isArray(res.data)) { + + if (res.data.length > 1) { + + if ("ImageID" in res.data[0]) { + return res.data.length; + } else { + throw new Error("Invalid Docker response, is it Docker really a daemon?"); + } + + } else { + return res.data.length; + } + + } else { + throw new Error("Invalid Docker response, is it Docker really a daemon?"); + } + + } +} + +module.exports = { + DockerHost, +}; diff --git a/server/model/docker_host.js b/server/model/docker_host.js new file mode 100644 index 00000000..20598292 --- /dev/null +++ b/server/model/docker_host.js @@ -0,0 +1,19 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +class DockerHost extends BeanModel { + /** + * Returns an object that ready to parse to JSON + * @returns {Object} + */ + toJSON() { + return { + id: this.id, + userID: this.user_id, + dockerDaemon: this.docker_daemon, + dockerType: this.docker_type, + name: this.name, + }; + } +} + +module.exports = DockerHost; diff --git a/server/model/monitor.js b/server/model/monitor.js index 81149b52..f96b4df0 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -7,7 +7,7 @@ dayjs.extend(timezone); const axios = require("axios"); const { Prometheus } = require("../prometheus"); const { log, UP, DOWN, PENDING, flipStatus, TimeLogger } = require("../../src/util"); -const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mqttAsync, setSetting, httpNtlm } = require("../util-server"); +const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mqttAsync, setSetting, httpNtlm, radius } = require("../util-server"); const { R } = require("redbean-node"); const { BeanModel } = require("redbean-node/dist/bean-model"); const { Notification } = require("../notification"); @@ -79,6 +79,7 @@ class Monitor extends BeanModel { type: this.type, interval: this.interval, retryInterval: this.retryInterval, + resendInterval: this.resendInterval, keyword: this.keyword, expiryNotification: this.isEnabledExpiryNotification(), ignoreTls: this.getIgnoreTls(), @@ -88,6 +89,9 @@ class Monitor extends BeanModel { dns_resolve_type: this.dns_resolve_type, dns_resolve_server: this.dns_resolve_server, dns_last_result: this.dns_last_result, + pushToken: this.pushToken, + docker_container: this.docker_container, + docker_host: this.docker_host, proxyId: this.proxy_id, notificationIDList, tags: tags, @@ -100,6 +104,11 @@ class Monitor extends BeanModel { authMethod: this.authMethod, authWorkstation: this.authWorkstation, authDomain: this.authDomain, + radiusUsername: this.radiusUsername, + radiusPassword: this.radiusPassword, + radiusCalledStationId: this.radiusCalledStationId, + radiusCallingStationId: this.radiusCallingStationId, + radiusSecret: this.radiusSecret, }; if (includeSensitiveData) { @@ -206,6 +215,7 @@ class Monitor extends BeanModel { bean.monitor_id = this.id; bean.time = R.isoDateTimeMillis(dayjs.utc()); bean.status = DOWN; + bean.downCount = previousBeat?.downCount || 0; if (this.isUpsideDown()) { bean.status = flipStatus(bean.status); @@ -468,6 +478,35 @@ class Monitor extends BeanModel { } else { throw new Error("Server not found on Steam"); } + } else if (this.type === "docker") { + log.debug(`[${this.name}] Prepare Options for Axios`); + + const dockerHost = await R.load("docker_host", this.docker_host); + + const options = { + url: `/containers/${this.docker_container}/json`, + headers: { + "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: ! this.getIgnoreTls(), + }), + }; + + if (dockerHost._dockerType === "socket") { + options.socketPath = dockerHost._dockerDaemon; + } else if (dockerHost._dockerType === "tcp") { + options.baseURL = dockerHost._dockerDaemon; + } + + log.debug(`[${this.name}] Axios Request`); + let res = await axios.request(options); + if (res.data.State.Running) { + bean.status = UP; + bean.msg = ""; + } } else if (this.type === "mqtt") { bean.msg = await mqttAsync(this.hostname, this.mqttTopic, this.mqttSuccessMessage, { port: this.port, @@ -492,6 +531,30 @@ class Monitor extends BeanModel { bean.msg = ""; bean.status = UP; bean.ping = dayjs().valueOf() - startTime; + } else if (this.type === "radius") { + let startTime = dayjs().valueOf(); + try { + const resp = await radius( + this.hostname, + this.radiusUsername, + this.radiusPassword, + this.radiusCalledStationId, + this.radiusCallingStationId, + this.radiusSecret + ); + 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; + } + } + bean.ping = dayjs().valueOf() - startTime; } else { bean.msg = "Unknown Monitor Type"; bean.status = PENDING; @@ -533,12 +596,27 @@ class Monitor extends BeanModel { log.debug("monitor", `[${this.name}] sendNotification`); await Monitor.sendNotification(isFirstBeat, this, bean); + // Reset down count + bean.downCount = 0; + // Clear Status Page Cache log.debug("monitor", `[${this.name}] apicache clear`); apicache.clear(); } else { bean.important = false; + + if (bean.status === DOWN && this.resendInterval > 0) { + ++bean.downCount; + if (bean.downCount >= this.resendInterval) { + // Send notification again, because we are still DOWN + log.debug("monitor", `[${this.name}] sendNotification again: Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`); + await Monitor.sendNotification(isFirstBeat, this, bean); + + // Reset down count + bean.downCount = 0; + } + } } if (bean.status === UP) { @@ -549,7 +627,7 @@ class Monitor extends BeanModel { } log.warn("monitor", `Monitor #${this.id} '${this.name}': Pending: ${bean.msg} | Max retries: ${this.maxretries} | Retry: ${retries} | Retry Interval: ${beatInterval} seconds | Type: ${this.type}`); } else { - log.warn("monitor", `Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type}`); + log.warn("monitor", `Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Interval: ${beatInterval} seconds | Type: ${this.type} | Down Count: ${bean.downCount} | Resend Interval: ${this.resendInterval}`); } log.debug("monitor", `[${this.name}] Send to socket`); diff --git a/server/notification-providers/bark.js b/server/notification-providers/bark.js index 092511d8..3258e7c5 100644 --- a/server/notification-providers/bark.js +++ b/server/notification-providers/bark.js @@ -12,9 +12,7 @@ const { default: axios } = require("axios"); // bark is an APN bridge that sends notifications to Apple devices. -const barkNotificationGroup = "UptimeKuma"; const barkNotificationAvatar = "https://github.com/louislam/uptime-kuma/raw/master/public/icon.png"; -const barkNotificationSound = "telegraph"; const successMessage = "Successes!"; class Bark extends NotificationProvider { @@ -50,13 +48,23 @@ class Bark extends NotificationProvider { * @param {string} postUrl URL to append parameters to * @returns {string} */ - appendAdditionalParameters(postUrl) { - // grouping all our notifications - postUrl += "?group=" + barkNotificationGroup; + appendAdditionalParameters(notification, postUrl) { // set icon to uptime kuma icon, 11kb should be fine postUrl += "&icon=" + barkNotificationAvatar; + // grouping all our notifications + if (notification.barkGroup != null) { + postUrl += "&group=" + notification.barkGroup; + } else { + // default name + postUrl += "&group=" + "UptimeKuma"; + } // picked a sound, this should follow system's mute status when arrival - postUrl += "&sound=" + barkNotificationSound; + if (notification.barkSound != null) { + postUrl += "&sound=" + notification.barkSound; + } else { + // default sound + postUrl += "&sound=" + "telegraph"; + } return postUrl; } diff --git a/server/notification-providers/home-assistant.js b/server/notification-providers/home-assistant.js new file mode 100644 index 00000000..285989ee --- /dev/null +++ b/server/notification-providers/home-assistant.js @@ -0,0 +1,38 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +const defaultNotificationService = "notify"; + +class HomeAssistant extends NotificationProvider { + name = "HomeAssistant"; + + async send(notification, message, monitor = null, heartbeat = null) { + const notificationService = notification?.notificationService || defaultNotificationService; + + try { + await axios.post( + `${notification.homeAssistantUrl}/api/services/notify/${notificationService}`, + { + title: "Uptime Kuma", + message, + ...(notificationService !== "persistent_notification" && { data: { + name: monitor?.name, + status: heartbeat?.status, + } }), + }, + { + headers: { + Authorization: `Bearer ${notification.longLivedAccessToken}`, + "Content-Type": "application/json", + }, + } + ); + + return "Sent Successfully."; + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = HomeAssistant; diff --git a/server/notification.js b/server/notification.js index ad1c8705..8093572a 100644 --- a/server/notification.js +++ b/server/notification.js @@ -12,6 +12,7 @@ const Feishu = require("./notification-providers/feishu"); const GoogleChat = require("./notification-providers/google-chat"); const Gorush = require("./notification-providers/gorush"); const Gotify = require("./notification-providers/gotify"); +const HomeAssistant = require("./notification-providers/home-assistant"); const Line = require("./notification-providers/line"); const LineNotify = require("./notification-providers/linenotify"); const LunaSea = require("./notification-providers/lunasea"); @@ -61,6 +62,7 @@ class Notification { new GoogleChat(), new Gorush(), new Gotify(), + new HomeAssistant(), new Line(), new LineNotify(), new LunaSea(), diff --git a/server/server.js b/server/server.js index 61bd9d93..818bd7d1 100644 --- a/server/server.js +++ b/server/server.js @@ -118,13 +118,14 @@ if (config.demoMode) { } // Must be after io instantiation -const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList, sendInfo, sendProxyList } = require("./client"); +const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList, sendInfo, sendProxyList, sendDockerHostList } = require("./client"); const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler"); const databaseSocketHandler = require("./socket-handlers/database-socket-handler"); const TwoFA = require("./2fa"); const StatusPage = require("./model/status_page"); const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart, stop: cloudflaredStop } = require("./socket-handlers/cloudflared-socket-handler"); const { proxySocketHandler } = require("./socket-handlers/proxy-socket-handler"); +const { dockerSocketHandler } = require("./socket-handlers/docker-socket-handler"); app.use(express.json()); @@ -668,6 +669,7 @@ let needSetup = false; bean.basic_auth_pass = monitor.basic_auth_pass; bean.interval = monitor.interval; bean.retryInterval = monitor.retryInterval; + bean.resendInterval = monitor.resendInterval; bean.hostname = monitor.hostname; bean.maxretries = monitor.maxretries; bean.port = parseInt(monitor.port); @@ -680,6 +682,8 @@ let needSetup = false; bean.dns_resolve_type = monitor.dns_resolve_type; bean.dns_resolve_server = monitor.dns_resolve_server; bean.pushToken = monitor.pushToken; + bean.docker_container = monitor.docker_container; + bean.docker_host = monitor.docker_host; bean.proxyId = Number.isInteger(monitor.proxyId) ? monitor.proxyId : null; bean.mqttUsername = monitor.mqttUsername; bean.mqttPassword = monitor.mqttPassword; @@ -690,6 +694,11 @@ let needSetup = false; bean.authMethod = monitor.authMethod; bean.authWorkstation = monitor.authWorkstation; bean.authDomain = monitor.authDomain; + bean.radiusUsername = monitor.radiusUsername; + bean.radiusPassword = monitor.radiusPassword; + bean.radiusCalledStationId = monitor.radiusCalledStationId; + bean.radiusCallingStationId = monitor.radiusCallingStationId; + bean.radiusSecret = monitor.radiusSecret; await R.store(bean); @@ -1270,6 +1279,7 @@ let needSetup = false; authDomain: monitorListData[i].authDomain, interval: monitorListData[i].interval, retryInterval: retryInterval, + resendInterval: monitorListData[i].resendInterval || 0, hostname: monitorListData[i].hostname, maxretries: monitorListData[i].maxretries, port: monitorListData[i].port, @@ -1438,6 +1448,7 @@ let needSetup = false; cloudflaredSocketHandler(socket); databaseSocketHandler(socket); proxySocketHandler(socket); + dockerSocketHandler(socket); log.debug("server", "added all socket handlers"); @@ -1538,6 +1549,7 @@ async function afterLogin(socket, user) { let monitorList = await server.sendMonitorList(socket); sendNotificationList(socket); sendProxyList(socket); + sendDockerHostList(socket); await sleep(500); diff --git a/server/socket-handlers/docker-socket-handler.js b/server/socket-handlers/docker-socket-handler.js new file mode 100644 index 00000000..5a53494d --- /dev/null +++ b/server/socket-handlers/docker-socket-handler.js @@ -0,0 +1,79 @@ +const { sendDockerHostList } = require("../client"); +const { checkLogin } = require("../util-server"); +const { DockerHost } = require("../docker"); +const { log } = require("../../src/util"); + +/** + * Handlers for docker hosts + * @param {Socket} socket Socket.io instance + */ +module.exports.dockerSocketHandler = (socket) => { + socket.on("addDockerHost", async (dockerHost, dockerHostID, callback) => { + try { + checkLogin(socket); + + let dockerHostBean = await DockerHost.save(dockerHost, dockerHostID, socket.userID); + await sendDockerHostList(socket); + + callback({ + ok: true, + msg: "Saved", + id: dockerHostBean.id, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteDockerHost", async (dockerHostID, callback) => { + try { + checkLogin(socket); + + await DockerHost.delete(dockerHostID, socket.userID); + await sendDockerHostList(socket); + + callback({ + ok: true, + msg: "Deleted", + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("testDockerHost", async (dockerHost, callback) => { + try { + checkLogin(socket); + + let amount = await DockerHost.testDockerHost(dockerHost); + let msg; + + if (amount > 1) { + msg = "Connected Successfully. Amount of containers: " + amount; + } else { + msg = "Connected Successfully, but there are no containers?"; + } + + callback({ + ok: true, + msg, + }); + + } catch (e) { + log.error("docker", e); + + callback({ + ok: false, + msg: e.message, + }); + } + }); +}; diff --git a/server/util-server.js b/server/util-server.js index df711cf0..067da6fd 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -15,6 +15,12 @@ const { Client } = require("pg"); const postgresConParse = require("pg-connection-string").parse; const { NtlmClient } = require("axios-ntlm"); const { Settings } = require("./settings"); +const radiusClient = require("node-radius-client"); +const { + dictionaries: { + rfc2865: { file, attributes }, + }, +} = require("node-radius-utils"); // From ping-lite exports.WIN = /^win/.test(process.platform); @@ -285,6 +291,30 @@ exports.postgresQuery = function (connectionString, query) { }); }; +exports.radius = function ( + hostname, + username, + password, + calledStationId, + callingStationId, + secret, +) { + const client = new radiusClient({ + host: hostname, + dictionaries: [ file ], + }); + + return client.accessRequest({ + secret: secret, + attributes: [ + [ attributes.USER_NAME, username ], + [ attributes.USER_PASSWORD, password ], + [ attributes.CALLING_STATION_ID, callingStationId ], + [ attributes.CALLED_STATION_ID, calledStationId ], + ], + }); +}; + /** * Retrieve value of setting based on key * @param {string} key Key of setting to retrieve diff --git a/src/components/DockerHostDialog.vue b/src/components/DockerHostDialog.vue new file mode 100644 index 00000000..92a8ce45 --- /dev/null +++ b/src/components/DockerHostDialog.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/src/components/notifications/Bark.vue b/src/components/notifications/Bark.vue index 014450de..6cac73d3 100644 --- a/src/components/notifications/Bark.vue +++ b/src/components/notifications/Bark.vue @@ -2,9 +2,6 @@
-
-

*{{ $t("Required") }}

-
{{ $t("here") }}
+
+ + +
+
+ + +
diff --git a/src/components/notifications/HomeAssistant.vue b/src/components/notifications/HomeAssistant.vue new file mode 100644 index 00000000..67e370a1 --- /dev/null +++ b/src/components/notifications/HomeAssistant.vue @@ -0,0 +1,40 @@ + diff --git a/src/components/notifications/index.js b/src/components/notifications/index.js index c1b7da4a..ff523052 100644 --- a/src/components/notifications/index.js +++ b/src/components/notifications/index.js @@ -10,6 +10,7 @@ import Feishu from "./Feishu.vue"; import GoogleChat from "./GoogleChat.vue"; import Gorush from "./Gorush.vue"; import Gotify from "./Gotify.vue"; +import HomeAssistant from "./HomeAssistant.vue"; import Line from "./Line.vue"; import LineNotify from "./LineNotify.vue"; import LunaSea from "./LunaSea.vue"; @@ -54,6 +55,7 @@ const NotificationFormList = { "GoogleChat": GoogleChat, "gorush": Gorush, "gotify": Gotify, + "HomeAssistant": HomeAssistant, "line": Line, "LineNotify": LineNotify, "lunasea": LunaSea, diff --git a/src/components/settings/Docker.vue b/src/components/settings/Docker.vue new file mode 100644 index 00000000..c411c307 --- /dev/null +++ b/src/components/settings/Docker.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/languages/de-DE.js b/src/languages/de-DE.js index 3df13b94..ef47909c 100644 --- a/src/languages/de-DE.js +++ b/src/languages/de-DE.js @@ -165,7 +165,10 @@ export default { Pink: "Pink", "Search...": "Suchen...", "Heartbeat Retry Interval": "Überprüfungsintervall", + "Resend Notification if Down X times consequently": "Benachrichtigung erneut senden, wenn Inaktiv X mal hintereinander", retryCheckEverySecond: "Alle {0} Sekunden neu versuchen", + resendEveryXTimes: "Erneut versenden alle {0} mal", + resendDisabled: "Erneut versenden deaktiviert", "Import Backup": "Backup importieren", "Export Backup": "Backup exportieren", "Avg. Ping": "Durchschn. Ping", diff --git a/src/languages/en.js b/src/languages/en.js index 352a63f6..3bb02585 100644 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -2,6 +2,8 @@ export default { languageName: "English", checkEverySecond: "Check every {0} seconds", retryCheckEverySecond: "Retry every {0} seconds", + resendEveryXTimes: "Resend every {0} times", + resendDisabled: "Resend disabled", retriesDescription: "Maximum retries before the service is marked as down and a notification is sent", ignoreTLSError: "Ignore TLS/SSL error for HTTPS websites", upsideDownModeDescription: "Flip the status upside down. If the service is reachable, it is DOWN.", @@ -72,6 +74,7 @@ export default { "Heartbeat Interval": "Heartbeat Interval", Retries: "Retries", "Heartbeat Retry Interval": "Heartbeat Retry Interval", + "Resend Notification if Down X times consequently": "Resend Notification if Down X times consequently", Advanced: "Advanced", "Upside Down Mode": "Upside Down Mode", "Max. Redirects": "Max. Redirects", @@ -408,6 +411,8 @@ export default { SignName: "SignName", "Sms template must contain parameters: ": "Sms template must contain parameters: ", "Bark Endpoint": "Bark Endpoint", + "Bark Group": "Bark Group", + "Bark Sound": "Bark Sound", WebHookUrl: "WebHookUrl", SecretKey: "SecretKey", "For safety, must use secret key": "For safety, must use secret key", @@ -467,6 +472,7 @@ export default { "Domain Name Expiry Notification": "Domain Name Expiry Notification", Proxy: "Proxy", "Date Created": "Date Created", + HomeAssistant: "Home Assistant", onebotHttpAddress: "OneBot HTTP Address", onebotMessageType: "OneBot Message Type", onebotGroupMessage: "Group", @@ -479,6 +485,12 @@ export default { "Domain Names": "Domain Names", signedInDisp: "Signed in as {0}", signedInDispDisabled: "Auth Disabled.", + RadiusSecret: "Radius Secret", + RadiusSecretDescription: "Shared Secret between client and server", + RadiusCalledStationId: "Called Station Id", + RadiusCalledStationIdDescription: "Identifier of the called device", + RadiusCallingStationId: "Calling Station Id", + RadiusCallingStationIdDescription: "Identifier of the calling device", "Certificate Expiry Notification": "Certificate Expiry Notification", "API Username": "API Username", "API Key": "API Key", @@ -487,7 +499,7 @@ export default { "Leave blank to use a shared sender number.": "Leave blank to use a shared sender number.", "Octopush API Version": "Octopush API Version", "Legacy Octopush-DM": "Legacy Octopush-DM", - "endpoint": "endpoint", + endpoint: "endpoint", octopushAPIKey: "\"API key\" from HTTP API credentials in control panel", octopushLogin: "\"Login\" from HTTP API credentials in control panel", promosmsLogin: "API Login Name", @@ -531,9 +543,19 @@ export default { "Coming Soon": "Coming Soon", wayToGetClickSendSMSToken: "You can get API Username and API Key from {0} .", "Connection String": "Connection String", - "Query": "Query", + Query: "Query", settingsCertificateExpiry: "TLS Certificate Expiry", certificationExpiryDescription: "HTTPS Monitors trigger notification when TLS certificate expires in:", + "Setup Docker Host": "Setup Docker Host", + "Connection Type": "Connection Type", + "Docker Daemon": "Docker Daemon", + deleteDockerHostMsg: "Are you sure want to delete this docker host for all monitors?", + socket: "Socket", + tcp: "TCP / HTTP", + "Docker Container": "Docker Container", + "Container Name / ID": "Container Name / ID", + "Docker Host": "Docker Host", + "Docker Hosts": "Docker Hosts", "ntfy Topic": "ntfy Topic", "Domain": "Domain", "Workstation": "Workstation", diff --git a/src/languages/zh-CN.js b/src/languages/zh-CN.js index 67077f38..8dbe05f0 100644 --- a/src/languages/zh-CN.js +++ b/src/languages/zh-CN.js @@ -404,6 +404,8 @@ export default { TemplateCode: "TemplateCode", SignName: "SignName", "Bark Endpoint": "Bark 接入点", + "Bark Group": "Bark 群组", + "Bark Sound": "Bark 铃声", "Device Token": "Apple Device Token", Platform: "平台", iOS: "iOS", diff --git a/src/languages/zh-TW.js b/src/languages/zh-TW.js index be87c540..3405c02a 100644 --- a/src/languages/zh-TW.js +++ b/src/languages/zh-TW.js @@ -408,6 +408,8 @@ export default { SignName: "SignName", "Sms template must contain parameters: ": "Sms 範本必須包含參數:", "Bark Endpoint": "Bark 端點", + "Bark Group": "Bark 群組", + "Bark Sound": "Bark 鈴聲", WebHookUrl: "WebHookUrl", SecretKey: "SecretKey", "For safety, must use secret key": "為了安全起見,必須使用秘密金鑰", diff --git a/src/mixins/socket.js b/src/mixins/socket.js index ed1620bf..52dd3891 100644 --- a/src/mixins/socket.js +++ b/src/mixins/socket.js @@ -39,6 +39,7 @@ export default { uptimeList: { }, tlsInfoList: {}, notificationList: [], + dockerHostList: [], statusPageListLoaded: false, statusPageList: [], proxyList: [], @@ -147,6 +148,10 @@ export default { }); }); + socket.on("dockerHostList", (data) => { + this.dockerHostList = data; + }); + socket.on("heartbeat", (data) => { if (! (data.monitorID in this.heartbeatList)) { this.heartbeatList[data.monitorID] = []; diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 3b260318..99cbeb95 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -27,6 +27,9 @@ + @@ -48,6 +51,9 @@ + @@ -84,8 +90,8 @@ - -
+ +
@@ -141,6 +147,34 @@
+ + +
+ + +
+ + + +
+

{{ $t("Docker Host") }}

+

+ {{ $t("Not available, please setup.") }} +

+ +
+ + + {{ $t("Edit") }} +
+ + +
+ + +