@komponent/unifi-protect-lib
Version:
Node library for connecting to Ubiquiti Unifi Protect controllers and listen for events
393 lines • 16.6 kB
JavaScript
"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