diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 975a939f1..d626e2cd0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -104,7 +104,7 @@ npm run dev # Frontend on port 3000, backend on port 3001 ### Backend Architecture -- `server/model/` — RedBean models mapped to tables. `monitor.js` holds the monitor loop and handles the core types inline: `http`, `keyword`, `json-query`, `ping`, `push`, `docker`, `radius`, `kafka-producer`. +- `server/model/` — RedBean models mapped to tables. `monitor.js` holds the monitor loop and handles the core types inline: `http`, `keyword`, `json-query`, `push`, `docker`, `radius`, `kafka-producer`. - `server/monitor-types/` — one class per monitor type, registered in `server/uptime-kuma-server.js` (`monitorTypeList`). - `server/notification-providers/` — one file per provider (110+), registered in `server/notification.js`. - `server/socket-handlers/` — Socket.IO event handlers (most of the real-time API surface). diff --git a/.github/workflows/auto-test.yml b/.github/workflows/auto-test.yml index 8c9bf3815..528fcbdab 100644 --- a/.github/workflows/auto-test.yml +++ b/.github/workflows/auto-test.yml @@ -15,6 +15,7 @@ jobs: runs-on: ${{ matrix.os }} permissions: contents: read + packages: read strategy: fail-fast: false @@ -71,6 +72,16 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + # The SFTP testcontainer is pulled from GHCR; anonymous pulls share the runner IP's rate limit. + - name: Login to GitHub Container Registry + if: github.repository == 'louislam/uptime-kuma' && runner.os == 'Linux' && runner.arch == 'X64' + continue-on-error: true + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - run: npm run test-backend env: HEADLESS_TEST: 1 @@ -129,8 +140,34 @@ jobs: - if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm clean-install --no-fund + - name: Point apt at a cacheable archive dir + run: | + mkdir -p "$HOME/.apt-archives/partial" + chmod 777 "$HOME/.apt-archives/partial" + sudo tee /etc/apt/apt.conf.d/99-playwright-cache >/dev/null <> "$GITHUB_OUTPUT" + + - name: Cache apt packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.apt-archives + key: apt-playwright-${{ runner.os }}-${{ env.PLAYWRIGHT_VERSION }}-${{ steps.apt-week.outputs.week }} + restore-keys: | + apt-playwright-${{ runner.os }}-${{ env.PLAYWRIGHT_VERSION }}- + - name: Install Playwright ${{ env.PLAYWRIGHT_VERSION }} - run: npx playwright@${{ env.PLAYWRIGHT_VERSION }} install --with-deps + run: npx playwright@${{ env.PLAYWRIGHT_VERSION }} install --with-deps chromium + + # apt runs as root, leaving root-owned lock/partial that the cache's tar cannot read. + - name: Hand the apt archives back to the cache + if: always() + run: sudo chown -R "$USER" "$HOME/.apt-archives" - run: npm run build - run: npm run test-e2e diff --git a/.prettierignore b/.prettierignore index e1451d2cb..8ed7f7ac4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,4 @@ # language files src/lang/*.json # vendored, kept close to upstream -server/modules/prometheus-api-metrics/* +server/modules diff --git a/package-lock.json b/package-lock.json index 655e6f178..d26e2c3bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@grpc/grpc-js": "~1.13.6", "@inquirer/core": "~11.2.1", "@inquirer/prompts": "~8.5.2", - "@louislam/ping": "~0.4.4-mod.1", "@vvo/tzdb": "~6.198.0", "args-parser": "~1.3.0", "axios": "~0.34.0", @@ -2210,19 +2209,6 @@ "debug": "^4.1.1" } }, - "node_modules/@louislam/ping": { - "version": "0.4.4-mod.1", - "resolved": "https://registry.npmjs.org/@louislam/ping/-/ping-0.4.4-mod.1.tgz", - "integrity": "sha512-uMq6qwL9/VYh2YBbDEhM7ZzJ8YON+juw/3k+28P3s9ue3uDMQ56MNPfywXoRpsxkU8RgjN0TDzEhQDzO1WisMw==", - "license": "MIT", - "dependencies": { - "command-exists": "~1.2.9", - "underscore": "~1.13.6" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/@mongodb-js/saslprep": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.1.tgz", @@ -15811,12 +15797,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/underscore": { - "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", - "license": "MIT" - }, "node_modules/undici": { "version": "6.29.1", "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", diff --git a/package.json b/package.json index ec3c4828f..75aa062b9 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "@grpc/grpc-js": "~1.13.6", "@inquirer/core": "~11.2.1", "@inquirer/prompts": "~8.5.2", - "@louislam/ping": "~0.4.4-mod.1", "@vvo/tzdb": "~6.198.0", "args-parser": "~1.3.0", "axios": "~0.34.0", diff --git a/server/model/monitor.js b/server/model/monitor.js index a72b29ab9..6d87f3aab 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -28,7 +28,6 @@ const { RESPONSE_BODY_LENGTH_MAX, } = require("../../src/util"); const { - ping, checkStatusCode, getTotalClientInRoom, httpNtlm, @@ -706,18 +705,6 @@ class Monitor extends BeanModel { ); } } - } else if (this.type === "ping") { - bean.ping = await ping( - this.hostname, - this.ping_count, - "", - this.ping_numeric, - this.packetSize, - this.timeout, - this.ping_per_request_timeout - ); - bean.msg = ""; - bean.status = UP; } else if (this.type === "push") { // Type: Push log.debug( diff --git a/server/modules/ping/LICENSE b/server/modules/ping/LICENSE new file mode 100644 index 000000000..be597311a --- /dev/null +++ b/server/modules/ping/LICENSE @@ -0,0 +1,8 @@ +MIT License +Copyright (c) 2016 Daniel Zelisko + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/server/modules/ping/README.md b/server/modules/ping/README.md new file mode 100644 index 000000000..6e9616fbc --- /dev/null +++ b/server/modules/ping/README.md @@ -0,0 +1,9 @@ +# NODE-PING for Uptime Kuma ![Workflow Status](https://github.com/louislam/node-ping/actions/workflows/build-test-coverage.yml/badge.svg) + +Custom made for Uptime Kuma. The license still belongs to Daniel Zelisko. Usually, you don't need this, please download the official one: http://github.com/danielzzz/node-ping + +# License MIT + +(C) Daniel Zelisko + +http://github.com/danielzzz/node-ping diff --git a/server/modules/ping/index.js b/server/modules/ping/index.js new file mode 100644 index 000000000..0929434d2 --- /dev/null +++ b/server/modules/ping/index.js @@ -0,0 +1,8 @@ +// Vendored from @louislam/ping 0.4.4-mod.1 (MIT, see LICENSE), a fork of +// https://github.com/danielzzz/node-ping. Changes: only the parts Uptime Kuma uses +// are kept (ping.sys and lib/ping-pcap.js are dropped), and underscore is removed. +var ping = {}; + +ping.promise = require("./lib/ping-promise"); + +module.exports = ping; diff --git a/server/modules/ping/lib/builder/factory.js b/server/modules/ping/lib/builder/factory.js new file mode 100644 index 000000000..7af8a9f13 --- /dev/null +++ b/server/modules/ping/lib/builder/factory.js @@ -0,0 +1,149 @@ +'use strict'; + +var util = require('util'); +const commandExistsSync = require('command-exists').sync; + +// Our library +var linuxBuilder = require('./linux'); +var macBuilder = require('./mac'); +var winBuilder = require('./win'); +var obsdBuilder = require('./openbsd'); + +let linuxPing = null; +let linuxPing6 = null; + +/** + * Get `ping` for Linux + * @returns {string} As description + */ +function getLinuxPing() { + if (!linuxPing) { + if (commandExistsSync('ping')) { + linuxPing = 'ping'; + } else { + linuxPing = '/bin/ping'; + } + } + return linuxPing; +} + +/** + * Get `ping6` for Linux + * @returns {string} As description + */ +function getLinuxPing6() { + if (!linuxPing6) { + if (commandExistsSync('ping6')) { + linuxPing6 = 'ping6'; + } else { + linuxPing6 = '/bin/ping6'; + } + } + return linuxPing6; +} + +/** + * A factory creates argument builders for different platform + * @constructor + */ +function factory() {} + +/** + * Check out linux platform + */ +factory.isLinux = function (p) { + var platforms = ['aix', 'android', 'linux']; + + return platforms.indexOf(p) >= 0; +}; + +/** + * Check out macos platform + */ +factory.isMacOS = function (p) { + var platforms = ['darwin', 'freebsd']; + + return platforms.indexOf(p) >= 0; +}; + +/** + * Check out openbsd platform + */ +factory.isOpenBSD = function (p) { + var platforms = ['openbsd']; + + return platforms.indexOf(p) >= 0; +}; + +/** + * Check out window platform + */ +factory.isWindow = function (p) { + return p && p.match(/^win/) !== null; +}; + +/** + * Check whether given platform is supported + * @param {string} p - Name of the platform + * @return {bool} - True or False + */ +factory.isPlatformSupport = function (p) { + return this.isWindow(p) || this.isLinux(p) || this.isMacOS(p) || this.isOpenBSD(p); +}; + +/** + * Return a path to the ping executable in the system + * @param {string} platform - Name of the platform + * @param {bool} v6 - Ping via ipv6 or not + * @return {string} - Executable path for system command ping + * @throw if given platform is not supported + */ +factory.getExecutablePath = function (platform, v6) { + if (!this.isPlatformSupport(platform)) { + throw new Error(util.format('Platform |%s| is not support', platform)); + } + + var ret = null; + + if (platform === 'aix') { + ret = '/usr/sbin/ping'; + } else if (factory.isLinux(platform)) { + ret = v6 ? getLinuxPing6() : getLinuxPing(); + } else if (factory.isWindow(platform)) { + ret = process.env.SystemRoot + '/system32/ping.exe'; + } else if (factory.isMacOS(platform)) { + ret = v6 ? '/sbin/ping6' : '/sbin/ping'; + } else if (factory.isOpenBSD(platform)) { + ret = v6 ? '/sbin/ping6' : '/sbin/ping'; + } + + return ret; +}; + +/** + * Create a builder + * @param {string} platform - Name of the platform + * @return {object} - Argument builder + * @throw if given platform is not supported + */ +factory.createBuilder = function (platform) { + if (!this.isPlatformSupport(platform)) { + throw new Error(util.format('Platform |%s| is not support', platform)); + } + + var ret = null; + + if (factory.isLinux(platform)) { + ret = linuxBuilder; + } else if (factory.isWindow(platform)) { + ret = winBuilder; + } else if (factory.isMacOS(platform)) { + ret = macBuilder; + } else if (factory.isOpenBSD(platform)) { + ret = obsdBuilder; + } + + return ret; +}; + +module.exports = factory; diff --git a/server/modules/ping/lib/builder/linux.js b/server/modules/ping/lib/builder/linux.js new file mode 100644 index 000000000..865c6cda0 --- /dev/null +++ b/server/modules/ping/lib/builder/linux.js @@ -0,0 +1,125 @@ +'use strict'; + +/** + * A builder builds command line arguments for ping in linux environment + * @module lib/builder/linux + */ +var util = require('util'); + +var builder = {}; + +/** + * Cross platform config representation + * @typedef {Object} PingConfig + * @property {boolean} numeric - Map IP address to hostname or not + * @property {number} timeout - Time to wait for a response, in seconds. + * The option affects only timeout in absence of any responses, + * otherwise ping waits for two RTTs. + * @property {number} deadline - Specify a timeout, in seconds, + * before ping exits regardless of how many packets have been sent or received. + * In this case ping does not stop after count packet are sent, + * it waits either for deadline expire or until count probes are answered + * or for some error notification from network. + * This option is only available on linux and mac. + * @property {number} min_reply - Exit after sending number of ECHO_REQUEST + * @property {boolean} v6 - Use IPv4 (default) or IPv6 + * @property {string} sourceAddr - source address for sending the ping + * @property {number} packetSize - Specifies the number of data bytes to be sent + * Default: Linux / MAC: 56 Bytes, + * Window: 32 Bytes + * @property {string[]} extra - Optional options does not provided + */ + +var defaultConfig = { + numeric: true, + timeout: 2, + deadline: false, + min_reply: 1, + v6: false, + sourceAddr: '', + packetSize: 56, + extra: [], +}; + +/** + * Get the finalized array of command line arguments + * @param {string} target - hostname or ip address + * @param {PingConfig} [config] - Configuration object for cmd line argument + * @return {string[]} - Command line argument according to the configuration + */ +builder.getCommandArguments = function (target, config) { + var _config = config || {}; + + // Empty argument + var ret = []; + + // Make every key in config has been setup properly + var keys = ['numeric', 'timeout', 'deadline', 'min_reply', 'v6', + 'sourceAddr', 'extra', 'packetSize']; + keys.forEach(function (k) { + // Falsy value will be overridden without below checking + if (typeof(_config[k]) !== 'boolean') { + _config[k] = _config[k] || defaultConfig[k]; + } + }); + + if (_config.numeric) { + ret.push('-n'); + } + + if (_config.timeout) { + ret = ret.concat([ + '-W', + util.format('%d', _config.timeout), + ]); + } + + if (_config.deadline) { + ret = ret.concat([ + '-w', + util.format('%d', _config.deadline), + ]); + } + + if (_config.min_reply) { + ret = ret.concat([ + '-c', + util.format('%d', _config.min_reply), + ]); + } + + if (_config.sourceAddr) { + ret = ret.concat([ + '-I', + util.format('%s', _config.sourceAddr), + ]); + } + + if (_config.packetSize) { + ret = ret.concat([ + '-s', + util.format('%d', _config.packetSize), + ]); + } + + if (_config.extra) { + ret = ret.concat(_config.extra); + } + + ret.push(target); + + return ret; +}; + +/** + * Compute an option object for child_process.spawn + * @return {object} - Refer to document of child_process.spawn + */ +builder.getSpawnOptions = function () { + return { + shell: false, + env: Object.assign(process.env, {LANG: 'C'}), + }; +}; + +module.exports = builder; diff --git a/server/modules/ping/lib/builder/mac.js b/server/modules/ping/lib/builder/mac.js new file mode 100644 index 000000000..53710ffd5 --- /dev/null +++ b/server/modules/ping/lib/builder/mac.js @@ -0,0 +1,132 @@ +'use strict'; + +/** + * A builder builds command line arguments for ping in mac environment + * @module lib/builder/mac + */ +var util = require('util'); + +var builder = {}; + +/** + * Cross platform config representation + * @typedef {Object} PingConfig + * @property {boolean} numeric - Map IP address to hostname or not + * @property {number} timeout - Time to wait for a response, in seconds. + * The option affects only timeout in absence of any responses, + * otherwise ping waits for two RTTs. + * @property {number} deadline - Specify a timeout, in seconds, + * before ping exits regardless of how many packets have been sent or received. + * In this case ping does not stop after count packet are sent, + * it waits either for deadline expire or until count probes are answered + * or for some error notification from network. + * This option is only available on linux and mac. + * @property {number} min_reply - Exit after sending number of ECHO_REQUEST + * @property {boolean} v6 - Use IPv4 (default) or IPv6 + * @property {string} sourceAddr - source address for sending the ping + * @property {number} packetSize - Specifies the number of data bytes to be sent + * Default: Linux / MAC: 56 Bytes, + * Window: 32 Bytes + * @property {string[]} extra - Optional options does not provided + */ + +var defaultConfig = { + numeric: true, + timeout: 2, + deadline: false, + min_reply: 1, + v6: false, + sourceAddr: '', + packetSize: 56, + extra: [], +}; + +/** + * Get the finalized array of command line arguments + * @param {string} target - hostname or ip address + * @param {PingConfig} [config] - Configuration object for cmd line argument + * @return {string[]} - Command line argument according to the configuration + * @throws If there are errors on building arguments with given inputs + */ +builder.getCommandArguments = function (target, config) { + var _config = config || {}; + + // Empty argument + var ret = []; + + // Make every key in config has been setup properly + var keys = ['numeric', 'timeout', 'deadline', 'min_reply', 'v6', + 'sourceAddr', 'extra', 'packetSize']; + keys.forEach(function (k) { + // Falsy value will be overridden without below checking + if (typeof(_config[k]) !== 'boolean') { + _config[k] = _config[k] || defaultConfig[k]; + } + }); + + if (_config.numeric) { + ret.push('-n'); + } + + if (_config.timeout) { + // XXX: There is no timeout option on mac's ping6 + // Fallback to deadline + if (config.v6) { + if (!_config.deadline) { + _config.deadline = _config.timeout; + } + } else { + ret = ret.concat([ + '-W', + util.format('%d', _config.timeout * 1000), + ]); + } + } + + if (_config.deadline) { + ret = ret.concat([ + '-t', + util.format('%d', _config.deadline), + ]); + } + + if (_config.min_reply) { + ret = ret.concat([ + '-c', + util.format('%d', _config.min_reply), + ]); + } + + if (_config.sourceAddr) { + ret = ret.concat([ + '-S', + util.format('%s', _config.sourceAddr), + ]); + } + + if (_config.packetSize) { + ret = ret.concat([ + '-s', + util.format('%d', _config.packetSize), + ]); + } + + if (_config.extra) { + ret = ret.concat(_config.extra); + } + + ret.push(target); + + return ret; +}; + +/** + * Compute an option object for child_process.spawn + * @return {object} - Refer to document of child_process.spawn + */ +builder.getSpawnOptions = function () { + return {}; +}; + + +module.exports = builder; diff --git a/server/modules/ping/lib/builder/openbsd.js b/server/modules/ping/lib/builder/openbsd.js new file mode 100644 index 000000000..bf99f69f2 --- /dev/null +++ b/server/modules/ping/lib/builder/openbsd.js @@ -0,0 +1,118 @@ +'use strict'; + +/** + * A builder builds command line arguments for ping in mac environment + * @module lib/builder/mac + */ +var util = require('util'); + +var builder = {}; + +/** + * Cross platform config representation + * @typedef {Object} PingConfig + * @property {boolean} numeric - Map IP address to hostname or not + * @property {number} timeout - Time to wait for a response, in seconds. + * The option affects only timeout in absence of any responses, + * otherwise ping waits for two RTTs. + * @property {number} deadline - Specify a timeout, in seconds, + * before ping exits regardless of how many packets have been sent or received. + * In this case ping does not stop after count packet are sent, + * it waits either for deadline expire or until count probes are answered + * or for some error notification from network. + * This option is only available on linux and mac. + * @property {number} min_reply - Exit after sending number of ECHO_REQUEST + * @property {boolean} v6 - Use IPv4 (default) or IPv6 + * @property {string} sourceAddr - source address for sending the ping + * @property {number} packetSize - Specifies the number of data bytes to be sent + * Default: Linux / MAC: 56 Bytes, + * Window: 32 Bytes + * @property {string[]} extra - Optional options does not provided + */ + +var defaultConfig = { + numeric: true, + timeout: 2, + deadline: false, + min_reply: 1, + v6: false, + sourceAddr: '', + packetSize: 56, + extra: [], +}; + +/** + * Get the finalized array of command line arguments + * @param {string} target - hostname or ip address + * @param {PingConfig} [config] - Configuration object for cmd line argument + * @return {string[]} - Command line argument according to the configuration + * @throws If there are errors on building arguments with given inputs + */ +builder.getCommandArguments = function (target, config) { + var _config = config || {}; + + // Empty argument + var ret = []; + + // Make every key in config has been setup properly + var keys = [ + 'numeric', + 'timeout', + 'deadline', + 'min_reply', + 'v6', + 'sourceAddr', + 'extra', + 'packetSize', + ]; + keys.forEach(function (k) { + // Falsy value will be overridden without below checking + if (typeof _config[k] !== 'boolean') { + _config[k] = _config[k] || defaultConfig[k]; + } + }); + + if (_config.numeric) { + ret.push('-n'); + } + + if (_config.timeout) { + // XXX: There is no timeout option on mac's ping6 + // Fallback to deadline + ret = ret.concat(['-w', util.format('%d', _config.timeout)]); + } + + if (_config.deadline) { + ret = ret.concat(['-w', util.format('%d', _config.deadline)]); + } + + if (_config.min_reply) { + ret = ret.concat(['-c', util.format('%d', _config.min_reply)]); + } + + if (_config.sourceAddr) { + ret = ret.concat(['-I', util.format('%s', _config.sourceAddr)]); + } + + if (_config.packetSize) { + ret = ret.concat(['-s', util.format('%d', _config.packetSize)]); + } + + if (_config.extra) { + ret = ret.concat(_config.extra); + } + + ret.push(target); + + return ret; +}; + +/** + * Compute an option object for child_process.spawn + * @return {object} - Refer to document of child_process.spawn + */ +builder.getSpawnOptions = function () { + return {}; +}; + +module.exports = builder; diff --git a/server/modules/ping/lib/builder/win.js b/server/modules/ping/lib/builder/win.js new file mode 100644 index 000000000..b63fb4678 --- /dev/null +++ b/server/modules/ping/lib/builder/win.js @@ -0,0 +1,121 @@ +'use strict'; + +/** + * A builder builds command line arguments for ping in window environment + * @module lib/builder/win + */ +var util = require('util'); + +var builder = {}; + +/** + * Cross platform config representation + * @typedef {Object} PingConfig + * @property {boolean} numeric - Map IP address to hostname or not + * @property {number} timeout - Timeout in seconds for each ping request + * @property {number} min_reply - Exit after sending number of ECHO_REQUEST + * @property {boolean} v6 - Use IPv4 (default) or IPv6 + * @property {string} sourceAddr - source address for sending the ping + * @property {number} packetSize - Specifies the number of data bytes to be sent + * Default: Linux / MAC: 56 Bytes, + * Window: 32 Bytes + * @property {string[]} extra - Optional options does not provided + */ + +var defaultConfig = { + numeric: true, + timeout: 5, + min_reply: 1, + v6: false, + sourceAddr: '', + packetSize: 32, + extra: [], +}; + +/** + * Get the finalized array of command line arguments + * @param {string} target - hostname or ip address + * @param {PingConfig} [config] - Configuration object for cmd line argument + * @return {string[]} - Command line argument according to the configuration + */ +builder.getCommandArguments = function (target, config) { + var _config = config || {}; + + // Empty argument + var ret = []; + + // Make every key in config has been setup properly + var keys = [ + 'numeric', 'timeout', 'min_reply', 'v6', 'sourceAddr', 'extra', + 'packetSize', + ]; + keys.forEach(function (k) { + // Falsy value will be overrided without below checking + if (typeof(_config[k]) !== 'boolean') { + _config[k] = _config[k] || defaultConfig[k]; + } + }); + + // Auto detect for Windows + // ret.push(_config.v6 ? '-6' : '-4'); + + if (!_config.numeric) { + ret.push('-a'); + } + + // No deadline, fallback to timeout + if (_config.deadline && !_config.timeout) { + _config.timeout = _config.deadline; + } + + if (_config.timeout) { + // refs #56: Unit problem + // Our timeout is in second while timeout in window is in milliseconds + // so we need to convert our units accordingly + ret = ret.concat([ + '-w', + util.format('%d', _config.timeout * 1000), + ]); + } + + if (_config.min_reply) { + ret = ret.concat([ + '-n', + util.format('%d', _config.min_reply), + ]); + } + + if (_config.sourceAddr) { + ret = ret.concat([ + '-S', + util.format('%s', _config.sourceAddr), + ]); + } + + if (_config.packetSize) { + ret = ret.concat([ + '-l', + util.format('%d', _config.packetSize), + ]); + } + + if (_config.extra) { + ret = ret.concat(_config.extra); + } + + ret.push(target); + + return ret; +}; + +/** + * Compute an option object for child_process.spawn + * @return {object} - Refer to document of child_process.spawn + */ +builder.getSpawnOptions = function () { + return { + windowsHide: true, + }; +}; + +module.exports = builder; diff --git a/server/modules/ping/lib/parser/base.js b/server/modules/ping/lib/parser/base.js new file mode 100644 index 000000000..84f2dc3ce --- /dev/null +++ b/server/modules/ping/lib/parser/base.js @@ -0,0 +1,192 @@ +'use strict'; + +/* eslint no-unused-vars: 0 */ + +/** + * Parsed response + * @typedef {object} PingResponse + * @param {string} inputHost - The input IP address or HOST + * @param {string} host - The input IP address or HOST + * @param {string} numeric_host - Target IP address + * @param {boolean} alive - True for existed host + * @param {string} output - Raw stdout from system ping + * @param {number} time - Time (float) in ms for first successful ping response + * @param {string} min - Minimum time for collection records + * @param {string} max - Maximum time for collection records + * @param {string} avg - Average time for collection records + * @param {number} packetLoss - Packet Losses in percent (number) + * @param {string} stddev - Standard deviation time for collected records + */ + +/** + * @constructor + * @param {string} addr - Hostname or ip addres + * @param {PingConfig} config - Config object in probe() + */ +function parser(addr, config) { + // Initial state is 0 + this._state = 0; + + // Initial cache value + this._response = { + inputHost: addr, + host: 'unknown', + alive: false, + output: 'unknown', + time: 'unknown', + times: [], + min: 'unknown', + max: 'unknown', + avg: 'unknown', + stddev: 'unknown', + packetLoss: 'unknown', + }; + + // Initial times storage for ping time + this._times = []; + + // Initial lines storage for ping output + this._lines = []; + + // strip string regexp + this._stripRegex = /[ ]*\r?\n?$/g; + + // Ping Config + this._pingConfig = config || {}; +} + +/** + * Enum for parser states + * @readonly + * @enum {number} + */ +parser.prototype.STATES = { + INIT: 0, + HEADER: 1, + BODY: 2, + FOOTER: 3, + END: 4, +}; + +/** + * Change state of this parser + * @param {number} state - parser.STATES + * @return {this} - This instance + */ +parser.prototype._changeState = function (state) { + // var states = Object.values(this.STATES); // If minimum engine version can be raised to >=7.0.0 in package.json + var states = Object.keys(this.STATES).map(function (key) { return this.STATES[key]; }, this); + if (states.indexOf(state) < 0) { + throw new Error('Unknown state'); + } + + this._state = state; + + return this; +}; + +/** + * Process output's header + * @param {string} line - A line from system ping + */ +parser.prototype._processHeader = function (line) { + throw new Error('Subclass should implement this method'); +}; + +/** + * Process output's body + * @param {string} line - A line from system ping + */ +parser.prototype._processBody = function (line) { + throw new Error('Subclass should implement this method'); +}; + +/** + * Process output's footer + * @param {string} line - A line from system ping + */ +parser.prototype._processFooter = function (line) { + throw new Error('Subclass should implement this method'); +}; + +/** + * Process a line from system ping + * @param {string} line - A line from system ping + * @return {this} - This instance + */ +parser.prototype.eat = function (line) { + var headerStates = [this.STATES.INIT, this.STATES.HEADER]; + + // Store lines + this._lines.push(line); + + // Strip all space \r\n at the end + var _line = line.replace(this._stripRegex, ''); + + if (_line.length === 0) { + // Do nothing if this is an empty line + } else if (headerStates.indexOf(this._state) >= 0) { + this._processHeader(_line); + } else if (this._state === this.STATES.BODY) { + this._processBody(_line); + } else if (this._state === this.STATES.FOOTER) { + this._processFooter(_line); + } else if (this._state === this.STATES.END) { + // Do nothing + } else { + throw new Error('Unknown state'); + } + + return this; +}; + +/** + * Get results after parsing certain lines from system ping + * @return {PingResponse} - Response from parsing ping output + */ +parser.prototype.getResult = function () { + var ret = Object.assign({}, this._response); + + // Concat output + ret.output = this._lines.join('\n'); + + // Determine alive + ret.alive = this._times.length > 0; + + // Update time at first successful line + if (ret.alive) { + this._response.time = this._times[0]; + ret.time = this._response.time; + this._response.times = this._times; + ret.times = this._response.times; + } + + // Get stddev + if (ret.stddev === 'unknown' && ret.alive) { + var numberOfSamples = this._times.length; + + var sumOfAllSquareDifferences = this._times.reduce( + function (memory, time) { + var differenceFromMean = time - ret.avg; + var squaredDifference = differenceFromMean * differenceFromMean; + return memory + squaredDifference; + }, + 0 + ); + var variances = sumOfAllSquareDifferences / numberOfSamples; + + ret.stddev = Math.round(Math.sqrt(variances) * 1000) / 1000; + } + + // Fix min, avg, max, stddev up to 3 decimal points + ['min', 'avg', 'max', 'stddev', 'packetLoss'].forEach(function (key) { + var v = ret[key]; + if (typeof v === 'number') { + ret[key] = v.toFixed(3); + } + }); + + return ret; +}; + +module.exports = parser; diff --git a/server/modules/ping/lib/parser/factory.js b/server/modules/ping/lib/parser/factory.js new file mode 100644 index 000000000..4cc8880ff --- /dev/null +++ b/server/modules/ping/lib/parser/factory.js @@ -0,0 +1,47 @@ +'use strict'; + +var util = require('util'); + +var builderFactory = require('../builder/factory'); +var WinParser = require('./win'); +var MacParser = require('./mac'); +var LinuxParser = require('./linux'); +var OpenBSDParser = require('./openbsd'); + +/** + * A factory creates a parser for parsing output from system ping + * @constructor + */ +function factory() {} + +/** + * Create a parser for a given platform + * @param {string} addr - Hostname or ip addres + * @param {string} platform - Name of the platform + * @param {PingConfig} [config] - Config object in probe() + * @return {object} - Parser + * @throw if given platform is not supported + */ +factory.createParser = function (addr, platform, config) { + // Avoid function reassignment + var _config = config || {}; + + if (!builderFactory.isPlatformSupport(platform)) { + throw new Error(util.format('Platform |%s| is not support', platform)); + } + + var ret = null; + if (builderFactory.isWindow(platform)) { + ret = new WinParser(addr, _config); + } else if (builderFactory.isMacOS(platform)) { + ret = new MacParser(addr, _config); + } else if (builderFactory.isLinux(platform)) { + ret = new LinuxParser(addr, _config); + } else if (builderFactory.isOpenBSD(platform)) { + ret = new OpenBSDParser(addr, _config); + } + + return ret; +}; + +module.exports = factory; diff --git a/server/modules/ping/lib/parser/linux.js b/server/modules/ping/lib/parser/linux.js new file mode 100644 index 000000000..850162e15 --- /dev/null +++ b/server/modules/ping/lib/parser/linux.js @@ -0,0 +1,61 @@ +'use strict'; + +var util = require('util'); +var base = require('./base'); +var MacParser = require('./mac'); + +/** + * @constructor + * @param {string} addr - Hostname or ip addres + * @param {PingConfig} config - Config object in probe() + */ +function LinuxParser(addr, config) { + base.call(this, addr, config); +} + +util.inherits(LinuxParser, base); + +/** + * Process output's body + * @param {string} line - A line from system ping + */ +LinuxParser.prototype._processHeader = function (line) { + // Get host and numeric_host + var tokens = line.split(' '); + var isProbablyIPv4 = tokens[1].indexOf('(') === -1; + + if (isProbablyIPv4) { + this._response.host = tokens[1]; + this._response.numeric_host = tokens[2].slice(1, -1); + } else { + // Normalise into either a 2 or 3 element array + var foundAddresses = tokens + .slice(1, -3) + .join('') + .match(/([^\s()]+)/g); + this._response.host = foundAddresses.shift(); + this._response.numeric_host = foundAddresses.pop(); + } + + this._changeState(this.STATES.BODY); +}; + +/** + * Process output's body + * @param {string} line - A line from system ping + */ +LinuxParser.prototype._processBody = function (line) { + // Reuse mac parser implementation + MacParser.prototype._processBody.call(this, line); +}; + +/** + * Process output's footer + * @param {string} line - A line from system ping + */ +LinuxParser.prototype._processFooter = function (line) { + // Reuse mac parser implementation + MacParser.prototype._processFooter.call(this, line); +}; + +module.exports = LinuxParser; diff --git a/server/modules/ping/lib/parser/mac.js b/server/modules/ping/lib/parser/mac.js new file mode 100644 index 000000000..3598be9c6 --- /dev/null +++ b/server/modules/ping/lib/parser/mac.js @@ -0,0 +1,82 @@ +'use strict'; + +var util = require('util'); +var base = require('./base'); + +/** + * @constructor + * @param {string} addr - Hostname or ip addres + * @param {PingConfig} config - Config object in probe() + */ +function MacParser(addr, config) { + base.call(this, addr, config); +} + +util.inherits(MacParser, base); + +/** + * Process output's header + * @param {string} line - A line from system ping + */ +MacParser.prototype._processHeader = function (line) { + // Get host and numeric_host + var tokens = line.split(' '); + + this._response.host = tokens[1]; + this._response.numeric_host = tokens[2].slice(1, -2); + + this._changeState(this.STATES.BODY); +}; + +/** + * Process output's body + * @param {string} line - A line from system ping + */ +MacParser.prototype._processBody = function (line) { + // XXX: Assume there is at least 3 '=' can be found + var count = (line.match(/=/g) || []).length; + if (count >= 3) { + var regExp = /([0-9.]+)[ ]*ms/; + var match = regExp.exec(line); + this._times.push(parseFloat(match[1], 10)); + } + + // Change state if it see a '---' + if (line.indexOf('---') >= 0) { + this._changeState(this.STATES.FOOTER); + } +}; + +/** + * Process output's footer + * @param {string} line - A line from system ping + */ +MacParser.prototype._processFooter = function (line) { + var packetLoss = line.match(/ ([\d.]+)%/); + if (packetLoss) { + this._response.packetLoss = parseFloat(packetLoss[1], 10); + } + + // XXX: Assume number of keywords '/' more than 3 + var count = (line.match(/[/]/g) || []).length; + if (count >= 3) { + var regExp = /([0-9.]+)/g; + // XXX: Assume min avg max stddev + var m1 = regExp.exec(line); + var m2 = regExp.exec(line); + var m3 = regExp.exec(line); + var m4 = regExp.exec(line); + + if (m1 && m2 && m3 && m4) { + this._response.min = parseFloat(m1[1], 10); + this._response.avg = parseFloat(m2[1], 10); + this._response.max = parseFloat(m3[1], 10); + this._response.stddev = parseFloat(m4[1], 10); + this._changeState(this.STATES.END); + } + + this._changeState(this.STATES.END); + } +}; + +module.exports = MacParser; diff --git a/server/modules/ping/lib/parser/openbsd.js b/server/modules/ping/lib/parser/openbsd.js new file mode 100644 index 000000000..a509bca68 --- /dev/null +++ b/server/modules/ping/lib/parser/openbsd.js @@ -0,0 +1,83 @@ +'use strict'; + +var util = require('util'); + +var base = require('./base'); + +/** + * @constructor + * @param {string} addr - Hostname or ip addres + * @param {PingConfig} config - Config object in probe() + */ +function OpenBSDParser(addr, config) { + base.call(this, addr, config); +} + +util.inherits(OpenBSDParser, base); + +/** + * Process output's header + * @param {string} line - A line from system ping + */ +OpenBSDParser.prototype._processHeader = function (line) { + // Get host and numeric_host + var tokens = line.split(' '); + + this._response.host = tokens[1]; + this._response.numeric_host = tokens[2].slice(1, -2); + + this._changeState(this.STATES.BODY); +}; + +/** + * Process output's body + * @param {string} line - A line from system ping + */ +OpenBSDParser.prototype._processBody = function (line) { + // XXX: Assume there is at least 3 '=' can be found + var count = (line.match(/=/g) || []).length; + if (count >= 3) { + var regExp = /([0-9.]+)[ ]*ms/; + var match = regExp.exec(line); + this._times.push(parseFloat(match[1], 10)); + } + + // Change state if it see a '---' + if (line.indexOf('---') >= 0) { + this._changeState(this.STATES.FOOTER); + } +}; + +/** + * Process output's footer + * @param {string} line - A line from system ping + */ +OpenBSDParser.prototype._processFooter = function (line) { + var packetLoss = line.match(/ ([\d.]+)%/); + if (packetLoss) { + this._response.packetLoss = parseFloat(packetLoss[1], 10); + } + + // XXX: Assume number of keywords '/' more than 3 + var count = (line.match(/[/]/g) || []).length; + if (count >= 3) { + var regExp = /([0-9.]+)/g; + // XXX: Assume min avg max stddev + var m1 = regExp.exec(line); + var m2 = regExp.exec(line); + var m3 = regExp.exec(line); + var m4 = regExp.exec(line); + + if (m1 && m2 && m3 && m4) { + this._response.min = parseFloat(m1[1], 10); + this._response.avg = parseFloat(m2[1], 10); + this._response.max = parseFloat(m3[1], 10); + this._response.stddev = parseFloat(m4[1], 10); + this._changeState(this.STATES.END); + } + + this._changeState(this.STATES.END); + } +}; + +module.exports = OpenBSDParser; diff --git a/server/modules/ping/lib/parser/win.js b/server/modules/ping/lib/parser/win.js new file mode 100644 index 000000000..1f91c822d --- /dev/null +++ b/server/modules/ping/lib/parser/win.js @@ -0,0 +1,183 @@ +'use strict'; + +var util = require('util'); +var base = require('./base'); + +/** + * @constructor + * @param {string} addr - Hostname or ip addres + * @param {PingConfig} config - Config object in probe() + */ +function WinParser(addr, config) { + base.call(this, addr, config); + this._ipv4Regex = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/; +} + +util.inherits(WinParser, base); + +/** + * Process output's header + * @param {string} line - A line from system ping + */ +WinParser.prototype._processHeader = function (line) { + // XXX: Expect to find [****] when pinging domain like google.com + // Read fixture/win/**/* for the detail + var isPingNumeric = line.indexOf('[') === -1; + + // Get host and numeric_host + var tokens = line.split(' '); + + if (isPingNumeric) { + // For those missing [***], get the first token which match IPV4 regex + this._response.host = tokens.find( + function (t) { + return this._ipv4Regex.test(t); + }, + this + ); + this._response.numeric_host = this._response.host; + } else { + // For those has [***], anchor with such token + var numericHost = tokens.find( + function (t) { + return t.indexOf('[') !== -1; + }, + this + ); + var numericHostIndex = tokens.indexOf(numericHost); + var match = /\[(.*)\]/.exec(numericHost); + + if (match) { + // Capture IP inside [] only. refs #71 + this._response.numeric_host = match[1]; + } else { + // Otherwise, just mark as NA to indicate an error + this._response.numeric_host = 'NA'; + } + this._response.host = tokens[numericHostIndex - 1]; + } + + this._changeState(this.STATES.BODY); +}; + +/** + * Process ipv6 output's body + * @param {string} line - A line from system ping + */ +WinParser.prototype._processIPV6Body = function (line) { + var tokens = line.split(' '); + var dataFields = tokens.filter(function (token) { + var isDataField = token.indexOf('=') >= 0 || token.indexOf('<') >= 0; + return isDataField; + }); + + // refs #65: Support system like french which has an extra space + dataFields = dataFields.map(function (dataField) { + var ret = dataField; + var dataFieldIndex = tokens.indexOf(dataField); + var nextIndex = dataFieldIndex + 1; + + // Append the missing *ms* + if (nextIndex < tokens.length) { + if (tokens[nextIndex] === 'ms') { + ret += 'ms'; + } + } + + return ret; + }); + + var expectDataFieldInReplyLine = 1; + if (dataFields.length >= expectDataFieldInReplyLine) { + // XXX: Assume time will alaways get keyword ms for all language + var timeKVP = dataFields.find(function (dataField) { + return dataField.search(/(ms|мс)/i) >= 0; + }); + var regExp = /([0-9.]+)/; + var match = regExp.exec(timeKVP); + + this._times.push(parseFloat(match[1], 10)); + } +}; + +/** + * Process ipv4 output's body + * @param {string} line - A line from system ping + */ +WinParser.prototype._processIPV4Body = function (line) { + var tokens = line.split(' '); + var byteTimeTTLFields = tokens.filter(function (token) { + var isDataField = token.indexOf('=') >= 0 || token.indexOf('<') >= 0; + return isDataField; + }); + + var expectDataFieldInReplyLine = 3; + var isReplyLine = byteTimeTTLFields.length >= expectDataFieldInReplyLine; + if (isReplyLine) { + var packetSize = this._pingConfig.packetSize; + var byteField = byteTimeTTLFields.find(function (dataField) { + var packetSizeToken = util.format('=%d', packetSize); + var isByteField = dataField.indexOf(packetSizeToken) >= 0; + return isByteField; + }); + + // XXX: Assume time field will always be next of byte field + var byteFieldIndex = byteTimeTTLFields.indexOf(byteField); + var timeFieldIndex = byteFieldIndex + 1; + var timeKVP = byteTimeTTLFields[timeFieldIndex]; + + var regExp = /([0-9.]+)/; + var match = regExp.exec(timeKVP); + + this._times.push(parseFloat(match[1], 10)); + return true; + } + return false; +}; + +/** + * Process output's body + * @param {string} line - A line from system ping + */ +WinParser.prototype._processBody = function (line) { + var isPingSummaryLineShown = line.slice(-1) === ':'; + if (isPingSummaryLineShown) { + this._changeState(this.STATES.FOOTER); + return; + } + + const ok = this._processIPV4Body(line); + + if (!ok) { + this._processIPV6Body(line); + } +}; + +/** + * Process output's footer + * @param {string} line - A line from system ping + */ +WinParser.prototype._processFooter = function (line) { + var packetLoss = line.match(/([\d.]+)%/); + if (packetLoss) { + this._response.packetLoss = parseFloat(packetLoss[1], 10); + } + + // XXX: Assume there is a keyword ms + if (line.search(/(ms|мсек)/i) >= 0) { + // XXX: Assume the ordering is Min Max Avg + var regExp = /([0-9.]+)/g; + var m1 = regExp.exec(line); + var m2 = regExp.exec(line); + var m3 = regExp.exec(line); + + if (m1 && m2 && m3) { + this._response.min = parseFloat(m1[1], 10); + this._response.max = parseFloat(m2[1], 10); + this._response.avg = parseFloat(m3[1], 10); + this._changeState(this.STATES.END); + } + } +}; + +module.exports = WinParser; diff --git a/server/modules/ping/lib/ping-promise.js b/server/modules/ping/lib/ping-promise.js new file mode 100644 index 000000000..361201781 --- /dev/null +++ b/server/modules/ping/lib/ping-promise.js @@ -0,0 +1,105 @@ +'use strict'; + +/** + * LICENSE MIT + * (C) Daniel Zelisko + * http://github.com/danielzzz/node-ping + * + * a simple wrapper for ping + * Now with support of not only english Windows. + * + */ + +// System library +var util = require('util'); +var net = require('net'); +var cp = require('child_process'); +var os = require('os'); + +// Our library +var builderFactory = require('./builder/factory'); +var parserFactory = require('./parser/factory'); + +/** + * Refer to probe() + */ +function _probe(addr, config) { + // Do not reassign function argument + var _config = config || {}; + if (_config.v6 === undefined) { + _config.v6 = net.isIPv6(addr); + } + + // Convert callback base system command to promise base + return new Promise(function (resolve, reject) { + // Spawn a ping process + var ping = null; + var platform = os.platform(); + try { + var argumentBuilder = builderFactory.createBuilder(platform); + var pingExecutablePath = builderFactory.getExecutablePath(platform, _config.v6); + var pingArgs = argumentBuilder.getCommandArguments(addr, _config); + var spawnOptions = argumentBuilder.getSpawnOptions(); + ping = cp.spawn(pingExecutablePath, pingArgs, spawnOptions); + } catch (err) { + reject(err); + return; + } + + // Initial parser + var parser = parserFactory.createParser(addr, platform, _config); + + // Register events from system ping + ping.once('error', function () { + var err = new Error( + util.format( + 'ping.probe: %s. %s', + 'there was an error while executing the ping program. ', + 'Check the path or permissions...' + ) + ); + reject(err); + }); + + // Cache all lines from the system ping + var outstring = []; + ping.stdout.on('data', function (data) { + outstring.push(String(data)); + }); + ping.stderr.on('data', function (data) { + outstring.push(String(data)); + }); + + // Parse lines we have on closing system ping + ping.once('close', function () { + // Merge lines we have and split it by \n + var lines = outstring.join('').split('\n'); + + // Parse line one by one + lines.forEach(parser.eat, parser); + + // Get result + var ret = parser.getResult(); + + resolve(ret); + }); + }); +} + +/** + * Class::PromisePing + * @param {string} addr - Hostname or ip addres + * @param {PingConfig} config - Configuration for command ping + * @return {Promise} + */ +function probe(addr, config) { + try { + var probePromise = _probe(addr, config); + return probePromise; + } catch (error) { + var errorPromise = Promise.reject(error); + return errorPromise; + } +} + +exports.probe = probe; diff --git a/server/modules/ping/lib/ping-sys.js b/server/modules/ping/lib/ping-sys.js new file mode 100644 index 000000000..e238fa350 --- /dev/null +++ b/server/modules/ping/lib/ping-sys.js @@ -0,0 +1,44 @@ +'use strict'; + +/** +* LICENSE MIT +* (C) Daniel Zelisko +* http://github.com/danielzzz/node-ping +* +* a simple wrapper for ping +* Now with support of not only english Windows. +* +*/ + +// Promise implementation +var ping = require('./ping-promise'); + +// TODO: +// 1. Port round trip time to this callback +// 2. However, it may breaks backward compatability +// 3. Need discussion +/** + * Callback after probing given host + * @callback probeCallback + * @param {boolean} isAlive - Whether target is alive or not + * @param {Object} error - Null if no error occurs + */ + +/** + * Class::Ping construtor + * @param {string} addr - Hostname or ip addres + * @param {probeCallback} cb - Callback + * @param {PingConfig} config - Configuration for command ping + */ +function probe(addr, cb, config) { + // Do not reassign function parameter + var _config = config || {}; + + return ping.probe(addr, _config).then(function (res) { + cb(res.alive, null); + }).catch(function (err) { + cb(null, err); + }); +} + +exports.probe = probe; diff --git a/server/monitor-types/ping.js b/server/monitor-types/ping.js new file mode 100644 index 000000000..7eb295a1b --- /dev/null +++ b/server/monitor-types/ping.js @@ -0,0 +1,136 @@ +const { MonitorType } = require("./monitor-type"); +const { + log, + UP, + PING_PACKET_SIZE_DEFAULT, + PING_GLOBAL_TIMEOUT_DEFAULT, + PING_COUNT_DEFAULT, + PING_PER_REQUEST_TIMEOUT_DEFAULT, +} = require("../../src/util"); +const { convertToUTF8 } = require("../util-server"); +const pingLib = require("../modules/ping"); + +const isWindows = process.platform === /^win/.test(process.platform); + +/** + * Ping the specified machine + * @param {string} destAddr Hostname / IP address of machine to ping + * @param {number} count Number of packets to send before stopping + * @param {string} sourceAddr Source address for sending/receiving echo requests + * @param {boolean} numeric If true, IP addresses will be output instead of symbolic hostnames + * @param {number} size Size (in bytes) of echo request to send + * @param {number} deadline Maximum time in seconds before ping stops, regardless of packets sent + * @param {number} timeout Maximum time in seconds to wait for each response + * @returns {Promise} Time for ping in ms rounded to nearest integer + */ +async function ping( + destAddr, + count = PING_COUNT_DEFAULT, + sourceAddr = "", + numeric = true, + size = PING_PACKET_SIZE_DEFAULT, + deadline = PING_GLOBAL_TIMEOUT_DEFAULT, + timeout = PING_PER_REQUEST_TIMEOUT_DEFAULT +) { + try { + return await pingAsync(destAddr, false, count, sourceAddr, numeric, size, deadline, timeout); + } catch (e) { + // If the host cannot be resolved, try again with ipv6 + log.debug("ping", "IPv6 error message: " + e.message); + + // As node-ping does not report a specific error for this, try again if it is an empty message with ipv6 no matter what. + if (!e.message) { + return await pingAsync(destAddr, true, count, sourceAddr, numeric, size, deadline, timeout); + } else { + throw e; + } + } +} + +/** + * Ping the specified machine + * @param {string} destAddr Hostname / IP address of machine to ping + * @param {boolean} ipv6 Should IPv6 be used? + * @param {number} count Number of packets to send before stopping + * @param {string} sourceAddr Source address for sending/receiving echo requests + * @param {boolean} numeric If true, IP addresses will be output instead of symbolic hostnames + * @param {number} size Size (in bytes) of echo request to send + * @param {number} deadline Maximum time in seconds before ping stops, regardless of packets sent + * @param {number} timeout Maximum time in seconds to wait for each response + * @returns {Promise} Time for ping in ms rounded to nearest integer + */ +function pingAsync( + destAddr, + ipv6 = false, + count = PING_COUNT_DEFAULT, + sourceAddr = "", + numeric = true, + size = PING_PACKET_SIZE_DEFAULT, + deadline = PING_GLOBAL_TIMEOUT_DEFAULT, + timeout = PING_PER_REQUEST_TIMEOUT_DEFAULT +) { + try { + const url = new URL(`http://${destAddr}`); + destAddr = url.hostname; + if (destAddr.startsWith("[") && destAddr.endsWith("]")) { + destAddr = destAddr.slice(1, -1); + } + } catch (e) { + // ignore + } + + return new Promise((resolve, reject) => { + pingLib.promise + .probe(destAddr, { + v6: ipv6, + min_reply: count, + sourceAddr: sourceAddr, + numeric: numeric, + packetSize: size, + deadline: deadline, + timeout: timeout, + }) + .then((res) => { + // If ping failed, it will set field to unknown + if (res.alive) { + resolve(res.time); + } else { + if (isWindows) { + reject(new Error(convertToUTF8(res.output))); + } else { + reject(new Error(res.output)); + } + } + }) + .catch((err) => { + reject(err); + }); + }); +} + +class PingMonitorType extends MonitorType { + name = "ping"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + heartbeat.ping = await ping( + monitor.hostname, + monitor.ping_count, + "", + monitor.ping_numeric, + monitor.packetSize, + monitor.timeout, + monitor.ping_per_request_timeout + ); + heartbeat.msg = ""; + heartbeat.status = UP; + } +} + +module.exports = { + PingMonitorType, + ping, + pingAsync, +}; diff --git a/server/monitor-types/steam.js b/server/monitor-types/steam.js index ec797eaa4..5eaad8c1c 100644 --- a/server/monitor-types/steam.js +++ b/server/monitor-types/steam.js @@ -6,7 +6,8 @@ const { PING_PER_REQUEST_TIMEOUT_DEFAULT, } = require("../../src/util"); const { Settings } = require("../settings"); -const { ping, checkStatusCode } = require("../util-server"); +const { checkStatusCode } = require("../util-server"); +const { ping } = require("./ping"); const axios = require("axios"); const crypto = require("crypto"); const dns = require("node:dns/promises"); diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 6e0040ee0..1e0e81309 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -107,6 +107,7 @@ class UptimeKumaServer { // Set Monitor Types UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType(); + UptimeKumaServer.monitorTypeList["ping"] = new PingMonitorType(); UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing(); UptimeKumaServer.monitorTypeList["websocket-upgrade"] = new WebSocketMonitorType(); UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType(); @@ -599,6 +600,7 @@ module.exports = { // Must be at the end to avoid circular dependencies const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type"); +const { PingMonitorType } = require("./monitor-types/ping"); const { TailscalePing } = require("./monitor-types/tailscale-ping"); const { WebSocketMonitorType } = require("./monitor-types/websocket-upgrade"); const { DnsMonitorType } = require("./monitor-types/dns"); diff --git a/server/util-server.js b/server/util-server.js index 01b54385e..b415a2791 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -1,15 +1,7 @@ import { checkLogin as betterAuthCheckLogin } from "./better-auth"; -const ping = require("@louislam/ping"); const { R } = require("redbean-node"); -const { - log, - badgeConstants, - PING_PACKET_SIZE_DEFAULT, - PING_GLOBAL_TIMEOUT_DEFAULT, - PING_COUNT_DEFAULT, - PING_PER_REQUEST_TIMEOUT_DEFAULT, -} = require("../src/util"); +const { log, badgeConstants } = require("../src/util"); const iconv = require("iconv-lite"); const chardet = require("chardet"); const chroma = require("chroma-js"); @@ -34,8 +26,6 @@ dayjs.extend(require("dayjs/plugin/utc")); const { Kafka, SASLOptions } = require("kafkajs"); const crypto = require("crypto"); -const isWindows = process.platform === /^win/.test(process.platform); - /** * Gets an Access Token from an oidc/oauth2 provider * @param {string} tokenEndpoint The token URI from the auth service provider @@ -76,102 +66,6 @@ exports.getOidcTokenClientCredentials = async ( return await client.grant(grantParams); }; -/** - * Ping the specified machine - * @param {string} destAddr Hostname / IP address of machine to ping - * @param {number} count Number of packets to send before stopping - * @param {string} sourceAddr Source address for sending/receiving echo requests - * @param {boolean} numeric If true, IP addresses will be output instead of symbolic hostnames - * @param {number} size Size (in bytes) of echo request to send - * @param {number} deadline Maximum time in seconds before ping stops, regardless of packets sent - * @param {number} timeout Maximum time in seconds to wait for each response - * @returns {Promise} Time for ping in ms rounded to nearest integer - */ -exports.ping = async ( - destAddr, - count = PING_COUNT_DEFAULT, - sourceAddr = "", - numeric = true, - size = PING_PACKET_SIZE_DEFAULT, - deadline = PING_GLOBAL_TIMEOUT_DEFAULT, - timeout = PING_PER_REQUEST_TIMEOUT_DEFAULT -) => { - try { - return await exports.pingAsync(destAddr, false, count, sourceAddr, numeric, size, deadline, timeout); - } catch (e) { - // If the host cannot be resolved, try again with ipv6 - log.debug("ping", "IPv6 error message: " + e.message); - - // As node-ping does not report a specific error for this, try again if it is an empty message with ipv6 no matter what. - if (!e.message) { - return await exports.pingAsync(destAddr, true, count, sourceAddr, numeric, size, deadline, timeout); - } else { - throw e; - } - } -}; - -/** - * Ping the specified machine - * @param {string} destAddr Hostname / IP address of machine to ping - * @param {boolean} ipv6 Should IPv6 be used? - * @param {number} count Number of packets to send before stopping - * @param {string} sourceAddr Source address for sending/receiving echo requests - * @param {boolean} numeric If true, IP addresses will be output instead of symbolic hostnames - * @param {number} size Size (in bytes) of echo request to send - * @param {number} deadline Maximum time in seconds before ping stops, regardless of packets sent - * @param {number} timeout Maximum time in seconds to wait for each response - * @returns {Promise} Time for ping in ms rounded to nearest integer - */ -exports.pingAsync = function ( - destAddr, - ipv6 = false, - count = PING_COUNT_DEFAULT, - sourceAddr = "", - numeric = true, - size = PING_PACKET_SIZE_DEFAULT, - deadline = PING_GLOBAL_TIMEOUT_DEFAULT, - timeout = PING_PER_REQUEST_TIMEOUT_DEFAULT -) { - try { - const url = new URL(`http://${destAddr}`); - destAddr = url.hostname; - if (destAddr.startsWith("[") && destAddr.endsWith("]")) { - destAddr = destAddr.slice(1, -1); - } - } catch (e) { - // ignore - } - - return new Promise((resolve, reject) => { - ping.promise - .probe(destAddr, { - v6: ipv6, - min_reply: count, - sourceAddr: sourceAddr, - numeric: numeric, - packetSize: size, - deadline: deadline, - timeout: timeout, - }) - .then((res) => { - // If ping failed, it will set field to unknown - if (res.alive) { - resolve(res.time); - } else { - if (isWindows) { - reject(new Error(exports.convertToUTF8(res.output))); - } else { - reject(new Error(res.output)); - } - } - }) - .catch((err) => { - reject(err); - }); - }); -}; - /** * Monitor Kafka using Producer * @param {string[]} brokers List of kafka brokers to connect, host and diff --git a/test/backend-test/monitors/test-sftp.js b/test/backend-test/monitors/test-sftp.js index b30a68433..81133aa7c 100644 --- a/test/backend-test/monitors/test-sftp.js +++ b/test/backend-test/monitors/test-sftp.js @@ -46,7 +46,7 @@ const EXISTING_PATH = "/existing/path"; // --------------------------------------------------------------------------- /** Pinned so the suite cannot break when a new image is published upstream. */ -const SSH_IMAGE = "lscr.io/linuxserver/openssh-server:10.3_p1-r0-ls233"; +const SSH_IMAGE = "ghcr.io/linuxserver/openssh-server:10.3_p1-r0-ls233"; /** Port sshd listens on inside the linuxserver image (not 22). */ const SSH_CONTAINER_PORT = 2222; /** Home directory of the ssh user in the image; used for the path-exists check. */ diff --git a/test/backend-test/test-util-server.js b/test/backend-test/test-ping.js similarity index 95% rename from test/backend-test/test-util-server.js rename to test/backend-test/test-ping.js index 29628d845..fc1d6134f 100644 --- a/test/backend-test/test-util-server.js +++ b/test/backend-test/test-ping.js @@ -1,8 +1,8 @@ const { describe, test } = require("node:test"); const assert = require("node:assert"); -const { pingAsync } = require("../../server/util-server"); +const { pingAsync } = require("../../server/monitor-types/ping"); -describe("Server Utilities: pingAsync", () => { +describe("Monitor Type: ping", () => { test("should convert IDN domains to Punycode before pinging", async () => { const idnDomain = "münchen.de"; const punycodeDomain = "xn--mnchen-3ya.de";