Merge branch 'master' into status-page-domain

pull/1415/head
Louis Lam 3 years ago
commit 355aec46dc

@ -28,6 +28,8 @@ SECURITY.md
tsconfig.json
.env
/tmp
/babel.config.js
/ecosystem.config.js
### .gitignore content (commented rules are duplicated)
@ -42,4 +44,6 @@ dist-ssr
#!/data/.gitkeep
#.vscode
### End of .gitignore content

@ -99,7 +99,7 @@ https://github.com/louislam/uptime-kuma/projects/1
Thank you so much! (GitHub Sponsors will be updated manually. OpenCollective sponsors will be updated automatically, the list will be cached by GitHub though. It may need some time to be updated)
<img src="https://uptime.kuma.pet/sponsors?v=3" alt />
<img src="https://uptime.kuma.pet/sponsors?v=6" alt />
## 🖼 More Screenshots

@ -0,0 +1,7 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
ALTER TABLE monitor
ADD expiry_notification BOOLEAN default 1;
COMMIT;

@ -0,0 +1,23 @@
-- You should not modify if this have pushed to Github, unless it does serious wrong with the db.
BEGIN TRANSACTION;
CREATE TABLE proxy (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
user_id INT NOT NULL,
protocol VARCHAR(10) NOT NULL,
host VARCHAR(255) NOT NULL,
port SMALLINT NOT NULL,
auth BOOLEAN NOT NULL,
username VARCHAR(255) NULL,
password VARCHAR(255) NULL,
active BOOLEAN NOT NULL DEFAULT 1,
'default' BOOLEAN NOT NULL DEFAULT 0,
created_date DATETIME DEFAULT (DATETIME('now')) NOT NULL
);
ALTER TABLE monitor ADD COLUMN proxy_id INTEGER REFERENCES proxy(id);
CREATE INDEX proxy_id ON monitor (proxy_id);
CREATE INDEX proxy_user_id ON proxy (user_id);
COMMIT;

@ -1,8 +1,11 @@
# DON'T UPDATE TO node:14-bullseye-slim, see #372.
# If the image changed, the second stage image should be changed too
FROM node:16-buster-slim
ARG TARGETPLATFORM
WORKDIR /app
# Install Curl
# Install Apprise, add sqlite3 cli for debugging in the future, iputils-ping for ping, util-linux for setpriv
# Stupid python3 and python3-pip actually install a lot of useless things into Debian, specify --no-install-recommends to skip them, make the base even smaller than alpine!
RUN apt update && \
@ -10,3 +13,14 @@ RUN apt update && \
sqlite3 iputils-ping util-linux dumb-init && \
pip3 --no-cache-dir install apprise==0.9.7 && \
rm -rf /var/lib/apt/lists/*
# Install cloudflared
# dpkg --add-architecture arm: cloudflared do not provide armhf, this is workaround. Read more: https://github.com/cloudflare/cloudflared/issues/583
COPY extra/download-cloudflared.js ./extra/download-cloudflared.js
RUN node ./extra/download-cloudflared.js $TARGETPLATFORM && \
dpkg --add-architecture arm && \
apt update && \
apt --yes --no-install-recommends install ./cloudflared.deb && \
rm -rf /var/lib/apt/lists/* && \
rm -f cloudflared.deb

@ -10,11 +10,6 @@ const version = process.env.VERSION;
console.log("Beta Version: " + version);
if (!oldVersion || oldVersion.includes("-beta.")) {
console.error("Error: old version should not be a beta version?");
process.exit(1);
}
if (!version || !version.includes("-beta.")) {
console.error("invalid version, beta version only");
process.exit(1);

@ -0,0 +1,44 @@
//
const http = require("https"); // or 'https' for https:// URLs
const fs = require("fs");
const platform = process.argv[2];
if (!platform) {
console.error("No platform??");
process.exit(1);
}
let arch = null;
if (platform === "linux/amd64") {
arch = "amd64";
} else if (platform === "linux/arm64") {
arch = "arm64";
} else if (platform === "linux/arm/v7") {
arch = "arm";
} else {
console.error("Invalid platform?? " + platform);
}
const file = fs.createWriteStream("cloudflared.deb");
get("https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-" + arch + ".deb");
function get(url) {
http.get(url, function (res) {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
console.log("Redirect to " + res.headers.location);
get(res.headers.location);
} else if (res.statusCode >= 200 && res.statusCode < 300) {
res.pipe(file);
res.on("end", function () {
console.log("Downloaded");
});
} else {
console.error(res.statusCode);
process.exit(1);
}
});
}

@ -4,6 +4,7 @@ const tar = require("tar");
const packageJSON = require("../package.json");
const fs = require("fs");
const rmSync = require("./fs-rmSync.js");
const version = packageJSON.version;
const filename = "dist.tar.gz";
@ -21,7 +22,7 @@ function download(url) {
if (fs.existsSync("./dist")) {
if (fs.existsSync("./dist-backup")) {
fs.rmdirSync("./dist-backup", {
rmSync("./dist-backup", {
recursive: true
});
}
@ -35,7 +36,7 @@ function download(url) {
tarStream.on("close", () => {
if (fs.existsSync("./dist-backup")) {
fs.rmdirSync("./dist-backup", {
rmSync("./dist-backup", {
recursive: true
});
}

@ -0,0 +1,20 @@
const fs = require("fs");
/**
* Detect if `fs.rmSync` is available
* to avoid the runtime deprecation warning triggered for using `fs.rmdirSync` with `{ recursive: true }` in Node.js v16,
* or the `recursive` property removing completely in the future Node.js version.
* See the link below.
* @link https://nodejs.org/docs/latest-v16.x/api/deprecations.html#dep0147-fsrmdirpath--recursive-true-
* @param {fs.PathLike} path Valid types for path values in "fs".
* @param {fs.RmDirOptions} [options] options for `fs.rmdirSync`, if `fs.rmSync` is available and property `recursive` is true, it will automatically have property `force` with value `true`.
*/
const rmSync = (path, options) => {
if (typeof fs.rmSync === "function") {
if (options.recursive) {
options.force = true;
}
return fs.rmSync(path, options);
}
return fs.rmdirSync(path, options);
};
module.exports = rmSync;

