diff --git a/.github/workflows/auto-test.yml b/.github/workflows/auto-test.yml index c791bc880..0cc3ebf3b 100644 --- a/.github/workflows/auto-test.yml +++ b/.github/workflows/auto-test.yml @@ -22,7 +22,7 @@ jobs: strategy: matrix: os: [macos-latest, ubuntu-latest, windows-latest, ARM64] - node: [ 18, 20.5 ] + node: [ 16, 20.5 ] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: diff --git a/db/patch-monitor-tls-info-add-fk.sql b/db/patch-monitor-tls-info-add-fk.sql new file mode 100644 index 000000000..9b9c2d2cb --- /dev/null +++ b/db/patch-monitor-tls-info-add-fk.sql @@ -0,0 +1,18 @@ +BEGIN TRANSACTION; + +PRAGMA writable_schema = TRUE; + +UPDATE + SQLITE_MASTER +SET + sql = replace(sql, + 'monitor_id INTEGER NOT NULL', + 'monitor_id INTEGER NOT NULL REFERENCES [monitor] ([id]) ON DELETE CASCADE ON UPDATE CASCADE' +) +WHERE + name = 'monitor_tls_info' + AND type = 'table'; + +PRAGMA writable_schema = RESET; + +COMMIT; diff --git a/package.json b/package.json index 567efa1b5..ae1ad6e30 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,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.11 && npm ci --production && npm run download-dist", + "setup": "git checkout 1.23.13 && 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", @@ -79,7 +79,7 @@ "@louislam/sqlite3": "15.1.6", "@vvo/tzdb": "^6.125.0", "args-parser": "~1.3.0", - "axios": "~0.28.0", + "axios": "~0.28.1", "axios-ntlm": "1.3.0", "badge-maker": "~3.3.1", "bcryptjs": "~2.4.3", @@ -117,7 +117,7 @@ "mongodb": "~4.17.1", "mqtt": "~4.3.7", "mssql": "~8.1.4", - "mysql2": "~3.6.2", + "mysql2": "~3.9.6", "nanoid": "~3.3.4", "node-cloudflared-tunnel": "~1.0.9", "node-radius-client": "~1.0.0", @@ -140,14 +140,14 @@ "socket.io": "~4.6.1", "socket.io-client": "~4.6.1", "socks-proxy-agent": "6.1.1", - "tar": "~6.1.11", + "tar": "~6.2.1", "tcp-ping": "~0.1.1", "thirty-two": "~1.0.2", "tough-cookie": "~4.1.3", "ws": "^8.13.0" }, "devDependencies": { - "@actions/github": "~5.0.1", + "@actions/github": "~5.1.1", "@fortawesome/fontawesome-svg-core": "~1.2.36", "@fortawesome/free-regular-svg-icons": "~5.15.4", "@fortawesome/free-solid-svg-icons": "~5.15.4", @@ -171,7 +171,7 @@ "cross-env": "~7.0.3", "delay": "^5.0.0", "dns2": "~2.0.1", - "dompurify": "~2.4.3", + "dompurify": "~3.0.11", "eslint": "~8.14.0", "eslint-plugin-jsdoc": "~46.4.6", "eslint-plugin-vue": "~8.7.1", @@ -192,7 +192,7 @@ "test": "~3.3.0", "typescript": "~4.4.4", "v-pagination-3": "~0.1.7", - "vite": "~5.0.10", + "vite": "~5.2.8", "vite-plugin-compression": "^0.5.1", "vite-plugin-vue-devtools": "^7.0.15", "vue": "~3.4.2", diff --git a/server/model/monitor.js b/server/model/monitor.js index 80cff6f97..7397ff011 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -245,12 +245,12 @@ class Monitor extends BeanModel { /** * Encode user and password to Base64 encoding * for HTTP "basic" auth, as per RFC-7617 - * @param {string} user Username to encode - * @param {string} pass Password to encode - * @returns {string} Encoded username:password + * @param {string|null} user - The username (nullable if not changed by a user) + * @param {string|null} pass - The password (nullable if not changed by a user) + * @returns {string} */ encodeBase64(user, pass) { - return Buffer.from(user + ":" + pass).toString("base64"); + return Buffer.from(`${user || ""}:${pass || ""}`).toString("base64"); } /** @@ -533,6 +533,18 @@ class Monitor extends BeanModel { } } + let tlsInfo = {}; + // Store tlsInfo when secureConnect event is emitted + // The keylog event listener is a workaround to access the tlsSocket + options.httpsAgent.once("keylog", async (line, tlsSocket) => { + tlsSocket.once("secureConnect", async () => { + tlsInfo = checkCertificate(tlsSocket); + tlsInfo.valid = tlsSocket.authorized || false; + + await this.handleTlsInfo(tlsInfo); + }); + }); + log.debug("monitor", `[${this.name}] Axios Options: ${JSON.stringify(options)}`); log.debug("monitor", `[${this.name}] Axios Request`); @@ -542,31 +554,19 @@ class Monitor extends BeanModel { bean.msg = `${res.status} - ${res.statusText}`; bean.ping = dayjs().valueOf() - startTime; - // Check certificate if https is used - let certInfoStartTime = dayjs().valueOf(); - if (this.getUrl()?.protocol === "https:") { - log.debug("monitor", `[${this.name}] Check cert`); - try { - let tlsInfoObject = checkCertificate(res); - tlsInfo = await this.updateTlsInfo(tlsInfoObject); + // fallback for if kelog event is not emitted, but we may still have tlsInfo, + // e.g. if the connection is made through a proxy + if (this.getUrl()?.protocol === "https:" && tlsInfo.valid === undefined) { + const tlsSocket = res.request.res.socket; - if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) { - log.debug("monitor", `[${this.name}] call checkCertExpiryNotifications`); - await this.checkCertExpiryNotifications(tlsInfoObject); - } + if (tlsSocket) { + tlsInfo = checkCertificate(tlsSocket); + tlsInfo.valid = tlsSocket.authorized || false; - } catch (e) { - if (e.message !== "No TLS certificate in response") { - log.error("monitor", "Caught error"); - log.error("monitor", e.message); - } + await this.handleTlsInfo(tlsInfo); } } - if (process.env.TIMELOGGER === "1") { - log.debug("monitor", "Cert Info Query Time: " + (dayjs().valueOf() - certInfoStartTime) + "ms"); - } - if (process.env.UPTIME_KUMA_LOG_RESPONSE_BODY_MONITOR_ID === this.id) { log.info("monitor", res.data); } @@ -599,8 +599,12 @@ class Monitor extends BeanModel { let data = res.data; // convert data to object - if (typeof data === "string") { - data = JSON.parse(data); + if (typeof data === "string" && res.headers["content-type"] !== "application/json") { + try { + data = JSON.parse(data); + } catch (_) { + // Failed to parse as JSON, just process it as a string + } } let expression = jsonata(this.jsonPath); @@ -1615,6 +1619,20 @@ class Monitor extends BeanModel { return oAuthAccessToken; } + /** + * Store TLS certificate information and check for expiry + * @param {Object} tlsInfo Information about the TLS connection + * @returns {Promise} + */ + async handleTlsInfo(tlsInfo) { + await this.updateTlsInfo(tlsInfo); + this.prometheus?.update(null, tlsInfo); + + if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) { + log.debug("monitor", `[${this.name}] call checkCertExpiryNotifications`); + await this.checkCertExpiryNotifications(tlsInfo); + } + } } module.exports = Monitor; diff --git a/server/monitor-types/real-browser-monitor-type.js b/server/monitor-types/real-browser-monitor-type.js index 23493b7cb..f1219af18 100644 --- a/server/monitor-types/real-browser-monitor-type.js +++ b/server/monitor-types/real-browser-monitor-type.js @@ -10,6 +10,10 @@ const jwt = require("jsonwebtoken"); const config = require("../config"); const { RemoteBrowser } = require("../remote-browser"); +/** + * Cached instance of a browser + * @type {import ("playwright-core").Browser} + */ let browser = null; let allowedList = []; @@ -71,10 +75,12 @@ async function isAllowedChromeExecutable(executablePath) { /** * Get the current instance of the browser. If there isn't one, create * it. - * @returns {Promise} The browser + * @returns {Promise} The browser */ async function getBrowser() { - if (!browser) { + if (browser && browser.isConnected()) { + return browser; + } else { let executablePath = await Settings.get("chromeExecutable"); executablePath = await prepareChromeExecutable(executablePath); @@ -83,8 +89,9 @@ async function getBrowser() { //headless: false, executablePath, }); + + return browser; } - return browser; } /** diff --git a/server/notification-providers/dingding.js b/server/notification-providers/dingding.js index 5e7f030b5..3ad36d547 100644 --- a/server/notification-providers/dingding.js +++ b/server/notification-providers/dingding.js @@ -62,7 +62,7 @@ class DingDing extends NotificationProvider { if (result.data.errmsg === "ok") { return true; } - return false; + throw new Error(result.data.errmsg); } /** diff --git a/server/prometheus.js b/server/prometheus.js index f44af6c1c..f26125d2c 100644 --- a/server/prometheus.js +++ b/server/prometheus.js @@ -80,23 +80,25 @@ class Prometheus { } } - try { - monitorStatus.set(this.monitorLabelValues, heartbeat.status); - } catch (e) { - log.error("prometheus", "Caught error"); - log.error("prometheus", e); - } - - try { - if (typeof heartbeat.ping === "number") { - monitorResponseTime.set(this.monitorLabelValues, heartbeat.ping); - } else { - // Is it good? - monitorResponseTime.set(this.monitorLabelValues, -1); + if (heartbeat) { + try { + monitorStatus.set(this.monitorLabelValues, heartbeat.status); + } catch (e) { + log.error("prometheus", "Caught error"); + log.error("prometheus", e); + } + + try { + if (typeof heartbeat.ping === "number") { + monitorResponseTime.set(this.monitorLabelValues, heartbeat.ping); + } else { + // Is it good? + monitorResponseTime.set(this.monitorLabelValues, -1); + } + } catch (e) { + log.error("prometheus", "Caught error"); + log.error("prometheus", e); } - } catch (e) { - log.error("prometheus", "Caught error"); - log.error("prometheus", e); } } diff --git a/server/server.js b/server/server.js index d5e3f215f..28fb44267 100644 --- a/server/server.js +++ b/server/server.js @@ -1319,6 +1319,12 @@ let needSetup = false; await doubleCheckPassword(socket, currentPassword); } + // Log out all clients if enabling auth + // GHSA-23q2-5gf8-gjpp + if (currentDisabledAuth && !data.disableAuth) { + server.disconnectAllSocketClients(socket.userID, socket.id); + } + const previousChromeExecutable = await Settings.get("chromeExecutable"); const previousNSCDStatus = await Settings.get("nscd"); diff --git a/server/util-server.js b/server/util-server.js index 8add5bc56..93d4180df 100644 --- a/server/util-server.js +++ b/server/util-server.js @@ -653,21 +653,27 @@ const parseCertificateInfo = function (info) { /** * Check if certificate is valid - * @param {object} res Response object from axios - * @returns {object} Object containing certificate information - * @throws No socket was found to check certificate for + * @param {tls.TLSSocket} socket TLSSocket, which may or may not be connected + * @returns {Object} Object containing certificate information */ -exports.checkCertificate = function (res) { - if (!res.request.res.socket) { - throw new Error("No socket found"); +exports.checkCertificate = function (socket) { + let certInfoStartTime = dayjs().valueOf(); + + // Return null if there is no socket + if (socket === undefined || socket == null) { + return null; } - const info = res.request.res.socket.getPeerCertificate(true); - const valid = res.request.res.socket.authorized || false; + const info = socket.getPeerCertificate(true); + const valid = socket.authorized || false; log.debug("cert", "Parsing Certificate Info"); const parsedInfo = parseCertificateInfo(info); + if (process.env.TIMELOGGER === "1") { + log.debug("monitor", "Cert Info Query Time: " + (dayjs().valueOf() - certInfoStartTime) + "ms"); + } + return { valid: valid, certInfo: parsedInfo diff --git a/src/components/notifications/SMTP.vue b/src/components/notifications/SMTP.vue index e2ac77713..003f90556 100644 --- a/src/components/notifications/SMTP.vue +++ b/src/components/notifications/SMTP.vue @@ -5,6 +5,14 @@ + + + +
diff --git a/src/i18n.js b/src/i18n.js index d309e1132..f52c297c9 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -59,10 +59,29 @@ for (let lang in languageList) { const rtlLangs = [ "he-IL", "fa", "ar-SY", "ur" ]; -export const currentLocale = () => localStorage.locale - || languageList[navigator.language] && navigator.language - || languageList[navigator.language.substring(0, 2)] && navigator.language.substring(0, 2) - || "en"; +/** + * Find the best matching locale to display + * If no locale can be matched, the default is "en" + * @returns {string} the locale that should be displayed + */ +export function currentLocale() { + for (const locale of [ localStorage.locale, navigator.language, ...navigator.languages ]) { + // localstorage might not have a value or there might not be a language in `navigator.language` + if (!locale) { + continue; + } + if (locale in messages) { + return locale; + } + // some locales are further specified such as "en-US". + // If we only have a generic locale for this, we can use it too + const genericLocale = locale.split("-")[0]; + if (genericLocale in messages) { + return genericLocale; + } + } + return "en"; +} export const localeDirection = () => { return rtlLangs.includes(currentLocale()) ? "rtl" : "ltr"; diff --git a/src/lang/en.json b/src/lang/en.json index 21b0eec70..726186f55 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -63,6 +63,8 @@ "Friendly Name": "Friendly Name", "URL": "URL", "Hostname": "Hostname", + "locally configured mail transfer agent": "locally configured mail transfer agent", + "Either enter the hostname of the server you want to connect to or localhost if you intend to use a locally configured mail transfer agent": "Either enter the hostname of the server you want to connect to or {localhost} if you intend to use a {local_mta}", "Port": "Port", "Heartbeat Interval": "Heartbeat Interval", "Request Timeout": "Request Timeout", diff --git a/test/cypress/unit/i18n.spec.js b/test/cypress/unit/i18n.spec.js new file mode 100644 index 000000000..e69de29bb