mirror of
				https://github.com/louislam/uptime-kuma.git
				synced 2025-11-04 13:46:13 +08:00 
			
		
		
		
	Co-authored-by: Sebastian Lang <sebastian.lang@damovo.com> Co-authored-by: Frank Elsinga <frank@elsinga.de>
		
			
				
	
	
		
			521 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			521 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
const express = require("express");
 | 
						|
const https = require("https");
 | 
						|
const fs = require("fs");
 | 
						|
const http = require("http");
 | 
						|
const { Server } = require("socket.io");
 | 
						|
const { R } = require("redbean-node");
 | 
						|
const { log, isDev } = require("../src/util");
 | 
						|
const Database = require("./database");
 | 
						|
const util = require("util");
 | 
						|
const { Settings } = require("./settings");
 | 
						|
const dayjs = require("dayjs");
 | 
						|
const childProcessAsync = require("promisify-child-process");
 | 
						|
const path = require("path");
 | 
						|
const axios = require("axios");
 | 
						|
const { isSSL, sslKey, sslCert, sslKeyPassphrase } = require("./config");
 | 
						|
// DO NOT IMPORT HERE IF THE MODULES USED `UptimeKumaServer.getInstance()`, put at the bottom of this file instead.
 | 
						|
 | 
						|
/**
 | 
						|
 * `module.exports` (alias: `server`) should be inside this class, in order to avoid circular dependency issue.
 | 
						|
 * @type {UptimeKumaServer}
 | 
						|
 */
 | 
						|
