UNPKG

fritzbox-api

Version:

Straightforward, lightweight and extendable Node.js library to communicate with FRITZ!Box devices

408 lines (345 loc) 13 kB
// Copyright (c) 2024, Thorsten A. Weintz. All rights reserved. // Licensed under the MIT license. See LICENSE in the project root for license information. import { XMLParser } from 'fast-xml-parser'; import HashUtil from './hashUtil.mjs'; import HttpClient from './httpClient.mjs'; /** * FritzBoxApi is a class to communicate with FRITZ!Box devices. * * This class partially supports TR-064 in addition to the web interface API. * * Differences: * - Web API (SID-based): used for /data.lua, login_sid.lua, etc. * - TR-064 (SOAP-based): used for telephony, device control, etc. * * Authentication: * - Web API → Session ID (SID) via challenge-response (PBKDF2) * - TR-064 → HTTP Digest Authentication (username + password) */ class FritzBoxApi extends HttpClient { /** * Default URL of FRITZ!Box device. */ url = 'http://fritz.box'; /** * Object with endpoint routes of FRITZ!Box device. */ routes = { login: '/login_sid.lua?version=2', data: '/data.lua', foncalls: '/fon_num/foncalls_list.lua?sid={sid}&csv=', firmwarecfg: '/cgi-bin/firmwarecfg', reboot: '/reboot.lua' }; /** * Default time in milliseconds till logout. */ timeInMsTillLogout = 1200 * 1000; /** * Initializes new instance of FritzBoxApi. * * @param {object} options Object with configuration values. */ constructor(options) { options = options || {}; if (!options.url) { options.url = this.url; } super(options.url); this.options = options; } /** * Sets the credentials used for authentication against the FRITZ!Box. * * @param {string} username String with username of device. * @param {string} password String with password of device. */ setCredentials(username, password) { this.options.credentials = { username, password }; } /** * Processes authentication by username and password. * * @param {string} username String with username of device. * @param {string} password String with password of device. * @returns Returns whether authentication succeeded. */ async login(username, password) { this.setCredentials(username, password); const sessionId = await this.getSessionId(); this.options.credentials.sid = sessionId; return sessionId != null; } /** * Gets session identifier by username and password. * * @param {boolean} renew If true, renew of session identifier is forced. * @returns Returns string with session identifier. */ async getSessionId(renew) { const { credentials: { username, password, sid } } = this.options; if (!renew && this.#isValidSid(sid)) return sid; const { data, status } = await this.get(this.routes.login); if (status === 200) { const blockTimeMs = this.#getXmlElementValue(data, 'BlockTime') * 1000; if (blockTimeMs > 0) { await this.#sleep(blockTimeMs); } const challenge = this.#getXmlElementValue(data, 'Challenge'); if (challenge) { const sessionId = await this.#calcReqChallengeResp( challenge, username, password ); if (sessionId !== '0000000000000000') { return sessionId; } } } } /** * Gets name of last user. * * @returns Returns string with name of last user. */ async getLastUser() { const { data, status } = await this.get(this.routes.login); if (status === 200) { return this.#getXmlElementValue(data, 'User', { last: 1 }); } } /** * Fetches data by pid and additional options. * * @param {object} options Object with configuration values. * @returns Returns JSON of responded data. */ async getData(options) { const sid = await this.getSessionId(); if (sid) { const { data, status } = await this.post(this.routes.data, { xhr: 1, sid, page: '', xhrId: '', ...options }); if (status === 200) { return data; } } } /** * Fetches fonbook of FRITZ!Box device. * * @param {number} phoneBookId Number with identifier of phone book. * @returns Returns */ async getFonBook(phoneBookId = 0) { const sid = await this.getSessionId(); if (sid) { const { data, status } = await this.post(this.routes.firmwarecfg, { sid, PhonebookId: phoneBookId, PhonebookExportName: 'Phonebook', PhonebookExport: '' }, true); if (status === 200) { const xmlParser = new XMLParser(); return xmlParser.parse(data); } } } /** * Fetches foncalls of FRITZ!Box device. * * @param {number} skip Number with offset to skip results. * @param {number} limit Maximum number of results to be returned. * @returns Returns object with head and body of foncall entries. */ async getFonCalls(skip = 0, limit) { const sid = await this.getSessionId(); if (sid) { const url = this.routes.foncalls.replace('{sid}', sid); const { data, status } = await this.get(url); if (status === 200) { const rows = data.split(/\r?\n|\r|\n/g); if (rows.length) { const sep = rows[0].split('=')[1]; const [head, ...entries] = rows.slice(1).map(row => row.split(sep)); return { head, entries: entries.slice(skip, skip + limit || entries.length) }; } } } return {}; } /** * Processes reboot of FRITZ!Box device. * * @returns Returns result of reboot process. */ async reboot() { const sid = await this.getSessionId(); if (sid) { const result = await this.getData({ page: 'reboot', reboot: 1 }); if (result?.data?.reboot === 'ok') { const { data, status } = await this.post(this.routes.reboot, { ajax: 1, sid, no_sidrenew: 1, xhr: 1, useajax: 1 }); if (status === 200) { return data; } } } } /** * Initiates a call to a given phone number via TR-064. * * This method directly forwards the provided number to the FRITZ!Box * dialing service (X_AVM-DE_DialNumber) without any mapping or transformation. * * @param {string} number Target phone number or internal code. * @returns {Promise<object>} TR-064 response. */ async callDevice(number) { if (!number || typeof number !== 'string') { return { error: 'Invalid phone number' }; } return this.#tr064( 'urn:dslforum-org:service:X_VoIP:1', 'X_AVM-DE_DialNumber', { 'NewX_AVM-DE_PhoneNumber': number } ); } /** * Terminates the currently active TR-064 initiated call. * * This will hang up a call that was previously started via * X_AVM-DE_DialNumber. * * @returns {Promise<object>} TR-064 response. */ async hangup() { try { return await this.#tr064( 'urn:dslforum-org:service:X_VoIP:1', 'X_AVM-DE_DialHangup' ); } catch (err) { return { error: `Failed to hang up call: ${err.message}` }; } } /** * Sends a TR-064 SOAP request to the FRITZ!Box. * * This method is a low-level helper to communicate with TR-064 services. * It builds a SOAP envelope and sends it to the corresponding control URL. * * @param {string} service URN of the TR-064 service. * @param {string} action Name of the SOAP action. * @param {object} params Key-value pairs mapped to XML elements. * @returns {Promise<object>} HTTP response from the device. */ async #tr064(service, action, params = {}) { const bodyParams = Object.entries(params) .map(([k, v]) => `<${k}>${v}</${k}>`) .join(''); const xml = `<?xml version="1.0" encoding="utf-8"?> <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"> <s:Body> <u:${action} xmlns:u="${service}"> ${bodyParams} </u:${action}> </s:Body> </s:Envelope>`; return this.postSoap( `${this.options.url}:49000/upnp/control/x_voip`, xml, this.options.credentials, { 'SOAPAction': `${service}#${action}` } ); } /** * Calculates the response for a given challenge. * * @param {string} challenge String with current challenge of device. * @param {string} username String with username of device. * @param {string} password String with password of device. * @returns Returns binary value in hexadecimal notation. */ async #calcReqChallengeResp(challenge, username, password) { const calcResp = challenge.startsWith('2$') ? this.#calcPbkdf2Resp : this.#calcMd5Resp; const response = calcResp(challenge, password); const { data, status } = await this.post(this.routes.login, { username, response }); if (status === 200) { const sessionId = this.#getXmlElementValue(data, 'SID'); return sessionId; } } /** * Calculates the response for a given challenge via PBKDF2 algorithm. * * @param {string} challenge String with current challenge of device. * @param {string} password String with password of device. * @returns Returns binary value in hexadecimal notation. */ #calcPbkdf2Resp(challenge, pwd) { const [_, iter1, salt1, iter2, salt2] = challenge.split('$'); const calcHash = HashUtil.pbkdf2; const hash1 = calcHash(pwd, salt1, iter1); const hash2 = calcHash(hash1, salt2, iter2); return salt2 + '$' + hash2.toString('hex'); } /** * Calculates the response for a given challenge via MD5 algorithm. * * @param {string} challenge String with current challenge of device. * @param {string} password String with password of device. * @returns Returns binary value in hexadecimal notation. */ #calcMd5Resp = (challenge, pwd) => challenge + '-' + HashUtil.md5(Buffer.from(challenge + '-' + pwd, 'utf-16le')); /** * Validates given session identifier. * * @param {string} sid String with session identifier. * @returns Returns whether session identifier is valid. */ #isValidSid = (sid) => sid && (!this.reqDate || new Date().getTime() - this.reqDate.getTime() < this.timeInMsTillLogout); /** * Gets string with XML attributes by keys and values of object. * * @param {object} attrs Object with keys and values. * @returns Returns string with XML attributes. */ #getXmlAttributes = (attrs) => attrs ? ` ${ Object.entries(attrs) .map(([key, value]) => `${key}="${value}"`).join(' ')}` : ''; /** * Gets string value of XMLElement by name. * * @param {string} data String with XML data. * @param {string} name String with name of the XMLElement. * @param {object} attrs Object with attributes of the XMLElement. * @returns Returns string with value of XMLElement. */ #getXmlElementValue = (data, name, attrs) => data?.match(`<${name}${this.#getXmlAttributes(attrs)}>(.*?)</${name}>`)?.[1]; /** * Processes asynchronous operation and resolves after given time in milliseconds. * * @param {number} ms Number with delay in milliseconds. * @returns Returns Promise object represents the eventual completion. */ #sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); } /** * Exports @see FritzBoxApi as default class. */ export default FritzBoxApi;