diff --git a/.eslintrc.js b/.eslintrc.js index 4b60d6728..ef556a346 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -84,7 +84,7 @@ module.exports = { "checkLoops": false, }], "space-before-blocks": "warn", - //'no-console': 'warn', + //"no-console": "warn", "no-extra-boolean-cast": "off", "no-multiple-empty-lines": [ "warn", { "max": 1, @@ -96,7 +96,8 @@ module.exports = { "no-unneeded-ternary": "error", "array-bracket-newline": [ "error", "consistent" ], "eol-last": [ "error", "always" ], - //'prefer-template': 'error', + //"prefer-template": "error", + "template-curly-spacing": [ "warn", "never" ], "comma-dangle": [ "warn", "only-multiline" ], "no-empty": [ "error", { "allowEmptyCatch": true diff --git a/.github/workflows/auto-test.yml b/.github/workflows/auto-test.yml index 227693199..bd60d8b34 100644 --- a/.github/workflows/auto-test.yml +++ b/.github/workflows/auto-test.yml @@ -27,10 +27,10 @@ jobs: steps: - run: git config --global core.autocrlf false # Mainly for Windows - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: npm install npm@9 -g @@ -55,10 +55,10 @@ jobs: steps: - run: git config --global core.autocrlf false # Mainly for Windows - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: npm install npm@9 -g @@ -69,14 +69,14 @@ jobs: steps: - run: git config --global core.autocrlf false # Mainly for Windows - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js 20 - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 - run: npm install - - run: npm run lint + - run: npm run lint:prod # TODO: Temporarily disable, as it cannot pass the test in 2.0.0 yet # e2e-tests: @@ -84,10 +84,10 @@ jobs: # runs-on: ubuntu-latest # steps: # - run: git config --global core.autocrlf false # Mainly for Windows -# - uses: actions/checkout@v3 +# - uses: actions/checkout@v4 # # - name: Use Node.js 14 -# uses: actions/setup-node@v3 +# uses: actions/setup-node@v4 # with: # node-version: 14 # - run: npm install @@ -99,10 +99,10 @@ jobs: runs-on: ubuntu-latest steps: - run: git config --global core.autocrlf false # Mainly for Windows - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js 14 - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 - run: npm install diff --git a/.github/workflows/close-incorrect-issue.yml b/.github/workflows/close-incorrect-issue.yml index 762bc9688..e26cf5e5e 100644 --- a/.github/workflows/close-incorrect-issue.yml +++ b/.github/workflows/close-incorrect-issue.yml @@ -14,10 +14,10 @@ jobs: node-version: [16] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' diff --git a/.github/workflows/json-yaml-validate.yml b/.github/workflows/json-yaml-validate.yml index 104e37a17..b6437ec49 100644 --- a/.github/workflows/json-yaml-validate.yml +++ b/.github/workflows/json-yaml-validate.yml @@ -6,7 +6,7 @@ on: pull_request: branches: - master - - 2.0.X + - 1.23.X workflow_dispatch: permissions: @@ -17,11 +17,11 @@ jobs: json-yaml-validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: json-yaml-validate id: json-yaml-validate - uses: GrantBirki/json-yaml-validate@v1.3.0 + uses: GrantBirki/json-yaml-validate@v2.4.0 with: comment: "true" # enable comment mode exclude_file: ".github/config/exclude.txt" # gitignore style file for exclusions diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index b39f68fc1..4d1cd93ec 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -1,4 +1,4 @@ -name: 'Automatically close stale issues and PRs' +name: 'Automatically close stale issues' on: workflow_dispatch: schedule: @@ -9,14 +9,14 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v7 + - uses: actions/stale@v8 with: stale-issue-message: 'We are clearing up our old issues and your ticket has been open for 3 months with no activity. Remove stale label or comment or this will be closed in 2 days.' close-issue-message: 'This issue was closed because it has been stalled for 2 days with no activity.' days-before-stale: 90 days-before-close: 2 - days-before-pr-stale: 999999999 - days-before-pr-close: 1 + days-before-pr-stale: -1 + days-before-pr-close: -1 exempt-issue-labels: 'News,Medium,High,discussion,bug,doc,feature-request' exempt-issue-assignees: 'louislam' operations-per-run: 200 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 33b7336d1..1712b66c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,12 +75,12 @@ Before deep into coding, discussion first is preferred. Creating an empty pull r 1. Fork the project 2. Clone your fork repo to local 3. Create a new branch -4. Create an empty commit: `git commit -m "[empty commit] pull request for " --allow-empty` +4. Create an empty commit: `git commit -m "" --allow-empty` 5. Push to your fork repo -6. Create a pull request: https://github.com/louislam/uptime-kuma/compare -7. Write a proper description -8. Click "Change to draft" -9. Discussion +6. Prepare a pull request: https://github.com/louislam/uptime-kuma/compare +7. Write a proper description. You can mention @louislam in it, so @louislam will get the notification. +8. Create your pull request as a Draft +9. Wait for the discussion ## Project Styles diff --git a/README.md b/README.md index 1502f392a..c79eefcaa 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,12 @@ I recommend using Google, GitHub Issues, or Uptime Kuma's subreddit for finding My Reddit account: [u/louislamlam](https://reddit.com/u/louislamlam) You can mention me if you ask a question on the subreddit. -## Contribute +## Contributions + +### Create Pull Requests + +We DO NOT accept all types of pull requests and do not want to waste your time. Please be sure that you have read and follow pull request rules: +[CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma](https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md#can-i-create-a-pull-request-for-uptime-kuma) ### Test Pull Requests @@ -183,6 +188,4 @@ If you want to translate Uptime Kuma into your language, please visit [Weblate R Feel free to correct the grammar in the documentation or code. My mother language is not English and my grammar is not that great. -### Create Pull Requests -If you want to modify Uptime Kuma, please read this guide and follow the rules here: https://github.com/louislam/uptime-kuma/blob/master/CONTRIBUTING.md diff --git a/config/vite.config.js b/config/vite.config.js index 5d9c5c1d5..8f2a076fb 100644 --- a/config/vite.config.js +++ b/config/vite.config.js @@ -2,7 +2,6 @@ import vue from "@vitejs/plugin-vue"; import { defineConfig } from "vite"; import visualizer from "rollup-plugin-visualizer"; import viteCompression from "vite-plugin-compression"; -import commonjs from "vite-plugin-commonjs"; const postCssScss = require("postcss-scss"); const postcssRTLCSS = require("postcss-rtlcss"); @@ -21,7 +20,6 @@ export default defineConfig({ "CODESPACE_NAME": JSON.stringify(process.env.CODESPACE_NAME), }, plugins: [ - commonjs(), vue(), visualizer({ filename: "tmp/dist-stats.html" diff --git a/db/knex_migrations/2023-10-08-0000-mqtt-query.js b/db/knex_migrations/2023-10-08-0000-mqtt-query.js new file mode 100644 index 000000000..f37bab832 --- /dev/null +++ b/db/knex_migrations/2023-10-08-0000-mqtt-query.js @@ -0,0 +1,16 @@ +exports.up = function (knex) { + // Add new column monitor.mqtt_check_type + return knex.schema + .alterTable("monitor", function (table) { + table.string("mqtt_check_type", 255).notNullable().defaultTo("keyword"); + }); + +}; + +exports.down = function (knex) { + // Drop column monitor.mqtt_check_type + return knex.schema + .alterTable("monitor", function (table) { + table.dropColumn("mqtt_check_type"); + }); +}; diff --git a/db/knex_migrations/2023-10-16-0000-create-remote-browsers.js b/db/knex_migrations/2023-10-16-0000-create-remote-browsers.js new file mode 100644 index 000000000..c720d3f4a --- /dev/null +++ b/db/knex_migrations/2023-10-16-0000-create-remote-browsers.js @@ -0,0 +1,21 @@ +exports.up = function (knex) { + return knex.schema + .createTable("remote_browser", function (table) { + table.increments("id"); + table.string("name", 255).notNullable(); + table.string("url", 255).notNullable(); + table.integer("user_id").unsigned(); + }).alterTable("monitor", function (table) { + // Add new column monitor.remote_browser + table.integer("remote_browser").nullable().defaultTo(null).unsigned() + .index() + .references("id") + .inTable("remote_browser"); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTable("remote_browser").alterTable("monitor", function (table) { + table.dropColumn("remote_browser"); + }); +}; diff --git a/extra/healthcheck.js b/extra/healthcheck.js index 5e06c2120..c9391c410 100644 --- a/extra/healthcheck.js +++ b/extra/healthcheck.js @@ -6,7 +6,7 @@ * ⚠️ Deprecated: Changed to healthcheck.go, it will be deleted in the future. * This script should be run after a period of time (180s), because the server may need some time to prepare. */ -const { FBSD } = require("../server/util-server"); +const FBSD = /^freebsd/.test(process.platform); process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; diff --git a/extra/reset-password.js b/extra/reset-password.js index 3f6f79c1d..3b40e93e8 100644 --- a/extra/reset-password.js +++ b/extra/reset-password.js @@ -5,6 +5,8 @@ const { R } = require("redbean-node"); const readline = require("readline"); const { initJWTSecret } = require("../server/util-server"); const User = require("../server/model/user"); +const { io } = require("socket.io-client"); +const { localWebSocketURL } = require("../server/config"); const args = require("args-parser")(process.argv); const rl = readline.createInterface({ input: process.stdin, @@ -12,6 +14,10 @@ const rl = readline.createInterface({ }); const main = async () => { + if ("dry-run" in args) { + console.log("Dry run mode, no changes will be made."); + } + console.log("Connecting the database"); Database.initDataDir(args); await Database.connect(false, false, true); @@ -27,21 +33,36 @@ const main = async () => { console.log("Found user: " + user.username); while (true) { - let password = await question("New Password: "); - let confirmPassword = await question("Confirm New Password: "); + let password; + let confirmPassword; + + // When called with "--new-password" argument for unattended modification (e.g. npm run reset-password -- --new_password=secret) + if ("new-password" in args) { + console.log("Using password from argument"); + console.warn("\x1b[31m%s\x1b[0m", "Warning: the password might be stored, in plain text, in your shell's history"); + password = confirmPassword = args["new-password"] + ""; + } else { + password = await question("New Password: "); + confirmPassword = await question("Confirm New Password: "); + } if (password === confirmPassword) { - await User.resetPassword(user.id, password); + if (!("dry-run" in args)) { + await User.resetPassword(user.id, password); - // Reset all sessions by reset jwt secret - await initJWTSecret(); + // Reset all sessions by reset jwt secret + await initJWTSecret(); + // Disconnect all other socket clients of the user + await disconnectAllSocketClients(user.username, password); + } break; } else { console.log("Passwords do not match, please try again."); } } console.log("Password reset successfully."); + } } catch (e) { console.error("Error: " + e.message); @@ -66,6 +87,50 @@ function question(question) { }); } +/** + * Disconnect all socket clients of the user + * @param {string} username Username + * @param {string} password Password + * @returns {Promise} Promise + */ +function disconnectAllSocketClients(username, password) { + return new Promise((resolve) => { + console.log("Connecting to " + localWebSocketURL + " to disconnect all other socket clients"); + + // Disconnect all socket connections + const socket = io(localWebSocketURL, { + reconnection: false, + timeout: 5000, + }); + socket.on("connect", () => { + socket.emit("login", { + username, + password, + }, (res) => { + if (res.ok) { + console.log("Logged in."); + socket.emit("disconnectOtherSocketClients"); + } else { + console.warn("Login failed."); + console.warn("Please restart the server to disconnect all sessions."); + } + socket.close(); + }); + }); + + socket.on("connect_error", function () { + // The localWebSocketURL is not guaranteed to be working for some complicated Uptime Kuma setup + // Ask the user to restart the server manually + console.warn("Failed to connect to " + localWebSocketURL); + console.warn("Please restart the server to disconnect all sessions manually."); + resolve(); + }); + socket.on("disconnect", () => { + resolve(); + }); + }); +} + if (!process.env.TEST_BACKEND) { main(); } diff --git a/package-lock.json b/package-lock.json index d5f1699f6..e82b8ebfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "axios-ntlm": "1.3.0", "badge-maker": "~3.3.1", "bcryptjs": "~2.4.3", - "cacheable-lookup": "~6.0.4", "chardet": "~1.4.0", "check-password-strength": "^2.0.5", "cheerio": "~1.0.0-rc.12", @@ -32,8 +31,9 @@ "express-basic-auth": "~1.2.1", "express-static-gzip": "~2.1.7", "form-data": "~4.0.0", - "gamedig": "~4.1.0", + "gamedig": "^4.2.0", "html-escaper": "^3.0.3", + "http-cookie-agent": "~5.0.4", "http-graceful-shutdown": "~3.1.7", "http-proxy-agent": "~5.0.0", "https-proxy-agent": "~5.0.1", @@ -65,6 +65,7 @@ "playwright-core": "~1.35.1", "prom-client": "~13.2.0", "prometheus-api-metrics": "~3.2.1", + "promisify-child-process": "~4.1.2", "protobufjs": "~7.2.4", "qs": "~6.10.4", "redbean-node": "~0.3.0", @@ -76,6 +77,7 @@ "tar": "~6.1.11", "tcp-ping": "~0.1.1", "thirty-two": "~1.0.2", + "tough-cookie": "~4.1.3", "ws": "^8.13.0" }, "devDependencies": { @@ -125,7 +127,6 @@ "typescript": "~4.4.4", "v-pagination-3": "~0.1.7", "vite": "~4.4.1", - "vite-plugin-commonjs": "^0.8.0", "vite-plugin-compression": "^0.5.1", "vue": "~3.3.4", "vue-chartjs": "~5.2.0", @@ -139,7 +140,7 @@ "vue-router": "~4.0.14", "vue-toastification": "~2.0.0-rc.5", "vuedraggable": "~4.1.0", - "wait-on": "^6.0.1", + "wait-on": "^7.2.0", "whatwg-url": "~12.0.1" }, "engines": { @@ -5850,14 +5851,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "optional": true }, - "node_modules/cacheable-lookup": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz", - "integrity": "sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A==", - "engines": { - "node": ">=10.6.0" - } - }, "node_modules/cacheable-request": { "version": "10.2.14", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", @@ -7492,12 +7485,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-module-lexer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", - "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", - "dev": true - }, "node_modules/es-set-tostringtag": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", @@ -8529,9 +8516,9 @@ } }, "node_modules/gamedig": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/gamedig/-/gamedig-4.1.0.tgz", - "integrity": "sha512-jvLUEakihJgpiw9t9yQRsbcemeALeTNlnaWY1gvYdwI63ZlkxznTaLqX5K/eluRTTCtAWNW3YceT6NVjyAZIwA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/gamedig/-/gamedig-4.2.0.tgz", + "integrity": "sha512-UwV9gT1PrOTxjwGzj9/i8XJLx9QqmzFtrRJnRLqN7fZWEIRHjbuUpx2b6Y3v/5QyRDFP+vaB3Pm0hw3Xg4RnOQ==", "dependencies": { "cheerio": "^1.0.0-rc.10", "gbxremote": "^0.2.1", @@ -9077,6 +9064,44 @@ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" }, + "node_modules/http-cookie-agent": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-5.0.4.tgz", + "integrity": "sha512-OtvikW69RvfyP6Lsequ0fN5R49S+8QcS9zwd58k6VSr6r57T8G29BkPdyrBcSwLq6ExLs9V+rBlfxu7gDstJag==", + "dependencies": { + "agent-base": "^7.1.0" + }, + "engines": { + "node": ">=14.18.0 <15.0.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/3846masa" + }, + "peerDependencies": { + "deasync": "^0.1.26", + "tough-cookie": "^4.0.0", + "undici": "^5.11.0" + }, + "peerDependenciesMeta": { + "deasync": { + "optional": true + }, + "undici": { + "optional": true + } + } + }, + "node_modules/http-cookie-agent/node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http-errors": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", @@ -12943,6 +12968,14 @@ "node": ">=10" } }, + "node_modules/promisify-child-process": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/promisify-child-process/-/promisify-child-process-4.1.2.tgz", + "integrity": "sha512-APnkIgmaHNJpkAn7k+CrJSi9WMuff5ctYFbD0CO2XIPkM8yO7d/ShouU2clywbpHV/DUsyc4bpJCsNgddNtx4g==", + "engines": { + "node": ">=8" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -13000,8 +13033,7 @@ "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" }, "node_modules/pump": { "version": "3.0.0", @@ -13155,8 +13187,7 @@ "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -13640,8 +13671,7 @@ "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, "node_modules/resolve": { "version": "1.22.8", @@ -15320,7 +15350,6 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", - "dev": true, "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", @@ -15335,7 +15364,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, "engines": { "node": ">= 4.0.0" } @@ -15670,7 +15698,6 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -15826,18 +15853,6 @@ } } }, - "node_modules/vite-plugin-commonjs": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/vite-plugin-commonjs/-/vite-plugin-commonjs-0.8.2.tgz", - "integrity": "sha512-UJlvuioutS7Tno3p3Dqxkr0G4xXt8ILYsJzOiyvFlEsyAxFRofsBGHN/Sl15q0Y4vtvvC7+QZCc6GuUxOM6Cmg==", - "dev": true, - "dependencies": { - "acorn": "^8.8.2", - "fast-glob": "^3.2.12", - "magic-string": "^0.30.1", - "vite-plugin-dynamic-import": "^1.5.0" - } - }, "node_modules/vite-plugin-compression": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/vite-plugin-compression/-/vite-plugin-compression-0.5.1.tgz", @@ -15866,18 +15881,6 @@ "node": ">=12" } }, - "node_modules/vite-plugin-dynamic-import": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/vite-plugin-dynamic-import/-/vite-plugin-dynamic-import-1.5.0.tgz", - "integrity": "sha512-Qp85c+AVJmLa8MLni74U4BDiWpUeFNx7NJqbGZyR2XJOU7mgW0cb7nwlAMucFyM4arEd92Nfxp4j44xPi6Fu7g==", - "dev": true, - "dependencies": { - "acorn": "^8.8.2", - "es-module-lexer": "^1.2.1", - "fast-glob": "^3.2.12", - "magic-string": "^0.30.1" - } - }, "node_modules/vue": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.8.tgz", @@ -16083,31 +16086,33 @@ } }, "node_modules/wait-on": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-6.0.1.tgz", - "integrity": "sha512-zht+KASY3usTY5u2LgaNqn/Cd8MukxLGjdcZxT2ns5QzDmTFc4XoWBgC+C/na+sMRZTuVygQoMYwdcVjHnYIVw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", + "integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==", "dev": true, "dependencies": { - "axios": "^0.25.0", - "joi": "^17.6.0", + "axios": "^1.6.1", + "joi": "^17.11.0", "lodash": "^4.17.21", - "minimist": "^1.2.5", - "rxjs": "^7.5.4" + "minimist": "^1.2.8", + "rxjs": "^7.8.1" }, "bin": { "wait-on": "bin/wait-on" }, "engines": { - "node": ">=10.0.0" + "node": ">=12.0.0" } }, "node_modules/wait-on/node_modules/axios": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", - "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", + "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", "dev": true, "dependencies": { - "follow-redirects": "^1.14.7" + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" } }, "node_modules/wait-on/node_modules/joi": { @@ -16123,6 +16128,12 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/wait-on/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/package.json b/package.json index 3d4ac3fb0..b73b015fb 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ }, "scripts": { "lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .", + "lint:js-prod": "npm run lint:js -- --max-warnings 0", "lint-fix:js": "eslint --ext \".js,.vue\" --fix --ignore-path .gitignore .", "lint:style": "stylelint \"**/*.{vue,css,scss}\" --ignore-path .gitignore", "lint-fix:style": "stylelint \"**/*.{vue,css,scss}\" --fix --ignore-path .gitignore", "lint": "npm run lint:js && npm run lint:style", + "lint:prod": "npm run lint:js-prod && npm run lint:style", "dev": "concurrently -k -r \"wait-on tcp:3000 && npm run start-server-dev \" \"npm run start-frontend-dev\"", "start-frontend-dev": "cross-env NODE_ENV=development vite --host --config ./config/vite.config.js", "start-frontend-devcontainer": "cross-env NODE_ENV=development DEVCONTAINER=1 vite --host --config ./config/vite.config.js", @@ -44,7 +46,7 @@ "build-docker-nightly-local": "npm run build && docker build -f docker/dockerfile -t louislam/uptime-kuma:nightly2 --target nightly .", "build-docker-pr-test": "docker buildx build -f docker/dockerfile --platform linux/amd64,linux/arm64 -t louislam/uptime-kuma:pr-test2 --target pr-test2 . --push", "upload-artifacts": "docker buildx build -f docker/dockerfile --platform linux/amd64 -t louislam/uptime-kuma:upload-artifact --build-arg VERSION --build-arg GITHUB_TOKEN --target upload-artifact . --progress plain", - "setup": "git checkout 1.23.7 && npm ci --production && npm run download-dist", + "setup": "git checkout 1.23.10 && npm ci --production && npm run download-dist", "download-dist": "node extra/download-dist.js", "mark-as-nightly": "node extra/mark-as-nightly.js", "reset-password": "node extra/reset-password.js", @@ -81,7 +83,6 @@ "axios-ntlm": "1.3.0", "badge-maker": "~3.3.1", "bcryptjs": "~2.4.3", - "cacheable-lookup": "~6.0.4", "chardet": "~1.4.0", "check-password-strength": "^2.0.5", "cheerio": "~1.0.0-rc.12", @@ -96,7 +97,8 @@ "express-basic-auth": "~1.2.1", "express-static-gzip": "~2.1.7", "form-data": "~4.0.0", - "gamedig": "~4.1.0", + "gamedig": "^4.2.0", + "http-cookie-agent": "~5.0.4", "html-escaper": "^3.0.3", "http-graceful-shutdown": "~3.1.7", "http-proxy-agent": "~5.0.0", @@ -129,6 +131,7 @@ "playwright-core": "~1.35.1", "prom-client": "~13.2.0", "prometheus-api-metrics": "~3.2.1", + "promisify-child-process": "~4.1.2", "protobufjs": "~7.2.4", "qs": "~6.10.4", "redbean-node": "~0.3.0", @@ -140,6 +143,7 @@ "tar": "~6.1.11", "tcp-ping": "~0.1.1", "thirty-two": "~1.0.2", + "tough-cookie": "~4.1.3", "ws": "^8.13.0" }, "devDependencies": { @@ -189,7 +193,6 @@ "typescript": "~4.4.4", "v-pagination-3": "~0.1.7", "vite": "~4.4.1", - "vite-plugin-commonjs": "^0.8.0", "vite-plugin-compression": "^0.5.1", "vue": "~3.3.4", "vue-chartjs": "~5.2.0", @@ -203,7 +206,7 @@ "vue-router": "~4.0.14", "vue-toastification": "~2.0.0-rc.5", "vuedraggable": "~4.1.0", - "wait-on": "^6.0.1", + "wait-on": "^7.2.0", "whatwg-url": "~12.0.1" } } diff --git a/public/icon.svg b/public/icon.svg index bad8394c2..c4217915d 100644 --- a/public/icon.svg +++ b/public/icon.svg @@ -1,10 +1,9 @@ - - - - - + + + - + + diff --git a/server/cacheable-dns-http-agent.js b/server/cacheable-dns-http-agent.js deleted file mode 100644 index bcc955689..000000000 --- a/server/cacheable-dns-http-agent.js +++ /dev/null @@ -1,88 +0,0 @@ -const https = require("https"); -const http = require("http"); -const CacheableLookup = require("cacheable-lookup"); -const { Settings } = require("./settings"); -const { log } = require("../src/util"); - -class CacheableDnsHttpAgent { - - static cacheable = new CacheableLookup(); - - static httpAgentList = {}; - static httpsAgentList = {}; - - static enable = false; - - /** - * Register/Disable cacheable to global agents - * @returns {void} - */ - static async update() { - log.debug("CacheableDnsHttpAgent", "update"); - let isEnable = await Settings.get("dnsCache"); - - if (isEnable !== this.enable) { - log.debug("CacheableDnsHttpAgent", "value changed"); - - if (isEnable) { - log.debug("CacheableDnsHttpAgent", "enable"); - this.cacheable.install(http.globalAgent); - this.cacheable.install(https.globalAgent); - } else { - log.debug("CacheableDnsHttpAgent", "disable"); - this.cacheable.uninstall(http.globalAgent); - this.cacheable.uninstall(https.globalAgent); - } - } - - this.enable = isEnable; - } - - /** - * Attach cacheable to HTTP agent - * @param {http.Agent} agent Agent to install - * @returns {void} - */ - static install(agent) { - this.cacheable.install(agent); - } - - /** - * @param {https.AgentOptions} agentOptions Options to pass to HTTPS agent - * @returns {https.Agent} The new HTTPS agent - */ - static getHttpsAgent(agentOptions) { - if (!this.enable) { - return new https.Agent(agentOptions); - } - - let key = JSON.stringify(agentOptions); - if (!(key in this.httpsAgentList)) { - this.httpsAgentList[key] = new https.Agent(agentOptions); - this.cacheable.install(this.httpsAgentList[key]); - } - return this.httpsAgentList[key]; - } - - /** - * @param {http.AgentOptions} agentOptions Options to pass to the HTTP agent - * @returns {https.Agents} The new HTTP agent - */ - static getHttpAgent(agentOptions) { - if (!this.enable) { - return new http.Agent(agentOptions); - } - - let key = JSON.stringify(agentOptions); - if (!(key in this.httpAgentList)) { - this.httpAgentList[key] = new http.Agent(agentOptions); - this.cacheable.install(this.httpAgentList[key]); - } - return this.httpAgentList[key]; - } - -} - -module.exports = { - CacheableDnsHttpAgent, -}; diff --git a/server/client.js b/server/client.js index d03065f36..260e77a73 100644 --- a/server/client.js +++ b/server/client.js @@ -185,6 +185,30 @@ async function sendDockerHostList(socket) { return list; } +/** + * Send list of docker hosts to client + * @param {Socket} socket Socket.io socket instance + * @returns {Promise} List of docker hosts + */ +async function sendRemoteBrowserList(socket) { + const timeLogger = new TimeLogger(); + + let result = []; + let list = await R.find("remote_browser", " user_id = ? ", [ + socket.userID, + ]); + + for (let bean of list) { + result.push(bean.toJSON()); + } + + io.to(socket.userID).emit("remoteBrowserList", result); + + timeLogger.print("Send Remote Browser List"); + + return list; +} + module.exports = { sendNotificationList, sendImportantHeartbeatList, @@ -192,5 +216,6 @@ module.exports = { sendProxyList, sendAPIKeyList, sendInfo, - sendDockerHostList + sendDockerHostList, + sendRemoteBrowserList, }; diff --git a/server/config.js b/server/config.js index 77f9e74b3..515b90465 100644 --- a/server/config.js +++ b/server/config.js @@ -1,29 +1,46 @@ +const isFreeBSD = /^freebsd/.test(process.platform); + // Interop with browser const args = (typeof process !== "undefined") ? require("args-parser")(process.argv) : {}; -const demoMode = args["demo"] || false; -const badgeConstants = { - naColor: "#999", - defaultUpColor: "#66c20a", - defaultWarnColor: "#eed202", - defaultDownColor: "#c2290a", - defaultPendingColor: "#f8a306", - defaultMaintenanceColor: "#1747f5", - defaultPingColor: "blue", // as defined by badge-maker / shields.io - defaultStyle: "flat", - defaultPingValueSuffix: "ms", - defaultPingLabelSuffix: "h", - defaultUptimeValueSuffix: "%", - defaultUptimeLabelSuffix: "h", - defaultCertExpValueSuffix: " days", - defaultCertExpLabelSuffix: "h", - // Values Come From Default Notification Times - defaultCertExpireWarnDays: "14", - defaultCertExpireDownDays: "7" -}; +// If host is omitted, the server will accept connections on the unspecified IPv6 address (::) when IPv6 is available and the unspecified IPv4 address (0.0.0.0) otherwise. +// Dual-stack support for (::) +// Also read HOST if not FreeBSD, as HOST is a system environment variable in FreeBSD +let hostEnv = isFreeBSD ? null : process.env.HOST; +const hostname = args.host || process.env.UPTIME_KUMA_HOST || hostEnv; + +const port = [ args.port, process.env.UPTIME_KUMA_PORT, process.env.PORT, 3001 ] + .map(portValue => parseInt(portValue)) + .find(portValue => !isNaN(portValue)); + +const sslKey = args["ssl-key"] || process.env.UPTIME_KUMA_SSL_KEY || process.env.SSL_KEY || undefined; +const sslCert = args["ssl-cert"] || process.env.UPTIME_KUMA_SSL_CERT || process.env.SSL_CERT || undefined; +const sslKeyPassphrase = args["ssl-key-passphrase"] || process.env.UPTIME_KUMA_SSL_KEY_PASSPHRASE || process.env.SSL_KEY_PASSPHRASE || undefined; + +const isSSL = sslKey && sslCert; + +/** + * Get the local WebSocket URL + * @returns {string} The local WebSocket URL + */ +function getLocalWebSocketURL() { + const protocol = isSSL ? "wss" : "ws"; + const host = hostname || "localhost"; + return `${protocol}://${host}:${port}`; +} + +const localWebSocketURL = getLocalWebSocketURL(); + +const demoMode = args["demo"] || false; module.exports = { args, + hostname, + port, + sslKey, + sslCert, + sslKeyPassphrase, + isSSL, + localWebSocketURL, demoMode, - badgeConstants, }; diff --git a/server/database.js b/server/database.js index 4162e3317..4a12746f5 100644 --- a/server/database.js +++ b/server/database.js @@ -263,7 +263,14 @@ class Database { user: dbConfig.username, password: dbConfig.password, database: dbConfig.dbName, - timezone: "+00:00", + timezone: "Z", + typeCast: function (field, next) { + if (field.type === "DATETIME") { + // Do not perform timezone conversion + return field.string(); + } + return next(); + }, }, pool: mariadbPoolConfig, }; @@ -277,6 +284,14 @@ class Database { socketPath: embeddedMariaDB.socketPath, user: "node", database: "kuma", + timezone: "Z", + typeCast: function (field, next) { + if (field.type === "DATETIME") { + // Do not perform timezone conversion + return field.string(); + } + return next(); + }, }, pool: mariadbPoolConfig, }; diff --git a/server/model/monitor.js b/server/model/monitor.js index 1c36e9a0c..87a6f2753 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -1,11 +1,10 @@ -const https = require("https"); const dayjs = require("dayjs"); const axios = require("axios"); const { Prometheus } = require("../prometheus"); const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MIN_INTERVAL_SECOND, SQL_DATETIME_FORMAT } = require("../../src/util"); -const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, mqttAsync, setSetting, httpNtlm, radius, grpcQuery, +const { tcping, ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, postgresQuery, mysqlQuery, setSetting, httpNtlm, radius, grpcQuery, redisPingAsync, mongodbPing, kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal } = require("../util-server"); const { R } = require("redbean-node"); @@ -16,13 +15,16 @@ const { demoMode } = require("../config"); const version = require("../../package.json").version; const apicache = require("../modules/apicache"); const { UptimeKumaServer } = require("../uptime-kuma-server"); -const { CacheableDnsHttpAgent } = require("../cacheable-dns-http-agent"); const { DockerHost } = require("../docker"); const Gamedig = require("gamedig"); const jsonata = require("jsonata"); const jwt = require("jsonwebtoken"); const crypto = require("crypto"); const { UptimeCalculator } = require("../uptime-calculator"); +const { CookieJar } = require("tough-cookie"); +const { HttpsCookieAgent } = require("http-cookie-agent/http"); +const https = require("https"); +const http = require("http"); const rootCertificates = rootCertificatesFingerprints(); @@ -133,6 +135,7 @@ class Monitor extends BeanModel { maintenance: await Monitor.isUnderMaintenance(this.id), mqttTopic: this.mqttTopic, mqttSuccessMessage: this.mqttSuccessMessage, + mqttCheckType: this.mqttCheckType, databaseQuery: this.databaseQuery, authMethod: this.authMethod, slowResponseNotification: this.isEnabledSlowResponseNotification(), @@ -160,6 +163,7 @@ class Monitor extends BeanModel { kafkaProducerAllowAutoTopicCreation: this.getKafkaProducerAllowAutoTopicCreation(), kafkaProducerMessage: this.kafkaProducerMessage, screenshot, + remote_browser: this.remote_browser, }; if (includeSensitiveData) { @@ -523,7 +527,12 @@ class Monitor extends BeanModel { } if (!options.httpsAgent) { - options.httpsAgent = new https.Agent(httpsAgentOptions); + let jar = new CookieJar(); + let httpsCookieAgentOptions = { + ...httpsAgentOptions, + cookies: { jar } + }; + options.httpsAgent = new HttpsCookieAgent(httpsCookieAgentOptions); } if (this.auth_method === "mtls") { @@ -675,12 +684,12 @@ class Monitor extends BeanModel { headers: { "Accept": "*/*", }, - httpsAgent: CacheableDnsHttpAgent.getHttpsAgent({ + httpsAgent: new https.Agent({ maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) rejectUnauthorized: !this.getIgnoreTls(), secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, }), - httpAgent: CacheableDnsHttpAgent.getHttpAgent({ + httpAgent: new http.Agent({ maxCachedSessions: 0, }), maxRedirects: this.maxredirects, @@ -727,12 +736,12 @@ class Monitor extends BeanModel { headers: { "Accept": "*/*", }, - httpsAgent: CacheableDnsHttpAgent.getHttpsAgent({ + httpsAgent: new https.Agent({ maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) rejectUnauthorized: !this.getIgnoreTls(), secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, }), - httpAgent: CacheableDnsHttpAgent.getHttpAgent({ + httpAgent: new http.Agent({ maxCachedSessions: 0, }), }; @@ -747,7 +756,7 @@ class Monitor extends BeanModel { options.socketPath = dockerHost._dockerDaemon; } else if (dockerHost._dockerType === "tcp") { options.baseURL = DockerHost.patchDockerURL(dockerHost._dockerDaemon); - options.httpsAgent = CacheableDnsHttpAgent.getHttpsAgent( + options.httpsAgent = new https.Agent( DockerHost.getHttpsAgentOptions(dockerHost._dockerType, options.baseURL) ); } @@ -766,14 +775,6 @@ class Monitor extends BeanModel { } else { throw Error("Container State is " + res.data.State.Status); } - } else if (this.type === "mqtt") { - bean.msg = await mqttAsync(this.hostname, this.mqttTopic, this.mqttSuccessMessage, { - port: this.port, - username: this.mqttUsername, - password: this.mqttPassword, - interval: this.interval, - }); - bean.status = UP; } else if (this.type === "sqlserver") { let startTime = dayjs().valueOf(); diff --git a/server/model/remote_browser.js b/server/model/remote_browser.js new file mode 100644 index 000000000..49299ad4f --- /dev/null +++ b/server/model/remote_browser.js @@ -0,0 +1,17 @@ +const { BeanModel } = require("redbean-node/dist/bean-model"); + +class RemoteBrowser extends BeanModel { + /** + * Returns an object that ready to parse to JSON + * @returns {object} Object ready to parse + */ + toJSON() { + return { + id: this.id, + url: this.url, + name: this.name, + }; + } +} + +module.exports = RemoteBrowser; diff --git a/server/model/status_page.js b/server/model/status_page.js index e6095a63c..06fa7a685 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -21,6 +21,12 @@ class StatusPage extends BeanModel { * @returns {void} */ static async handleStatusPageResponse(response, indexHTML, slug) { + // Handle url with trailing slash (http://localhost:3001/status/) + // The slug comes from the route "/status/:slug". If the slug is empty, express converts it to "index.html" + if (slug === "index.html") { + slug = "default"; + } + let statusPage = await R.findOne("status_page", " slug = ? ", [ slug ]); diff --git a/server/monitor-types/mqtt.js b/server/monitor-types/mqtt.js new file mode 100644 index 000000000..cff5c93d7 --- /dev/null +++ b/server/monitor-types/mqtt.js @@ -0,0 +1,121 @@ +const { MonitorType } = require("./monitor-type"); +const { log, UP } = require("../../src/util"); +const mqtt = require("mqtt"); +const jsonata = require("jsonata"); + +class MqttMonitorType extends MonitorType { + + name = "mqtt"; + + /** + * Run the monitoring check on the MQTT monitor + * @param {Monitor} monitor Monitor to check + * @param {Heartbeat} heartbeat Monitor heartbeat to update + * @param {UptimeKumaServer} server Uptime Kuma server + * @returns {Promise} + */ + async check(monitor, heartbeat, server) { + const receivedMessage = await this.mqttAsync(monitor.hostname, monitor.mqttTopic, { + port: monitor.port, + username: monitor.mqttUsername, + password: monitor.mqttPassword, + interval: monitor.interval, + }); + + if (monitor.mqttCheckType == null || monitor.mqttCheckType === "") { + // use old default + monitor.mqttCheckType = "keyword"; + } + + if (monitor.mqttCheckType === "keyword") { + if (receivedMessage != null && receivedMessage.includes(monitor.mqttSuccessMessage)) { + heartbeat.msg = `Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`; + heartbeat.status = UP; + } else { + throw Error(`Message Mismatch - Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`); + } + } else if (monitor.mqttCheckType === "json-query") { + const parsedMessage = JSON.parse(receivedMessage); + + let expression = jsonata(monitor.jsonPath); + + let result = await expression.evaluate(parsedMessage); + + if (result?.toString() === monitor.expectedValue) { + heartbeat.msg = "Message received, expected value is found"; + heartbeat.status = UP; + } else { + throw new Error("Message received but value is not equal to expected value, value was: [" + result + "]"); + } + } else { + throw Error("Unknown MQTT Check Type"); + } + } + + /** + * Connect to MQTT Broker, subscribe to topic and receive message as String + * @param {string} hostname Hostname / address of machine to test + * @param {string} topic MQTT topic + * @param {object} options MQTT options. Contains port, username, + * password and interval (interval defaults to 20) + * @returns {Promise} Received MQTT message + */ + mqttAsync(hostname, topic, options = {}) { + return new Promise((resolve, reject) => { + const { port, username, password, interval = 20 } = options; + + // Adds MQTT protocol to the hostname if not already present + if (!/^(?:http|mqtt|ws)s?:\/\//.test(hostname)) { + hostname = "mqtt://" + hostname; + } + + const timeoutID = setTimeout(() => { + log.debug("mqtt", "MQTT timeout triggered"); + client.end(); + reject(new Error("Timeout, Message not received")); + }, interval * 1000 * 0.8); + + const mqttUrl = `${hostname}:${port}`; + + log.debug("mqtt", `MQTT connecting to ${mqttUrl}`); + + let client = mqtt.connect(mqttUrl, { + username, + password + }); + + client.on("connect", () => { + log.debug("mqtt", "MQTT connected"); + + try { + client.subscribe(topic, () => { + log.debug("mqtt", "MQTT subscribed to topic"); + }); + } catch (e) { + client.end(); + clearTimeout(timeoutID); + reject(new Error("Cannot subscribe topic")); + } + }); + + client.on("error", (error) => { + client.end(); + clearTimeout(timeoutID); + reject(error); + }); + + client.on("message", (messageTopic, message) => { + if (messageTopic === topic) { + client.end(); + clearTimeout(timeoutID); + resolve(message.toString("utf8")); + } + }); + + }); + } +} + +module.exports = { + MqttMonitorType, +}; diff --git a/server/monitor-types/real-browser-monitor-type.js b/server/monitor-types/real-browser-monitor-type.js index 4003c68eb..c03d36ca6 100644 --- a/server/monitor-types/real-browser-monitor-type.js +++ b/server/monitor-types/real-browser-monitor-type.js @@ -8,6 +8,7 @@ const path = require("path"); const Database = require("../database"); const jwt = require("jsonwebtoken"); const config = require("../config"); +const { RemoteBrowser } = require("../remote-browser"); let browser = null; @@ -43,6 +44,7 @@ if (process.platform === "win32") { "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome", + "/snap/bin/chromium", // Ubuntu ]; } else if (process.platform === "darwin") { allowedList = [ @@ -85,6 +87,19 @@ async function getBrowser() { return browser; } +/** + * Get the current instance of the browser. If there isn't one, create it + * @param {integer} remoteBrowserID Path to executable + * @param {integer} userId User ID + * @returns {Promise} The browser + */ +async function getRemoteBrowser(remoteBrowserID, userId) { + let remoteBrowser = await RemoteBrowser.get(remoteBrowserID, userId); + log.debug("MONITOR", `Using remote browser: ${remoteBrowser.name} (${remoteBrowser.id})`); + browser = chromium.connect(remoteBrowser.url); + return browser; +} + /** * Prepare the chrome executable path * @param {string} executablePath Path to chrome executable @@ -191,11 +206,21 @@ async function testChrome(executablePath) { throw new Error(e.message); } } - +// test remote browser /** - * TODO: connect remote browser? https://playwright.dev/docs/api/class-browsertype#browser-type-connect - * + * @param {string} remoteBrowserURL Remote Browser URL + * @returns {Promise} Returns if connection worked */ +async function testRemoteBrowser(remoteBrowserURL) { + try { + const browser = await chromium.connect(remoteBrowserURL); + browser.version(); + await browser.close(); + return true; + } catch (e) { + throw new Error(e.message); + } +} class RealBrowserMonitorType extends MonitorType { name = "real-browser"; @@ -204,7 +229,7 @@ class RealBrowserMonitorType extends MonitorType { * @inheritdoc */ async check(monitor, heartbeat, server) { - const browser = await getBrowser(); + const browser = monitor.remote_browser ? await getRemoteBrowser(monitor.remote_browser, monitor.user_id) : await getBrowser(); const context = await browser.newContext(); const page = await context.newPage(); @@ -237,4 +262,5 @@ module.exports = { RealBrowserMonitorType, testChrome, resetChrome, + testRemoteBrowser, }; diff --git a/server/monitor-types/tailscale-ping.js b/server/monitor-types/tailscale-ping.js index 2f26894fc..1549ac4ef 100644 --- a/server/monitor-types/tailscale-ping.js +++ b/server/monitor-types/tailscale-ping.js @@ -1,6 +1,6 @@ const { MonitorType } = require("./monitor-type"); const { UP } = require("../../src/util"); -const childProcess = require("child_process"); +const childProcessAsync = require("promisify-child-process"); /** * A TailscalePing class extends the MonitorType. @@ -37,12 +37,9 @@ class TailscalePing extends MonitorType { */ async runTailscalePing(hostname, interval) { let timeout = interval * 1000 * 0.8; - let res = childProcess.spawnSync("tailscale", [ "ping", hostname ], { + let res = await childProcessAsync.spawn("tailscale", [ "ping", "--c", "1", hostname ], { timeout: timeout }); - if (res.error) { - throw new Error(`Execution error: ${res.error.message}`); - } if (res.stderr && res.stderr.toString()) { throw new Error(`Error in output: ${res.stderr.toString()}`); } diff --git a/server/notification-providers/apprise.js b/server/notification-providers/apprise.js index b624b9101..811993585 100644 --- a/server/notification-providers/apprise.js +++ b/server/notification-providers/apprise.js @@ -1,5 +1,5 @@ const NotificationProvider = require("./notification-provider"); -const childProcess = require("child_process"); +const childProcessAsync = require("promisify-child-process"); class Apprise extends NotificationProvider { @@ -14,7 +14,7 @@ class Apprise extends NotificationProvider { args.push("-t"); args.push(notification.title); } - const s = childProcess.spawnSync("apprise", args); + const s = await childProcessAsync.spawn("apprise", args); const output = (s.stdout) ? s.stdout.toString() : "ERROR: maybe apprise not found"; diff --git a/server/notification-providers/mattermost.js b/server/notification-providers/mattermost.js index 9cbb51bbf..5a6a8e2c5 100644 --- a/server/notification-providers/mattermost.js +++ b/server/notification-providers/mattermost.js @@ -78,12 +78,12 @@ class Mattermost extends NotificationProvider { { fallback: "Your " + - monitorJSON.name + + monitorJSON.pathName + " service went " + statusText, color: color, title: - monitorJSON.name + + monitorJSON.pathName + " service went " + statusText, title_link: monitorJSON.url, diff --git a/server/proxy.js b/server/proxy.js index a0b4378d1..3f3771ab9 100644 --- a/server/proxy.js +++ b/server/proxy.js @@ -4,6 +4,8 @@ const HttpsProxyAgent = require("https-proxy-agent"); const SocksProxyAgent = require("socks-proxy-agent"); const { debug } = require("../src/util"); const { UptimeKumaServer } = require("./uptime-kuma-server"); +const { CookieJar } = require("tough-cookie"); +const { createCookieAgent } = require("http-cookie-agent/http"); class Proxy { @@ -95,10 +97,13 @@ class Proxy { let httpAgent; let httpsAgent; + let jar = new CookieJar(); + const proxyOptions = { protocol: proxy.protocol, host: proxy.host, port: proxy.port, + cookies: { jar }, }; if (proxy.auth) { @@ -112,12 +117,17 @@ class Proxy { switch (proxy.protocol) { case "http": case "https": - httpAgent = new HttpProxyAgent({ + // eslint-disable-next-line no-case-declarations + const HttpCookieProxyAgent = createCookieAgent(HttpProxyAgent); + // eslint-disable-next-line no-case-declarations + const HttpsCookieProxyAgent = createCookieAgent(HttpsProxyAgent); + + httpAgent = new HttpCookieProxyAgent({ ...httpAgentOptions || {}, - ...proxyOptions + ...proxyOptions, }); - httpsAgent = new HttpsProxyAgent({ + httpsAgent = new HttpsCookieProxyAgent({ ...httpsAgentOptions || {}, ...proxyOptions, }); @@ -126,7 +136,9 @@ class Proxy { case "socks5": case "socks5h": case "socks4": - agent = new SocksProxyAgent({ + // eslint-disable-next-line no-case-declarations + const SocksCookieProxyAgent = createCookieAgent(SocksProxyAgent); + agent = new SocksCookieProxyAgent({ ...httpAgentOptions, ...httpsAgentOptions, ...proxyOptions, diff --git a/server/remote-browser.js b/server/remote-browser.js new file mode 100644 index 000000000..da8e9a5ec --- /dev/null +++ b/server/remote-browser.js @@ -0,0 +1,74 @@ +const { R } = require("redbean-node"); + +class RemoteBrowser { + + /** + * Gets remote browser from ID + * @param {number} remoteBrowserID ID of the remote browser + * @param {number} userID ID of the user who created the remote browser + * @returns {Promise} Remote Browser + */ + static async get(remoteBrowserID, userID) { + let bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]); + + if (!bean) { + throw new Error("Remote browser not found"); + } + + return bean; + } + + /** + * Save a Remote Browser + * @param {object} remoteBrowser Remote Browser to save + * @param {?number} remoteBrowserID ID of the Remote Browser to update + * @param {number} userID ID of the user who adds the Remote Browser + * @returns {Promise} Updated Remote Browser + */ + static async save(remoteBrowser, remoteBrowserID, userID) { + let bean; + + if (remoteBrowserID) { + bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]); + + if (!bean) { + throw new Error("Remote browser not found"); + } + + } else { + bean = R.dispense("remote_browser"); + } + + bean.user_id = userID; + bean.name = remoteBrowser.name; + bean.url = remoteBrowser.url; + + await R.store(bean); + + return bean; + } + + /** + * Delete a Remote Browser + * @param {number} remoteBrowserID ID of the Remote Browser to delete + * @param {number} userID ID of the user who created the Remote Browser + * @returns {Promise} + */ + static async delete(remoteBrowserID, userID) { + let bean = await R.findOne("remote_browser", " id = ? AND user_id = ? ", [ remoteBrowserID, userID ]); + + if (!bean) { + throw new Error("Remote Browser not found"); + } + + // Delete removed remote browser from monitors if exists + await R.exec("UPDATE monitor SET remote_browser = null WHERE remote_browser = ?", [ remoteBrowserID ]); + + await R.trash(bean); + } + +} + +module.exports = { + RemoteBrowser, +}; diff --git a/server/routers/api-router.js b/server/routers/api-router.js index 7b14a6dac..b63b5123b 100644 --- a/server/routers/api-router.js +++ b/server/routers/api-router.js @@ -11,11 +11,10 @@ const { R } = require("redbean-node"); const apicache = require("../modules/apicache"); const Monitor = require("../model/monitor"); const dayjs = require("dayjs"); -const { UP, MAINTENANCE, DOWN, PENDING, flipStatus, log } = require("../../src/util"); +const { UP, MAINTENANCE, DOWN, PENDING, flipStatus, log, badgeConstants } = require("../../src/util"); const StatusPage = require("../model/status_page"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const { makeBadge } = require("badge-maker"); -const { badgeConstants } = require("../config"); const { Prometheus } = require("../prometheus"); const Database = require("../database"); const { UptimeCalculator } = require("../uptime-calculator"); diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js index 86d2af9a9..29acaf8ea 100644 --- a/server/routers/status-page-router.js +++ b/server/routers/status-page-router.js @@ -4,7 +4,7 @@ const { UptimeKumaServer } = require("../uptime-kuma-server"); const StatusPage = require("../model/status_page"); const { allowDevAllOrigin, sendHttpError } = require("../util-server"); const { R } = require("redbean-node"); -const { badgeConstants } = require("../config"); +const { badgeConstants } = require("../../src/util"); const { makeBadge } = require("badge-maker"); const { UptimeCalculator } = require("../uptime-calculator"); diff --git a/server/server.js b/server/server.js index 91bd7e3ea..7ae3b6249 100644 --- a/server/server.js +++ b/server/server.js @@ -38,6 +38,7 @@ if (!semver.satisfies(nodeVersion, requiredNodeVersions)) { const args = require("args-parser")(process.argv); const { sleep, log, getRandomInt, genSecret, isDev } = require("../src/util"); +const config = require("./config"); log.debug("server", "Arguments"); log.debug("server", args); @@ -46,9 +47,17 @@ if (! process.env.NODE_ENV) { process.env.NODE_ENV = "production"; } +if (!process.env.UPTIME_KUMA_WS_ORIGIN_CHECK) { + process.env.UPTIME_KUMA_WS_ORIGIN_CHECK = "cors-like"; +} + log.info("server", "Env: " + process.env.NODE_ENV); log.debug("server", "Inside Container: " + (process.env.UPTIME_KUMA_IS_CONTAINER === "1")); +if (process.env.UPTIME_KUMA_WS_ORIGIN_CHECK === "bypass") { + log.warn("server", "WebSocket Origin Check: " + process.env.UPTIME_KUMA_WS_ORIGIN_CHECK); +} + const checkVersion = require("./check-version"); log.info("server", "Uptime Kuma Version: " + checkVersion.version); @@ -72,8 +81,7 @@ const notp = require("notp"); const base32 = require("thirty-two"); const { UptimeKumaServer } = require("./uptime-kuma-server"); - -const server = UptimeKumaServer.getInstance(args); +const server = UptimeKumaServer.getInstance(); const io = module.exports.io = server.io; const app = server.app; @@ -82,7 +90,7 @@ const Monitor = require("./model/monitor"); const User = require("./model/user"); log.debug("server", "Importing Settings"); -const { getSettings, setSettings, setting, initJWTSecret, checkLogin, FBSD, doubleCheckPassword, startE2eTests, shake256, SHAKE256_LENGTH, allowDevAllOrigin, +const { getSettings, setSettings, setting, initJWTSecret, checkLogin, doubleCheckPassword, startE2eTests, shake256, SHAKE256_LENGTH, allowDevAllOrigin, } = require("./util-server"); log.debug("server", "Importing Notification"); @@ -100,19 +108,13 @@ const { apiAuth } = require("./auth"); const { login } = require("./auth"); const passwordHash = require("./password-hash"); -// If host is omitted, the server will accept connections on the unspecified IPv6 address (::) when IPv6 is available and the unspecified IPv4 address (0.0.0.0) otherwise. -// Dual-stack support for (::) -// Also read HOST if not FreeBSD, as HOST is a system environment variable in FreeBSD -let hostEnv = FBSD ? null : process.env.HOST; -let hostname = args.host || process.env.UPTIME_KUMA_HOST || hostEnv; +const hostname = config.hostname; if (hostname) { log.info("server", "Custom hostname: " + hostname); } -const port = [ args.port, process.env.UPTIME_KUMA_PORT, process.env.PORT, 3001 ] - .map(portValue => parseInt(portValue)) - .find(portValue => !isNaN(portValue)); +const port = config.port; const disableFrameSameOrigin = !!process.env.UPTIME_KUMA_DISABLE_FRAME_SAMEORIGIN || args["disable-frame-sameorigin"] || false; const cloudflaredToken = args["cloudflared-token"] || process.env.UPTIME_KUMA_CLOUDFLARED_TOKEN || undefined; @@ -131,9 +133,10 @@ const testMode = !!args["test"] || false; const e2eTestMode = !!args["e2e"] || false; // Must be after io instantiation -const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList } = require("./client"); +const { sendNotificationList, sendHeartbeatList, sendInfo, sendProxyList, sendDockerHostList, sendAPIKeyList, sendRemoteBrowserList } = require("./client"); const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler"); const databaseSocketHandler = require("./socket-handlers/database-socket-handler"); +const { remoteBrowserSocketHandler } = require("./socket-handlers/remote-browser-socket-handler"); const TwoFA = require("./2fa"); const StatusPage = require("./model/status_page"); const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart, stop: cloudflaredStop } = require("./socket-handlers/cloudflared-socket-handler"); @@ -143,7 +146,6 @@ const { maintenanceSocketHandler } = require("./socket-handlers/maintenance-sock const { apiKeySocketHandler } = require("./socket-handlers/api-key-socket-handler"); const { generalSocketHandler } = require("./socket-handlers/general-socket-handler"); const { Settings } = require("./settings"); -const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); const apicache = require("./modules/apicache"); const { resetChrome } = require("./monitor-types/real-browser-monitor-type"); const { EmbeddedMariaDB } = require("./embedded-mariadb"); @@ -798,6 +800,7 @@ let needSetup = false; bean.mqttPassword = monitor.mqttPassword; bean.mqttTopic = monitor.mqttTopic; bean.mqttSuccessMessage = monitor.mqttSuccessMessage; + bean.mqttCheckType = monitor.mqttCheckType; bean.databaseConnectionString = monitor.databaseConnectionString; bean.databaseQuery = monitor.databaseQuery; bean.authMethod = monitor.authMethod; @@ -834,6 +837,7 @@ let needSetup = false; bean.kafkaProducerAllowAutoTopicCreation = monitor.kafkaProducerAllowAutoTopicCreation; bean.gamedigGivenPortOnly = monitor.gamedigGivenPortOnly; + bean.remote_browser = monitor.remote_browser; bean.validate(); @@ -1270,6 +1274,8 @@ let needSetup = false; let user = await doubleCheckPassword(socket, password.currentPassword); await user.resetPassword(password.newPassword); + server.disconnectAllSocketClients(user.id, socket.id); + callback({ ok: true, msg: "successAuthChangePassword", @@ -1326,8 +1332,6 @@ let needSetup = false; await setSettings("general", data); server.entryPage = data.entryPage; - await CacheableDnsHttpAgent.update(); - // Also need to apply timezone globally if (data.serverTimezone) { await server.setTimezone(data.serverTimezone); @@ -1342,9 +1346,9 @@ let needSetup = false; // Update nscd status if (previousNSCDStatus !== data.nscd) { if (data.nscd) { - server.startNSCDServices(); + await server.startNSCDServices(); } else { - server.stopNSCDServices(); + await server.stopNSCDServices(); } } @@ -1515,6 +1519,7 @@ let needSetup = false; dockerSocketHandler(socket); maintenanceSocketHandler(socket); apiKeySocketHandler(socket); + remoteBrowserSocketHandler(socket); generalSocketHandler(socket, server); log.debug("server", "added all socket handlers"); @@ -1623,6 +1628,7 @@ async function afterLogin(socket, user) { sendProxyList(socket); sendDockerHostList(socket); sendAPIKeyList(socket); + sendRemoteBrowserList(socket); await sleep(500); diff --git a/server/setup-database.js b/server/setup-database.js index db1e9c156..483f2c9a4 100644 --- a/server/setup-database.js +++ b/server/setup-database.js @@ -74,7 +74,7 @@ class SetupDatabase { dbConfig.type = process.env.UPTIME_KUMA_DB_TYPE; dbConfig.hostname = process.env.UPTIME_KUMA_DB_HOSTNAME; dbConfig.port = process.env.UPTIME_KUMA_DB_PORT; - dbConfig.database = process.env.UPTIME_KUMA_DB_NAME; + dbConfig.dbName = process.env.UPTIME_KUMA_DB_NAME; dbConfig.username = process.env.UPTIME_KUMA_DB_USERNAME; dbConfig.password = process.env.UPTIME_KUMA_DB_PASSWORD; Database.writeDBConfig(dbConfig); diff --git a/server/socket-handlers/general-socket-handler.js b/server/socket-handlers/general-socket-handler.js index 1269bc25e..68e1f814c 100644 --- a/server/socket-handlers/general-socket-handler.js +++ b/server/socket-handlers/general-socket-handler.js @@ -109,4 +109,14 @@ module.exports.generalSocketHandler = (socket, server) => { msg: "Not found", }); }); + + // Disconnect all other socket clients of the user + socket.on("disconnectOtherSocketClients", async () => { + try { + checkLogin(socket); + server.disconnectAllSocketClients(socket.userID, socket.id); + } catch (e) { + log.warn("disconnectAllSocketClients", e.message); + } + }); }; diff --git a/server/socket-handlers/remote-browser-socket-handler.js b/server/socket-handlers/remote-browser-socket-handler.js new file mode 100644 index 000000000..ae53030ec --- /dev/null +++ b/server/socket-handlers/remote-browser-socket-handler.js @@ -0,0 +1,82 @@ +const { sendRemoteBrowserList } = require("../client"); +const { checkLogin } = require("../util-server"); +const { RemoteBrowser } = require("../remote-browser"); + +const { log } = require("../../src/util"); +const { testRemoteBrowser } = require("../monitor-types/real-browser-monitor-type"); + +/** + * Handlers for docker hosts + * @param {Socket} socket Socket.io instance + * @returns {void} + */ +module.exports.remoteBrowserSocketHandler = (socket) => { + socket.on("addRemoteBrowser", async (remoteBrowser, remoteBrowserID, callback) => { + try { + checkLogin(socket); + + let remoteBrowserBean = await RemoteBrowser.save(remoteBrowser, remoteBrowserID, socket.userID); + await sendRemoteBrowserList(socket); + + callback({ + ok: true, + msg: "Saved.", + msgi18n: true, + id: remoteBrowserBean.id, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("deleteRemoteBrowser", async (dockerHostID, callback) => { + try { + checkLogin(socket); + + await RemoteBrowser.delete(dockerHostID, socket.userID); + await sendRemoteBrowserList(socket); + + callback({ + ok: true, + msg: "successDeleted", + msgi18n: true, + }); + + } catch (e) { + callback({ + ok: false, + msg: e.message, + }); + } + }); + + socket.on("testRemoteBrowser", async (remoteBrowser, callback) => { + try { + checkLogin(socket); + let check = await testRemoteBrowser(remoteBrowser.url); + log.info("remoteBrowser", "Tested remote browser: " + check); + let msg; + + if (check) { + msg = "Connected Successfully."; + } + + callback({ + ok: true, + msg, + }); + + } catch (e) { + log.error("remoteBrowser", e); + + callback({ + ok: false, + msg: e.message, + }); + } + }); +}; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 67321abca..2eec9c7c1 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -4,15 +4,15 @@ const fs = require("fs"); const http = require("http"); const { Server } = require("socket.io"); const { R } = require("redbean-node"); -const { log } = require("../src/util"); +const { log, isDev } = require("../src/util"); const Database = require("./database"); const util = require("util"); -const { CacheableDnsHttpAgent } = require("./cacheable-dns-http-agent"); const { Settings } = require("./settings"); const dayjs = require("dayjs"); -const childProcess = require("child_process"); +const childProcessAsync = require("promisify-child-process"); const path = require("path"); const axios = require("axios"); +const { isSSL, sslKey, sslCert, sslKeyPassphrase } = require("./config"); // DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead. /** @@ -65,25 +65,19 @@ class UptimeKumaServer { /** * Get the current instance of the server if it exists, otherwise * create a new instance. - * @param {object} args Arguments to pass to instance constructor * @returns {UptimeKumaServer} Server instance */ - static getInstance(args) { + static getInstance() { if (UptimeKumaServer.instance == null) { - UptimeKumaServer.instance = new UptimeKumaServer(args); + UptimeKumaServer.instance = new UptimeKumaServer(); } return UptimeKumaServer.instance; } /** - * @param {object} args Arguments to initialise server with + * */ - constructor(args) { - // SSL - const sslKey = args["ssl-key"] || process.env.UPTIME_KUMA_SSL_KEY || process.env.SSL_KEY || undefined; - const sslCert = args["ssl-cert"] || process.env.UPTIME_KUMA_SSL_CERT || process.env.SSL_CERT || undefined; - const sslKeyPassphrase = args["ssl-key-passphrase"] || process.env.UPTIME_KUMA_SSL_KEY_PASSPHRASE || process.env.SSL_KEY_PASSPHRASE || undefined; - + constructor() { // Set axios default user-agent to Uptime-Kuma/version axios.defaults.headers.common["User-Agent"] = this.getUserAgent(); @@ -92,7 +86,7 @@ class UptimeKumaServer { log.info("server", "Creating express and socket.io instance"); this.app = express(); - if (sslKey && sslCert) { + if (isSSL) { log.info("server", "Server Type: HTTPS"); this.httpServer = https.createServer({ key: fs.readFileSync(sslKey), @@ -118,8 +112,68 @@ class UptimeKumaServer { UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType(); UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing(); UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType(); + UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType(); - this.io = new Server(this.httpServer); + // Allow all CORS origins (polling) in development + let cors = undefined; + if (isDev) { + cors = { + origin: "*", + }; + } + + this.io = new Server(this.httpServer, { + cors, + allowRequest: async (req, callback) => { + let transport; + // It should be always true, but just in case, because this property is not documented + if (req._query) { + transport = req._query.transport; + } else { + log.error("socket", "Ops!!! Cannot get transport type, assume that it is polling"); + transport = "polling"; + } + + const clientIP = await this.getClientIPwithProxy(req.connection.remoteAddress, req.headers); + log.info("socket", `New ${transport} connection, IP = ${clientIP}`); + + // The following check is only for websocket connections, polling connections are already protected by CORS + if (transport === "polling") { + callback(null, true); + } else if (transport === "websocket") { + const bypass = process.env.UPTIME_KUMA_WS_ORIGIN_CHECK === "bypass"; + if (bypass) { + log.info("auth", "WebSocket origin check is bypassed"); + callback(null, true); + } else if (!req.headers.origin) { + log.info("auth", "WebSocket with no origin is allowed"); + callback(null, true); + } else { + let host = req.headers.host; + let origin = req.headers.origin; + + try { + let originURL = new URL(origin); + let xForwardedFor; + if (await Settings.get("trustProxy")) { + xForwardedFor = req.headers["x-forwarded-for"]; + } + + if (host !== originURL.host && xForwardedFor !== originURL.host) { + callback(null, false); + log.error("auth", `Origin (${origin}) does not match host (${host}), IP: ${clientIP}`); + } else { + callback(null, true); + } + } catch (e) { + // Invalid origin url, probably not from browser + callback(null, false); + log.error("auth", `Invalid origin url (${origin}), IP: ${clientIP}`); + } + } + } + } + }); } /** @@ -130,8 +184,6 @@ class UptimeKumaServer { // Static this.app.use("/screenshots", express.static(Database.screenshotDir)); - await CacheableDnsHttpAgent.update(); - process.env.TZ = await this.getTimezone(); dayjs.tz.setDefault(process.env.TZ); log.debug("DEBUG", "Timezone: " + process.env.TZ); @@ -263,20 +315,27 @@ class UptimeKumaServer { /** * Get the IP of the client connected to the socket * @param {Socket} socket Socket to query - * @returns {string} IP of client + * @returns {Promise} IP of client */ - async getClientIP(socket) { - let clientIP = socket.client.conn.remoteAddress; + getClientIP(socket) { + return this.getClientIPwithProxy(socket.client.conn.remoteAddress, socket.client.conn.request.headers); + } + /** + * @param {string} clientIP Raw client IP + * @param {IncomingHttpHeaders} headers HTTP headers + * @returns {Promise} Client IP with proxy (if trusted) + */ + async getClientIPwithProxy(clientIP, headers) { if (clientIP === undefined) { clientIP = ""; } if (await Settings.get("trustProxy")) { - const forwardedFor = socket.client.conn.request.headers["x-forwarded-for"]; + const forwardedFor = headers["x-forwarded-for"]; return (typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : null) - || socket.client.conn.request.headers["x-real-ip"] + || headers["x-real-ip"] || clientIP.replace(/^::ffff:/, ""); } else { return clientIP.replace(/^::ffff:/, ""); @@ -372,7 +431,7 @@ class UptimeKumaServer { let enable = await Settings.get("nscd"); if (enable || enable === null) { - this.startNSCDServices(); + await this.startNSCDServices(); } } @@ -384,7 +443,7 @@ class UptimeKumaServer { let enable = await Settings.get("nscd"); if (enable || enable === null) { - this.stopNSCDServices(); + await this.stopNSCDServices(); } } @@ -393,11 +452,11 @@ class UptimeKumaServer { * For now, only used in Docker * @returns {void} */ - startNSCDServices() { + async startNSCDServices() { if (process.env.UPTIME_KUMA_IS_CONTAINER) { try { log.info("services", "Starting nscd"); - childProcess.execSync("sudo service nscd start", { stdio: "pipe" }); + await childProcessAsync.exec("sudo service nscd start"); } catch (e) { log.info("services", "Failed to start nscd"); } @@ -408,11 +467,11 @@ class UptimeKumaServer { * Stop all system services * @returns {void} */ - stopNSCDServices() { + async stopNSCDServices() { if (process.env.UPTIME_KUMA_IS_CONTAINER) { try { log.info("services", "Stopping nscd"); - childProcess.execSync("sudo service nscd stop"); + await childProcessAsync.exec("sudo service nscd stop"); } catch (e) { log.info("services", "Failed to stop nscd"); } @@ -426,6 +485,26 @@ class UptimeKumaServer { getUserAgent() { return "Uptime-Kuma/" + require("../package.json").version; } + + /** + * Force connected sockets of a user to refresh and disconnect. + * Used for resetting password. + * @param {string} userID User ID + * @param {string?} currentSocketID Current socket ID + * @returns {void} + */ + disconnectAllSocketClients(userID, currentSocketID = undefined) { + for (const socket of this.io.sockets.sockets.values()) { + if (socket.userID === userID && socket.id !== currentSocketID) { + try { + socket.emit("refresh"); + socket.disconnect(); + } catch (e) { + + } + } + } + } } module.exports = { @@ -436,3 +515,4 @@ module.exports = { const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type"); const { TailscalePing } = require("./monitor-types/tailscale-ping"); const { DnsMonitorType } = require("./monitor-types/dns"); +const { MqttMonitorType } = require("./monitor-types/mqtt"); diff --git a/server/util-server.js b/server/util-server.js index 3e95e70aa..5f8c063bf 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -1,15 +1,13 @@ const tcpp = require("tcp-ping"); const ping = require("@louislam/ping"); const { R } = require("redbean-node"); -const { log, genSecret } = require("../src/util"); +const { log, genSecret, badgeConstants } = require("../src/util"); const passwordHash = require("./password-hash"); const { Resolver } = require("dns"); const childProcess = require("child_process"); const iconv = require("iconv-lite"); const chardet = require("chardet"); -const mqtt = require("mqtt"); const chroma = require("chroma-js"); -const { badgeConstants } = require("./config"); const mssql = require("mssql"); const { Client } = require("pg"); const postgresConParse = require("pg-connection-string").parse; @@ -173,73 +171,6 @@ exports.pingAsync = function (hostname, ipv6 = false, size = 56) { }); }; -/** - * MQTT Monitor - * @param {string} hostname Hostname / address of machine to test - * @param {string} topic MQTT topic - * @param {string} okMessage Expected result - * @param {object} options MQTT options. Contains port, username, - * password and interval (interval defaults to 20) - * @returns {Promise} Received MQTT message - */ -exports.mqttAsync = function (hostname, topic, okMessage, options = {}) { - return new Promise((resolve, reject) => { - const { port, username, password, interval = 20 } = options; - - // Adds MQTT protocol to the hostname if not already present - if (!/^(?:http|mqtt|ws)s?:\/\//.test(hostname)) { - hostname = "mqtt://" + hostname; - } - - const timeoutID = setTimeout(() => { - log.debug("mqtt", "MQTT timeout triggered"); - client.end(); - reject(new Error("Timeout")); - }, interval * 1000 * 0.8); - - const mqttUrl = `${hostname}:${port}`; - - log.debug("mqtt", `MQTT connecting to ${mqttUrl}`); - - let client = mqtt.connect(mqttUrl, { - username, - password - }); - - client.on("connect", () => { - log.debug("mqtt", "MQTT connected"); - - try { - log.debug("mqtt", "MQTT subscribe topic"); - client.subscribe(topic); - } catch (e) { - client.end(); - clearTimeout(timeoutID); - reject(new Error("Cannot subscribe topic")); - } - }); - - client.on("error", (error) => { - client.end(); - clearTimeout(timeoutID); - reject(error); - }); - - client.on("message", (messageTopic, message) => { - if (messageTopic === topic) { - client.end(); - clearTimeout(timeoutID); - if (okMessage != null && okMessage !== "" && message.toString() !== okMessage) { - reject(new Error(`Message Mismatch - Topic: ${messageTopic}; Message: ${message.toString()}`)); - } else { - resolve(`Topic: ${messageTopic}; Message: ${message.toString()}`); - } - } - }); - - }); -}; - /** * Monitor Kafka using Producer * @param {string[]} brokers List of kafka brokers to connect, host and diff --git a/src/components/ActionInput.vue b/src/components/ActionInput.vue index fe3504b17..a61e4f961 100644 --- a/src/components/ActionInput.vue +++ b/src/components/ActionInput.vue @@ -8,9 +8,9 @@ :placeholder="placeholder" :disabled="!enabled" > - + @@ -66,6 +66,13 @@ export default { action: { type: Function, default: () => {}, + }, + /** + * The aria-label of the action button + */ + actionAriaLabel: { + type: String, + required: true, } }, emits: [ "update:modelValue" ], diff --git a/src/components/ActionSelect.vue b/src/components/ActionSelect.vue index 78daebc26..d47154f49 100644 --- a/src/components/ActionSelect.vue +++ b/src/components/ActionSelect.vue @@ -1,11 +1,11 @@ @@ -20,6 +20,13 @@ export default { type: Array, default: () => [], }, + /** + * The id of the form which will be targeted by a