class UptimeKumaServer {
 | 
						|
    /**
 | 
						|
     * Current server instance
 | 
						|
     * @type {UptimeKumaServer}
 | 
						|
     */
 | 
						|
    static instance = null;
 | 
						|
 | 
						|
    /**
 | 
						|
     * Main monitor list
 | 
						|
     * @type {{}}
 | 
						|
     */
 | 
						|
    monitorList = {};
 | 
						|
 | 
						|
    /**
 | 
						|
     * Main maintenance list
 | 
						|
     * @type {{}}
 | 
						|
     */
 | 
						|
    maintenanceList = {};
 | 
						|
 | 
						|
    entryPage = "dashboard";
 | 
						|
    app = undefined;
 | 
						|
    httpServer = undefined;
 | 
						|
    io = undefined;
 | 
						|
 | 
						|
    /**
 | 
						|
     * Cache Index HTML
 | 
						|
     * @type {string}
 | 
						|
     */
 | 
						|
    indexHTML = "";
 | 
						|
 | 
						|
    /**
 | 
						|
     * @type {{}}
 | 
						|
     */
 | 
						|
    static monitorTypeList = {
 | 
						|
 | 
						|
    };
 | 
						|
 | 
						|
    /**
 | 
						|
     * Use for decode the auth object
 | 
						|
     * @type {null}
 | 
						|
     */
 | 
						|
    jwtSecret = null;
 | 
						|
 | 
						|
    /**
 | 
						|
     * Get the current instance of the server if it exists, otherwise
 | 
						|
     * create a new instance.
 | 
						|
     * @returns {UptimeKumaServer} Server instance
 | 
						|
     */
 | 
						|
    static getInstance() {
 | 
						|
        if (UptimeKumaServer.instance == null) {
 | 
						|
            UptimeKumaServer.instance = new UptimeKumaServer();
 | 
						|
        }
 | 
						|
        return UptimeKumaServer.instance;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     *
 | 
						|
     */
 | 
						|
    constructor() {
 | 
						|
        // Set axios default user-agent to Uptime-Kuma/version
 | 
						|
        axios.defaults.headers.common["User-Agent"] = this.getUserAgent();
 | 
						|
 | 
						|
        // Set default axios timeout to 5 minutes instead of infinity
 | 
						|
        axios.defaults.timeout = 300 * 1000;
 | 
						|
 | 
						|
        log.info("server", "Creating express and socket.io instance");
 | 
						|
        this.app = express();
 | 
						|
        if (isSSL) {
 | 
						|
            log.info("server", "Server Type: HTTPS");
 | 
						|
            this.httpServer = https.createServer({
 | 
						|
                key: fs.readFileSync(sslKey),
 | 
						|
                cert: fs.readFileSync(sslCert),
 | 
						|
                passphrase: sslKeyPassphrase,
 | 
						|
            }, this.app);
 | 
						|
        } else {
 | 
						|
            log.info("server", "Server Type: HTTP");
 | 
						|
            this.httpServer = http.createServer(this.app);
 | 
						|
        }
 | 
						|
 | 
						|
        try {
 | 
						|
            this.indexHTML = fs.readFileSync("./dist/index.html").toString();
 | 
						|
        } catch (e) {
 | 
						|
            // "dist/index.html" is not necessary for development
 | 
						|
            if (process.env.NODE_ENV !== "development") {
 | 
						|
                log.error("server", "Error: Cannot find 'dist/index.html', did you install correctly?");
 | 
						|
                process.exit(1);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        // Set Monitor Types
 | 
						|
        UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType();
 | 
						|
        UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing();
 | 
						|
        UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType();
 | 
						|
        UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType();
 | 
						|
        UptimeKumaServer.monitorTypeList["mongodb"] = new MongodbMonitorType();
 | 
						|
 | 
						|
        // Allow all CORS origins (polling) in development
 | 
						|
        let cors = undefined;
 | 
						|
        if (isDev) {
 | 
						|
            cors = {
 | 
						|
                origin: "*",
 | 
						|
            };
 | 
						|
        }
 | 
						|
 | 
						|
        this.io = new Server(this.httpServer, {
 | 
						|
            cors,
 | 
						|
            allowRequest: async (req, callback) => {
 | 
						|
                let transport;
 | 
						|
                // It should be always true, but just in case, because this property is not documented
 | 
						|
                if (req._query) {
 | 
						|
                    transport = req._query.transport;
 | 
						|
                } else {
 | 
						|
                    log.error("socket", "Ops!!! Cannot get transport type, assume that it is polling");
 | 
						|
                    transport = "polling";
 | 
						|
                }
 | 
						|
 | 
						|
                const clientIP = await this.getClientIPwithProxy(req.connection.remoteAddress, req.headers);
 | 
						|
                log.info("socket", `New ${transport} connection, IP = ${clientIP}`);
 | 
						|
 | 
						|
                // The following check is only for websocket connections, polling connections are already protected by CORS
 | 
						|
                if (transport === "polling") {
 | 
						|
                    callback(null, true);
 | 
						|
                } else if (transport === "websocket") {
 | 
						|
                    const bypass = process.env.UPTIME_KUMA_WS_ORIGIN_CHECK === "bypass";
 | 
						|
                    if (bypass) {
 | 
						|
                        log.info("auth", "WebSocket origin check is bypassed");
 | 
						|
                        callback(null, true);
 | 
						|
                    } else if (!req.headers.origin) {
 | 
						|
                        log.info("auth", "WebSocket with no origin is allowed");
 | 
						|
                        callback(null, true);
 | 
						|
                    } else {
 | 
						|
                        let host = req.headers.host;
 | 
						|
                        let origin = req.headers.origin;
 | 
						|
 | 
						|
                        try {
 | 
						|
                            let originURL = new URL(origin);
 | 
						|
                            let xForwardedFor;
 | 
						|
                            if (await Settings.get("trustProxy")) {
 | 
						|
                                xForwardedFor = req.headers["x-forwarded-for"];
 | 
						|
                            }
 | 
						|
 | 
						|
                            if (host !== originURL.host && xForwardedFor !== originURL.host) {
 | 
						|
                                callback(null, false);
 | 
						|
                                log.error("auth", `Origin (${origin}) does not match host (${host}), IP: ${clientIP}`);
 | 
						|
                            } else {
 | 
						|
                                callback(null, true);
 | 
						|
                            }
 | 
						|
                        } catch (e) {
 | 
						|
                            // Invalid origin url, probably not from browser
 | 
						|
                            callback(null, false);
 | 
						|
                            log.error("auth", `Invalid origin url (${origin}), IP: ${clientIP}`);
 | 
						|
                        }
 | 
						|
                    }
 | 
						|
                }
 | 
						|
            }
 | 
						|
        });
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Initialise app after the database has been set up
 | 
						|
     * @returns {Promise<void>}
 | 
						|
     */
 | 
						|
    async initAfterDatabaseReady() {
 | 
						|
        // Static
 | 
						|
        this.app.use("/screenshots", express.static(Database.screenshotDir));
 | 
						|
 | 
						|
        process.env.TZ = await this.getTimezone();
 | 
						|
        dayjs.tz.setDefault(process.env.TZ);
 | 
						|
        log.debug("DEBUG", "Timezone: " + process.env.TZ);
 | 
						|
        log.debug("DEBUG", "Current Time: " + dayjs.tz().format());
 | 
						|
 | 
						|
        await this.loadMaintenanceList();
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Send list of monitors to client
 | 
						|
     * @param {Socket} socket Socket to send list on
 | 
						|
     * @returns {Promise<object>} List of monitors
 | 
						|
     */
 | 
						|
    async sendMonitorList(socket) {
 | 
						|
        let list = await this.getMonitorJSONList(socket.userID);
 | 
						|
        this.io.to(socket.userID).emit("monitorList", list);
 | 
						|
        return list;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Get a list of monitors for the given user.
 | 
						|
     * @param {string} userID - The ID of the user to get monitors for.
 | 
						|
     * @returns {Promise<object>} A promise that resolves to an object with monitor IDs as keys and monitor objects as values.
 | 
						|
     *
 | 
						|
     * Generated by Trelent
 | 
						|
     */
 | 
						|
    async getMonitorJSONList(userID) {
 | 
						|
        let result = {};
 | 
						|
 | 
						|
        let monitorList = await R.find("monitor", " user_id = ? ORDER BY weight DESC, name", [
 | 
						|
            userID,
 | 
						|
        ]);
 | 
						|
 | 
						|
        for (let monitor of monitorList) {
 | 
						|
            result[monitor.id] = await monitor.toJSON();
 | 
						|
        }
 | 
						|
 | 
						|
        return result;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Send maintenance list to client
 | 
						|
     * @param {Socket} socket Socket.io instance to send to
 | 
						|
     * @returns {Promise<object>} Maintenance list
 | 
						|
     */
 | 
						|
    async sendMaintenanceList(socket) {
 | 
						|
        return await this.sendMaintenanceListByUserID(socket.userID);
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Send list of maintenances to user
 | 
						|
     * @param {number} userID User to send list to
 | 
						|
     * @returns {Promise<object>} Maintenance list
 | 
						|
     */
 | 
						|
    async sendMaintenanceListByUserID(userID) {
 | 
						|
        let list = await this.getMaintenanceJSONList(userID);
 | 
						|
        this.io.to(userID).emit("maintenanceList", list);
 | 
						|
        return list;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Get a list of maintenances for the given user.
 | 
						|
     * @param {string} userID - The ID of the user to get maintenances for.
 | 
						|
     * @returns {Promise<object>} A promise that resolves to an object with maintenance IDs as keys and maintenances objects as values.
 | 
						|
     */
 | 
						|
    async getMaintenanceJSONList(userID) {
 | 
						|
        let result = {};
 | 
						|
        for (let maintenanceID in this.maintenanceList) {
 | 
						|
            result[maintenanceID] = await this.maintenanceList[maintenanceID].toJSON();
 | 
						|
        }
 | 
						|
        return result;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Load maintenance list and run
 | 
						|
     * @param {any} userID Unused
 | 
						|
     * @returns {Promise<void>}
 | 
						|
     */
 | 
						|
    async loadMaintenanceList(userID) {
 | 
						|
        let maintenanceList = await R.findAll("maintenance", " ORDER BY end_date DESC, title", [
 | 
						|
 | 
						|
        ]);
 | 
						|
 | 
						|
        for (let maintenance of maintenanceList) {
 | 
						|
            this.maintenanceList[maintenance.id] = maintenance;
 | 
						|
            maintenance.run(this);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Retrieve a specific maintenance
 | 
						|
     * @param {number} maintenanceID ID of maintenance to retrieve
 | 
						|
     * @returns {(object|null)} Maintenance if it exists
 | 
						|
     */
 | 
						|
    getMaintenance(maintenanceID) {
 | 
						|
        if (this.maintenanceList[maintenanceID]) {
 | 
						|
            return this.maintenanceList[maintenanceID];
 | 
						|
        }
 | 
						|
        return null;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Write error to log file
 | 
						|
     * @param {any} error The error to write
 | 
						|
     * @param {boolean} outputToConsole Should the error also be output to console?
 | 
						|
     * @returns {void}
 | 
						|
     */
 | 
						|
    static errorLog(error, outputToConsole = true) {
 | 
						|
        const errorLogStream = fs.createWriteStream(path.join(Database.dataDir, "/error.log"), {
 | 
						|
            flags: "a"
 | 
						|
        });
 | 
						|
 | 
						|
        errorLogStream.on("error", () => {
 | 
						|
            log.info("", "Cannot write to error.log");
 | 
						|
        });
 | 
						|
 | 
						|
        if (errorLogStream) {
 | 
						|
            const dateTime = R.isoDateTime();
 | 
						|
            errorLogStream.write(`[${dateTime}] ` + util.format(error) + "\n");
 | 
						|
 | 
						|
            if (outputToConsole) {
 | 
						|
                console.error(error);
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        errorLogStream.end();
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Get the IP of the client connected to the socket
 | 
						|
     * @param {Socket} socket Socket to query
 | 
						|
     * @returns {Promise<string>} IP of client
 | 
						|
     */
 | 
						|
    getClientIP(socket) {
 | 
						|
        return this.getClientIPwithProxy(socket.client.conn.remoteAddress, socket.client.conn.request.headers);
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * @param {string} clientIP Raw client IP
 | 
						|
     * @param {IncomingHttpHeaders} headers HTTP headers
 | 
						|
     * @returns {Promise<string>} Client IP with proxy (if trusted)
 | 
						|
     */
 | 
						|
    async getClientIPwithProxy(clientIP, headers) {
 | 
						|
        if (clientIP === undefined) {
 | 
						|
            clientIP = "";
 | 
						|
        }
 | 
						|
 | 
						|
        if (await Settings.get("trustProxy")) {
 | 
						|
            const forwardedFor = headers["x-forwarded-for"];
 | 
						|
 | 
						|
            return (typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : null)
 | 
						|
                || headers["x-real-ip"]
 | 
						|
                || clientIP.replace(/^::ffff:/, "");
 | 
						|
        } else {
 | 
						|
            return clientIP.replace(/^::ffff:/, "");
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Attempt to get the current server timezone
 | 
						|
     * If this fails, fall back to environment variables and then make a
 | 
						|
     * guess.
 | 
						|
     * @returns {Promise<string>} Current timezone
 | 
						|
     */
 | 
						|
    async getTimezone() {
 | 
						|
        // From process.env.TZ
 | 
						|
        try {
 | 
						|
            if (process.env.TZ) {
 | 
						|
                this.checkTimezone(process.env.TZ);
 | 
						|
                return process.env.TZ;
 | 
						|
            }
 | 
						|
        } catch (e) {
 | 
						|
            log.warn("timezone", e.message + " in process.env.TZ");
 | 
						|
        }
 | 
						|
 | 
						|
        let timezone = await Settings.get("serverTimezone");
 | 
						|
 | 
						|
        // From Settings
 | 
						|
        try {
 | 
						|
            log.debug("timezone", "Using timezone from settings: " + timezone);
 | 
						|
            if (timezone) {
 | 
						|
                this.checkTimezone(timezone);
 | 
						|
                return timezone;
 | 
						|
            }
 | 
						|
        } catch (e) {
 | 
						|
            log.warn("timezone", e.message + " in settings");
 | 
						|
        }
 | 
						|
 | 
						|
        // Guess
 | 
						|
        try {
 | 
						|
            let guess = dayjs.tz.guess();
 | 
						|
            log.debug("timezone", "Guessing timezone: " + guess);
 | 
						|
            if (guess) {
 | 
						|
                this.checkTimezone(guess);
 | 
						|
                return guess;
 | 
						|
            } else {
 | 
						|
                return "UTC";
 | 
						|
            }
 | 
						|
        } catch (e) {
 | 
						|
            // Guess failed, fall back to UTC
 | 
						|
            log.debug("timezone", "Guessed an invalid timezone. Use UTC as fallback");
 | 
						|
            return "UTC";
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Get the current offset
 | 
						|
     * @returns {string} Time offset
 | 
						|
     */
 | 
						|
    getTimezoneOffset() {
 | 
						|
        return dayjs().format("Z");
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Throw an error if the timezone is invalid
 | 
						|
     * @param {string} timezone Timezone to test
 | 
						|
     * @returns {void}
 | 
						|
     * @throws The timezone is invalid
 | 
						|
     */
 | 
						|
    checkTimezone(timezone) {
 | 
						|
        try {
 | 
						|
            dayjs.utc("2013-11-18 11:55").tz(timezone).format();
 | 
						|
        } catch (e) {
 | 
						|
            throw new Error("Invalid timezone:" + timezone);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Set the current server timezone and environment variables
 | 
						|
     * @param {string} timezone Timezone to set
 | 
						|
     * @returns {Promise<void>}
 | 
						|
     */
 | 
						|
    async setTimezone(timezone) {
 | 
						|
        this.checkTimezone(timezone);
 | 
						|
        await Settings.set("serverTimezone", timezone, "general");
 | 
						|
        process.env.TZ = timezone;
 | 
						|
        dayjs.tz.setDefault(timezone);
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * TODO: Listen logic should be moved to here
 | 
						|
     * @returns {Promise<void>}
 | 
						|
     */
 | 
						|
    async start() {
 | 
						|
        let enable = await Settings.get("nscd");
 | 
						|
 | 
						|
        if (enable || enable === null) {
 | 
						|
            await this.startNSCDServices();
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Stop the server
 | 
						|
     * @returns {Promise<void>}
 | 
						|
     */
 | 
						|
    async stop() {
 | 
						|
        let enable = await Settings.get("nscd");
 | 
						|
 | 
						|
        if (enable || enable === null) {
 | 
						|
            await this.stopNSCDServices();
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Start all system services (e.g. nscd)
 | 
						|
     * For now, only used in Docker
 | 
						|
     * @returns {void}
 | 
						|
     */
 | 
						|
    async startNSCDServices() {
 | 
						|
        if (process.env.UPTIME_KUMA_IS_CONTAINER) {
 | 
						|
            try {
 | 
						|
                log.info("services", "Starting nscd");
 | 
						|
                await childProcessAsync.exec("sudo service nscd start");
 | 
						|
            } catch (e) {
 | 
						|
                log.info("services", "Failed to start nscd");
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Stop all system services
 | 
						|
     * @returns {void}
 | 
						|
     */
 | 
						|
    async stopNSCDServices() {
 | 
						|
        if (process.env.UPTIME_KUMA_IS_CONTAINER) {
 | 
						|
            try {
 | 
						|
                log.info("services", "Stopping nscd");
 | 
						|
                await childProcessAsync.exec("sudo service nscd stop");
 | 
						|
            } catch (e) {
 | 
						|
                log.info("services", "Failed to stop nscd");
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Default User-Agent when making HTTP requests
 | 
						|
     * @returns {string} User-Agent
 | 
						|
     */
 | 
						|
    getUserAgent() {
 | 
						|
        return "Uptime-Kuma/" + require("../package.json").version;
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Force connected sockets of a user to refresh and disconnect.
 | 
						|
     * Used for resetting password.
 | 
						|
     * @param {string} userID User ID
 | 
						|
     * @param {string?} currentSocketID Current socket ID
 | 
						|
     * @returns {void}
 | 
						|
     */
 | 
						|
    disconnectAllSocketClients(userID, currentSocketID = undefined) {
 | 
						|
        for (const socket of this.io.sockets.sockets.values()) {
 | 
						|
            if (socket.userID === userID && socket.id !== currentSocketID) {
 | 
						|
                try {
 | 
						|
                    socket.emit("refresh");
 | 
						|
                    socket.disconnect();
 | 
						|
                } catch (e) {
 | 
						|
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
module.exports = {
 | 
						|
    UptimeKumaServer
 | 
						|
};
 | 
						|
 | 
						|
// Must be at the end to avoid circular dependencies
 | 
						|
const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type");
 | 
						|
const { TailscalePing } = require("./monitor-types/tailscale-ping");
 | 
						|
const { DnsMonitorType } = require("./monitor-types/dns");
 | 
						|
const { MqttMonitorType } = require("./monitor-types/mqtt");
 | 
						|
const { MongodbMonitorType } = require("./monitor-types/mongodb");
 |