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
JavaScript
;
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;