UNPKG

@skylord123/node-red-contrib-backrest

Version:

Node-RED nodes for interacting with Backrest, a restic-powered backup management tool.

294 lines (265 loc) 10.8 kB
const grpc = require("@grpc/grpc-js"); const protoLoader = require("@grpc/proto-loader"); const path = require("path"); // Defaults for reconnect logic const DEFAULT_RECONNECT_INTERVAL = 1000; const DEFAULT_MAX_RECONNECT_TRIES = 0; // gRPC error codes const ERROR_CODES = { CANCELLED: 1, UNKNOWN: 2, DEADLINE_EXCEEDED: 4, UNAVAILABLE: 14 }; module.exports = function(RED) { // In-memory cache so we load proto only once let protoDescriptor = null; function loadProtoDefinitions() { if (protoDescriptor) return protoDescriptor; const PROTO_PATH = path.join(__dirname, "proto", "v1", "service.proto"); const packageDef = protoLoader.loadSync(PROTO_PATH, { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true, includeDirs: [path.join(__dirname, "proto")] }); protoDescriptor = grpc.loadPackageDefinition(packageDef); return protoDescriptor; } /** * Utility: Strip http:// or https:// from the URL for gRPC usage. */ function stripHttpPrefix(urlString) { if (!urlString) return ""; return urlString.replace(/^https?:\/\//, ""); } /** * Utility: Create Basic Auth metadata if username/password exist. */ function createAuthMetadata(username, password) { const metadata = new grpc.Metadata(); if (username && password) { const authString = Buffer.from(`${username}:${password}`).toString("base64"); metadata.add("Authorization", `Basic ${authString}`); } return metadata; } function BackrestConfigNode(config) { RED.nodes.createNode(this, config); // Exposed in the UI this.autoReconnect = config.autoReconnect || false; this.reconnectInterval = parseInt(config.reconnectInterval) || DEFAULT_RECONNECT_INTERVAL; this.maxReconnectTries = parseInt(config.reconnectTries) || DEFAULT_MAX_RECONNECT_TRIES; // Credentials if (this.credentials) { this.backrest_url = (this.credentials.backrest_url || "").replace(/\/?$/, ""); this.username = this.credentials.username || ""; this.password = this.credentials.password || ""; } else { this.backrest_url = ""; this.username = ""; this.password = ""; } // gRPC client + streaming state this.client = null; this.operationStreamCall = null; // active call for GetOperationEvents this.operationStreamEndOrErrorHandled = false; this.subscribers = []; // array of { node, callback } objects this.reconnectAttempts = 0; this.manualStop = false; // Create the gRPC client (insecure in this example) this.setupClient(); // When this config node is closed, stop the stream. this.on("close", () => { this.stopOperationEventsStream(); }); } /** * Set up the gRPC client (insecure example). For TLS, replace createInsecure with createSsl. */ BackrestConfigNode.prototype.setupClient = function() { const proto = loadProtoDefinitions(); const backrestPackage = proto.v1; if (!backrestPackage || !backrestPackage.Backrest) { this.error("Could not load the Backrest service from proto definitions."); return; } const grpcTarget = stripHttpPrefix(this.backrest_url); this.log(`Creating gRPC client for Backrest at: ${grpcTarget}`); this.client = new backrestPackage.Backrest( grpcTarget, grpc.credentials.createInsecure() ); }; /** * Update status on all subscribed OperationEvents nodes. */ BackrestConfigNode.prototype.updateSubscribersStatus = function() { let statusObj; if (this.operationStreamCall) { statusObj = { fill: "green", shape: "dot", text: "subscribed" }; } else { statusObj = { fill: "red", shape: "dot", text: `disconnected, reconnecting in ${this.reconnectInterval}ms` + (this.maxReconnectTries !== 0 ? ` (${this.reconnectAttempts}/${this.maxReconnectTries})` : '') }; } this.subscribers.forEach(sub => { sub.node.status(statusObj); }); }; /** * Start the single OperationEvents stream, if not already started. */ BackrestConfigNode.prototype.startOperationEventsStream = function() { if (this.operationStreamCall) { this.log("OperationEvents stream already active; ignoring start request."); return; } if (!this.client) { this.error("No gRPC client available; cannot start OperationEvents stream."); return; } this.log("Starting OperationEvents stream..."); this.manualStop = false; this.operationStreamEndOrErrorHandled = false; // Build metadata const metadata = createAuthMetadata(this.username, this.password); // Start the call this.operationStreamCall = this.client.GetOperationEvents({}, metadata); // Wire up events this.operationStreamCall.on("data", (data) => { // If we receive data, the connection is working; // reset reconnectAttempts if needed. if (this.reconnectAttempts > 0) { this.reconnectAttempts = 0; this.updateSubscribersStatus(); } // Broadcast to all subscribers. for (const sub of this.subscribers) { sub.callback(data); } }); this.operationStreamCall.on("error", (err) => { if (this.operationStreamEndOrErrorHandled) return; this.operationStreamEndOrErrorHandled = true; this.operationStreamCall = null; this.handleOperationStreamError(err); }); this.operationStreamCall.on("end", () => { if (this.operationStreamEndOrErrorHandled) return; this.operationStreamEndOrErrorHandled = true; this.log("OperationEvents stream ended."); this.operationStreamCall = null; this.maybeReconnect(); }); // Do not reset reconnectAttempts here; only reset on receiving data. this.updateSubscribersStatus(); }; /** * Stop the OperationEvents stream, if active. */ BackrestConfigNode.prototype.stopOperationEventsStream = function() { if (this.operationStreamCall) { this.log("Stopping OperationEvents stream..."); this.manualStop = true; this.operationStreamCall.cancel(); this.operationStreamCall = null; } // Do not reset reconnectAttempts here since they persist until a successful connection. this.subscribers.forEach(sub => { sub.node.status({ fill: "yellow", shape: "ring", text: "not subscribed" }); }); }; /** * Handle stream error (or unexpected end) and possibly trigger a reconnect. */ BackrestConfigNode.prototype.handleOperationStreamError = function(err) { if (err.code === ERROR_CODES.CANCELLED && err.details === "Cancelled on client") { this.log("OperationEvents stream cancelled by client (manual stop)."); return; } switch (err.code) { case ERROR_CODES.DEADLINE_EXCEEDED: this.error("OperationEvents stream timed out."); break; case ERROR_CODES.UNAVAILABLE: this.error("OperationEvents stream: server unavailable."); break; default: this.error(`OperationEvents stream error: ${err.message}`); break; } this.maybeReconnect(); }; /** * Attempt reconnect if autoReconnect is enabled and there are subscribers. * Note: The reconnectAttempts counter is only incremented on each failed attempt and * is reset when we successfully receive data. */ BackrestConfigNode.prototype.maybeReconnect = function() { if (this.manualStop) { this.log("Not reconnecting: manual stop triggered."); return; } if (!this.autoReconnect) { this.log("Not reconnecting: autoReconnect is disabled."); return; } if (this.subscribers.length === 0) { this.log("Not reconnecting: no subscribers."); return; } if (this.maxReconnectTries !== 0 && this.reconnectAttempts >= this.maxReconnectTries) { this.error(`Max reconnect attempts (${this.maxReconnectTries}) reached; giving up.`); this.updateSubscribersStatus(); return; } this.reconnectAttempts++; this.log(`Reconnecting OperationEvents stream (attempt #${this.reconnectAttempts}) in ${this.reconnectInterval}ms...`); this.updateSubscribersStatus(); setTimeout(() => { this.startOperationEventsStream(); }, this.reconnectInterval); }; /** * Subscribe a node to the OperationEvents stream. Provide a callback for receiving data. */ BackrestConfigNode.prototype.subscribeOperationEvents = function(node, callback) { const existing = this.subscribers.find(s => s.node.id === node.id); if (!existing) { this.log(`Node ${node.id} subscribed to OperationEvents.`); this.subscribers.push({ node, callback }); } if (!this.operationStreamCall) { this.startOperationEventsStream(); } else { node.status({ fill: "green", shape: "dot", text: "subscribed" }); } }; /** * Unsubscribe a node from the OperationEvents stream. If no subscribers remain, stop the stream. */ BackrestConfigNode.prototype.unsubscribeOperationEvents = function(node) { const idx = this.subscribers.findIndex(s => s.node.id === node.id); if (idx !== -1) { this.log(`Node ${node.id} unsubscribing from OperationEvents.`); this.subscribers[idx].node.status({ fill: "yellow", shape: "ring", text: "not subscribed" }); this.subscribers.splice(idx, 1); } if (this.subscribers.length === 0) { this.stopOperationEventsStream(); } }; RED.nodes.registerType("backrest-config", BackrestConfigNode, { credentials: { backrest_url: { type: "text" }, username: { type: "text" }, password: { type: "password" } } }); };