@ -3,6 +3,7 @@
import fs from "fs";
import path from "path";
import util from "util";
import rmSync from "../fs-rmSync.js";
// https://stackoverflow.com/questions/13786160/copy-folder-recursively-in-node-js
/**
@ -30,7 +31,7 @@ console.log("Arguments:", process.argv);
const baseLangCode = process.argv[2] || "en";
console.log("Base Lang: " + baseLangCode);
if (fs.existsSync("./languages")) {
fs.rmdirSync("./languages", { recursive: true });
rmSync("./languages", { recursive: true });
}
copyRecursiveSync("../../src/languages", "./languages");
@ -40,7 +41,7 @@ const files = fs.readdirSync("./languages");
console.log("Files:", files);
for (const file of files) {
if (!file.endsWith(".js")) {
if (! file.endsWith(".js")) {
console.log("Skipping " + file);
continue;
}
@ -82,5 +83,5 @@ for (const file of files) {
fs.writeFileSync(`../../src/languages/${file}`, code);
}
fs.rmdirSync("./languages", { recursive: true });
rmSync("./languages", { recursive: true });
console.log("Done. Fixing formatting by ESLint...");

@ -1,5 +1,6 @@
const pkg = require("../package.json");
const fs = require("fs");
const rmSync = require("./fs-rmSync.js");
const child_process = require("child_process");
const util = require("../src/util");
@ -58,4 +59,3 @@ function tagExists(version) {
return res.stdout.toString().trim() === version;
}

1077
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
{
"name": "uptime-kuma",
"version": "1.13.1",
"version": "1.14.0-beta.1",
"license": "MIT",
"repository": {
"type": "git",
@ -79,10 +79,13 @@
"favico.js": "^0.3.10",
"form-data": "~4.0.0",
"http-graceful-shutdown": "~3.1.7",
"http-proxy-agent": "^5.0.0",
"https-proxy-agent": "^5.0.0",
"iconv-lite": "^0.6.3",
"jsonwebtoken": "~8.5.1",
"jwt-decode": "^3.1.2",
"limiter": "^2.1.0",
"node-cloudflared-tunnel": "~1.0.9",
"nodemailer": "~6.6.5",
"notp": "~2.0.3",
"password-hash": "~1.2.2",
@ -94,6 +97,7 @@
"redbean-node": "0.1.3",
"socket.io": "~4.4.1",
"socket.io-client": "~4.4.1",
"socks-proxy-agent": "^6.1.1",
"tar": "^6.1.11",
"tcp-ping": "~0.1.1",
"thirty-two": "~1.0.2",
@ -112,7 +116,7 @@
"vuedraggable": "~4.1.0"
},
"devDependencies": {
"@actions/github": "~5.0.0",
"@actions/github": "~5.0.1",
"@babel/eslint-parser": "~7.15.8",
"@babel/preset-env": "^7.15.8",
"@types/bootstrap": "~5.1.9",
@ -127,7 +131,7 @@
"eslint-plugin-vue": "~7.18.0",
"jest": "~27.2.5",
"jest-puppeteer": "~6.0.3",
"npm-check-updates": "^12.5.4",
"npm-check-updates": "^12.5.5",
"puppeteer": "~13.1.3",
"sass": "~1.42.1",
"stylelint": "~14.2.0",

@ -12,6 +12,10 @@ const { loginRateLimiter } = require("./rate-limiter");
* @returns {Promise<Bean|null>}
*/
exports.login = async function (username, password) {
if (typeof username !== "string" || typeof password !== "string") {
return null;
}
let user = await R.findOne("user", " username = ? AND active = 1 ", [
username,
]);

@ -17,7 +17,7 @@ exports.startInterval = () => {
res.data.slow = "1000.0.0";
}
if (!await setting("checkUpdate")) {
if (await setting("checkUpdate") === false) {
return;
}

@ -83,6 +83,23 @@ async function sendImportantHeartbeatList(socket, monitorID, toUser = false, ove
}
/**
* Delivers proxy list
*
* @param socket
* @return {Promise<Bean[]>}
*/
async function sendProxyList(socket) {
const timeLogger = new TimeLogger();
const list = await R.find("proxy", " user_id = ? ", [socket.userID]);
io.to(socket.userID).emit("proxyList", list.map(bean => bean.export()));
timeLogger.print("Send Proxy List");
return list;
}
async function sendInfo(socket) {
socket.emit("info", {
version: checkVersion.version,
@ -95,6 +112,6 @@ module.exports = {
sendNotificationList,
sendImportantHeartbeatList,
sendHeartbeatList,
sendInfo
sendProxyList,
sendInfo,
};

@ -54,6 +54,8 @@ class Database {
"patch-notification_sent_history.sql": true,
"patch-monitor-basic-auth.sql": true,
"patch-status-page.sql": true,
"patch-proxy.sql": true,
"patch-monitor-expiry-notification.sql": true,
}
/**

@ -1,7 +1,7 @@
const path = require("path");
const Bree = require("bree");
const { SHARE_ENV } = require("worker_threads");
let bree;
const jobs = [
{
name: "clear-old-data",
@ -10,7 +10,7 @@ const jobs = [
];
const initBackgroundJobs = function (args) {
const bree = new Bree({
bree = new Bree({
root: path.resolve("server", "jobs"),
jobs,
worker: {
@ -26,6 +26,13 @@ const initBackgroundJobs = function (args) {
return bree;
};
const stopBackgroundJobs = function () {
if (bree) {
bree.stop();
}
};
module.exports = {
initBackgroundJobs
initBackgroundJobs,
stopBackgroundJobs
};

@ -11,6 +11,7 @@ const { tcping, ping, dnsResolve, checkCertificate, checkStatusCode, getTotalCli
const { R } = require("redbean-node");
const { BeanModel } = require("redbean-node/dist/bean-model");
const { Notification } = require("../notification");
const { Proxy } = require("../proxy");
const { demoMode } = require("../config");
const version = require("../../package.json").version;
const apicache = require("../modules/apicache");
@ -73,6 +74,7 @@ class Monitor extends BeanModel {
interval: this.interval,
retryInterval: this.retryInterval,
keyword: this.keyword,
expiryNotification: this.isEnabledExpiryNotification(),
ignoreTls: this.getIgnoreTls(),
upsideDown: this.isUpsideDown(),
maxredirects: this.maxredirects,
@ -81,6 +83,7 @@ class Monitor extends BeanModel {
dns_resolve_server: this.dns_resolve_server,
dns_last_result: this.dns_last_result,
pushToken: this.pushToken,
proxyId: this.proxy_id,
notificationIDList,
tags: tags,
};
@ -99,6 +102,10 @@ class Monitor extends BeanModel {
return Buffer.from(user + ":" + pass).toString("base64");
}
isEnabledExpiryNotification() {
return Boolean(this.expiryNotification);
}
/**
* Parse to boolean
* @returns {boolean}
@ -181,6 +188,11 @@ class Monitor extends BeanModel {
};
}
const httpsAgentOptions = {
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
rejectUnauthorized: !this.getIgnoreTls(),
};
debug(`[${this.name}] Prepare Options for axios`);
const options = {
@ -194,17 +206,33 @@ class Monitor extends BeanModel {
...(this.headers ? JSON.parse(this.headers) : {}),
...(basicAuthHeader),
},
httpsAgent: new https.Agent({
maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940)
rejectUnauthorized: ! this.getIgnoreTls(),
}),
maxRedirects: this.maxredirects,
validateStatus: (status) => {
return checkStatusCode(status, this.getAcceptedStatuscodes());
},
};
if (this.proxy_id) {
const proxy = await R.load("proxy", this.proxy_id);
if (proxy && proxy.active) {
const { httpAgent, httpsAgent } = Proxy.createAgents(proxy, {
httpsAgentOptions: httpsAgentOptions,
});
options.proxy = false;
options.httpAgent = httpAgent;
options.httpsAgent = httpsAgent;
}
}
if (!options.httpsAgent) {
options.httpsAgent = new https.Agent(httpsAgentOptions);
}
debug(`[${this.name}] Axios Options: ${JSON.stringify(options)}`);
debug(`[${this.name}] Axios Request`);
let res = await axios.request(options);
bean.msg = `${res.status} - ${res.statusText}`;
bean.ping = dayjs().valueOf() - startTime;
@ -217,7 +245,7 @@ class Monitor extends BeanModel {
let tlsInfoObject = checkCertificate(res);
tlsInfo = await this.updateTlsInfo(tlsInfoObject);
if (!this.getIgnoreTls()) {
if (!this.getIgnoreTls() && this.isEnabledExpiryNotification()) {
debug(`[${this.name}] call sendCertNotification`);
await this.sendCertNotification(tlsInfoObject);
}
@ -477,6 +505,12 @@ class Monitor extends BeanModel {
stop() {
clearTimeout(this.heartbeatInterval);
this.isStop = true;
this.prometheus().remove();
}
prometheus() {
return new Prometheus(this);
}
/**

@ -0,0 +1,21 @@
const { BeanModel } = require("redbean-node/dist/bean-model");
class Proxy extends BeanModel {
toJSON() {
return {
id: this._id,
userId: this._user_id,
protocol: this._protocol,
host: this._host,
port: this._port,
auth: !!this._auth,
username: this._username,
password: this._password,
active: !!this._active,
default: !!this._default,
createdDate: this._created_date,
};
}
}
module.exports = Proxy;

@ -14,7 +14,7 @@ class Alerta extends NotificationProvider {
let config = {
headers: {
"Content-Type": "application/json;charset=UTF-8",
"Authorization": "Key " + notification.alertaapiKey,
"Authorization": "Key " + notification.alertaApiKey,
}
};
let data = {

@ -86,6 +86,16 @@ class Prometheus {
}
}
remove() {
try {
monitor_cert_days_remaining.remove(this.monitorLabelValues);
monitor_cert_is_valid.remove(this.monitorLabelValues);
monitor_response_time.remove(this.monitorLabelValues);
monitor_status.remove(this.monitorLabelValues);
} catch (e) {
console.error(e);
}
}
}
module.exports = {

@ -0,0 +1,170 @@
const { R } = require("redbean-node");
const HttpProxyAgent = require("http-proxy-agent");
const HttpsProxyAgent = require("https-proxy-agent");
const SocksProxyAgent = require("socks-proxy-agent");
const { debug } = require("../src/util");
class Proxy {
static SUPPORTED_PROXY_PROTOCOLS = ["http", "https", "socks", "socks5", "socks4"]
/**
* Saves and updates given proxy entity
*
* @param proxy
* @param proxyID
* @param userID
* @return {Promise<Bean>}
*/
static async save(proxy, proxyID, userID) {
let bean;
if (proxyID) {
bean = await R.findOne("proxy", " id = ? AND user_id = ? ", [proxyID, userID]);
if (!bean) {
throw new Error("proxy not found");
}
} else {
bean = R.dispense("proxy");
}
// Make sure given proxy protocol is supported
if (!this.SUPPORTED_PROXY_PROTOCOLS.includes(proxy.protocol)) {
throw new Error(`
Unsupported proxy protocol "${proxy.protocol}.
Supported protocols are ${this.SUPPORTED_PROXY_PROTOCOLS.join(", ")}."`
);
}
// When proxy is default update deactivate old default proxy
if (proxy.default) {
await R.exec("UPDATE proxy SET `default` = 0 WHERE `default` = 1");
}
bean.user_id = userID;
bean.protocol = proxy.protocol;
bean.host = proxy.host;
bean.port = proxy.port;
bean.auth = proxy.auth;
bean.username = proxy.username;
bean.password = proxy.password;
bean.active = proxy.active || true;
bean.default = proxy.default || false;
await R.store(bean);
if (proxy.applyExisting) {
await applyProxyEveryMonitor(bean.id, userID);
}
return bean;
}
/**
* Deletes proxy with given id and removes it from monitors
*
* @param proxyID
* @param userID
* @return {Promise<void>}
*/
static async delete(proxyID, userID) {
const bean = await R.findOne("proxy", " id = ? AND user_id = ? ", [proxyID, userID]);
if (!bean) {
throw new Error("proxy not found");
}
// Delete removed proxy from monitors if exists
await R.exec("UPDATE monitor SET proxy_id = null WHERE proxy_id = ?", [proxyID]);
// Delete proxy from list
await R.trash(bean);
}
/**
* Create HTTP and HTTPS agents related with given proxy bean object
*
* @param proxy proxy bean object
* @param options http and https agent options
* @return {{httpAgent: Agent, httpsAgent: Agent}}
*/
static createAgents(proxy, options) {
const { httpAgentOptions, httpsAgentOptions } = options || {};
let agent;
let httpAgent;
let httpsAgent;
const proxyOptions = {
protocol: proxy.protocol,
host: proxy.host,
port: proxy.port,
};
if (proxy.auth) {
proxyOptions.auth = `${proxy.username}:${proxy.password}`;
}
debug(`Proxy Options: ${JSON.stringify(proxyOptions)}`);
debug(`HTTP Agent Options: ${JSON.stringify(httpAgentOptions)}`);
debug(`HTTPS Agent Options: ${JSON.stringify(httpsAgentOptions)}`);
switch (proxy.protocol) {
case "http":
case "https":
httpAgent = new HttpProxyAgent({
...httpAgentOptions || {},
...proxyOptions
});
httpsAgent = new HttpsProxyAgent({
...httpsAgentOptions || {},
...proxyOptions,
});
break;
case "socks":
case "socks5":
case "socks4":
agent = new SocksProxyAgent({
...httpAgentOptions,
...httpsAgentOptions,
...proxyOptions,
});
httpAgent = agent;
httpsAgent = agent;
break;
default: throw new Error(`Unsupported proxy protocol provided. ${proxy.protocol}`);
}
return {
httpAgent,
httpsAgent
};
}
}
/**
* Applies given proxy id to monitors
*
* @param proxyID
* @param userID
* @return {Promise<void>}
*/
async function applyProxyEveryMonitor(proxyID, userID) {
// Find all monitors with id and proxy id
const monitors = await R.getAll("SELECT id, proxy_id FROM monitor WHERE user_id = ?", [userID]);
// Update proxy id not match with given proxy id
for (const monitor of monitors) {
if (monitor.proxy_id !== proxyID) {
await R.exec("UPDATE monitor SET proxy_id = ? WHERE id = ?", [proxyID, monitor.id]);
}
}
}
module.exports = {
Proxy,
};

@ -34,6 +34,14 @@ const loginRateLimiter = new KumaRateLimiter({
errorMessage: "Too frequently, try again later."
});
const twoFaRateLimiter = new KumaRateLimiter({
tokensPerInterval: 30,
interval: "minute",
fireImmediately: true,
errorMessage: "Too frequently, try again later."
});
module.exports = {
loginRateLimiter
loginRateLimiter,
twoFaRateLimiter,
};

@ -52,18 +52,21 @@ console.log("Importing this project modules");
debug("Importing Monitor");
const Monitor = require("./model/monitor");
debug("Importing Settings");
const { getSettings, setSettings, setting, initJWTSecret, checkLogin, startUnitTest, FBSD, errorLog } = require("./util-server");
const { getSettings, setSettings, setting, initJWTSecret, checkLogin, startUnitTest, FBSD, errorLog, doubleCheckPassword } = require("./util-server");
debug("Importing Notification");
const { Notification } = require("./notification");
Notification.init();
debug("Importing Proxy");
const { Proxy } = require("./proxy");
debug("Importing Database");
const Database = require("./database");
debug("Importing Background Jobs");
const { initBackgroundJobs } = require("./jobs");
const { loginRateLimiter } = require("./rate-limiter");
const { initBackgroundJobs, stopBackgroundJobs } = require("./jobs");
const { loginRateLimiter, twoFaRateLimiter } = require("./rate-limiter");
const { basicAuth } = require("./auth");
const { login } = require("./auth");
@ -91,6 +94,7 @@ const port = parseInt(process.env.UPTIME_KUMA_PORT || process.env.PORT || args.p
const sslKey = process.env.UPTIME_KUMA_SSL_KEY || process.env.SSL_KEY || args["ssl-key"] || undefined;
const sslCert = process.env.UPTIME_KUMA_SSL_CERT || process.env.SSL_CERT || args["ssl-cert"] || undefined;
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;
// 2FA / notp verification defaults
const twofa_verification_opts = {
@ -128,11 +132,12 @@ const io = new Server(server);
module.exports.io = io;
// Must be after io instantiation
const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList, sendInfo } = require("./client");
const { sendNotificationList, sendHeartbeatList, sendImportantHeartbeatList, sendInfo, sendProxyList } = require("./client");
const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler");
const databaseSocketHandler = require("./socket-handlers/database-socket-handler");
const TwoFA = require("./2fa");
const StatusPage = require("./model/status_page");
const { cloudflaredSocketHandler, autoStart: cloudflaredAutoStart, stop: cloudflaredStop } = require("./socket-handlers/cloudflared-socket-handler");
app.use(express.json());
@ -305,6 +310,15 @@ exports.entryPage = "dashboard";
socket.on("login", async (data, callback) => {
console.log("Login");
// Checking
if (typeof callback !== "function") {
return;
}
if (!data) {
return;
}
// Login Rate Limit
if (! await loginRateLimiter.pass(callback)) {
return;
@ -363,14 +377,27 @@ exports.entryPage = "dashboard";
});
socket.on("logout", async (callback) => {
// Rate Limit
if (! await loginRateLimiter.pass(callback)) {
return;
}
socket.leave(socket.userID);
socket.userID = null;
callback();
if (typeof callback === "function") {
callback();
}
});
socket.on("prepare2FA", async (callback) => {
socket.on("prepare2FA", async (currentPassword, callback) => {
try {
if (! await twoFaRateLimiter.pass(callback)) {
return;
}
checkLogin(socket);
await doubleCheckPassword(socket, currentPassword);
let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID,
@ -405,14 +432,19 @@ exports.entryPage = "dashboard";
} catch (error) {
callback({
ok: false,
msg: "Error while trying to prepare 2FA.",
msg: error.message,
});
}
});
socket.on("save2FA", async (callback) => {
socket.on("save2FA", async (currentPassword, callback) => {
try {
if (! await twoFaRateLimiter.pass(callback)) {
return;
}
checkLogin(socket);
await doubleCheckPassword(socket, currentPassword);
await R.exec("UPDATE `user` SET twofa_status = 1 WHERE id = ? ", [
socket.userID,
@ -425,14 +457,19 @@ exports.entryPage = "dashboard";
} catch (error) {
callback({
ok: false,
msg: "Error while trying to change 2FA.",
msg: error.message,
});
}
});
socket.on("disable2FA", async (callback) => {
socket.on("disable2FA", async (currentPassword, callback) => {
try {
if (! await twoFaRateLimiter.pass(callback)) {
return;
}
checkLogin(socket);
await doubleCheckPassword(socket, currentPassword);
await TwoFA.disable2FA(socket.userID);
callback({
@ -442,36 +479,47 @@ exports.entryPage = "dashboard";
} catch (error) {
callback({
ok: false,
msg: "Error while trying to change 2FA.",
msg: error.message,
});
}
});
socket.on("verifyToken", async (token, callback) => {
let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID,
]);
socket.on("verifyToken", async (token, currentPassword, callback) => {
try {
checkLogin(socket);
await doubleCheckPassword(socket, currentPassword);
let verify = notp.totp.verify(token, user.twofa_secret, twofa_verification_opts);
let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID,
]);
if (user.twofa_last_token !== token && verify) {
callback({
ok: true,
valid: true,
});
} else {
let verify = notp.totp.verify(token, user.twofa_secret, twofa_verification_opts);
if (user.twofa_last_token !== token && verify) {
callback({
ok: true,
valid: true,
});
} else {
callback({
ok: false,
msg: "Invalid Token.",
valid: false,
});
}
} catch (error) {
callback({
ok: false,
msg: "Invalid Token.",
valid: false,
msg: error.message,
});
}
});
socket.on("twoFAStatus", async (callback) => {
checkLogin(socket);
try {
checkLogin(socket);
let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID,
]);
@ -488,9 +536,10 @@ exports.entryPage = "dashboard";
});
}
} catch (error) {
console.log(error);
callback({
ok: false,
msg: "Error while trying to get 2FA status.",
msg: error.message,
});
}
});
@ -579,6 +628,9 @@ exports.entryPage = "dashboard";
throw new Error("Permission denied.");
}
// Reset Prometheus labels
monitorList[monitor.id]?.prometheus()?.remove();
bean.name = monitor.name;
bean.type = monitor.type;
bean.url = monitor.url;
@ -594,12 +646,14 @@ exports.entryPage = "dashboard";
bean.port = monitor.port;
bean.keyword = monitor.keyword;
bean.ignoreTls = monitor.ignoreTls;
bean.expiryNotification = monitor.expiryNotification;
bean.upsideDown = monitor.upsideDown;
bean.maxredirects = monitor.maxredirects;
bean.accepted_statuscodes_json = JSON.stringify(monitor.accepted_statuscodes);
bean.dns_resolve_type = monitor.dns_resolve_type;
bean.dns_resolve_server = monitor.dns_resolve_server;
bean.pushToken = monitor.pushToken;
bean.proxyId = Number.isInteger(monitor.proxyId) ? monitor.proxyId : null;
await R.store(bean);
@ -936,21 +990,13 @@ exports.entryPage = "dashboard";
throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.");
}
let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID,
]);
let user = await doubleCheckPassword(socket, password.currentPassword);
await user.resetPassword(password.newPassword);
if (user && passwordHash.verify(password.currentPassword, user.password)) {
user.resetPassword(password.newPassword);
callback({
ok: true,
msg: "Password has been updated successfully.",
});
} else {
throw new Error("Incorrect current password");
}
callback({
ok: true,
msg: "Password has been updated successfully.",
});
} catch (e) {
callback({
@ -977,10 +1023,14 @@ exports.entryPage = "dashboard";
}
});
socket.on("setSettings", async (data, callback) => {
socket.on("setSettings", async (data, currentPassword, callback) => {
try {
checkLogin(socket);
if (data.disableAuth) {
await doubleCheckPassword(socket, currentPassword);
}
await setSettings("general", data);
exports.entryPage = data.entryPage;
@ -1062,6 +1112,52 @@ exports.entryPage = "dashboard";
}
});
socket.on("addProxy", async (proxy, proxyID, callback) => {
try {
checkLogin(socket);
const proxyBean = await Proxy.save(proxy, proxyID, socket.userID);
await sendProxyList(socket);
if (proxy.applyExisting) {
await restartMonitors(socket.userID);
}
callback({
ok: true,
msg: "Saved",
id: proxyBean.id,
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("deleteProxy", async (proxyID, callback) => {
try {
checkLogin(socket);
await Proxy.delete(proxyID, socket.userID);
await sendProxyList(socket);
await restartMonitors(socket.userID);
callback({
ok: true,
msg: "Deleted",
});
} catch (e) {
callback({
ok: false,
msg: e.message,
});
}
});
socket.on("checkApprise", async (callback) => {
try {
checkLogin(socket);
@ -1080,6 +1176,7 @@ exports.entryPage = "dashboard";
console.log(`Importing Backup, User ID: ${socket.userID}, Version: ${backupData.version}`);
let notificationListData = backupData.notificationList;
let proxyListData = backupData.proxyList;
let monitorListData = backupData.monitorList;
let version17x = compareVersions.compare(backupData.version, "1.7.0", ">=");
@ -1098,6 +1195,7 @@ exports.entryPage = "dashboard";
await R.exec("DELETE FROM monitor_tag");
await R.exec("DELETE FROM tag");
await R.exec("DELETE FROM monitor");
await R.exec("DELETE FROM proxy");
}
// Only starts importing if the backup file contains at least one notification
@ -1117,6 +1215,24 @@ exports.entryPage = "dashboard";
}
}
// Only starts importing if the backup file contains at least one proxy
if (proxyListData.length >= 1) {
const proxies = await R.findAll("proxy");
// Loop over proxy list and save proxies
for (const proxy of proxyListData) {
const exists = proxies.find(item => item.id === proxy.id);
// Do not process when proxy already exists in import handle is skip and keep
if (["skip", "keep"].includes(importHandle) && !exists) {
return;
}
// Save proxy as new entry if exists update exists one
await Proxy.save(proxy, exists ? proxy.id : undefined, proxy.userId);
}
}
// Only starts importing if the backup file contains at least one monitor
if (monitorListData.length >= 1) {
// Get every existing monitor name and puts them in one simple string
@ -1166,6 +1282,7 @@ exports.entryPage = "dashboard";
dns_resolve_type: monitorListData[i].dns_resolve_type,
dns_resolve_server: monitorListData[i].dns_resolve_server,
notificationIDList: {},
proxy_id: monitorListData[i].proxy_id || null,
};
if (monitorListData[i].pushToken) {
@ -1319,6 +1436,7 @@ exports.entryPage = "dashboard";
// Status Page Socket Handler for admin only
statusPageSocketHandler(socket);
cloudflaredSocketHandler(socket);
databaseSocketHandler(socket);
debug("added all socket handlers");
@ -1342,7 +1460,7 @@ exports.entryPage = "dashboard";
server.once("error", async (err) => {
console.error("Cannot listen: " + err.message);
await Database.close();
await shutdownFunction();
});
server.listen(port, hostname, () => {
@ -1361,6 +1479,9 @@ exports.entryPage = "dashboard";
initBackgroundJobs(args);
// Start cloudflared at the end if configured
await cloudflaredAutoStart(cloudflaredToken);
})();
async function updateMonitorNotification(monitorID, notificationIDList) {
@ -1401,6 +1522,7 @@ async function afterLogin(socket, user) {
let monitorList = await sendMonitorList(socket);
sendNotificationList(socket);
sendProxyList(socket);
await sleep(500);
@ -1493,6 +1615,19 @@ async function restartMonitor(userID, monitorID) {
return await startMonitor(userID, monitorID);
}
async function restartMonitors(userID) {
// Fetch all active monitors for user
const monitors = await R.getAll("SELECT id FROM monitor WHERE active = 1 AND user_id = ?", [userID]);
for (const monitor of monitors) {
// Start updated monitor
await startMonitor(userID, monitor.id);
// Give some delays, so all monitors won't make request at the same moment when just start the server.
await sleep(getRandomInt(300, 1000));
}
}
async function pauseMonitor(userID, monitorID) {
await checkOwner(userID, monitorID);
@ -1536,6 +1671,9 @@ async function shutdownFunction(signal) {
}
await sleep(2000);
await Database.close();
stopBackgroundJobs();
await cloudflaredStop();
}
function finalFunction() {

@ -0,0 +1,90 @@
const { checkLogin, setSetting, setting, doubleCheckPassword } = require("../util-server");
const { CloudflaredTunnel } = require("node-cloudflared-tunnel");
const { io } = require("../server");
const prefix = "cloudflared_";
const cloudflared = new CloudflaredTunnel();
cloudflared.change = (running, message) => {
io.to("cloudflared").emit(prefix + "running", running);
io.to("cloudflared").emit(prefix + "message", message);
};
cloudflared.error = (errorMessage) => {
io.to("cloudflared").emit(prefix + "errorMessage", errorMessage);
};
module.exports.cloudflaredSocketHandler = (socket) => {
socket.on(prefix + "join", async () => {
try {
checkLogin(socket);
socket.join("cloudflared");
io.to(socket.userID).emit(prefix + "installed", cloudflared.checkInstalled());
io.to(socket.userID).emit(prefix + "running", cloudflared.running);
io.to(socket.userID).emit(prefix + "token", await setting("cloudflaredTunnelToken"));
} catch (error) { }
});
socket.on(prefix + "leave", async () => {
try {
checkLogin(socket);
socket.leave("cloudflared");
} catch (error) { }
});
socket.on(prefix + "start", async (token) => {
try {
checkLogin(socket);
if (token && typeof token === "string") {
await setSetting("cloudflaredTunnelToken", token);
cloudflared.token = token;
} else {
cloudflared.token = null;
}
cloudflared.start();
} catch (error) { }
});
socket.on(prefix + "stop", async (currentPassword, callback) => {
try {
checkLogin(socket);
await doubleCheckPassword(socket, currentPassword);
cloudflared.stop();
} catch (error) {
callback({
ok: false,
msg: error.message,
});
}
});
socket.on(prefix + "removeToken", async () => {
try {
checkLogin(socket);
await setSetting("cloudflaredTunnelToken", "");
} catch (error) { }
});
};
module.exports.autoStart = async (token) => {
if (!token) {
token = await setting("cloudflaredTunnelToken");
} else {
// Override the current token via args or env var
await setSetting("cloudflaredTunnelToken", token);
console.log("Use cloudflared token from args or env var");
}
if (token) {
console.log("Start cloudflared");
cloudflared.token = token;
cloudflared.start();
}
};
module.exports.stop = async () => {
console.log("Stop cloudflared");
cloudflared.stop();
};

@ -1,9 +1,8 @@
const tcpp = require("tcp-ping");
const Ping = require("./ping-lite");
const { R } = require("redbean-node");
const { debug } = require("../src/util");
const { debug, genSecret } = require("../src/util");
const passwordHash = require("./password-hash");
const dayjs = require("dayjs");
const { Resolver } = require("dns");
const child_process = require("child_process");
const iconv = require("iconv-lite");
@ -32,7 +31,7 @@ exports.initJWTSecret = async () => {
jwtSecretBean.key = "jwtSecret";
}
jwtSecretBean.value = passwordHash.generate(dayjs() + "");
jwtSecretBean.value = passwordHash.generate(genSecret());
await R.store(jwtSecretBean);
return jwtSecretBean;
};
@ -321,6 +320,28 @@ exports.checkLogin = (socket) => {
}
};
/**
* For logged-in users, double-check the password
* @param socket
* @param currentPassword
* @returns {Promise<Bean>}
*/
exports.doubleCheckPassword = async (socket, currentPassword) => {
if (typeof currentPassword !== "string") {
throw new Error("Wrong data type?");
}
let user = await R.findOne("user", " id = ? AND active = 1 ", [
socket.userID,
]);
if (!user || !passwordHash.verify(currentPassword, user.password)) {
throw new Error("Incorrect current password");
}
return user;
};
exports.startUnitTest = async () => {
console.log("Starting unit test...");
const npm = /^win/.test(process.platform) ? "npm.cmd" : "npm";

@ -9,7 +9,9 @@
<a v-if="searchText != ''" class="search-icon" @click="clearSearchText">
<font-awesome-icon icon="times" />
</a>
<input v-model="searchText" class="form-control search-input" :placeholder="$t('Search...')" />
<form>
<input v-model="searchText" class="form-control search-input" :placeholder="$t('Search...')" autocomplete="off" />
</form>
</div>
</div>
<div class="monitor-list" :class="{ scrollbar: scrollbar }">

@ -0,0 +1,206 @@
<template>
<form @submit.prevent="submit">
<div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 id="exampleModalLabel" class="modal-title">
{{ $t("Setup Proxy") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" />
</div>
<div class="modal-body">
<div class="mb-3">
<label for="proxy-protocol" class="form-label">{{ $t("Proxy Protocol") }}</label>
<select id="proxy-protocol" v-model="proxy.protocol" class="form-select">
<option value="https">HTTPS</option>
<option value="http">HTTP</option>
<option value="socks">SOCKS</option>
<option value="socks5">SOCKS v5</option>
<option value="socks4">SOCKS v4</option>
</select>
</div>
<div class="mb-3">
<label for="proxy-host" class="form-label">{{ $t("Proxy Server") }}</label>
<div class="d-flex">
<input id="proxy-host" v-model="proxy.host" type="text" class="form-control" required :placeholder="$t('Server Address')">
<input v-model="proxy.port" type="number" class="form-control ms-2" style="width: 100px" required min="1" max="65535" :placeholder="$t('Port')">
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input id="mark-auth" v-model="proxy.auth" class="form-check-input" type="checkbox">
<label for="mark-auth" class="form-check-label">{{ $t("Proxy server has authentication") }}</label>
</div>
</div>
<div v-if="proxy.auth" class="mb-3">
<label for="proxy-username" class="form-label">{{ $t("User") }}</label>
<input id="proxy-username" v-model="proxy.username" type="text" class="form-control" required>
</div>
<div v-if="proxy.auth" class="mb-3">
<label for="proxy-password" class="form-label">{{ $t("Password") }}</label>
<input id="proxy-password" v-model="proxy.password" type="password" class="form-control" required>
</div>
<div class="mb-3 mt-4">
<hr class="dropdown-divider mb-4">
<div class="form-check form-switch">
<input id="mark-active" v-model="proxy.active" class="form-check-input" type="checkbox">
<label for="mark-active" class="form-check-label">{{ $t("enabled") }}</label>
</div>
<div class="form-text">
{{ $t("enableProxyDescription") }}
</div>
<br />
<div class="form-check form-switch">
<input id="mark-default" v-model="proxy.default" class="form-check-input" type="checkbox">
<label for="mark-default" class="form-check-label">{{ $t("setAsDefault") }}</label>
</div>
<div class="form-text">
{{ $t("setAsDefaultProxyDescription") }}
</div>
<br />
<div class="form-check form-switch">
<input id="apply-existing" v-model="proxy.applyExisting" class="form-check-input" type="checkbox">
<label class="form-check-label" for="apply-existing">{{ $t("Apply on all existing monitors") }}</label>
</div>
</div>
</div>
<div class="modal-footer">
<button v-if="id" type="button" class="btn btn-danger" :disabled="processing" @click="deleteConfirm">
{{ $t("Delete") }}
</button>
<button type="submit" class="btn btn-primary" :disabled="processing">
<div v-if="processing" class="spinner-border spinner-border-sm me-1"></div>
{{ $t("Save") }}
</button>
</div>
</div>
</div>
</div>
</form>
<Confirm ref="confirmDelete" btn-style="btn-danger" :yes-text="$t('Yes')" :no-text="$t('No')" @yes="deleteProxy">
{{ $t("deleteProxyMsg") }}
</Confirm>
</template>
<script lang="ts">
import { Modal } from "bootstrap";
import Confirm from "./Confirm.vue";
export default {
components: {
Confirm,
},
props: {},
emits: ["added"],
data() {
return {
model: null,
processing: false,
id: null,
proxy: {
protocol: null,
host: null,
port: null,
auth: false,
username: null,
password: null,
active: false,
default: false,
applyExisting: false,
}
};
},
mounted() {
this.modal = new Modal(this.$refs.modal);
},
methods: {
deleteConfirm() {
this.modal.hide();
this.$refs.confirmDelete.show();
},
show(proxyID) {
if (proxyID) {
this.id = proxyID;
for (let proxy of this.$root.proxyList) {
if (proxy.id === proxyID) {
this.proxy = proxy;
break;
}
}
} else {
this.id = null;
this.proxy = {
protocol: "https",
host: null,
port: null,
auth: false,
username: null,
password: null,
active: true,
default: false,
applyExisting: false,
};
}
this.modal.show();
},
submit() {
this.processing = true;
this.$root.getSocket().emit("addProxy", this.proxy, this.id, (res) => {
this.$root.toastRes(res);
this.processing = false;
if (res.ok) {
this.modal.hide();
// Emit added event, doesn't emit edit.
if (! this.id) {
this.$emit("added", res.id);
}
}
});
},
deleteProxy() {
this.processing = true;
this.$root.getSocket().emit("deleteProxy", this.id, (res) => {
this.$root.toastRes(res);
this.processing = false;
if (res.ok) {
this.modal.hide();
}
});
},
},
};
</script>
<style lang="scss" scoped>
@import "../assets/vars.scss";
.dark {
.modal-dialog .form-text, .modal-dialog p {
color: $dark-font-color;
}
}
</style>

@ -19,6 +19,19 @@
</div>
<p v-if="showURI && twoFAStatus == false" class="text-break mt-2">{{ uri }}</p>
<div v-if="!(uri && twoFAStatus == false)" class="mb-3">
<label for="current-password" class="form-label">
{{ $t("Current Password") }}
</label>
<input
id="current-password"
v-model="currentPassword"
type="password"
class="form-control"
required
/>
</div>
<button v-if="uri == null && twoFAStatus == false" class="btn btn-primary" type="button" @click="prepare2FA()">
{{ $t("Enable 2FA") }}
</button>
@ -59,11 +72,11 @@
</template>
<script lang="ts">
import { Modal } from "bootstrap"
import { Modal } from "bootstrap";
import Confirm from "./Confirm.vue";
import VueQrcode from "vue-qrcode"
import { useToast } from "vue-toastification"
const toast = useToast()
import VueQrcode from "vue-qrcode";
import { useToast } from "vue-toastification";
const toast = useToast();
export default {
components: {
@ -73,35 +86,36 @@ export default {
props: {},
data() {
return {
currentPassword: "",
processing: false,
uri: null,
tokenValid: false,
twoFAStatus: null,
token: null,
showURI: false,
}
};
},
mounted() {
this.modal = new Modal(this.$refs.modal)
this.modal = new Modal(this.$refs.modal);
this.getStatus();
},
methods: {
show() {
this.modal.show()
this.modal.show();
},
confirmEnableTwoFA() {
this.$refs.confirmEnableTwoFA.show()
this.$refs.confirmEnableTwoFA.show();
},
confirmDisableTwoFA() {
this.$refs.confirmDisableTwoFA.show()
this.$refs.confirmDisableTwoFA.show();
},
prepare2FA() {
this.processing = true;
this.$root.getSocket().emit("prepare2FA", (res) => {
this.$root.getSocket().emit("prepare2FA", this.currentPassword, (res) => {
this.processing = false;
if (res.ok) {
@ -109,49 +123,51 @@ export default {
} else {
toast.error(res.msg);
}
})
});
},
save2FA() {
this.processing = true;
this.$root.getSocket().emit("save2FA", (res) => {
this.$root.getSocket().emit("save2FA", this.currentPassword, (res) => {
this.processing = false;
if (res.ok) {
this.$root.toastRes(res)
this.$root.toastRes(res);
this.getStatus();
this.currentPassword = "";
this.modal.hide();
} else {
toast.error(res.msg);
}
})
});
},
disable2FA() {
this.processing = true;
this.$root.getSocket().emit("disable2FA", (res) => {
this.$root.getSocket().emit("disable2FA", this.currentPassword, (res) => {
this.processing = false;
if (res.ok) {
this.$root.toastRes(res)
this.$root.toastRes(res);
this.getStatus();
this.currentPassword = "";
this.modal.hide();
} else {
toast.error(res.msg);
}
})
});
},
verifyToken() {
this.$root.getSocket().emit("verifyToken", this.token, (res) => {
this.$root.getSocket().emit("verifyToken", this.token, this.currentPassword, (res) => {
if (res.ok) {
this.tokenValid = res.valid;
} else {
toast.error(res.msg);
}
})
});
},
getStatus() {
@ -161,10 +177,10 @@ export default {
} else {
toast.error(res.msg);
}
})
});
},
},
}
};
</script>
<style lang="scss" scoped>

@ -0,0 +1,48 @@
<template>
<div>
<!-- Proxies -->
<div class="proxy-list my-4">
<p v-if="$root.proxyList.length === 0">
{{ $t("Not available, please setup.") }}
</p>
<p v-else>
{{ $t("proxyDescription") }}
</p>
<ul class="list-group mb-3" style="border-radius: 1rem;">
<li v-for="(proxy, index) in $root.proxyList" :key="index" class="list-group-item">
{{ proxy.host }}:{{ proxy.port }} ({{ proxy.protocol }})
<span v-if="proxy.default === true" class="badge bg-primary ms-2">{{ $t("Default") }}</span><br>
<a href="#" @click="$refs.proxyDialog.show(proxy.id)">{{ $t("Edit") }}</a>
</li>
</ul>
<button class="btn btn-primary me-2" type="button" @click="$refs.proxyDialog.show()">
{{ $t("Setup Proxy") }}
</button>
</div>
<ProxyDialog ref="proxyDialog" />
</div>
</template>
<script>
import ProxyDialog from "../../components/ProxyDialog.vue";
export default {
components: {
ProxyDialog
},
};
</script>
<style lang="scss" scoped>
@import "../../assets/vars.scss";
.dark {
.list-group-item {
background-color: $dark-bg2;
color: $dark-font-color;
}
}
</style>

@ -0,0 +1,139 @@
<template>
<div>
<h4 class="mt-4">Cloudflare Tunnel</h4>
<div class="my-3">
<div>
cloudflared:
<span v-if="installed === true" class="text-primary">{{ $t("Installed") }}</span>
<span v-else-if="installed === false" class="text-danger">{{ $t("Not installed") }}</span>
</div>
<div>
{{ $t("Status") }}:
<span v-if="running" class="text-primary">{{ $t("Running") }}</span>
<span v-else-if="!running" class="text-danger">{{ $t("Not running") }}</span>
</div>
<div v-if="false">
{{ message }}
</div>
<div v-if="errorMessage" class="mt-3">
Message:
<textarea v-model="errorMessage" class="form-control" readonly></textarea>
</div>
<p v-if="installed === false">(Download cloudflared from <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/">Cloudflare Website</a>)</p>
</div>
<!-- If installed show token input -->
<div v-if="installed" class="mb-2">
<div class="mb-4">
<label class="form-label" for="cloudflareTunnelToken">
Cloudflare Tunnel {{ $t("Token") }}
</label>
<HiddenInput
id="cloudflareTunnelToken"
v-model="cloudflareTunnelToken"
autocomplete="one-time-code"
:readonly="running"
/>
<div class="form-text">
<div v-if="cloudflareTunnelToken" class="mb-3">
<span v-if="!running" class="remove-token" @click="removeToken">{{ $t("Remove Token") }}</span>
</div>
Don't know how to get the token? Please read the guide:<br />
<a href="https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy-with-Cloudflare-Tunnel" target="_blank">
https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy-with-Cloudflare-Tunnel
</a>
</div>
</div>
<div>
<button v-if="!running" class="btn btn-primary" type="submit" @click="start">
{{ $t("Start") }} cloudflared
</button>
<button v-if="running" class="btn btn-danger" type="submit" @click="$refs.confirmStop.show();">
{{ $t("Stop") }} cloudflared
</button>
<Confirm ref="confirmStop" btn-style="btn-danger" :yes-text="$t('Stop') + ' cloudflared'" :no-text="$t('Cancel')" @yes="stop">
The current connection may be lost if you are currently connecting via Cloudflare Tunnel. Are you sure want to stop it? Type your current password to confirm it.
<div class="mt-3">
<label for="current-password2" class="form-label">
{{ $t("Current Password") }}
</label>
<input
id="current-password2"
v-model="currentPassword"
type="password"
class="form-control"
required
/>
</div>
</Confirm>
</div>
</div>
<h4 class="mt-4">Other Software</h4>
<div>
For example: nginx, Apache and Traefik. <br />
Please read <a href="https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy" target="_blank">https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy</a>.
</div>
</div>
</template>
<script>
import HiddenInput from "../../components/HiddenInput.vue";
import Confirm from "../Confirm.vue";
const prefix = "cloudflared_";
export default {
components: {
HiddenInput,
Confirm
},
data() {
// See /src/mixins/socket.js
return this.$root.cloudflared;
},
computed: {
},
watch: {
},
created() {
this.$root.getSocket().emit(prefix + "join");
},
unmounted() {
this.$root.getSocket().emit(prefix + "leave");
},
methods: {
start() {
this.$root.getSocket().emit(prefix + "start", this.cloudflareTunnelToken);
},
stop() {
this.$root.getSocket().emit(prefix + "stop", this.currentPassword, (res) => {
this.$root.toastRes(res);
});
},
removeToken() {
this.$root.getSocket().emit(prefix + "removeToken");
this.cloudflareTunnelToken = "";
}
}
};
</script>
<style lang="scss" scoped>
.remove-token {
text-decoration: underline;
cursor: pointer;
}
</style>

@ -192,6 +192,12 @@
<p>Пожалуйста, используйте с осторожностью.</p>
</template>
<template v-else-if="$i18n.locale === 'uk-UA' ">
<p>Ви впевнені, що бажаєте <strong>вимкнути авторизацію</strong>?</p>
<p>Це підходить для <strong>тих, у кого встановлена інша авторизація</strong> пееред відкриттям Uptime Kuma, наприклад Cloudflare Access.</p>
<p>Будь ласка, використовуйте з обережністю.</p>
</template>
<template v-else-if="$i18n.locale === 'fa' ">
<p>آیا مطمئن هستید که میخواهید <strong>احراز هویت را غیر فعال کنید</strong>?</p>
<p>این ویژگی برای کسانی است که <strong> لایه امنیتی شخص ثالث دیگر بر روی این آدرس فعال کردهاند</strong>، مانند Cloudflare Access.</p>
@ -215,14 +221,14 @@
<p>Dette er for <strong>de som har tredjepartsautorisering</strong> foran Uptime Kuma, for eksempel Cloudflare Access.</p>
<p>Vennligst vær forsiktig.</p>
</template>
<template v-else-if="$i18n.locale === 'cs-CZ' ">
<p>Opravdu chcete <strong>deaktivovat autentifikaci</strong>?</p>
<p>Tato možnost je určena pro případy, kdy <strong>máte autentifikaci zajištěnou třetí stranou</strong> ještě před přístupem do Uptime Kuma, například prostřednictvím Cloudflare Access.</p>
<p>Používejte ji prosím s rozmyslem.</p>
</template>
<template v-else-if="$i18n.locale === 'vi-VN' ">
<template v-else-if="$i18n.locale === 'vi-VN' ">
<p>Bạn muốn <strong>TẮT XÁC THỰC</strong> không?</p>
<p>Điều này rất nguy hiểm<strong>BẤT KỲ AI</strong> cũng thể truy cập cướp quyền điều khiển.</p>
<p>Vui lòng <strong>cẩn thận</strong>.</p>
@ -234,6 +240,19 @@
<p>It is designed for scenarios <strong>where you intend to implement third-party authentication</strong> in front of Uptime Kuma such as Cloudflare Access, Authelia or other authentication mechanisms.</p>
<p>Please use this option carefully!</p>
</template>
<div class="mb-3">
<label for="current-password2" class="form-label">
{{ $t("Current Password") }}
</label>
<input
id="current-password2"
v-model="password.currentPassword"
type="password"
class="form-control"
required
/>
</div>
</Confirm>
</div>
</template>
@ -310,7 +329,12 @@ export default {
disableAuth() {
this.settings.disableAuth = true;
this.saveSettings();
// Need current password to disable auth
// Set it to empty if done
this.saveSettings(() => {
this.password.currentPassword = "";
}, this.password.currentPassword);
},
enableAuth() {

@ -29,7 +29,8 @@ const languageList = {
"pl": "Polski",
"et-EE": "eesti",
"vi-VN": "Tiếng Việt",
"zh-TW": "繁體中文 (台灣)"
"zh-TW": "繁體中文 (台灣)",
"uk-UA": "Український",
};
let messages = {

@ -371,4 +371,12 @@ export default {
alertaAlertState: "Alert State",
alertaRecoverState: "Recover State",
deleteStatusPageMsg: "Are you sure want to delete this status page?",
Proxies: "Proxies",
default: "Default",
enabled: "Enabled",
setAsDefault: "Set As Default",
deleteProxyMsg: "Are you sure want to delete this proxy for all monitors?",
proxyDescription: "Proxies must be assigned to a monitor to function.",
enableProxyDescription: "This proxy will not effect on monitor requests until it is activated. You can control temporarily disable the proxy from all monitors by activation status.",
setAsDefaultProxyDescription: "This proxy will be enabled by default for new monitors. You can still disable the proxy separately for each monitor.",
};

@ -183,7 +183,7 @@ export default {
"Edit Status Page": "Uredi Statusnu stranicu",
"Go to Dashboard": "Na Kontrolnu ploču",
"Status Page": "Statusna stranica",
"Status Pages": "Statusna stranica",
"Status Pages": "Statusne stranice",
defaultNotificationName: "Moja {number}. {notification} obavijest",
here: "ovdje",
Required: "Potrebno",
@ -347,4 +347,30 @@ export default {
Cancel: "Otkaži",
"Powered by": "Pokreće",
Saved: "Spremljeno",
PushByTechulus: "Push by Techulus",
GoogleChat: "Google Chat (preko platforme Google Workspace)",
shrinkDatabaseDescription: "Pokreni VACUUM operaciju za SQLite. Ako je baza podataka kreirana nakon inačice 1.10.0, AUTO_VACUUM opcija već je uključena te ova akcija nije nužna.",
serwersms: "SerwerSMS.pl",
serwersmsAPIUser: "API korisničko ime (uključujući webapi_ prefiks)",
serwersmsAPIPassword: "API lozinka",
serwersmsPhoneNumber: "Broj telefona",
serwersmsSenderName: "Ime SMS pošiljatelja (registrirano preko korisničkog portala)",
stackfield: "Stackfield",
smtpDkimSettings: "DKIM postavke",
smtpDkimDesc: "Za više informacija, postoji Nodemailer DKIM {0}.",
documentation: "dokumentacija",
smtpDkimDomain: "Domena",
smtpDkimKeySelector: "Odabir ključa",
smtpDkimPrivateKey: "Privatni ključ",
smtpDkimHashAlgo: "Hash algoritam (neobavezno)",
smtpDkimheaderFieldNames: "Ključevi zaglavlja za potpis (neobavezno)",
smtpDkimskipFields: "Ključevi zaglavlja koji se neće potpisati (neobavezno)",
gorush: "Gorush",
alerta: "Alerta",
alertaApiEndpoint: "Krajnja točka API-ja (Endpoint)",
alertaEnvironment: "Okruženje (Environment)",
alertaApiKey: "API ključ",
alertaAlertState: "Stanje upozorenja",
alertaRecoverState: "Stanje oporavka",
deleteStatusPageMsg: "Sigurno želite obrisati ovu statusnu stranicu?",
};

@ -197,7 +197,7 @@ export default {
line: "Line Messenger",
mattermost: "Mattermost",
"Status Page": "Státusz oldal",
"Status Pages": "Státusz oldal",
"Status Pages": "Státusz oldalak",
"Primary Base URL": "Elsődleges URL",
"Push URL": "Meghívandó URL",
needPushEvery: "Ezt az URL-t kell meghívni minden {0} másodpercben.",
@ -370,4 +370,5 @@ export default {
alertaApiKey: "API kulcs",
alertaAlertState: "Figyelmeztetési állapot",
alertaRecoverState: "Visszaállási állapot",
deleteStatusPageMsg: "Biztos, hogy törölni akarja a státusz oldalt?",
};

@ -180,8 +180,8 @@ export default {
"Add a monitor": "Добавить монитор",
"Edit Status Page": "Редактировать",
"Go to Dashboard": "Панель управления",
"Status Page": "Мониторинг",
"Status Pages": "Página de Status",
"Status Page": "Страница статуса",
"Status Pages": "Страницы статуса",
Discard: "Отмена",
"Create Incident": "Создать инцидент",
"Switch to Dark Theme": "Тёмная тема",
@ -311,28 +311,28 @@ export default {
"One record": "Одна запись",
steamApiKeyDescription: "Для мониторинга игрового сервера Steam вам необходим Web-API ключ Steam. Зарегистрировать его можно здесь: ",
"Certificate Chain": "Цепочка сертификатов",
"Valid": "Действительный",
Valid: "Действительный",
"Hide Tags": "Скрыть тэги",
Title: "Название инцидента:",
Content: "Содержание инцидента:",
Post: "Опубликовать",
"Cancel": "Отмена",
"Created": "Создано",
"Unpin": "Открепить",
Cancel: "Отмена",
Created: "Создано",
Unpin: "Открепить",
"Show Tags": "Показать тэги",
"recent": "Сейчас",
recent: "Сейчас",
"3h": "3 часа",
"6h": "6 часов",
"24h": "24 часа",
"1w": "1 неделя",
"No monitors available.": "Нет доступных мониторов",
"Add one": "Добавить новый",
"Backup": "Резервная копия",
"Security": "Безопасность",
Backup: "Резервная копия",
Security: "Безопасность",
"Shrink Database": "Сжать Базу Данных",
"Current User": "Текущий пользователь",
"About": "О программе",
"Description": "Описание",
About: "О программе",
Description: "Описание",
"Powered by": "Работает на основе скрипта от",
shrinkDatabaseDescription: "Включает VACUUM для базы данных SQLite. Если ваша база данных была создана на версии 1.10.0 и более, AUTO_VACUUM уже включен и это действие не требуется.",
deleteStatusPageMsg: "Вы действительно хотите удалить эту страницу статуса сервисов?",
@ -343,4 +343,50 @@ export default {
primary: "ОСНОВНОЙ",
light: "СВЕТЛЫЙ",
dark: "ТЕМНЫЙ",
"New Status Page": "Новая страница статуса",
"Show update if available": "Показывать доступные обновления",
"Also check beta release": "Проверять обновления для бета версий",
"Add New Status Page": "Добавить страницу статуса",
Next: "Далее",
"Accept characters: a-z 0-9 -": "Разрешены символы: a-z 0-9 -",
"Start or end with a-z 0-9 only": "Начало и окончание имени только на символы: a-z 0-9",
"No consecutive dashes --": "Запрещено использовать тире --",
"HTTP Options": "HTTP Опции",
"Basic Auth": "HTTP Авторизация",
PushByTechulus: "Push by Techulus",
clicksendsms: "ClickSend SMS",
GoogleChat: "Google Chat (только Google Workspace)",
apiCredentials: "API реквизиты",
Done: "Готово",
Info: "Инфо",
"Steam API Key": "Steam API-Ключ",
"Pick a RR-Type...": "Выберите RR-Тип...",
"Pick Accepted Status Codes...": "Выберите принятые коды состояния...",
Default: "По умолчанию",
"Please input title and content": "Пожалуйста, введите название и содержание",
"Last Updated": "Последнее Обновление",
"Untitled Group": "Группа без названия",
Services: "Сервисы",
serwersms: "SerwerSMS.pl",
serwersmsAPIUser: "API Пользователь (включая префикс webapi_)",
serwersmsAPIPassword: "API Пароль",
serwersmsPhoneNumber: "Номер телефона",
serwersmsSenderName: "SMS Имя Отправителя (регистрированный через пользовательский портал)",
stackfield: "Stackfield",
smtpDkimSettings: "DKIM Настройки",
smtpDkimDesc: "Please refer to the Nodemailer DKIM {0} for usage.",
documentation: "документация",
smtpDkimDomain: "Имя Домена",
smtpDkimKeySelector: "Ключ",
smtpDkimPrivateKey: "Приватный ключ",
smtpDkimHashAlgo: "Алгоритм хэша (опционально)",
smtpDkimheaderFieldNames: "Заголовок ключей для подписи (опционально)",
smtpDkimskipFields: "Заколовок ключей не для подписи (опционально)",
gorush: "Gorush",
alerta: "Alerta",
alertaApiEndpoint: "Конечная точка API",
alertaEnvironment: "Среда",
alertaApiKey: "Ключ API",
alertaAlertState: "Состояние алерта",
alertaRecoverState: "Состояние восстановления",
};

@ -0,0 +1,392 @@
export default {
languageName: "Український",
checkEverySecond: "Перевірка кожні {0} секунд",
retriesDescription: "Максимальна кількість спроб перед позначенням сервісу як недоступного та надсиланням повідомлення",
ignoreTLSError: "Ігнорувати помилку TLS/SSL для сайтів HTTPS",
upsideDownModeDescription: "Реверс статусу сервісу. Якщо сервіс доступний, він позначається як НЕДОСТУПНИЙ.",
maxRedirectDescription: "Максимальна кількість перенаправлень. Поставте 0, щоб вимкнути перенаправлення.",
acceptedStatusCodesDescription: "Виберіть коди статусів для визначення доступності сервісу.",
passwordNotMatchMsg: "Повторення паролю не збігається.",
notificationDescription: "Прив'яжіть повідомлення до моніторів.",
keywordDescription: "Пошук слова в чистому HTML або JSON-відповіді (чутливо до регістру)",
pauseDashboardHome: "Пауза",
deleteMonitorMsg: "Ви дійсно хочете видалити цей монітор?",
deleteNotificationMsg: "Ви дійсно хочете видалити це повідомлення для всіх моніторів?",
resolverserverDescription: "Cloudflare є сервером за замовчуванням. Ви завжди можете змінити цей сервер.",
rrtypeDescription: "Виберіть тип ресурсного запису, який ви хочете відстежувати",
pauseMonitorMsg: "Ви дійсно хочете поставити на паузу?",
Settings: "Налаштування",
Dashboard: "Панель управління",
"New Update": "Оновлення",
Language: "Мова",
Appearance: "Зовнішній вигляд",
Theme: "Тема",
General: "Загальне",
Version: "Версія",
"Check Update On GitHub": "Перевірити оновлення на GitHub",
List: "Список",
Add: "Додати",
"Add New Monitor": "Новий монітор",
"Quick Stats": "Статистика",
Up: "Доступний",
Down: "Недоступний",
Pending: "Очікування",
Unknown: "Невідомо",
Pause: "Пауза",
Name: "Ім'я",
Status: "Статус",
DateTime: "Дата і час",
Message: "Повідомлення",
"No important events": "Важливих подій немає",
Resume: "Відновити",
Edit: "Змінити",
Delete: "Видалити",
Current: "Поточний",
Uptime: "Аптайм",
"Cert Exp.": "Сертифікат спливає",
days: "днів",
day: "день",
"-day": " днів",
hour: "година",
"-hour": " години",
Response: "Відповідь",
Ping: "Пінг",
"Monitor Type": "Тип монітора",
Keyword: "Ключове слово",
"Friendly Name": "Ім'я",
URL: "URL",
Hostname: "Ім'я хоста",
Port: "Порт",
"Heartbeat Interval": "Частота опитування",
Retries: "Спроб",
Advanced: "Додатково",
"Upside Down Mode": "Реверс статусу",
"Max. Redirects": "Макс. кількість перенаправлень",
"Accepted Status Codes": "Припустимі коди статусу",
Save: "Зберегти",
Notifications: "Повідомлення",
"Not available, please setup.": "Доступних сповіщень немає, необхідно створити.",
"Setup Notification": "Створити сповіщення",
Light: "Світла",
Dark: "Темна",
Auto: "Авто",
"Theme - Heartbeat Bar": "Тема - Смуга частоти опитування",
Normal: "Звичайний",
Bottom: "Знизу",
None: "Відсутня",
Timezone: "Часовий пояс",
"Search Engine Visibility": "Індексація пошуковими системами:",
"Allow indexing": "Дозволити індексування",
"Discourage search engines from indexing site": "Заборонити індексування",
"Change Password": "Змінити пароль",
"Current Password": "Поточний пароль",
"New Password": "Новий пароль",
"Repeat New Password": "Повтор нового пароля",
"Update Password": "Оновити пароль",
"Disable Auth": "Вимкнути авторизацію",
"Enable Auth": "Увімкнути авторизацію",
Logout: "Вийти",
Leave: "Відміна",
"I understand, please disable": "Я розумію, все одно відключити",
Confirm: "Підтвердити",
Yes: "Так",
No: "Ні",
Username: "Логін",
Password: "Пароль",
"Remember me": "Запам'ятати мене",
Login: "Вхід до системи",
"No Monitors, please": "Моніторів немає, будь ласка",
"No Monitors": "Монітори відсутні",
"add one": "створіть новий",
"Notification Type": "Тип повідомлення",
Email: "Пошта",
Test: "Перевірка",
"Certificate Info": "Інформація про сертифікат",
"Resolver Server": "DNS сервер",
"Resource Record Type": "Тип ресурсного запису",
"Last Result": "Останній результат",
"Create your admin account": "Створіть обліковий запис адміністратора",
"Repeat Password": "Повторіть пароль",
respTime: "Час відповіді (мс)",
notAvailableShort: "Н/д",
Create: "Створити",
clearEventsMsg: "Ви дійсно хочете видалити всю статистику подій цього монітора?",
clearHeartbeatsMsg: "Ви дійсно хочете видалити всю статистику опитувань цього монітора?",
confirmClearStatisticsMsg: "Ви дійсно хочете видалити ВСЮ статистику?",
"Clear Data": "Видалити статистику",
Events: "Події",
Heartbeats: "Опитування",
"Auto Get": "Авто-отримання",
enableDefaultNotificationDescription: "Для кожного нового монітора це повідомлення буде включено за замовчуванням. Ви все ще можете відключити повідомлення в кожному моніторі окремо.",
"Default enabled": "Використовувати за промовчанням",
"Also apply to existing monitors": "Застосувати до існуючих моніторів",
Export: "Експорт",
Import: "Імпорт",
backupDescription: "Ви можете зберегти резервну копію всіх моніторів та повідомлень у вигляді JSON-файлу",
backupDescription2: "P.S.: Історія та події збережені не будуть",
backupDescription3: "Важливі дані, такі як токени повідомлень, додаються під час експорту, тому зберігайте файли в безпечному місці",
alertNoFile: "Виберіть файл для імпорту.",
alertWrongFileType: "Виберіть JSON-файл.",
twoFAVerifyLabel: "Будь ласка, введіть свій токен, щоб перевірити роботу 2FA",
tokenValidSettingsMsg: "Токен дійсний! Тепер ви можете зберегти налаштування 2FA.",
confirmEnableTwoFAMsg: "Ви дійсно хочете увімкнути 2FA?",
confirmDisableTwoFAMsg: "Ви дійсно хочете вимкнути 2FA?",
"Apply on all existing monitors": "Застосувати до всіх існуючих моніторів",
"Verify Token": "Перевірити токен",
"Setup 2FA": "Налаштування 2FA",
"Enable 2FA": "Увімкнути 2FA",
"Disable 2FA": "Вимкнути 2FA",
"2FA Settings": "Налаштування 2FA",
"Two Factor Authentication": "Двофакторна аутентифікація",
Active: "Активно",
Inactive: "Неактивно",
Token: "Токен",
"Show URI": "Показати URI",
"Clear all statistics": "Очистити статистику",
retryCheckEverySecond: "Повтор кожні {0} секунд",
importHandleDescription: "Виберіть \"Пропустити існуючі\", якщо ви хочете пропустити кожен монітор або повідомлення з таким же ім'ям. \"Перезаписати\" видалить кожен існуючий монітор або повідомлення та додасть заново. Варіант \"Не перевіряти\" примусово відновлює всі монітори і повідомлення, навіть якщо вони вже існують.",
confirmImportMsg: "Ви дійсно хочете відновити резервну копію? Переконайтеся, що ви вибрали відповідний варіант імпорту.",
"Heartbeat Retry Interval": "Інтервал повтору опитування",
"Import Backup": "Імпорт",
"Export Backup": "Експорт",
"Skip existing": "Пропустити існуючі",
Overwrite: "Перезаписати",
Options: "Опції",
"Keep both": "Не перевіряти",
Tags: "Теги",
"Add New below or Select...": "Додати новий або вибрати...",
"Tag with this name already exist.": "Такий тег вже існує.",
"Tag with this value already exist.": "Тег із таким значенням вже існує.",
color: "колір",
"value (optional)": "значення (опціонально)",
Gray: "Сірий",
Red: "Червоний",
Orange: "Помаранчевий",
Green: "Зелений",
Blue: "Синій",
Indigo: "Індиго",
Purple: "Пурпурний",
Pink: "Рожевий",
"Search...": "Пошук...",
"Avg. Ping": "Середнє значення пінгу",
"Avg. Response": "Середній час відповіді",
"Entry Page": "Головна сторінка",
statusPageNothing: "Тут порожньо. Додайте групу або монітор.",
"No Services": "Немає сервісів",
"All Systems Operational": "Всі системи працюють у штатному режимі",
"Partially Degraded Service": "Сервіси працюють частково",
"Degraded Service": "Всі сервіси не працюють",
"Add Group": "Додати групу",
"Add a monitor": "Додати монітор",
"Edit Status Page": "Редагувати",
"Go to Dashboard": "Панель управління",
"Status Page": "Сторінка статусу",
"Status Pages": "Сторінки статусу",
Discard: "Скасування",
"Create Incident": "Створити інцидент",
"Switch to Dark Theme": "Темна тема",
"Switch to Light Theme": "Світла тема",
telegram: "Telegram",
webhook: "Вебхук",
smtp: "Email (SMTP)",
discord: "Discord",
teams: "Microsoft Teams",
signal: "Signal",
gotify: "Gotify",
slack: "Slack",
"rocket.chat": "Rocket.chat",
pushover: "Pushover",
pushy: "Pushy",
octopush: "Octopush",
promosms: "PromoSMS",
lunasea: "LunaSea",
apprise: "Apprise (Підтримка 50+ сервісів повідомлень)",
pushbullet: "Pushbullet",
line: "Line Messenger",
mattermost: "Mattermost",
"Primary Base URL": "Основна URL",
"Push URL": "URL пуша",
needPushEvery: "До цієї URL необхідно звертатися кожні {0} секунд",
pushOptionalParams: "Опціональні параметри: {0}",
defaultNotificationName: "Моє повідомлення {notification} ({number})",
here: "тут",
Required: "Потрібно",
"Bot Token": "Токен бота",
wayToGetTelegramToken: "Ви можете взяти токен тут - {0}.",
"Chat ID": "ID чату",
supportTelegramChatID: "Підтримуються ID чатів, груп та каналів",
wayToGetTelegramChatID: "Ви можете взяти ID вашого чату, відправивши повідомлення боту і перейшовши по цьому URL для перегляду chat_id:",
"YOUR BOT TOKEN HERE": "ВАШ ТОКЕН БОТА ТУТ",
chatIDNotFound: "ID чату не знайдено; будь ласка, відправте спочатку повідомлення боту",
"Post URL": "Post URL",
"Content Type": "Тип контенту",
webhookJsonDesc: "{0} підходить для будь-яких сучасних HTTP-серверів, наприклад Express.js",
webhookFormDataDesc: "{multipart} підходить для PHP. JSON-вивід необхідно буде обробити за допомогою {decodeFunction}",
secureOptionNone: "Ні / STARTTLS (25, 587)",
secureOptionTLS: "TLS (465)",
"Ignore TLS Error": "Ігнорувати помилки TLS",
"From Email": "Від кого",
emailCustomSubject: "Своя тема",
"To Email": "Кому",
smtpCC: "Копія",
smtpBCC: "Прихована копія",
"Discord Webhook URL": "Discord Вебхук URL",
wayToGetDiscordURL: "Ви можете створити його в Параметрах сервера -> Інтеграції -> Створити вебхук",
"Bot Display Name": "Ім'я бота, що відображається",
"Prefix Custom Message": "Свій префікс повідомлення",
"Hello @everyone is...": "Привіт {'@'}everyone це...",
"Webhook URL": "URL вебхука",
wayToGetTeamsURL: "Як створити URL вебхука ви можете дізнатися тут - {0}.",
Номер: "Номер",
Recipients: "Одержувачі",
needSignalAPI: "Вам необхідний клієнт Signal із підтримкою REST API.",
wayToCheckSignalURL: "Пройдіть по цьому URL, щоб дізнатися як налаштувати такий клієнт:",
signalImportant: "ВАЖЛИВО: Не можна змішувати в Одержувачах групи та номери!",
"Application Token": "Токен програми",
"Server URL": "URL сервера",
Priority: "Пріоритет",
"Icon Emoji": "Іконка Emoji",
"Channel Name": "Ім'я каналу",
"Uptime Kuma URL": "Uptime Kuma URL",
aboutWebhooks: "Більше інформації про вебхуки: {0}",
aboutChannelName: "Введіть ім'я каналу в поле {0} Ім'я каналу, якщо ви хочете обійти канал вебхука. Наприклад: #other-channel",
aboutKumaURL: "Якщо поле Uptime Kuma URL в налаштуваннях залишиться порожнім, за замовчуванням буде використовуватися посилання на проект на GitHub.",
emojiCheatSheet: "Шпаргалка по Emoji: {0}",
"User Key": "Ключ користувача",
Device: "Пристрій",
"Message Title": "Заголовок повідомлення",
"Notification Sound": "Звук повідомлення",
"More info on:": "Більше інформації: {0}",
pushoverDesc1: "Екстренний пріоритет (2) має таймуут повтору за замовчуванням 30 секунд і закінчується через 1 годину.",
pushoverDesc2: "Якщо ви бажаєте надсилати повідомлення різним пристроям, необхідно заповнити поле Пристрій.",
"SMS Type": "Тип SMS",
octopushTypePremium: "Преміум (Швидкий - рекомендується для алертів)",
octopushTypeLowCost: "Дешевий (Повільний - іноді блокується операторами)",
checkPrice: "Тарифи {0}:",
octopushLegacyHint: "Ви використовуєте стару версію Octopush (2011-2020) або нову?",
"Check octopush prices": "Тарифи Octopush {0}.",
octopushPhoneNumber: "Номер телефону (між. формат, наприклад: +380123456789)",
octopushSMSSender: "Ім'я відправника SMS: 3-11 символів алвафіту, цифр та пробілів (a-zA-Z0-9)",
"LunaSea Device ID": "ID пристрою LunaSea",
"Apprise URL": "Apprise URL",
"Example:": "Приклад: {0}",
"Read more:": "Докладніше: {0}",
"Status:": "Статус: {0}",
"Read more": "Докладніше",
appriseInstalled: "Apprise встановлено.",
appriseNotInstalled: "Apprise не встановлено. {0}",
"Access Token": "Токен доступу",
"Channel access token": "Токен доступу каналу",
"Line Developers Console": "Консоль розробників Line",
lineDevConsoleTo: "Консоль розробників Line - {0}",
"Basic Settings": "Базові налаштування",
"User ID": "ID користувача",
"Messaging API": "API повідомлень",
wayToGetLineChannelToken: "Спочатку зайдіть в {0}, створіть провайдера та канал (API повідомлень), потім ви зможете отримати токен доступу каналу та ID користувача з вищезгаданих пунктів меню.",
"Icon URL": "URL іконки",
aboutIconURL: "Ви можете надати посилання на іконку в полі \"URL іконки\", щоб перевизначити картинку профілю за замовчуванням. Не використовується, якщо задана іконка Emoji.",
aboutMattermostChannelName: "Ви можете перевизначити канал за замовчуванням, в який пише вебхук, ввівши ім'я каналу в полі \"Ім'я каналу\". Це необхідно включити в налаштуваннях вебхука Mattermost. Наприклад: #other-channel",
matrix: "Matrix",
promosmsTypeEco: "SMS ECO - дешево та повільно, часто перевантажений. Тільки для одержувачів з Польщі.",
promosmsTypeFlash: "SMS FLASH - повідомлення автоматично з'являться на пристрої одержувача. Тільки для одержувачів з Польщі.",
promosmsTypeFull: "SMS FULL - преміум-рівень SMS, можна використовувати своє ім'я відправника (попередньо зареєструвавши його). Надійно для алертів.",
promosmsTypeSpeed: "SMS SPEED - найвищий пріоритет у системі. Дуже швидко і надійно, але дуже дорого (вдвічі дорожче, ніж SMS FULL).",
promosmsPhoneNumber: "Номер телефону (для одержувачів з Польщі можна пропустити код регіону)",
promosmsSMSSender: "Ім'я відправника SMS: Зареєстроване або одне з імен за замовчуванням: InfoSMS, SMS Info, MaxSMS, INFO, SMS",
"Feishu WebHookURL": "Feishu WebHookURL",
matrixHomeserverURL: "URL сервера (разом з http(s):// і опціонально порт)",
"Internal Room Id": "Внутрішній ID кімнати",
matrixDesc1: "Внутрішній ID кімнати можна знайти в Подробицях у параметрах каналу вашого Matrix клієнта. Він повинен виглядати приблизно як !QMdRCpUIfLwsfjxye6:home.server.",
matrixDesc2: "Рекомендується створити нового користувача і не використовувати токен доступу особистого користувача Matrix, тому що це спричиняє повний доступ до облікового запису та до кімнат, в яких ви є. Замість цього створіть нового користувача і запросіть його тільки в ту кімнату, в якій ви хочете отримувати повідомлення.Токен доступу можна отримати, виконавши команду {0}",
Method: "Метод",
Body: "Тіло",
Headers: "Заголовки",
PushUrl: "URL пуша",
HeadersInvalidFormat: "Заголовки запиту некоректні JSON: ",
BodyInvalidFormat: "Тіло запиту некоректне JSON: ",
"Monitor History": "Статистика",
clearDataOlderThan: "Зберігати статистику за {0} днів.",
PasswordsDoNotMatch: "Паролі не співпадають.",
records: "записів",
"One record": "Один запис",
steamApiKeyDescription: "Для моніторингу ігрового сервера Steam вам потрібен Web-API ключ Steam. Зареєструвати його можна тут: ",
"Certificate Chain": "Ланцюжок сертифікатів",
Valid: "Дійсний",
"Hide Tags": "Приховати теги",
Title: "Назва інциденту:",
Content: "Зміст інциденту:",
Post: "Опублікувати",
Cancel: "Скасувати",
Created: "Створено",
Unpin: "Відкріпити",
"Show Tags": "Показати теги",
recent: "Зараз",
"3h": "3 години",
"6h": "6 годин",
"24h": "24 години",
"1w": "1 тиждень",
"No monitors available.": "Немає доступних моніторів",
"Add one": "Додати новий",
Backup: "Резервна копія",
Security: "Безпека",
"Shrink Database": "Стиснути базу даних",
"Current User": "Поточний користувач",
About: "Про програму",
Description: "Опис",
"Powered by": "Працює на основі скрипту від",
shrinkDatabaseDescription: "Включає VACUUM для бази даних SQLite. Якщо база даних була створена на версії 1.10.0 і більше, AUTO_VACUUM вже включений і ця дія не потрібна.",
Style: "Стиль",
info: "ІНФО",
warning: "УВАГА",
danger: "ПОМИЛКА",
primary: "ОСНОВНИЙ",
light: "СВІТЛИЙ",
dark: "ТЕМНИЙ",
"New Status Page": "Нова сторінка статусу",
"Show update if available": "Показувати доступні оновлення",
"Also check beta release": "Перевіряти оновлення для бета версій",
"Add New Status Page": "Додати сторінку статусу",
Next: "Далі",
"Acz characters: a-z 0-9 -": "Дозволені символи: a-z 0-9 -",
"Start or end with a-z 0-9 only": "Початок та закінчення імені лише на символи: a-z 0-9",
"No consecutive dashes --": "Заборонено використовувати тире --",
"HTTP Options": "HTTP Опції",
"Basic Auth": "HTTP Авторизація",
PushByTechulus: "Push by Techulus",
clicksendsms: "ClickSend SMS",
GoogleChat: "Google Chat (тільки Google Workspace)",
apiCredentials: "API реквізити",
Done: "Готово",
Info: "Інфо",
"Steam API Key": "Steam API-Ключ",
"Pick a RR-Type...": "Виберіть RR-тип...",
"Pick Accepted Status Codes...": "Виберіть прийняті коди стану...",
Default: "За замовчуванням",
"Please input title and content": "Будь ласка, введіть назву та зміст",
"Last Updated": "Останнє Оновлення",
"Untitled Group": "Група без назви",
Services: "Сервіси",
serwersms: "SerwerSMS.pl",
serwersmsAPIUser: "API Користувач (включаючи префікс webapi_)",
serwersmsAPIPassword: "API Пароль",
serwersmsPhoneNumber: "Номер телефону",
serwersmsSenderName: "SMS ім'я відправника (реєстрований через портал користувача)",
stackfield: "Stackfield",
smtpDkimSettings: "DKIM Налаштування",
smtpDkimDesc: "Повернутися до Nodemailer DKIM {0} для використання.",
documentation: "документація",
smtpDkimDomain: "Ім'я домена",
smtpDkimKeySelector: "Ключ",
smtpDkimPrivateKey: "Приватний ключ",
smtpDkimHashAlgo: "Алгоритм хеша (опціонально)",
smtpDkimheaderFieldNames: "Заголовок ключів для підпису (опціонально)",
smtpDkimskipFields: "Заколовок ключів не для підпису (опціонально)",
gorush: "Gorush",
alerta: "Alerta",
alertaApiEndpoint: "Кінцева точка API",
alertaEnvironment: "Середовище",
alertaApiKey: "Ключ API",
alertaAlertState: "Стан алерту",
alertaRecoverState: "Стан відновлення",
deleteStatusPageMsg: "Дійсно хочете видалити цю сторінку статусів?",
};

@ -48,7 +48,7 @@
</header>
<main>
<router-view v-if="$root.loggedIn" />
<router-view v-if="$root.loggedIn || forceShowContent" />
<Login v-if="! $root.loggedIn && $root.allowLoginDialog" />
</main>
@ -189,6 +189,7 @@ main {
color: white;
position: fixed;
width: 100%;
z-index: 99999;
}
.dark {

@ -40,8 +40,17 @@ export default {
notificationList: [],
statusPageListLoaded: false,
statusPageList: [],
proxyList: [],
connectionErrorMsg: "Cannot connect to the socket server. Reconnecting...",
showReverseProxyGuide: true,
cloudflared: {
cloudflareTunnelToken: "",
installed: null,
running: false,
message: "",
errorMessage: "",
currentPassword: "",
}
};
},
@ -120,6 +129,16 @@ export default {
this.statusPageList = data;
});
socket.on("proxyList", (data) => {
this.proxyList = data.map(item => {
item.auth = !!item.auth;
item.active = !!item.active;
item.default = !!item.default;
return item;
});
});
socket.on("heartbeat", (data) => {
if (! (data.monitorID in this.heartbeatList)) {
this.heartbeatList[data.monitorID] = [];
@ -231,6 +250,12 @@ export default {
this.socket.firstConnect = false;
});
// cloudflared
socket.on("cloudflared_installed", (res) => this.cloudflared.installed = res);
socket.on("cloudflared_running", (res) => this.cloudflared.running = res);
socket.on("cloudflared_message", (res) => this.cloudflared.message = res);
socket.on("cloudflared_errorMessage", (res) => this.cloudflared.errorMessage = res);
socket.on("cloudflared_token", (res) => this.cloudflared.cloudflareTunnelToken = res);
},
storage() {

@ -139,6 +139,15 @@
<h2 v-if="monitor.type !== 'push'" class="mt-5 mb-2">{{ $t("Advanced") }}</h2>
<div class="my-3 form-check">
<input id="expiry-notification" v-model="monitor.expiryNotification" class="form-check-input" type="checkbox">
<label class="form-check-label" for="expiry-notification">
{{ $t("Domain Name Expiry Notification") }}
</label>
<div class="form-text">
</div>
</div>
<div v-if="monitor.type === 'http' || monitor.type === 'keyword' " class="my-3 form-check">
<input id="ignore-tls" v-model="monitor.ignoreTls" class="form-check-input" type="checkbox" value="">
<label class="form-check-label" for="ignore-tls">
@ -222,6 +231,32 @@
{{ $t("Setup Notification") }}
</button>
<!-- Proxies -->
<h2 class="mt-5 mb-2">{{ $t("Proxies") }}</h2>
<p v-if="$root.proxyList.length === 0">
{{ $t("Not available, please setup.") }}
</p>
<div v-if="$root.proxyList.length > 0" class="form-check form-switch my-3">
<input id="proxy-disable" v-model="monitor.proxyId" :value="null" name="proxy" class="form-check-input" type="radio">
<label class="form-check-label" for="proxy-disable">{{ $t("No Proxy") }}</label>
</div>
<div v-for="proxy in $root.proxyList" :key="proxy.id" class="form-check form-switch my-3">
<input :id="`proxy-${proxy.id}`" v-model="monitor.proxyId" :value="proxy.id" name="proxy" class="form-check-input" type="radio">
<label class="form-check-label" :for="`proxy-${proxy.id}`">
{{ proxy.host }}:{{ proxy.port }} ({{ proxy.protocol }})
<a href="#" @click="$refs.proxyDialog.show(proxy.id)">{{ $t("Edit") }}</a>
</label>
<span v-if="proxy.default === true" class="badge bg-primary ms-2">{{ $t("default") }}</span>
</div>
<button class="btn btn-primary me-2" type="button" @click="$refs.proxyDialog.show()">
{{ $t("Setup Proxy") }}
</button>
<!-- HTTP Options -->
<template v-if="monitor.type === 'http' || monitor.type === 'keyword' ">
<h2 class="mt-5 mb-2">{{ $t("HTTP Options") }}</h2>
@ -285,12 +320,14 @@
</form>
<NotificationDialog ref="notificationDialog" @added="addedNotification" />
<ProxyDialog ref="proxyDialog" @added="addedProxy" />
</div>
</transition>
</template>
<script>
import NotificationDialog from "../components/NotificationDialog.vue";
import ProxyDialog from "../components/ProxyDialog.vue";
import TagsManager from "../components/TagsManager.vue";
import CopyableInput from "../components/CopyableInput.vue";
@ -302,6 +339,7 @@ const toast = useToast();
export default {
components: {
ProxyDialog,
CopyableInput,
NotificationDialog,
TagsManager,
@ -368,6 +406,17 @@ export default {
},
watch: {
"$root.proxyList"() {
if (this.isAdd) {
if (this.$root.proxyList && !this.monitor.proxyId) {
const proxy = this.$root.proxyList.find(proxy => proxy.default);
if (proxy) {
this.monitor.proxyId = proxy.id;
}
}
}
},
"$route.fullPath"() {
this.init();
@ -435,12 +484,22 @@ export default {
notificationIDList: {},
ignoreTls: false,
upsideDown: false,
expiryNotification: false,
maxredirects: 10,
accepted_statuscodes: ["200-299"],
dns_resolve_type: "A",
dns_resolve_server: "1.1.1.1",
proxyId: null,
};
if (this.$root.proxyList && !this.monitor.proxyId) {
const proxy = this.$root.proxyList.find(proxy => proxy.default);
if (proxy) {
this.monitor.proxyId = proxy.id;
}
}
for (let i = 0; i < this.$root.notificationList.length; i++) {
if (this.$root.notificationList[i].isDefault == true) {
this.monitor.notificationIDList[this.$root.notificationList[i].id] = true;
@ -532,6 +591,12 @@ export default {
addedNotification(id) {
this.monitor.notificationIDList[id] = true;
},
// Added a Proxy Event
// Enable it if the proxy is added in EditMonitor.vue
addedProxy(id) {
this.monitor.proxyId = id;
},
},
};
</script>

@ -0,0 +1,99 @@
<template>
<div>
<!-- Desktop header -->
<header v-if="! $root.isMobile" class="d-flex flex-wrap justify-content-center py-3 mb-3 border-bottom">
<router-link to="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
<object class="bi me-2 ms-4" width="40" height="40" data="/icon.svg" />
<span class="fs-4 title">Uptime Kuma</span>
</router-link>
</header>
<!-- Mobile header -->
<header v-else class="d-flex flex-wrap justify-content-center pt-2 pb-2 mb-3">
<router-link to="/dashboard" class="d-flex align-items-center text-dark text-decoration-none">
<object class="bi" width="40" height="40" data="/icon.svg" />
<span class="fs-4 title ms-2">Uptime Kuma</span>
</router-link>
</header>
<div class="content">
<div>
<strong>🐻 {{ $t("Page Not Found") }}</strong>
</div>
<div class="guide">
Most likely causes:
<ul>
<li>The resource is no longer available.</li>
<li>There might be a typing error in the address.</li>
</ul>
What you can try:<br />
<ul>
<li>Retype the address.</li>
<li><a href="#" class="go-back" @click="goBack()">Go back to the previous page.</a></li>
</ul>
</div>
</div>
</div>
</template>
<script>
export default {
async mounted() {
},
methods: {
goBack() {
history.back();
}
}
};
</script>
<style scoped lang="scss">
@import "../assets/vars.scss";
.go-back {
text-decoration: none;
color: $primary !important;
}
.content {
display: flex;
justify-content: center;
align-content: center;
align-items: center;
flex-direction: column;
gap: 50px;
padding-top: 30px;
strong {
font-size: 24px;
}
}
.guide {
max-width: 800px;
font-size: 14px;
}
.title {
font-weight: bold;
}
.dark {
header {
background-color: $dark-header-bg;
border-bottom-color: $dark-header-bg !important;
span {
color: #f0f6fc;
}
}
.bottom-nav {
background-color: $dark-bg;
}
}
</style>

@ -75,12 +75,18 @@ export default {
notifications: {
title: this.$t("Notifications"),
},
"reverse-proxy": {
title: this.$t("Reverse Proxy"),
},
"monitor-history": {
title: this.$t("Monitor History"),
},
security: {
title: this.$t("Security"),
},
proxies: {
title: this.$t("Proxies"),
},
backup: {
title: this.$t("Backup"),
},
@ -115,6 +121,10 @@ export default {
this.$root.getSocket().emit("getSettings", (res) => {
this.settings = res.data;
if (this.settings.checkUpdate === undefined) {
this.settings.checkUpdate = true;
}
if (this.settings.searchEngineIndex === undefined) {
this.settings.searchEngineIndex = false;
}
@ -131,10 +141,18 @@ export default {
});
},
saveSettings() {
this.$root.getSocket().emit("setSettings", this.settings, (res) => {
/**
* Save Settings
* @param currentPassword (Optional) Only need for disableAuth to true
*/
saveSettings(callback, currentPassword) {
this.$root.getSocket().emit("setSettings", this.settings, currentPassword, (res) => {
this.$root.toastRes(res);
this.loadSettings();
if (callback) {
callback();
}
});
},
}

@ -67,7 +67,7 @@
<h1 class="mb-4 title-flex">
<!-- Logo -->
<span class="logo-wrapper" @click="showImageCropUploadMethod">
<img :src="logoURL" alt class="logo me-2" :class="logoClass" />
<img :src="logoURL" alt class="logo me-2" :class="logoClass" @load="statusPageLogoLoaded" />
<font-awesome-icon v-if="enableEditMode" class="icon-upload" icon="upload" />
</span>
@ -592,6 +592,11 @@ export default {
}
},
statusPageLogoLoaded(eventPayload) {
// Remark: may not work in dev, due to cros
favicon.image(eventPayload.target);
},
createIncident() {
this.enableEditIncidentMode = true;

@ -14,12 +14,15 @@ import Entry from "./pages/Entry.vue";
import Appearance from "./components/settings/Appearance.vue";
import General from "./components/settings/General.vue";
import Notifications from "./components/settings/Notifications.vue";
import ReverseProxy from "./components/settings/ReverseProxy.vue";
import MonitorHistory from "./components/settings/MonitorHistory.vue";
import Security from "./components/settings/Security.vue";
import Proxies from "./components/settings/Proxies.vue";
import Backup from "./components/settings/Backup.vue";
import About from "./components/settings/About.vue";
import ManageStatusPage from "./pages/ManageStatusPage.vue";
import AddStatusPage from "./pages/AddStatusPage.vue";
import NotFound from "./pages/NotFound.vue";
const routes = [
{
@ -82,6 +85,10 @@ const routes = [
path: "notifications",
component: Notifications,
},
{
path: "reverse-proxy",
component: ReverseProxy,
},
{
path: "monitor-history",
component: MonitorHistory,
@ -90,6 +97,10 @@ const routes = [
path: "security",
component: Security,
},
{
path: "proxies",
component: Proxies,
},
{
path: "backup",
component: Backup,
@ -128,6 +139,10 @@ const routes = [
path: "/status/:slug",
component: StatusPage,
},
{
path: "/:pathMatch(.*)*",
component: NotFound,
},
];
export const router = createRouter({

@ -1,9 +1,10 @@
const fs = require("fs");
const rmSync = require("../extra/fs-rmSync.js");
const path = "./data/test-chrome-profile";
if (fs.existsSync(path)) {
fs.rmdirSync(path, {
rmSync(path, {
recursive: true,
});
}

@ -1,9 +1,10 @@
const fs = require("fs");
const rmSync = require("../extra/fs-rmSync.js");
const path = "./data/test";
if (fs.existsSync(path)) {
fs.rmdirSync(path, {
rmSync(path, {
recursive: true,
});
}

Loading…
Cancel
Save