UNPKG

dbus-sdk

Version:

A Node.js SDK for interacting with DBus, enabling seamless service calling and exposure with TypeScript support

498 lines (497 loc) 23.6 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.DBusConnection = void 0; const net = __importStar(require("node:net")); const Errors_1 = require("./Errors"); const node_crypto_1 = require("node:crypto"); const node_path_1 = __importDefault(require("node:path")); const promises_1 = require("node:fs/promises"); const node_events_1 = __importDefault(require("node:events")); const DBusMessage_1 = require("./DBusMessage"); /** * A class representing a low-level DBus connection. * Handles the creation of streams (TCP, Unix socket, etc.), authentication handshake, * and message reading/writing over the connection. Extends EventEmitter to emit events * for messages, connection closure, and errors. */ class DBusConnection extends node_events_1.default { /** * Default authentication methods supported by this connection class. * Used in the handshake process in order of preference. */ static { this.defaultAuthMethods = ['EXTERNAL', 'DBUS_COOKIE_SHA1', 'ANONYMOUS']; } /** * Default timeout for connection attempts in milliseconds. * Used if no specific timeout is provided in connection options. */ static { this.defaultConnectTimeout = 20000; } /** * Computes the SHA-1 hash of the input data. * Used during DBUS_COOKIE_SHA1 authentication to generate a response based on challenges and cookie. * * @param input - The data to hash, typically a string or buffer. * @returns The hexadecimal representation of the SHA-1 hash. */ static sha1(input) { return (0, node_crypto_1.createHash)('sha1').update(input).digest('hex'); } /** * Retrieves the user's home directory path based on the platform. * Used to locate DBus keyring files for authentication, adapting to Windows or Unix-like systems. * * @returns The path to the user's home directory as a string. */ static getUserHome() { return process.env[process.platform.match(/\$win/) ? 'USERPROFILE' : 'HOME']; } /** * Retrieves a DBus authentication cookie from the user's keyring file. * Used during DBUS_COOKIE_SHA1 authentication to fetch a secret cookie for the response. * * @param context - The context name for the cookie (defaults to 'org_freedesktop_general' if empty). * @param id - The ID of the cookie to retrieve from the keyring file. * @returns A Promise resolving to the cookie value as a string. * @throws {UserPermissionError} If the keyring directory has incorrect permissions or the cookie is not found. */ static async getCookie(context, id) { // Reference: http://dbus.freedesktop.org/doc/dbus-specification.html#auth-mechanisms-sha const dirname = node_path_1.default.join(this.getUserHome(), '.dbus-keyrings'); // There is a default context, "org_freedesktop_general", that's used by servers that do not specify otherwise. if (context.length === 0) context = 'org_freedesktop_general'; const filename = node_path_1.default.join(dirname, context); const stats = await (0, promises_1.stat)(dirname); // Check if the directory is not writable by others and is readable by the user if (stats.mode & 0o22) throw new Errors_1.UserPermissionError('User keyrings directory is writeable by other users. Aborting authentication'); if (process.hasOwnProperty('getuid') && stats.uid !== process.getuid()) throw new Errors_1.UserPermissionError('Keyrings directory is not owned by the current user. Aborting authentication!'); const keyrings = await (0, promises_1.readFile)(filename, { encoding: 'ascii' }); const lines = keyrings.split('\n'); for (let l = 0; l < lines.length; ++l) { const data = lines[l].split(' '); if (data.length > 2 && id === data[0]) return data[2]; } throw new Errors_1.UserPermissionError('Cookie not found'); } /** * Performs the DBus connection handshake with the server over the provided stream. * Attempts authentication using the specified or default methods in sequence until one succeeds. * * @param stream - The duplex stream for communication with the DBus server. * @param opts - Optional handshake options, including custom authentication methods and UID. * @returns A Promise resolving to a tuple of [authMethod, uid, guid] upon successful handshake. * @throws {AuthError} If no authentication method succeeds or if all attempts fail. */ static async handshake(stream, opts) { const authMethods = opts?.authMethods || this.defaultAuthMethods; stream.write('\0'); // Initial null byte required by DBus protocol to start handshake const uid = opts?.uid ? opts.uid : process?.hasOwnProperty('getuid') ? process.getuid() : 0; const id = Buffer.from(uid.toString(), 'ascii').toString('hex'); let authError = new Errors_1.AuthError('No auth methods available'); for (const authMethod of authMethods) { try { return [authMethod, uid.toString(), await this.tryAuth(stream, authMethod, id)]; } catch (e) { authError = e; } } throw authError; } /** * Attempts authentication using a specific method. * Reads server responses and handles the authentication protocol for the chosen method. * Supports 'EXTERNAL', 'DBUS_COOKIE_SHA1', and 'ANONYMOUS' authentication mechanisms. * * @param stream - The duplex stream for communication with the DBus server. * @param authMethod - The authentication method to try (e.g., 'EXTERNAL', 'DBUS_COOKIE_SHA1', 'ANONYMOUS'). * @param id - The hexadecimal representation of the user ID used for authentication. * @returns A Promise resolving to the server GUID upon successful authentication. * @throws {AuthError} If authentication fails or the method is unsupported. */ static async tryAuth(stream, authMethod, id) { const readLine = () => { return new Promise((resolve, reject) => { const bytes = []; const readable = () => { while (1) { const buf = stream.read(1); if (!buf) return; const b = buf[0]; if (b === 0x0a) { // Line feed character indicates end of response line try { resolve(Buffer.from(bytes)); } catch (error) { reject(error); } finally { stream.removeListener('readable', readable); } return; } bytes.push(b); } }; stream.on('readable', readable); }); }; const successAndBegin = async () => { const line = await readLine(); const ok = line.toString('ascii').match(/^([A-Za-z]+) (.*)/); if (ok && ok.length > 2 && ok[1] === 'OK') { stream.write('BEGIN\r\n'); // Signal the start of normal DBus communication return ok[2]; // ok[2] = guid } throw new Errors_1.AuthError(line.toString('ascii')); }; switch (authMethod) { case 'EXTERNAL': stream.write(`AUTH ${authMethod} ${id}\r\n`); return await successAndBegin(); case 'DBUS_COOKIE_SHA1': stream.write(`AUTH ${authMethod} ${id}\r\n`); const line = await readLine(); const data = Buffer .from(line.toString().split(' ')[1].trim(), 'hex') .toString() .split(' '); const cookieContext = data[0]; const cookieId = data[1]; const serverChallenge = data[2]; // Any random 16 bytes should work, used as client challenge in response const clientChallenge = (0, node_crypto_1.randomBytes)(16).toString('hex'); const cookie = await this.getCookie(cookieContext, cookieId); const response = this.sha1([serverChallenge, clientChallenge, cookie].join(':')); const reply = Buffer.from(`${clientChallenge}${response}`, 'ascii').toString('hex'); stream.write(`DATA ${reply}\r\n`); return await successAndBegin(); case 'ANONYMOUS': stream.write('AUTH ANONYMOUS \r\n'); return await successAndBegin(); default: throw new Errors_1.AuthError(`Unsupported auth method: ${authMethod}`); } } /** * Creates a duplex stream for DBus communication based on network connection options. * Handles connection setup, timeouts, and errors for TCP or Unix socket connections. * * @param opts - Network connection options, including host, port, path, and timeout settings. * @returns A Promise resolving to a Duplex stream for communication with the DBus server. * @throws {TimeoutError} If the connection attempt times out after the specified duration. * @throws {Error} If the connection fails due to other reasons (e.g., network issues). */ static async createDuplexStream(opts) { return new Promise((resolve, reject) => { const socket = net.createConnection(opts); const clean = (callback) => { socket .off('timeout', timeoutHandler) .off('error', errorHandler) .off('connect', connectHandler); return callback(); }; const createResolve = () => clean(() => resolve(socket)); const createReject = (error) => clean(() => reject(error)); const timeoutHandler = () => { socket.destroy(); return createReject(new Errors_1.TimeoutError(`Connect timeout after ${opts.timeout} seconds`)); }; const errorHandler = (error) => createReject(error); const connectHandler = () => createResolve(); socket .once('timeout', timeoutHandler) .once('error', errorHandler) .once('connect', connectHandler); }); } /** * Creates a TCP stream for DBus communication. * Converts port input to a number and sets a default host if not provided. * * @param timeout - The connection timeout duration in milliseconds. * @param port - The port number (or string representation) to connect to. * @param host - Optional host address to connect to (defaults to 'localhost' if not specified). * @returns A Promise resolving to a Duplex stream for TCP communication with the DBus server. */ static async createTCPStream(timeout, port, host) { port = parseInt(port.toString()); host = host ? host : 'localhost'; return await this.createDuplexStream({ port: port, host: host, timeout: timeout }); } /** * Creates a Unix socket stream for DBus communication. * Uses the provided socket path for local communication. * * @param timeout - The connection timeout duration in milliseconds. * @param addr - The file path to the Unix socket for communication. * @returns A Promise resolving to a Duplex stream for Unix socket communication with the DBus server. */ static async createUnixStream(timeout, addr) { return this.createDuplexStream({ path: addr, timeout: timeout }); } /** * Creates a stream for DBus communication based on the provided options or environment variables. * Supports custom streams, direct socket paths, TCP connections, or bus addresses from environment. * Iterates through semicolon-separated bus addresses if multiple are provided, attempting each in sequence. * * @param opts - Optional connection options specifying a stream, socket path, TCP details, or bus address. * @returns A Promise resolving to a Duplex stream for communication with the DBus server. * @throws {UnknownBusAddressError} If no bus address is provided or found in environment variables. * @throws {UnknownBusTypeError} If the bus address type is unsupported (not 'tcp' or 'unix'). * @throws {NotEnoughParamsError} If required parameters are missing for a specific bus type. * @throws {CreateStreamFailedError} If stream creation fails for all attempted addresses. */ static async createStream(opts) { opts = opts ? opts : {}; if ('stream' in opts) { return opts.stream; } if ('socket' in opts) { return this.createUnixStream(opts.timeout ? opts.timeout : this.defaultConnectTimeout, opts.socket); } if ('port' in opts) { return this.createTCPStream(opts.timeout ? opts.timeout : this.defaultConnectTimeout, opts.port, opts.host); } const busAddress = opts.busAddress || process.env.DBUS_SESSION_BUS_ADDRESS; if (!busAddress) throw new Errors_1.UnknownBusAddressError('Unknown bus address'); const addresses = busAddress.split(';'); const connectErrorHandler = (e, isLastAddress) => { if (isLastAddress) throw e; console.warn(e.message); }; for (let i = 0; i < addresses.length; i++) { const isLastAddress = i < (addresses.length - 1); const address = addresses[i]; const familyParams = address.split(':'); const family = familyParams[0].toLowerCase(); const params = {}; familyParams[1].split(',').map((param) => param.split('=')).forEach(([key, value]) => params[key] = value); switch (family) { case 'tcp': return this.createTCPStream(opts.timeout ? opts.timeout : this.defaultConnectTimeout, params.port, params.host); case 'unix': if (!params.socket && !params.path) connectErrorHandler(new Errors_1.NotEnoughParamsError('Not enough parameters for \'unix\' connection - you need to specify \'socket\' or \'path\' parameter'), isLastAddress); return this.createUnixStream(opts.timeout ? opts.timeout : this.defaultConnectTimeout, params.socket || params.path); default: connectErrorHandler(new Errors_1.UnknownBusTypeError(`Unknown address type: ${family}`), isLastAddress); } } throw new Errors_1.CreateStreamFailedError('Create stream failed'); } /** * Static method to create a DBus connection. * Establishes a stream based on provided or default options, performs the handshake to authenticate, * and returns a fully initialized DBusConnection instance. * * @param opts - Optional connection and handshake options, including stream, bus address, or auth methods. * @returns A Promise resolving to a DBusConnection instance ready for communication. */ static async createConnection(opts) { opts = opts ? opts : {}; const stream = await this.createStream(opts); const [authMethod, uid, guid] = await this.handshake(stream, opts); return new DBusConnection(stream, authMethod, uid, guid, !!opts.advancedResponse, !!opts.convertBigIntToNumber); } /** * The duplex stream used for communication with the DBus server. * This private field holds the active stream for reading and writing data. */ #stream; /** * The authentication method used for this connection. * Stores the method (e.g., 'EXTERNAL') that succeeded during handshake. */ #authMethod; /** * The user ID used during authentication. * Represents the UID of the connecting user, parsed as a number. */ #uid; /** * The GUID provided by the server after successful authentication. * A unique identifier for the connection provided by the DBus daemon. */ #guid; /** * Getter for the authentication method used in this connection. * * @returns The authentication method (e.g., 'EXTERNAL', 'DBUS_COOKIE_SHA1', 'ANONYMOUS') as a string. */ get authMethod() { return this.#authMethod; } /** * Getter for the user ID used during authentication. * * @returns The user ID as a number. */ get uid() { return this.#uid; } /** * Getter for the server-provided GUID. * * @returns The GUID as a string, identifying this connection on the server. */ get guid() { return this.#guid; } /** * Checks if the connection is currently active. * Determines the connection status by checking if the stream is not closed. * * @returns True if the stream is not closed (connection is active), false otherwise. */ get connected() { return this.#stream ? !this.#stream.closed : false; } /** * Constructor for DBusConnection. * Initializes the connection with the provided stream and authentication details, * sets up event listeners for reading messages, and handles connection closure and errors. * Implements a state machine to parse incoming DBus messages by reading headers and bodies. * * @param stream - The duplex stream for communication with the DBus server. * @param authMethod - The authentication method that succeeded during handshake. * @param uid - The user ID used during authentication, as a string (later parsed to number). * @param guid - The GUID provided by the server after successful authentication. * @param advancedResponse - Boolean flag to enable advanced response handling, where DBus return messages are organized using DBusTypeClass instances. * @param convertBigIntToNumber - Boolean flag to enable auto convert bigint to javascript number. */ constructor(stream, authMethod, uid, guid, advancedResponse = false, convertBigIntToNumber = false) { super(); this.#stream = stream; this.#authMethod = authMethod; this.#uid = parseInt(uid); this.#guid = guid; let state = false; // false: header, true: fields + body let header; let fieldsAndBody; let fieldsLength; let fieldsLengthPadded; let fieldsAndBodyLength = 0; let bodyLength = 0; this.#stream.on('close', () => this.emit('close')) .on('error', (error) => this.emit('error', error)) .on('readable', () => { while (true) { if (!state) { header = stream.read(16); // DBus message header is 16 bytes if (!header) break; state = true; fieldsLength = header.readUInt32LE(12); // Length of header fields fieldsLengthPadded = ((fieldsLength + 7) >> 3) << 3; // Padded to 8-byte boundary bodyLength = header.readUInt32LE(4); // Length of message body fieldsAndBodyLength = fieldsLengthPadded + bodyLength; } else { fieldsAndBody = stream.read(fieldsAndBodyLength); if (!fieldsAndBody) break; state = false; this.emit('message', DBusMessage_1.DBusMessage.decode(header, fieldsAndBody, fieldsLength, bodyLength, advancedResponse, convertBigIntToNumber)); } } }); if ('setNoDelay' in this.#stream && typeof this.#stream.setNoDelay === 'function') this.#stream.setNoDelay(); } /** * Writes data to the DBus connection stream. * Used to send encoded DBus messages to the server. * * @param data - The Buffer containing the data to write to the stream. * @returns True if the write operation was successful, false otherwise (e.g., if the stream is not writable). */ write(data) { return this.#stream.write(data); } /** * Closes the DBus connection stream. * Ends the connection, optionally executing a callback when the stream is fully closed. * * @param callback - Optional callback function to execute when the stream is closed. * @returns This instance for method chaining. */ end(callback) { this.#stream.end(callback); return this; } /** * Fallback overload for the 'on' method to ensure compatibility with the base EventEmitter class. * This is a catch-all signature for any event name and listener combination. * * @param eventName - Any string representing an event name. * @param listener - Any callback function with variable arguments. * @returns This instance for method chaining. */ on(eventName, listener) { super.on(eventName, listener); return this; } /** * Fallback overload for the 'once' method to ensure compatibility with the base EventEmitter class. * This is a catch-all signature for any event name and listener combination. * * @param eventName - Any string representing an event name. * @param listener - Any callback function with variable arguments. * @returns This instance for method chaining. */ once(eventName, listener) { super.once(eventName, listener); return this; } } exports.DBusConnection = DBusConnection;