diff --git a/.dockerignore b/.dockerignore index 825d58038..4ddce98b0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,14 +1,35 @@ /.idea /dist /node_modules -/data/kuma.db +/data /.do **/.dockerignore **/.git **/.gitignore **/docker-compose* -**/Dockerfile* +**/[Dd]ockerfile* LICENSE README.md .editorconfig .vscode +.eslint* +.stylelint* +/.github +package-lock.json +yarn.lock +app.json + +### .gitignore content (commented rules are duplicated) + +#node_modules +.DS_Store +#dist +dist-ssr +*.local +#.idea + +#/data +#!/data/.gitkeep +#.vscode + +### End of .gitignore content diff --git a/.editorconfig b/.editorconfig index a882f5c2d..3b2721931 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,3 +16,6 @@ indent_size = 2 [*.yml] indent_size = 2 + +[*.vue] +trim_trailing_whitespace = false diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..41ad54b81 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,73 @@ +module.exports = { + env: { + browser: true, + commonjs: true, + es2020: true, + node: true, + }, + extends: [ + "eslint:recommended", + "plugin:vue/vue3-recommended", + ], + parser: "vue-eslint-parser", + parserOptions: { + parser: "@babel/eslint-parser", + sourceType: "module", + requireConfigFile: false, + }, + rules: { + // override/add rules settings here, such as: + // 'vue/no-unused-vars': 'error' + "no-unused-vars": "warn", + indent: [ + "error", + 4, + { + ignoredNodes: ["TemplateLiteral"], + SwitchCase: 1, + }, + ], + quotes: ["warn", "double"], + //semi: ['off', 'never'], + "vue/html-indent": ["warn", 4], // default: 2 + "vue/max-attributes-per-line": "off", + "vue/singleline-html-element-content-newline": "off", + "vue/html-self-closing": "off", + "no-multi-spaces": ["error", { + ignoreEOLComments: true, + }], + "curly": "error", + "object-curly-spacing": ["error", "always"], + "object-curly-newline": "off", + "object-property-newline": "error", + "comma-spacing": "error", + "brace-style": "error", + "no-var": "error", + "key-spacing": "warn", + "keyword-spacing": "warn", + "space-infix-ops": "warn", + "arrow-spacing": "warn", + "no-trailing-spaces": "warn", + "no-constant-condition": ["error", { + "checkLoops": false, + }], + "space-before-blocks": "warn", + //'no-console': 'warn', + "no-extra-boolean-cast": "off", + "no-multiple-empty-lines": ["warn", { + "max": 1, + "maxBOF": 0, + }], + "lines-between-class-members": ["warn", "always", { + exceptAfterSingleLine: true, + }], + "no-unneeded-ternary": "error", + "no-else-return": ["error", { + "allowElseIf": false, + }], + "array-bracket-newline": ["error", "consistent"], + "eol-last": ["error", "always"], + //'prefer-template': 'error', + "comma-dangle": ["warn", "only-multiline"], + }, +} diff --git a/.github/ISSUE_TEMPLATE/--please-go-to--discussion--tab-if-you-want-to-ask-or-share-something.md b/.github/ISSUE_TEMPLATE/--please-go-to--discussion--tab-if-you-want-to-ask-or-share-something.md deleted file mode 100644 index eb8623709..000000000 --- a/.github/ISSUE_TEMPLATE/--please-go-to--discussion--tab-if-you-want-to-ask-or-share-something.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: ⚠ Please go to "Discussions" Tab if you want to ask or share something -about: BUG REPORT ONLY HERE -title: '' -labels: '' -assignees: '' - ---- - -BUG REPORT ONLY HERE diff --git a/.github/ISSUE_TEMPLATE/ask-for-help.md b/.github/ISSUE_TEMPLATE/ask-for-help.md new file mode 100644 index 000000000..c3657267a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ask-for-help.md @@ -0,0 +1,10 @@ +--- +name: Ask for help +about: You can ask any question related to Uptime Kuma. +title: '' +labels: help +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..11fc491ef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.stylelintrc b/.stylelintrc new file mode 100644 index 000000000..4d3d9d1f8 --- /dev/null +++ b/.stylelintrc @@ -0,0 +1,3 @@ +{ + "extends": "stylelint-config-recommended", +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..4cbcc7bdb --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +louis@uptimekuma.louislam.net. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..b3308df09 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,104 @@ +# Project Info + +First of all, thank you everyone who made pull requests for Uptime Kuma, I never thought GitHub Community can be that nice! And also because of this, I also never thought other people actually read my code and edit my code. It is not structed and commented so well, lol. Sorry about that. + +The project was created with vite.js (vue3). Then I created a sub-directory called "server" for server part. Both frontend and backend share the same package.json. + +The frontend code build into "dist" directory. The server uses "dist" as root. This is how production is working. + +Your IDE should follow the config in ".editorconfig". The most special thing is I set it to 4 spaces indentation. I know 2 spaces indentation became a kind of standard nowadays for js, but my eyes is not so comfortable for this. In my opinion, there is no callback-hell nowadays, it is good to go back 4 spaces world again. + +# Project Styles + +I personally do not like something need to learn so much and need to config so much before you can finally start the app. + +For example, recently, because I am not a python expert, I spent a 2 hours to resolve all problems in order to install and use the Apprise cli. Apprise requires so many hidden requirements, I have to figure out myself how to solve the problems by Google search for my OS. That is painful. I do not want Uptime Kuma to be like this way, so: + +- Easy to install for non-Docker users, no native build dependency is needed (at least for x86_64), no extra config, no extra effort to get it run +- Single container for Docker users, no very complex docker-composer file. Just map the volume and expose the port, then good to go +- All settings in frontend. +- Easy to use + +# Tools +- Node.js >= 14 +- Git +- IDE that supports .editorconfig (I am using Intellji Idea) +- A SQLite tool (I am using SQLite Expert Personal) + +# Prepare the dev + +```bash +npm install +``` + +# Backend Dev + +```bash +npm run start-server + +# Or + +node server/server.js + +``` + +It binds to 0.0.0.0:3001 by default. + + +## Backend Details + +It is mainly a socket.io app + express.js. + +express.js is just used for serving the frontend built files (index.html, .js and .css etc.) + +# Frontend Dev + +Start frontend dev server. Hot-reload enabled in this way. It binds to 0.0.0.0:3000. + +```bash +npm run dev +``` + +PS: You can ignore those scss warnings, those warnings are from Bootstrap that I cannot fix. + +You can use Vue Devtool Chrome extension for debugging. + +After the frontend server started. It cannot connect to the websocket server even you have started the server. You need to tell the frontend that is a dev env by running this in DevTool console and refresh: + +```javascript +localStorage.dev = "dev"; +``` + +So that the frontend will try to connect websocket server in 3001. + +Alternately, you can specific NODE_ENV to "development". + + +## Build the frontend + +```bash +npm run build +``` + +## Frontend Details + +Uptime Kuma Frontend is a single page application (SPA). Most paths are handled by Vue Router. + +The router in "src/main.js" + +As you can see, most data in frontend is stored in root level, even though you changed the current router to any other pages. + +The data and socket logic in "src/mixins/socket.js" + +# Database Migration + +TODO + +# Unit Test + +Yes, no unit test for now. I know it is very important, but at the same time my spare time is very limited. I want to implement my ideas first. I will go back to this in some points. + + + + + diff --git a/app.json b/app.json new file mode 100644 index 000000000..ab6432121 --- /dev/null +++ b/app.json @@ -0,0 +1,7 @@ +{ + "name": "Uptime Kuma", + "description": "A fancy self-hosted monitoring tool", + "repository": "https://github.com/louislam/uptime-kuma", + "logo": "https://raw.githubusercontent.com/louislam/uptime-kuma/master/public/icon.png", + "keywords": ["node", "express", "socket-io", "uptime-kuma", "uptime"] +} diff --git a/db/patch2.sql b/db/patch2.sql new file mode 100644 index 000000000..012d01502 --- /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/patch4.sql b/db/patch4.sql new file mode 100644 index 000000000..ff40da2e2 --- /dev/null +++ b/db/patch4.sql @@ -0,0 +1,40 @@ +-- You should not modify if this have pushed to Github, unless it does serious wrong with the db. +-- OK.... serious wrong, missing maxretries column +-- Developers should patch it manually if you have missing the maxretries column +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, + ignore_tls BOOLEAN default 0 not null, + upside_down BOOLEAN default 0 not null +); + +insert into monitor_dg_tmp(id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword, maxretries) select id, name, active, user_id, interval, url, type, weight, hostname, port, created_date, keyword, maxretries 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.json b/package.json index 542c0b7d6..5b77bfcb5 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,10 @@ "vite-preview-dist": "vite preview --host" }, "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", @@ -18,6 +22,7 @@ "command-exists": "1.2.9", "dayjs": "1.10.6", "express": "4.17.1", + "express-basic-auth": "1.2.0", "form-data": "4.0.0", "http-graceful-shutdown": "3.1.2", "jsonwebtoken": "8.5.1", diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 000000000..35d2a080c --- /dev/null +++ b/server/auth.js @@ -0,0 +1,51 @@ +const basicAuth = require("express-basic-auth") +const passwordHash = require("./password-hash"); +const { R } = require("redbean-node"); +const { setting } = require("./util-server"); +const { debug } = require("../src/util"); + +/** + * + * @param username : string + * @param password : string + * @returns {Promise} + */ +exports.login = async function (username, password) { + let user = await R.findOne("user", " username = ? AND active = 1 ", [ + username, + ]) + + if (user && passwordHash.verify(password, user.password)) { + // Upgrade the hash to bcrypt + if (passwordHash.needRehash(user.password)) { + await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [ + passwordHash.generate(password), + user.id, + ]); + } + return user; + } + + return null; +} + +function myAuthorizer(username, password, callback) { + + setting("disableAuth").then((result) => { + + if (result) { + callback(null, true) + } else { + exports.login(username, password).then((user) => { + callback(null, user != null) + }) + } + }) + +} + +exports.basicAuth = basicAuth({ + authorizer: myAuthorizer, + authorizeAsync: true, + challenge: true, +}); diff --git a/server/database.js b/server/database.js index 74d671d3f..04f764e7a 100644 --- a/server/database.js +++ b/server/database.js @@ -1,14 +1,15 @@ const fs = require("fs"); -const {sleep} = require("./util"); -const {R} = require("redbean-node"); -const {setSetting, setting} = require("./util-server"); - +const { sleep } = require("../src/util"); +const { R } = require("redbean-node"); +const { + setSetting, setting, +} = require("./util-server"); class Database { static templatePath = "./db/kuma.db" - static path = './data/kuma.db'; - static latestVersion = 1; + static path = "./data/kuma.db"; + static latestVersion = 4; static noReject = true; static async patch() { @@ -95,7 +96,7 @@ class Database { const listener = (reason, p) => { Database.noReject = false; }; - process.addListener('unhandledRejection', listener); + process.addListener("unhandledRejection", listener); console.log("Closing DB") @@ -112,7 +113,7 @@ class Database { } console.log("SQLite closed") - process.removeListener('unhandledRejection', listener); + process.removeListener("unhandledRejection", listener); } } diff --git a/server/model/heartbeat.js b/server/model/heartbeat.js index 01fb71ff9..546794140 100644 --- a/server/model/heartbeat.js +++ b/server/model/heartbeat.js @@ -1,15 +1,15 @@ const dayjs = require("dayjs"); -const utc = require('dayjs/plugin/utc') -var timezone = require('dayjs/plugin/timezone') +const utc = require("dayjs/plugin/utc") +let timezone = require("dayjs/plugin/timezone") dayjs.extend(utc) dayjs.extend(timezone) -const {BeanModel} = require("redbean-node/dist/bean-model"); - +const { BeanModel } = require("redbean-node/dist/bean-model"); /** * status: * 0 = DOWN * 1 = UP + * 2 = PENDING */ class Heartbeat extends BeanModel { diff --git a/server/model/monitor.js b/server/model/monitor.js index c366869b7..49fcfb303 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -1,47 +1,30 @@ -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') +const utc = require("dayjs/plugin/utc") +let timezone = require("dayjs/plugin/timezone") dayjs.extend(utc) dayjs.extend(timezone) const axios = require("axios"); -const {tcping, ping} = require("../util-server"); -const {R} = require("redbean-node"); -const {BeanModel} = require("redbean-node/dist/bean-model"); -const {Notification} = require("../notification") +const { Prometheus } = require("../prometheus"); +const { debug, UP, DOWN, PENDING, flipStatus } = require("../../src/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") -const commonLabels = [ - 'monitor_name', - 'monitor_type', - 'monitor_url', - 'monitor_hostname', - '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 + * 2 = PENDING */ class Monitor extends BeanModel { - async toJSON() { let notificationIDList = {}; let list = await R.find("monitor_notification", " monitor_id = ? ", [ - this.id + this.id, ]) for (let bean of list) { @@ -54,42 +37,62 @@ 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, interval: this.interval, keyword: this.keyword, - notificationIDList + ignoreTls: this.getIgnoreTls(), + upsideDown: this.isUpsideDown(), + notificationIDList, }; } + /** + * Parse to boolean + * @returns {boolean} + */ + getIgnoreTls() { + return Boolean(this.ignoreTls) + } + + /** + * Parse to boolean + * @returns {boolean} + */ + isUpsideDown() { + return Boolean(this.upsideDown); + } + start(io) { let previousBeat = null; + let retries = 0; - const monitorLabelValues = { - monitor_name: this.name, - monitor_type: this.type, - monitor_url: this.url, - monitor_hostname: this.hostname, - monitor_port: this.port - } - + let prometheus = new Prometheus(this); const beat = async () => { + if (! previousBeat) { previousBeat = await R.findOne("heartbeat", " monitor_id = ? ORDER BY time DESC", [ - this.id + 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; + + if (this.isUpsideDown()) { + bean.status = flipStatus(bean.status); + } // Duration - if (previousBeat) { - bean.duration = dayjs(bean.time).diff(dayjs(previousBeat.time), 'second'); + if (! isFirstBeat) { + bean.duration = dayjs(bean.time).diff(dayjs(previousBeat.time), "second"); } else { bean.duration = 0; } @@ -97,14 +100,36 @@ class Monitor extends BeanModel { try { if (this.type === "http" || this.type === "keyword") { let startTime = dayjs().valueOf(); + + // Use Custom agent to disable session reuse + // https://github.com/nodejs/node/issues/3940 let res = await axios.get(this.url, { - headers: { 'User-Agent':'Uptime-Kuma' } - }) + headers: { + "User-Agent": "Uptime-Kuma", + }, + httpsAgent: new https.Agent({ + maxCachedSessions: 0, + rejectUnauthorized: ! this.getIgnoreTls(), + }), + }); 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,41 +141,77 @@ 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") } } - } 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; } + if (this.isUpsideDown()) { + bean.status = flipStatus(bean.status); + + if (bean.status === DOWN) { + throw new Error("Flip UP to DOWN"); + } + } + + retries = 0; + } catch (error) { + bean.msg = error.message; + + // If UP come in here, it must be upside down mode + // Just reset the retries + if (this.isUpsideDown() && bean.status === UP) { + retries = 0; + + } else if ((this.maxretries > 0) && (retries < this.maxretries)) { + retries++; + bean.status = PENDING; + } } - // 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) { - let notificationList = await R.getAll(`SELECT notification.* FROM notification, monitor_notification WHERE monitor_id = ? AND monitor_notification.notification_id = notification.id `, [ - this.id + // 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" @@ -158,7 +219,7 @@ class Monitor extends BeanModel { let msg = `[${this.name}] [${text}] ${bean.msg}`; - for(let notification of notificationList) { + for (let notification of notificationList) { try { await Notification.send(JSON.parse(notification.config), msg, await this.toJSON(), bean.toJSON()) } catch (e) { @@ -171,16 +232,15 @@ 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} | Max retries: ${this.maxretries} | Type: ${this.type}`) } else { console.warn(`Monitor #${this.id} '${this.name}': Failing: ${bean.msg} | Type: ${this.type}`) } - monitor_response_time.set(monitorLabelValues, bean.ping) + prometheus.update(bean) io.to(this.user_id).emit("heartbeat", bean.toJSON()); @@ -198,10 +258,42 @@ class Monitor extends BeanModel { clearInterval(this.heartbeatInterval) } + /** + * Helper Method: + * returns URL object for further usage + * returns null if url is invalid + * @returns {null|URL} + */ + getUrl() { + try { + return new URL(this.url); + } catch (_) { + return null; + } + } + + /** + * Store TLS info to database + * @param checkCertificateResult + * @returns {Promise} + */ + 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); } /** @@ -216,12 +308,21 @@ class Monitor extends BeanModel { AND ping IS NOT NULL AND monitor_id = ? `, [ -duration, - monitorID + monitorID, ])); 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: @@ -237,7 +338,7 @@ class Monitor extends BeanModel { WHERE time > DATETIME('now', ? || ' hours') AND monitor_id = ? `, [ -duration, - monitorID + monitorID, ]); let downtime = 0; @@ -261,7 +362,7 @@ class Monitor extends BeanModel { // Handle if heartbeat duration longer than the target duration // e.g. Heartbeat duration = 28hrs, but target duration = 24hrs if (value > sec) { - let trim = dayjs.utc().diff(dayjs(time), 'second'); + let trim = dayjs.utc().diff(dayjs(time), "second"); value = sec - trim; if (value < 0) { @@ -270,7 +371,7 @@ class Monitor extends BeanModel { } total += value; - if (row.status === 0) { + if (row.status === 0 || row.status === 2) { downtime += value; } } @@ -282,8 +383,6 @@ class Monitor extends BeanModel { } } - - io.to(userID).emit("uptime", monitorID, duration, uptime); } } diff --git a/server/notification.js b/server/notification.js index 06cc6598d..0e963a128 100644 --- a/server/notification.js +++ b/server/notification.js @@ -1,6 +1,6 @@ const axios = require("axios"); -const {R} = require("redbean-node"); -const FormData = require('form-data'); +const { R } = require("redbean-node"); +const FormData = require("form-data"); const nodemailer = require("nodemailer"); const child_process = require("child_process"); @@ -24,7 +24,7 @@ class Notification { params: { chat_id: notification.telegramChatID, text: msg, - } + }, }) return okMsg; @@ -41,7 +41,7 @@ class Notification { await axios.post(`${notification.gotifyserverurl}/message?token=${notification.gotifyapplicationToken}`, { "message": msg, "priority": notification.gotifyPriority || 8, - "title": "Uptime-Kuma" + "title": "Uptime-Kuma", }) return okMsg; @@ -62,10 +62,10 @@ class Notification { if (notification.webhookContentType === "form-data") { finalData = new FormData(); - finalData.append('data', JSON.stringify(data)); + finalData.append("data", JSON.stringify(data)); config = { - headers: finalData.getHeaders() + headers: finalData.getHeaders(), } } else { @@ -84,63 +84,68 @@ class Notification { } else if (notification.type === "discord") { try { - // If heartbeatJSON is null, assume we're testing. - if(heartbeatJSON == null) { + // If heartbeatJSON is null, assume we're testing. + if (heartbeatJSON == null) { + let data = { + username: "Uptime-Kuma", + content: msg, + } + await axios.post(notification.discordWebhookUrl, data) + return okMsg; + } + // If heartbeatJSON is not null, we go into the normal alerting loop. + if (heartbeatJSON["status"] == 0) { + var alertColor = "16711680"; + } else if (heartbeatJSON["status"] == 1) { + var alertColor = "65280"; + } let data = { - username: 'Uptime-Kuma', - content: msg + username: "Uptime-Kuma", + embeds: [{ + title: "Uptime-Kuma Alert", + color: alertColor, + fields: [ + { + name: "Time (UTC)", + value: heartbeatJSON["time"], + }, + { + name: "Message", + value: msg, + }, + ], + }], } await axios.post(notification.discordWebhookUrl, data) return okMsg; - } - // If heartbeatJSON is not null, we go into the normal alerting loop. - if(heartbeatJSON['status'] == 0) { - var alertColor = "16711680"; - } else if(heartbeatJSON['status'] == 1) { - var alertColor = "65280"; - } - let data = { - username: 'Uptime-Kuma', - embeds: [{ - title: "Uptime-Kuma Alert", - color: alertColor, - fields: [ - { - name: "Time (UTC)", - value: heartbeatJSON["time"] - }, - { - name: "Message", - value: msg - } - ] - }] - } - await axios.post(notification.discordWebhookUrl, data) - return okMsg; - } catch(error) { - throwGeneralAxiosError(error) + } catch (error) { + throwGeneralAxiosError(error) } } else if (notification.type === "signal") { - try { - let data = { - "message": msg, - "number": notification.signalNumber, - "recipients": notification.signalRecipients.replace(/\s/g, '').split(",") - }; - let config = {}; + try { + let data = { + "message": msg, + "number": notification.signalNumber, + "recipients": notification.signalRecipients.replace(/\s/g, "").split(","), + }; + let config = {}; - await axios.post(notification.signalURL, data, config) - return okMsg; - } catch (error) { - throwGeneralAxiosError(error) - } + await axios.post(notification.signalURL, data, config) + return okMsg; + } catch (error) { + throwGeneralAxiosError(error) + } } else if (notification.type === "slack") { try { if (heartbeatJSON == null) { - let data = {'text': "Uptime Kuma Slack testing successful.", 'channel': notification.slackchannel, 'username': notification.slackusername, 'icon_emoji': notification.slackiconemo} + let data = { + "text": "Uptime Kuma Slack testing successful.", + "channel": notification.slackchannel, + "username": notification.slackusername, + "icon_emoji": notification.slackiconemo, + } await axios.post(notification.slackwebhookURL, data) return okMsg; } @@ -148,96 +153,123 @@ class Notification { const time = heartbeatJSON["time"]; let data = { "text": "Uptime Kuma Alert", - "channel":notification.slackchannel, + "channel": notification.slackchannel, "username": notification.slackusername, "icon_emoji": notification.slackiconemo, "blocks": [{ - "type": "header", - "text": { - "type": "plain_text", - "text": "Uptime Kuma Alert" - } + "type": "header", + "text": { + "type": "plain_text", + "text": "Uptime Kuma Alert", + }, + }, + { + "type": "section", + "fields": [{ + "type": "mrkdwn", + "text": "*Message*\n" + msg, }, { - "type": "section", - "fields": [{ - "type": "mrkdwn", - "text": '*Message*\n'+msg + "type": "mrkdwn", + "text": "*Time (UTC)*\n" + time, + }], + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Visit Uptime Kuma", }, - { - "type": "mrkdwn", - "text": "*Time (UTC)*\n"+time - } - ] - }, - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": "Visit Uptime Kuma", - }, - "value": "Uptime-Kuma", - "url": notification.slackbutton || "https://github.com/philippdormann/uptime-kuma" - } - ] - } - ] - } + "value": "Uptime-Kuma", + "url": notification.slackbutton || "https://github.com/louislam/uptime-kuma", + }, + ], + }], + } await axios.post(notification.slackwebhookURL, data) return okMsg; } catch (error) { throwGeneralAxiosError(error) } - }else if (notification.type === "pushy") { - try { - await axios.post(`https://api.pushy.me/push?api_key=${notification.pushyAPIKey}`, { - "to": notification.pushyToken, - "data": { - "message": "Uptime-Kuma" - }, - "notification": { - "body": msg, - "badge": 1, - "sound": "ping.aiff" - } - }) - return true; - } catch (error) { - console.log(error) - return false; - } - } else if (notification.type === "pushover") { - var pushoverlink = 'https://api.pushover.net/1/messages.json' + let pushoverlink = "https://api.pushover.net/1/messages.json" try { if (heartbeatJSON == null) { - let data = {'message': "Uptime Kuma Pushover testing successful.", - 'user': notification.pushoveruserkey, 'token': notification.pushoverapptoken, 'sound':notification.pushoversounds, - 'priority': notification.pushoverpriority, 'title':notification.pushovertitle, 'retry': "30", 'expire':"3600", 'html': 1} + let data = { + "message": "Uptime Kuma Pushover testing successful.", + "user": notification.pushoveruserkey, + "token": notification.pushoverapptoken, + "sound": notification.pushoversounds, + "priority": notification.pushoverpriority, + "title": notification.pushovertitle, + "retry": "30", + "expire": "3600", + "html": 1, + } await axios.post(pushoverlink, data) return okMsg; } let data = { - "message": "Uptime Kuma Alert\n\nMessage:"+msg+ '\nTime (UTC):' +heartbeatJSON["time"], - "user":notification.pushoveruserkey, + "message": "Uptime Kuma Alert\n\nMessage:" + msg + "\nTime (UTC):" + heartbeatJSON["time"], + "user": notification.pushoveruserkey, "token": notification.pushoverapptoken, "sound": notification.pushoversounds, "priority": notification.pushoverpriority, "title": notification.pushovertitle, "retry": "30", "expire": "3600", - "html": 1 - } + "html": 1, + } await axios.post(pushoverlink, data) return okMsg; } catch (error) { throwGeneralAxiosError(error) } + + } else if (notification.type === "apprise") { + + return Notification.apprise(notification, msg) + + } else if (notification.type === "lunasea") { + let lunaseadevice = "https://notify.lunasea.app/v1/custom/device/" + notification.lunaseaDevice + + try { + if (heartbeatJSON == null) { + let testdata = { + "title": "Uptime Kuma Alert", + "body": "Testing Successful.", + } + await axios.post(lunaseadevice, testdata) + return okMsg; + } + + if (heartbeatJSON["status"] == 0) { + let downdata = { + "title": "UptimeKuma Alert:" + monitorJSON["name"], + "body": "[🔴 Down]" + heartbeatJSON["msg"] + "\nTime (UTC):" + heartbeatJSON["time"], + } + await axios.post(lunaseadevice, downdata) + return okMsg; + } + + if (heartbeatJSON["status"] == 1) { + let updata = { + "title": "UptimeKuma Alert:" + monitorJSON["name"], + "body": "[✅ Up]" + heartbeatJSON["msg"] + "\nTime (UTC):" + heartbeatJSON["time"], + } + await axios.post(lunaseadevice, updata) + return okMsg; + } + + } catch (error) { + throwGeneralAxiosError(error) + } + } else { throw new Error("Notification type is not supported") } @@ -301,6 +333,30 @@ class Notification { return "Sent Successfully."; } + + static async apprise(notification, msg) { + let s = child_process.spawnSync("apprise", [ "-vv", "-b", msg, notification.appriseURL]) + + let output = (s.stdout) ? s.stdout.toString() : "ERROR: maybe apprise not found"; + + if (output) { + + if (! output.includes("ERROR")) { + return "Sent Successfully"; + } + + throw new Error(output) + } else { + return "" + } + } + + static checkApprise() { + let commandExistsSync = require("command-exists").sync; + let exists = commandExistsSync("apprise"); + return exists; + } + } function throwGeneralAxiosError(error) { diff --git a/server/password-hash.js b/server/password-hash.js index 39bc0c20c..52e26b959 100644 --- a/server/password-hash.js +++ b/server/password-hash.js @@ -1,5 +1,5 @@ -const passwordHashOld = require('password-hash'); -const bcrypt = require('bcrypt'); +const passwordHashOld = require("password-hash"); +const bcrypt = require("bcrypt"); const saltRounds = 10; exports.generate = function (password) { @@ -9,9 +9,9 @@ exports.generate = function (password) { exports.verify = function (password, hash) { if (isSHA1(hash)) { return passwordHashOld.verify(password, hash) - } else { - return bcrypt.compareSync(password, hash); } + + return bcrypt.compareSync(password, hash); } function isSHA1(hash) { diff --git a/server/ping-lite.js b/server/ping-lite.js index e290d8876..0b9a7401a 100644 --- a/server/ping-lite.js +++ b/server/ping-lite.js @@ -1,9 +1,9 @@ // https://github.com/ben-bradley/ping-lite/blob/master/ping-lite.js // Fixed on Windows -var spawn = require('child_process').spawn, - events = require('events'), - fs = require('fs'), +let spawn = require("child_process").spawn, + events = require("events"), + fs = require("fs"), WIN = /^win/.test(process.platform), LIN = /^linux/.test(process.platform), MAC = /^darwin/.test(process.platform); @@ -11,8 +11,9 @@ var spawn = require('child_process').spawn, module.exports = Ping; function Ping(host, options) { - if (!host) - throw new Error('You must specify a host to ping!'); + if (!host) { + throw new Error("You must specify a host to ping!"); + } this._host = host; this._options = options = (options || {}); @@ -20,26 +21,24 @@ function Ping(host, options) { events.EventEmitter.call(this); if (WIN) { - this._bin = 'c:/windows/system32/ping.exe'; - this._args = (options.args) ? options.args : [ '-n', '1', '-w', '5000', host ]; + this._bin = "c:/windows/system32/ping.exe"; + this._args = (options.args) ? options.args : [ "-n", "1", "-w", "5000", host ]; this._regmatch = /[><=]([0-9.]+?)ms/; - } - else if (LIN) { - this._bin = '/bin/ping'; - this._args = (options.args) ? options.args : [ '-n', '-w', '2', '-c', '1', host ]; + } else if (LIN) { + this._bin = "/bin/ping"; + this._args = (options.args) ? options.args : [ "-n", "-w", "2", "-c", "1", host ]; this._regmatch = /=([0-9.]+?) ms/; // need to verify this - } - else if (MAC) { - this._bin = '/sbin/ping'; - this._args = (options.args) ? options.args : [ '-n', '-t', '2', '-c', '1', host ]; + } else if (MAC) { + this._bin = "/sbin/ping"; + this._args = (options.args) ? options.args : [ "-n", "-t", "2", "-c", "1", host ]; this._regmatch = /=([0-9.]+?) ms/; - } - else { - throw new Error('Could not detect your ping binary.'); + } else { + throw new Error("Could not detect your ping binary."); } - if (!fs.existsSync(this._bin)) - throw new Error('Could not detect '+this._bin+' on your system'); + if (!fs.existsSync(this._bin)) { + throw new Error("Could not detect " + this._bin + " on your system"); + } this._i = 0; @@ -51,48 +50,57 @@ Ping.prototype.__proto__ = events.EventEmitter.prototype; // SEND A PING // =========== Ping.prototype.send = function(callback) { - var self = this; + let self = this; callback = callback || function(err, ms) { - if (err) return self.emit('error', err); - else return self.emit('result', ms); + if (err) { + return self.emit("error", err); + } + return self.emit("result", ms); }; - var _ended, _exited, _errored; + let _ended, _exited, _errored; this._ping = spawn(this._bin, this._args); // spawn the binary - this._ping.on('error', function(err) { // handle binary errors + this._ping.on("error", function(err) { // handle binary errors _errored = true; callback(err); }); - this._ping.stdout.on('data', function(data) { // log stdout - this._stdout = (this._stdout || '') + data; + this._ping.stdout.on("data", function(data) { // log stdout + this._stdout = (this._stdout || "") + data; }); - this._ping.stdout.on('end', function() { + this._ping.stdout.on("end", function() { _ended = true; - if (_exited && !_errored) onEnd.call(self._ping); + if (_exited && !_errored) { + onEnd.call(self._ping); + } }); - this._ping.stderr.on('data', function(data) { // log stderr - this._stderr = (this._stderr || '') + data; + this._ping.stderr.on("data", function(data) { // log stderr + this._stderr = (this._stderr || "") + data; }); - this._ping.on('exit', function(code) { // handle complete + this._ping.on("exit", function(code) { // handle complete _exited = true; - if (_ended && !_errored) onEnd.call(self._ping); + if (_ended && !_errored) { + onEnd.call(self._ping); + } }); function onEnd() { - var stdout = this.stdout._stdout, + let stdout = this.stdout._stdout, stderr = this.stderr._stderr, ms; - if (stderr) + if (stderr) { return callback(new Error(stderr)); - else if (!stdout) - return callback(new Error('No stdout detected')); + } + + if (!stdout) { + return callback(new Error("No stdout detected")); + } ms = stdout.match(self._regmatch); // parse out the ##ms response ms = (ms && ms[1]) ? Number(ms[1]) : ms; @@ -104,7 +112,7 @@ Ping.prototype.send = function(callback) { // CALL Ping#send(callback) ON A TIMER // =================================== Ping.prototype.start = function(callback) { - var self = this; + let self = this; this._i = setInterval(function() { self.send(callback); }, (self._options.interval || 5000)); diff --git a/server/prometheus.js b/server/prometheus.js new file mode 100644 index 000000000..f60ec45a6 --- /dev/null +++ b/server/prometheus.js @@ -0,0 +1,59 @@ +const PrometheusClient = require('prom-client'); + +const commonLabels = [ + 'monitor_name', + 'monitor_type', + 'monitor_url', + 'monitor_hostname', + 'monitor_port', +] + +const monitor_response_time = new PrometheusClient.Gauge({ + name: 'monitor_response_time', + help: 'Monitor Response Time (ms)', + labelNames: commonLabels +}); + +const monitor_status = new PrometheusClient.Gauge({ + name: 'monitor_status', + help: 'Monitor Status (1 = UP, 0= DOWN)', + labelNames: commonLabels +}); + +class Prometheus { + monitorLabelValues = {} + + constructor(monitor) { + this.monitorLabelValues = { + monitor_name: monitor.name, + monitor_type: monitor.type, + monitor_url: monitor.url, + monitor_hostname: monitor.hostname, + monitor_port: monitor.port + } + } + + update(heartbeat) { + try { + monitor_status.set(this.monitorLabelValues, heartbeat.status) + } catch (e) { + console.error(e) + } + + try { + if (typeof heartbeat.ping === 'number') { + monitor_response_time.set(this.monitorLabelValues, heartbeat.ping) + } else { + // Is it good? + monitor_response_time.set(this.monitorLabelValues, -1) + } + } catch (e) { + console.error(e) + } + } + +} + +module.exports = { + Prometheus +} diff --git a/server/server.js b/server/server.js index a89e04701..b03fcc0cd 100644 --- a/server/server.js +++ b/server/server.js @@ -1,24 +1,46 @@ -console.log("Welcome to Uptime Kuma ") -console.log("Importing libraries") -const express = require('express'); -const http = require('http'); -const { Server } = require("socket.io"); -const dayjs = require("dayjs"); -const {R} = require("redbean-node"); -const passwordHash = require('./password-hash'); -const jwt = require('jsonwebtoken'); -const Monitor = require("./model/monitor"); +console.log("Welcome to Uptime Kuma") + +const { sleep, debug } = require("../src/util"); + +console.log("Importing Node libraries") const fs = require("fs"); -const {getSettings} = require("./util-server"); -const {Notification} = require("./notification") -const gracefulShutdown = require('http-graceful-shutdown'); +const http = require("http"); + +console.log("Importing 3rd-party libraries") +debug("Importing express"); +const express = require("express"); +debug("Importing socket.io"); +const { Server } = require("socket.io"); +debug("Importing dayjs"); +const dayjs = require("dayjs"); +debug("Importing redbean-node"); +const { R } = require("redbean-node"); +debug("Importing jsonwebtoken"); +const jwt = require("jsonwebtoken"); +debug("Importing http-graceful-shutdown"); +const gracefulShutdown = require("http-graceful-shutdown"); +debug("Importing prometheus-api-metrics"); +const prometheusAPIMetrics = require("prometheus-api-metrics"); + +console.log("Importing this project modules"); +debug("Importing Monitor"); +const Monitor = require("./model/monitor"); +debug("Importing Settings"); +const { getSettings, setSettings, setting } = require("./util-server"); +debug("Importing Notification"); +const { Notification } = require("./notification"); +debug("Importing Database"); const Database = require("./database"); -const {sleep} = require("./util"); -const args = require('args-parser')(process.argv); -const apiMetrics = require('prometheus-api-metrics'); -const version = require('../package.json').version; -const hostname = args.host || "0.0.0.0" -const port = args.port || 3001 + +const { basicAuth } = require("./auth"); +const { login } = require("./auth"); +const passwordHash = require("./password-hash"); + +const args = require("args-parser")(process.argv); + +const version = require("../package.json").version; +const hostname = process.env.HOST || args.host || "0.0.0.0" +const port = parseInt(process.env.PORT || args.port || 3001); console.info("Version: " + version) @@ -52,21 +74,34 @@ let monitorList = {}; */ let needSetup = false; +/** + * Cache Index HTML + * @type {string} + */ +let indexHTML = fs.readFileSync("./dist/index.html").toString(); + (async () => { await initDatabase(); console.log("Adding route") - app.use('/', express.static("dist")); - app.use(apiMetrics()) + // Normal Router here - app.get('*', function(request, response, next) { - response.sendFile(process.cwd() + '/dist/index.html'); + app.use("/", express.static("dist")); + + // Basic Auth Router here + + // Prometheus API metrics /metrics + // With Basic Auth using the first user's username/password + app.get("/metrics", basicAuth, prometheusAPIMetrics()) + + // Universal Route Handler, must be at the end + app.get("*", function(request, response, next) { + response.end(indexHTML) }); - console.log("Adding socket handler") - io.on('connection', async (socket) => { + io.on("connection", async (socket) => { socket.emit("info", { version, @@ -79,11 +114,18 @@ let needSetup = false; socket.emit("setup") } - socket.on('disconnect', () => { + if (await setting("disableAuth")) { + console.log("Disabled Auth: auto login to admin") + await afterLogin(socket, await R.findOne("user", " username = 'admin' ")) + } + + socket.on("disconnect", () => { totalClient--; }); + // *************************** // Public API + // *************************** socket.on("loginByToken", async (token, callback) => { @@ -93,7 +135,7 @@ let needSetup = false; console.log("Username from JWT: " + decoded.username) let user = await R.findOne("user", " username = ? AND active = 1 ", [ - decoded.username + decoded.username, ]) if (user) { @@ -105,13 +147,13 @@ let needSetup = false; } else { callback({ ok: false, - msg: "The user is inactive or deleted." + msg: "The user is inactive or deleted.", }) } } catch (error) { callback({ ok: false, - msg: "Invalid token." + msg: "Invalid token.", }) } @@ -120,32 +162,21 @@ let needSetup = false; socket.on("login", async (data, callback) => { console.log("Login") - let user = await R.findOne("user", " username = ? AND active = 1 ", [ - data.username - ]) - - if (user && passwordHash.verify(data.password, user.password)) { - - // Upgrade the hash to bcrypt - if (passwordHash.needRehash(user.password)) { - await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [ - passwordHash.generate(data.password), - user.id - ]); - } + let user = await login(data.username, data.password) + if (user) { await afterLogin(socket, user) callback({ ok: true, token: jwt.sign({ - username: data.username - }, jwtSecret) + username: data.username, + }, jwtSecret), }) } else { callback({ ok: false, - msg: "Incorrect username or password." + msg: "Incorrect username or password.", }) } @@ -176,19 +207,22 @@ let needSetup = false; callback({ ok: true, - msg: "Added Successfully." + msg: "Added Successfully.", }); } catch (e) { callback({ ok: false, - msg: e.message + msg: e.message, }); } }); + // *************************** // Auth Only API + // *************************** + // Add a new monitor socket.on("add", async (monitor, callback) => { try { checkLogin(socket) @@ -209,17 +243,18 @@ let needSetup = false; callback({ ok: true, msg: "Added Successfully.", - monitorID: bean.id + monitorID: bean.id, }); } catch (e) { callback({ ok: false, - msg: e.message + msg: e.message, }); } }); + // Edit a monitor socket.on("editMonitor", async (monitor, callback) => { try { checkLogin(socket) @@ -238,6 +273,8 @@ let needSetup = false; bean.maxretries = monitor.maxretries; bean.port = monitor.port; bean.keyword = monitor.keyword; + bean.ignoreTls = monitor.ignoreTls; + bean.upsideDown = monitor.upsideDown; await R.store(bean) @@ -252,14 +289,14 @@ let needSetup = false; callback({ ok: true, msg: "Saved.", - monitorID: bean.id + monitorID: bean.id, }); } catch (e) { console.error(e) callback({ ok: false, - msg: e.message + msg: e.message, }); } }); @@ -283,7 +320,7 @@ let needSetup = false; } catch (e) { callback({ ok: false, - msg: e.message + msg: e.message, }); } }); @@ -297,13 +334,13 @@ let needSetup = false; callback({ ok: true, - msg: "Resumed Successfully." + msg: "Resumed Successfully.", }); } catch (e) { callback({ ok: false, - msg: e.message + msg: e.message, }); } }); @@ -316,14 +353,13 @@ let needSetup = false; callback({ ok: true, - msg: "Paused Successfully." + msg: "Paused Successfully.", }); - } catch (e) { callback({ ok: false, - msg: e.message + msg: e.message, }); } }); @@ -341,12 +377,12 @@ let needSetup = false; await R.exec("DELETE FROM monitor WHERE id = ? AND user_id = ? ", [ monitorID, - socket.userID + socket.userID, ]); callback({ ok: true, - msg: "Deleted Successfully." + msg: "Deleted Successfully.", }); await sendMonitorList(socket); @@ -354,7 +390,7 @@ let needSetup = false; } catch (e) { callback({ ok: false, - msg: e.message + msg: e.message, }); } }); @@ -368,19 +404,19 @@ let needSetup = false; } let user = await R.findOne("user", " id = ? AND active = 1 ", [ - socket.userID + socket.userID, ]) if (user && passwordHash.verify(password.currentPassword, user.password)) { await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [ passwordHash.generate(password.newPassword), - socket.userID + socket.userID, ]); callback({ ok: true, - msg: "Password has been updated successfully." + msg: "Password has been updated successfully.", }) } else { throw new Error("Incorrect current password") @@ -389,25 +425,43 @@ let needSetup = false; } catch (e) { callback({ ok: false, - msg: e.message + msg: e.message, }); } }); - socket.on("getSettings", async (type, callback) => { + socket.on("getSettings", async (callback) => { try { checkLogin(socket) - callback({ ok: true, - data: await getSettings(type), + data: await getSettings("general"), }); } catch (e) { callback({ ok: false, - msg: e.message + msg: e.message, + }); + } + }); + + socket.on("setSettings", async (data, callback) => { + try { + checkLogin(socket) + + await setSettings("general", data) + + callback({ + ok: true, + msg: "Saved" + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, }); } }); @@ -428,7 +482,7 @@ let needSetup = false; } catch (e) { callback({ ok: false, - msg: e.message + msg: e.message, }); } }); @@ -448,7 +502,7 @@ let needSetup = false; } catch (e) { callback({ ok: false, - msg: e.message + msg: e.message, }); } }); @@ -461,7 +515,7 @@ let needSetup = false; callback({ ok: true, - msg + msg, }); } catch (e) { @@ -469,7 +523,7 @@ let needSetup = false; callback({ ok: false, - msg: e.message + msg: e.message, }); } }); @@ -485,7 +539,7 @@ let needSetup = false; async function updateMonitorNotification(monitorID, notificationIDList) { R.exec("DELETE FROM monitor_notification WHERE monitor_id = ? ", [ - monitorID + monitorID, ]) for (let notificationID in notificationIDList) { @@ -518,7 +572,7 @@ async function sendMonitorList(socket) { async function sendNotificationList(socket) { let result = []; let list = await R.find("notification", " user_id = ? ", [ - socket.userID + socket.userID, ]); for (let bean of list) { @@ -536,19 +590,21 @@ 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) + + socket.emit("autoLogin") } async function getMonitorJSONList(userID) { let result = {}; let monitorList = await R.find("monitor", " user_id = ? ", [ - userID + userID, ]) for (let monitor of monitorList) { @@ -571,8 +627,8 @@ async function initDatabase() { } console.log("Connecting to Database") - R.setup('sqlite', { - filename: Database.path + R.setup("sqlite", { + filename: Database.path, }); console.log("Connected") @@ -584,7 +640,7 @@ async function initDatabase() { await R.autoloadModels("./server/model"); let jwtSecretBean = await R.findOne("setting", " `key` = ? ", [ - "jwtSecret" + "jwtSecret", ]); if (! jwtSecretBean) { @@ -615,11 +671,11 @@ async function startMonitor(userID, monitorID) { await R.exec("UPDATE monitor SET active = 1 WHERE id = ? AND user_id = ? ", [ monitorID, - userID + userID, ]); let monitor = await R.findOne("monitor", " id = ? ", [ - monitorID + monitorID, ]) if (monitor.id in monitorList) { @@ -641,7 +697,7 @@ async function pauseMonitor(userID, monitorID) { await R.exec("UPDATE monitor SET active = 0 WHERE id = ? AND user_id = ? ", [ monitorID, - userID + userID, ]); if (monitorID in monitorList) { @@ -670,13 +726,13 @@ async function sendHeartbeatList(socket, monitorID) { ORDER BY time DESC LIMIT 100 `, [ - monitorID + monitorID, ]) let result = []; for (let bean of list) { - result.unshift(bean.toJSON()) + result.unshift(bean.toJSON()) } socket.emit("heartbeatList", monitorID, result) @@ -689,37 +745,15 @@ async function sendImportantHeartbeatList(socket, monitorID) { ORDER BY time DESC LIMIT 500 `, [ - monitorID + monitorID, ]) socket.emit("importantHeartbeatList", monitorID, list) } - - -const startGracefulShutdown = async () => { - console.log('Shutdown requested'); - - - await (new Promise((resolve) => { - server.close(async function () { - console.log('Stopped Express.'); - process.exit(0) - setTimeout(async () =>{ - await R.close(); - console.log("Stopped DB") - - resolve(); - }, 5000) - - }); - })); - - -} - async function shutdownFunction(signal) { - console.log('Called signal: ' + signal); + console.log("Shutdown requested"); + console.log("Called signal: " + signal); console.log("Stopping all monitors") for (let id in monitorList) { @@ -728,17 +762,18 @@ async function shutdownFunction(signal) { } await sleep(2000); await Database.close(); + console.log("Stopped DB") } function finalFunction() { - console.log('Graceful Shutdown') + console.log("Graceful Shutdown Done") } gracefulShutdown(server, { - signals: 'SIGINT SIGTERM', + signals: "SIGINT SIGTERM", timeout: 30000, // timeout: 30 secs development: false, // not in dev mode forceExit: true, // triggers process.exit() at the end of shutdown process onShutdown: shutdownFunction, // shutdown function (async) - e.g. for cleanup DB, ... - finally: finalFunction // finally function (sync) - e.g. for logging + finally: finalFunction, // finally function (sync) - e.g. for logging }); diff --git a/server/util-server.js b/server/util-server.js index b387f4c7c..1411a3f69 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -1,6 +1,7 @@ -const tcpp = require('tcp-ping'); +const tcpp = require("tcp-ping"); const Ping = require("./ping-lite"); -const {R} = require("redbean-node"); +const { R } = require("redbean-node"); +const { debug } = require("../src/util"); exports.tcping = function (hostname, port) { return new Promise((resolve, reject) => { @@ -40,33 +41,120 @@ exports.ping = function (hostname) { } exports.setting = async function (key) { - return await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [ - key - ]) + let value = await R.getCell("SELECT `value` FROM setting WHERE `key` = ? ", [ + key, + ]); + + try { + const v = JSON.parse(value); + debug(`Get Setting: ${key}: ${v}`) + return v; + } catch (e) { + return value; + } } exports.setSetting = async function (key, value) { let bean = await R.findOne("setting", " `key` = ? ", [ - key + key, ]) if (! bean) { bean = R.dispense("setting") bean.key = key; } - bean.value = value; + bean.value = JSON.stringify(value); await R.store(bean) } exports.getSettings = async function (type) { - let list = await R.getAll("SELECT * FROM setting WHERE `type` = ? ", [ - type + let list = await R.getAll("SELECT `key`, `value` FROM setting WHERE `type` = ? ", [ + type, ]) let result = {}; for (let row of list) { - result[row.key] = row.value; + try { + result[row.key] = JSON.parse(row.value); + } catch (e) { + result[row.key] = row.value; + } } return result; } + +exports.setSettings = async function (type, data) { + let keyList = Object.keys(data); + + let promiseList = []; + + for (let key of keyList) { + let bean = await R.findOne("setting", " `key` = ? ", [ + key + ]); + + if (bean == null) { + bean = R.dispense("setting"); + bean.type = type; + bean.key = key; + } + + if (bean.type === type) { + bean.value = JSON.stringify(data[key]); + promiseList.push(R.store(bean)) + } + } + + await Promise.all(promiseList); +} + +// 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, + }; +} diff --git a/server/util.js b/server/util.js deleted file mode 100644 index 33b306b5c..000000000 --- a/server/util.js +++ /dev/null @@ -1,20 +0,0 @@ -// 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)); -} - -exports.ucfirst = function (str) { - if (! str) { - return str; - } - - const firstLetter = str.substr(0, 1); - return firstLetter.toUpperCase() + str.substr(1); -} - diff --git a/src/App.vue b/src/App.vue index 1f05560e6..a16d42085 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,11 +3,5 @@ - - diff --git a/src/components/Confirm.vue b/src/components/Confirm.vue index 063ece257..b235824be 100644 --- a/src/components/Confirm.vue +++ b/src/components/Confirm.vue @@ -1,17 +1,23 @@ - - diff --git a/src/components/CountUp.vue b/src/components/CountUp.vue index 33904b6a9..b321fde19 100644 --- a/src/components/CountUp.vue +++ b/src/components/CountUp.vue @@ -3,26 +3,22 @@ {{ value }} - - - diff --git a/src/components/Datetime.vue b/src/components/Datetime.vue index e84c877bc..84093afb1 100644 --- a/src/components/Datetime.vue +++ b/src/components/Datetime.vue @@ -5,8 +5,8 @@ - - diff --git a/src/components/HeartbeatBar.vue b/src/components/HeartbeatBar.vue index 03cdceca6..eed132d1c 100644 --- a/src/components/HeartbeatBar.vue +++ b/src/components/HeartbeatBar.vue @@ -1,28 +1,27 @@ diff --git a/src/components/Login.vue b/src/components/Login.vue index 017261ccd..4b08de066 100644 --- a/src/components/Login.vue +++ b/src/components/Login.vue @@ -2,31 +2,32 @@
- -

+

- +
- +
- +
- + -