UNPKG

@komponent/unifi-protect-lib

Version:

Node library for connecting to Ubiquiti Unifi Protect controllers and listen for events

393 lines 16.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const https_1 = __importDefault(require("https")); const axios_1 = __importDefault(require("axios")); const cookie_1 = __importDefault(require("cookie")); const jwt_decode_1 = __importDefault(require("jwt-decode")); const ws_1 = __importDefault(require("ws")); const Http_1 = require("../types/Http"); const Settings_1 = require("./Settings"); const url_1 = require("url"); class UnifiApiClient { constructor(log, host, username, password) { this.apiErrorCount = 0; this.apiLastSuccess = 0; this.loginTimestamp = 0; this.loggedIn = false; this.headers = new Http_1.Headers(); this.log = log; this.host = host; this.username = username; this.password = password; this.nvrName = host; this.eventListener = null; this.authUrl = `https://${host}/api/auth/login`; this.bootstrapUrl = `https://${host}/proxy/protect/api/bootstrap`; this.updatesUrl = `wss://${host}/proxy/protect/ws/updates`; this.camerasUrl = `https://${host}/proxy/protect/api/cameras`; this.clearLoginCredentials(); } /** * Utility to check the heartbeat of our listener. * * @private * @memberof UnifiApiClient */ heartbeatEventListener() { // Clear out our last timer and set a new one. clearTimeout(this.eventHeartbeatTimer); // We use terminate() to immediately destroy the connection, instead of close(), which waits for the close timer. this.eventHeartbeatTimer = setTimeout(() => { this.eventListener?.terminate(); this.eventListener = null; this.eventListenerConfigured = false; }, Settings_1.PROTECT_EVENTS_HEARTBEAT_INTERVAL * 1000); } /** * Identify which NVR device type we're logging into and acquire a CSRF token if needed. * * @private * @return {*} {Promise<boolean>} * @memberof ProtectApi */ async acquireToken() { // We only need to acquire a token if we aren't already logged in, or we don't already have a token, // or don't know which device type we're on. if (this.loggedIn || this.headers.has("X-CSRF-Token") || this.headers.has("Authorization")) { return true; } // UniFi OS has cross-site request forgery protection built into it's web management UI. // We use this fact to fingerprint it by connecting directly to the supplied NVR address // and see ifing there's a CSRF token waiting for us. const response = await this.fetch("https://" + this.host, { method: "GET", }); if (response?.status == 200) { const csrfToken = response.headers["x-csrf-token"]; // We found a token. if (csrfToken) { this.headers.set("X-CSRF-Token", csrfToken); // UniFi OS has support for keepalive. Let's take advantage of that and reduce the workload on controllers. this.httpsAgent = new https_1.default.Agent({ keepAlive: true, maxFreeSockets: 5, maxSockets: 10, rejectUnauthorized: false, timeout: 60 * 1000, }); return true; } } this.log.error("Unable to get CSRF Token from controller"); return false; } /** * Log into UniFi Protect. * * @return {*} {Promise<boolean>} * @memberof ProtectApi */ async login() { const now = Date.now(); // Is it time to renew our credentials? if (now > this.loginTimestamp + Settings_1.PROTECT_LOGIN_REFRESH_INTERVAL * 1000) { this.loggedIn = false; this.headers = new Http_1.Headers(); this.headers.set("Content-Type", "application/json"); } // If we're already logged in, and it's not time to renew our credentials, we're done. if (this.loggedIn) { return true; } // Make sure we have a token, or get one if needed. if (!(await this.acquireToken())) { return false; } // Log us in. const response = await this.fetch(this.authUrl, { data: { password: this.password, username: this.username, }, method: "POST", }, false); if (!response || response.status != 200) { return false; } // We're logged in. this.loggedIn = true; this.loginTimestamp = now; // Configure headers. const csrfToken = response.headers["x-csrf-token"]; const loginCookie = response.headers["set-cookie"]; if (csrfToken && loginCookie && this.headers.has("X-CSRF-Token")) { this.headers.set("Cookie", loginCookie); this.headers.set("X-CSRF-Token", csrfToken); this.log.info("Successful login"); return true; } return false; } /** * Gets the current token from the stored cookie * * @private * @return {*} {string} * @memberof UnifiApiClient */ getToken() { var cookieHeader = this.headers.get("Cookie"); if (cookieHeader && cookieHeader.length == 1) { const cookieValue = cookie_1.default.parse(cookieHeader[0]); if (cookieValue) { const token = (0, jwt_decode_1.default)(cookieValue.TOKEN); return token; } } return {}; } clearLoginCredentials() { this.loggedIn = false; this.loginTimestamp = 0; this.bootstrap = null; // Shutdown any event listeners, if we have them. this.eventListener?.terminate(); this.eventListener = null; this.eventListenerConfigured = false; // Initialize the headers we need. this.headers = new Http_1.Headers(); this.headers.set("Content-Type", "application/json"); // We want the initial agent to be connection-agnostic, except for certificate validate since Protect uses self-signed certificates. // and we want to disable TLS validation, at a minimum. We want to take advantage of the fact that it supports keepalives to reduce // workloads, but we deal with that elsewhere in acquireToken. this.httpsAgent?.destroy(); this.httpsAgent = new https_1.default.Agent({ rejectUnauthorized: false }); } /** * Get our UniFi Protect NVR configuration. * * @private * @return {*} {Promise<boolean>} * @memberof UnifiApiClient */ async bootstrapProtect() { // Return the bootstrap if we already got it if (this.bootstrap) { return this.bootstrap; } const response = await this.fetch(this.bootstrapUrl, { method: "GET" }); if (!response || response.status != 200) { this.log.error("%s: Unable to retrieve NVR configuration information from UniFi Protect. Will retry again later.", this.nvrName); // Clear out our login credentials and reset for another try. this.clearLoginCredentials(); return; } // Now let's get our NVR configuration information. let data = null; try { data = (await response.data); } catch (error) { data = null; this.log.error("%s: Unable to parse response from UniFi Protect. Will retry again later.", this.nvrName); } // No camera information returned. if (!data?.cameras) { this.log.error("%s: Unable to retrieve camera information from UniFi Protect. Will retry again later.", this.nvrName); // Clear out our login credentials and reset for another try. this.clearLoginCredentials(); return; } // On launch, let the user know we made it. const firstRun = this.bootstrap ? false : true; this.bootstrap = data; // Set nvr name if (data?.nvr) { this.nvrName = `${data.nvr.name} [${data.nvr.type}]`; } if (firstRun) { this.log.info("%s: Connected to the Protect controller API (address: %s mac: %s).", this.nvrName, data.nvr.host, data.nvr.mac); } return data; } /** * Connect to the realtime update events API. * * @private * @return {*} {Promise<boolean>} * @memberof UnifiCameraHandler */ async listen() { // If we already have a listener, we're already all set. if (this.eventListener) { return null; } const params = new url_1.URLSearchParams({ lastUpdateId: this.bootstrap?.lastUpdateId ?? "", }); this.log.debug("Update listener: %s", this.updatesUrl + "?" + params.toString()); try { const ws = new ws_1.default(this.updatesUrl + "?" + params.toString(), { headers: { Cookie: this.headers.get("Cookie") ?? "", }, rejectUnauthorized: false, }); if (!ws) { this.log.error("Unable to connect to the realtime update events API. Will retry again later."); this.eventListener = null; this.eventListenerConfigured = false; return null; } this.eventListener = ws; // Setup our heartbeat to ensure we can revive our connection if needed. this.eventListener.on("message", this.heartbeatEventListener.bind(this)); this.eventListener.on("open", this.heartbeatEventListener.bind(this)); this.eventListener.on("ping", this.heartbeatEventListener.bind(this)); this.eventListener.on("close", () => { this.log.debug("Websocket closed"); clearTimeout(this.eventHeartbeatTimer); }); this.eventListener.on("error", (error) => { // If we're closing before fully established it's because we're shutting down the API - ignore it. if (error.message !== "WebSocket was closed before the connection was established") { this.log.error("%s: %s", this.nvrName, error); } this.eventListener?.terminate(); this.eventListener = null; this.eventListenerConfigured = false; }); this.log.info("%s: Connected to the UniFi realtime update events API.", this.nvrName); } catch (error) { this.log.error("%s: Error connecting to the realtime update events API: %s", this.nvrName, error); throw error; } return this.eventListener; } /** * Utility to let us streamline error handling and return checking from the Protect API. * * @param {RequestInfo} url * @param {RequestInit} [options={ method: "GET" }] * @param {boolean} [ensureLoggedIn=true] * @return {*} {(Promise<Response | null>)} * @memberof ProtectApi */ async fetch(url, options = { method: "GET" }, ensureLoggedIn = true) { let response; const abortController = axios_1.default.CancelToken.source(); // Ensure we are logged in if (ensureLoggedIn) { await this.login(); } // Ensure API responsiveness and guard against hung connections. const timeout = setTimeout(() => { abortController.cancel(); }, 1000 * Settings_1.PROTECT_API_TIMEOUT); options.httpsAgent = this.httpsAgent; options.headers = this.headers.getHeaders(); options.cancelToken = abortController.token; try { const now = Date.now(); // Throttle this after PROTECT_API_ERROR_LIMIT attempts. if (this.apiErrorCount >= Settings_1.PROTECT_API_ERROR_LIMIT) { // Let the user know we've got an API problem. if (this.apiErrorCount === Settings_1.PROTECT_API_ERROR_LIMIT) { this.log.info("%s: Throttling API calls due to errors with the %s previous attempts. I'll retry again in %s minutes.", this.nvrName, this.apiErrorCount, Settings_1.PROTECT_API_RETRY_INTERVAL / 60); this.apiErrorCount++; this.apiLastSuccess = now; return null; } // Throttle our API calls. if (this.apiLastSuccess + Settings_1.PROTECT_API_RETRY_INTERVAL * 1000 > now) { this.log.info("%s: Giving up. We have retried %s times.", this.nvrName, this.apiErrorCount); return null; } // Inform the user that we're out of the penalty box and try again. this.log.info("%s: Resuming connectivity to the UniFi Protect API after throttling for %s minutes.", this.nvrName, Settings_1.PROTECT_API_RETRY_INTERVAL / 60); this.apiErrorCount = 0; } this.log.debug("%s: Resuming connectivity to the UniFi Protect API after throttling for %s minutes.", this.nvrName, Settings_1.PROTECT_API_RETRY_INTERVAL / 60); response = await axios_1.default.request({ ...options, url: url, }); // Bad username and password. if (response.status === 401) { this.log.error("Invalid login credentials given. Please check your login and password."); this.apiErrorCount++; return null; } // Insufficient privileges. if (response.status === 403) { this.apiErrorCount++; this.log.error("Insufficient privileges for this user. Please check the roles assigned to this user and ensure it has sufficient privileges."); return null; } // Some other unknown error occurred. if (!response || response.status != 200) { this.apiErrorCount++; this.log.error("API access error: %s - %s", response.status, response.statusText); return null; } this.apiLastSuccess = Date.now(); this.apiErrorCount = 0; return response; } catch (error) { this.log.error(JSON.stringify(error)); this.apiErrorCount++; if (axios_1.default.isAxiosError(error) && error?.code) { switch (error.code) { case "ECONNREFUSED": this.log.error("%s: Controller API connection refused.", this.nvrName); break; case "ECONNRESET": this.log.error("%s: Controller API connection reset.", this.nvrName); break; case "ENOTFOUND": this.log.error("%s: Hostname or IP address not found. Please ensure the address you configured for this UniFi Protect controller is correct.", this.nvrName); throw new Error("Hostname or IP address not found. Please ensure the address you configured for this UniFi Protect controller is correct."); default: this.log.error(error.message); } } else { this.log.error("%s: Request failed.", this.nvrName); } return null; } finally { // Clear out our response timeout if needed. clearTimeout(timeout); } } /** * Prints the current status of the API client * * @return {*} {boolean} * @memberof UnifiApiClient */ status() { this.log.info(`Unifi Api Client: { loggedIn: ${this.loggedIn}, loginTimestamp: ${new Date(this.loginTimestamp)}, token: ${JSON.stringify(this.getToken())} }`); return this.loggedIn; } /** * Initializes the API client * * @memberof UnifiApiClient */ async init() { await this.login(); await this.bootstrapProtect(); } } exports.default = UnifiApiClient; //# sourceMappingURL=UnifiApiClient.js.map